Files
pi-extensions/memory/sources.ts
T
shahondin1624 eb1063da28 add cross-agent memory extension
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>
2026-05-29 08:23:39 +02:00

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");
}