eb1063da28
A pi extension for reading and writing persistent memory across agent systems. Reads its own canonical store (project ./.pi/memory/ + user ~/.pi/memory/, typed markdown with frontmatter — Claude's user/feedback/project/reference taxonomy) AND other agents' memory verbatim (Claude CLAUDE.md + ~/.claude/projects/<slug>/ memory/, Copilot, AGENTS.md, GEMINI.md, Aider CONVENTIONS.md). Writes only ever touch the canonical store; foreign files are never modified. Surfaces: memory_read / memory_search / memory_write / memory_forget tools, /memory-* commands, and a canonical index appended to the system prompt at agent start. Enum-valued args (type/scope/system) are lenient strings normalized in-tool rather than anyOf/const unions: some local models emit const-union values JSON-quoted (e.g. "\"feedback\""), which strict schema validation rejected before the tool ran. normalizeEnumArg strips the stray quoting; memory_read by name also falls back to a foreign file of that name. store.ts / sources.ts import only Node built-ins so tests load them directly under --experimental-strip-types. 31 unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.3 KiB
TypeScript
221 lines
7.3 KiB
TypeScript
/**
|
|
* memory/sources.ts — read-only adapters for OTHER agent systems' memory.
|
|
*
|
|
* This extension never writes here. It surfaces what already exists on disk,
|
|
* verbatim, each block tagged with the system + scope + path it came from
|
|
* ("raw, source-tagged" — no lossy re-parsing into our typed model). The model
|
|
* always sees exactly what is on disk.
|
|
*
|
|
* Covered systems
|
|
* ───────────────
|
|
* claude CLAUDE.md / CLAUDE.local.md (project), ~/.claude/CLAUDE.md (user),
|
|
* and the structured auto-memory store
|
|
* ~/.claude/projects/<slug>/memory/*.md (project-associated).
|
|
* copilot .github/copilot-instructions.md and .github/instructions/*.instructions.md.
|
|
* agents AGENTS.md (the generic cross-tool convention; Codex, Amp, etc.).
|
|
* gemini GEMINI.md (project) and ~/.gemini/GEMINI.md (user).
|
|
* aider CONVENTIONS.md (Aider's conventions file).
|
|
*
|
|
* Import-free of pi-coding-agent / typebox (Node built-ins only) so the test
|
|
* suite can load it with `node --experimental-strip-types`.
|
|
*/
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
export const FOREIGN_SYSTEMS = ["claude", "copilot", "agents", "gemini", "aider"] as const;
|
|
export type ForeignSystem = (typeof FOREIGN_SYSTEMS)[number];
|
|
|
|
export type ForeignScope = "project" | "user";
|
|
|
|
export interface ForeignSource {
|
|
system: ForeignSystem;
|
|
scope: ForeignScope;
|
|
/** Absolute path of the file the content came from. */
|
|
path: string;
|
|
/** Verbatim file content. */
|
|
content: string;
|
|
}
|
|
|
|
export function isForeignSystem(value: unknown): value is ForeignSystem {
|
|
return typeof value === "string" && (FOREIGN_SYSTEMS as readonly string[]).includes(value);
|
|
}
|
|
|
|
// ── Claude auto-memory project slug ──────────────────────────────────────
|
|
|
|
/**
|
|
* Claude Code stores per-project auto-memory under
|
|
* ~/.claude/projects/<slug>/memory/, where <slug> is the project's absolute
|
|
* path with separators replaced. The exact encoding has shifted across
|
|
* versions, so we return CANDIDATES (most-likely first) and let discovery pick
|
|
* the one that actually exists on disk:
|
|
*
|
|
* 1. "/" → "-" e.g. /home/u/proj → -home-u-proj
|
|
* 2. every non-alphanumeric → "-" folds dots, spaces, etc.
|
|
*
|
|
* Pure (no fs) so it is unit-testable in isolation.
|
|
*/
|
|
export function claudeProjectSlugCandidates(cwd: string): string[] {
|
|
const bySlash = cwd.replace(/\//g, "-");
|
|
const byNonAlnum = cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
return [...new Set([bySlash, byNonAlnum])];
|
|
}
|
|
|
|
/** Resolve the Claude auto-memory dir for this cwd, or null if none exists. */
|
|
export function claudeMemoryDir(cwd: string, home: string): string | null {
|
|
for (const slug of claudeProjectSlugCandidates(cwd)) {
|
|
const dir = join(home, ".claude", "projects", slug, "memory");
|
|
if (safeIsDir(dir)) return dir;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── fs helpers ────────────────────────────────────────────────────────────
|
|
|
|
function safeIsFile(path: string): boolean {
|
|
try {
|
|
return statSync(path).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function safeIsDir(path: string): boolean {
|
|
try {
|
|
return statSync(path).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readIfFile(
|
|
system: ForeignSystem,
|
|
scope: ForeignScope,
|
|
path: string,
|
|
out: ForeignSource[],
|
|
): void {
|
|
if (!safeIsFile(path)) return;
|
|
try {
|
|
const content = readFileSync(path, "utf8");
|
|
if (content.trim().length > 0) out.push({ system, scope, path, content });
|
|
} catch {
|
|
// Unreadable — skip silently; memory reading must never hard-fail.
|
|
}
|
|
}
|
|
|
|
function readMatchingInDir(
|
|
system: ForeignSystem,
|
|
scope: ForeignScope,
|
|
dir: string,
|
|
match: (filename: string) => boolean,
|
|
out: ForeignSource[],
|
|
): void {
|
|
if (!safeIsDir(dir)) return;
|
|
let names: string[];
|
|
try {
|
|
names = readdirSync(dir).sort();
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const name of names) {
|
|
if (match(name)) readIfFile(system, scope, join(dir, name), out);
|
|
}
|
|
}
|
|
|
|
// ── Discovery ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Discover and read every foreign memory source reachable from `cwd` / `home`.
|
|
* Only existing, non-empty, readable files are returned. Optional `systems`
|
|
* restricts the set (default: all). Order is stable: project before user, and
|
|
* grouped by system in FOREIGN_SYSTEMS order.
|
|
*/
|
|
export function discoverForeignSources(
|
|
cwd: string,
|
|
home: string,
|
|
systems: readonly ForeignSystem[] = FOREIGN_SYSTEMS,
|
|
): ForeignSource[] {
|
|
const want = new Set(systems);
|
|
const out: ForeignSource[] = [];
|
|
|
|
if (want.has("claude")) {
|
|
// Guidance files.
|
|
readIfFile("claude", "project", join(cwd, "CLAUDE.md"), out);
|
|
readIfFile("claude", "project", join(cwd, "CLAUDE.local.md"), out);
|
|
readIfFile("claude", "user", join(home, ".claude", "CLAUDE.md"), out);
|
|
// Structured auto-memory store for this project.
|
|
const memDir = claudeMemoryDir(cwd, home);
|
|
if (memDir) {
|
|
readMatchingInDir(
|
|
"claude",
|
|
"project",
|
|
memDir,
|
|
(n) => n.toLowerCase().endsWith(".md"),
|
|
out,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (want.has("copilot")) {
|
|
readIfFile("copilot", "project", join(cwd, ".github", "copilot-instructions.md"), out);
|
|
readMatchingInDir(
|
|
"copilot",
|
|
"project",
|
|
join(cwd, ".github", "instructions"),
|
|
(n) => n.toLowerCase().endsWith(".instructions.md"),
|
|
out,
|
|
);
|
|
}
|
|
|
|
if (want.has("agents")) {
|
|
readIfFile("agents", "project", join(cwd, "AGENTS.md"), out);
|
|
}
|
|
|
|
if (want.has("gemini")) {
|
|
readIfFile("gemini", "project", join(cwd, "GEMINI.md"), out);
|
|
readIfFile("gemini", "user", join(home, ".gemini", "GEMINI.md"), out);
|
|
}
|
|
|
|
if (want.has("aider")) {
|
|
readIfFile("aider", "project", join(cwd, "CONVENTIONS.md"), out);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Find a discovered foreign source by file name. Matches the path's basename
|
|
* case-insensitively, with or without a trailing `.md`, so a caller can ask for
|
|
* "MEMORY.md", "memory", or "project_nextcloud". Returns the first match or null.
|
|
*/
|
|
export function findForeignByName(
|
|
sources: readonly ForeignSource[],
|
|
name: string,
|
|
): ForeignSource | null {
|
|
const want = name.trim().toLowerCase();
|
|
if (!want) return null;
|
|
const wantNoExt = want.replace(/\.md$/i, "");
|
|
for (const s of sources) {
|
|
const base = (s.path.split(/[\\/]/).pop() ?? "").toLowerCase();
|
|
if (base === want || base.replace(/\.md$/i, "") === wantNoExt) return s;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Formatting ────────────────────────────────────────────────────────────
|
|
|
|
/** One line per source: `- [system/scope] path (N chars)`. For read-maps. */
|
|
export function formatForeignDigest(sources: readonly ForeignSource[]): string {
|
|
if (sources.length === 0) return "";
|
|
return sources
|
|
.map((s) => `- [${s.system}/${s.scope}] ${s.path} (${s.content.length} chars)`)
|
|
.join("\n");
|
|
}
|
|
|
|
/** Full source-tagged dump: a heading per source followed by its verbatim body. */
|
|
export function formatForeignFull(sources: readonly ForeignSource[]): string {
|
|
return sources
|
|
.map((s) => `### ${s.system} · ${s.scope} · ${s.path}\n\n${s.content.trim()}`)
|
|
.join("\n\n");
|
|
}
|