import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readFileSync } from "node:fs"; import { parse as parseYaml } from "yaml"; import { expandDeterministicCommand, type DeterministicCommandExpansion, } from "../../../core/runtime/command-expansion.js"; const exec = promisify(execFile); const CRTR = "crtr"; // --------------------------------------------------------------------------- // memory-slash-commands: any memory doc frontmatter-flagged `slash: true` // becomes an invocable slash command (`/`). This mirrors Hermes Agent's // "every skill is automatically a slash command" — /research-alpha injects // the doc body (plus the user's free-text args) as a normal turn instead of // requiring a separate skill-invocation mechanism. // // Discovery shells out to `crtr memory list --json --paths`, which already // resolves the substrate's scope precedence (project stack > profile > user > // builtin) — this file does not walk memory/ directories or reimplement scope // resolution. `list`'s JSON items are already scope-precedence sorted (see // `scopeRank` in crouter's memory/shared.ts), so first-occurrence-wins per doc // name reproduces the resolver's identity precedence for free. `list` is // bounded/paginated, so discovery pages through the full corpus via // `next_cursor` until exhausted, and passes `--paths` because `path` is // load-bearing here (isSlashFlagged reads the file at each item's path). // --------------------------------------------------------------------------- const FRONTMATTER_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/; interface MemoryListItem { name: string; path: string; short_form: string; is_dir: boolean; } export interface SlashDoc { /** Path-derived doc name, e.g. "research-alpha" or "taste/foo" — what * `crtr memory read` resolves. */ name: string; /** `name` with `/` -> `:` — the registered command name (a flat name is * unchanged). */ commandName: string; /** The memory document's short-form description, shown in the slash menu. */ description: string; } interface MemoryListPage { items: MemoryListItem[]; next_cursor: string | null; } async function defaultListMemoryDocs(): Promise { const items: MemoryListItem[] = []; let cursor: string | undefined; for (;;) { const args = ["memory", "list", "--json", "--paths", "--limit", "200"]; if (cursor !== undefined) args.push("--cursor", cursor); const { stdout } = await exec(CRTR, args, { maxBuffer: 8 * 1024 * 1024 }); const parsed = JSON.parse(stdout) as MemoryListPage; items.push(...parsed.items); if (parsed.next_cursor === null) break; cursor = parsed.next_cursor; } return items; } async function defaultReadMemoryDoc(name: string): Promise { const { stdout } = await exec(CRTR, ["memory", "read", name, "--json"], { maxBuffer: 8 * 1024 * 1024 }); const parsed = JSON.parse(stdout) as { content: string }; return parsed.content; } let listMemoryDocsImpl = defaultListMemoryDocs; let readMemoryDocImpl = defaultReadMemoryDoc; /** Test seam: override doc discovery so tests never shell out to `crtr`. */ export function __setListMemoryDocsForTest(fn?: typeof listMemoryDocsImpl): void { listMemoryDocsImpl = fn ?? defaultListMemoryDocs; } /** Test seam: override doc-body reads so tests never shell out to `crtr`. */ export function __setReadMemoryDocForTest(fn?: typeof readMemoryDocImpl): void { readMemoryDocImpl = fn ?? defaultReadMemoryDoc; } /** Read a memory doc's raw frontmatter directly off its already-resolved * `path` (from `crtr memory list`) and report whether `slash` is `true`. * No directory walk, no scope reasoning — the path is already resolved. */ function isSlashFlagged(path: string): boolean { let raw: string; try { raw = readFileSync(path, "utf8"); } catch { return false; } const match = raw.match(FRONTMATTER_RE); if (!match) return false; let data: unknown; try { data = parseYaml(match[1]); } catch { return false; } if (data === null || typeof data !== "object" || Array.isArray(data)) return false; return (data as Record).slash === true; } /** `/`-joined path-derived doc name -> the registered slash-command name. */ export function slashCommandName(docName: string): string { return docName.split("/").join(":"); } /** Every `slash: true` memory doc, scope-precedence-deduped by name (see * module header). Directory INDEX entries never register as commands. A doc * without `slash` (or `slash: false`) is silently excluded. */ export async function discoverSlashDocs(): Promise { let items: MemoryListItem[]; try { items = await listMemoryDocsImpl(); } catch { return []; } const seen = new Set(); const out: SlashDoc[] = []; for (const item of items) { if (item.is_dir) continue; if (seen.has(item.name)) continue; seen.add(item.name); if (isSlashFlagged(item.path)) { out.push({ name: item.name, commandName: slashCommandName(item.name), description: item.short_form }); } } return out; } /** Compose the invocation prompt from the freshly-read doc body plus the * user's raw invocation args, following Claude Code's `$ARGUMENTS` * convention. When the body contains `$ARGUMENTS`, every occurrence is * replaced with the trimmed args (empty string when none were given) and the * substituted body is injected AS-IS — the doc placed its own args, so no * extra header is added. Otherwise: a short invocation header naming the * command and the raw args (both the given-args and no-args cases are * load-bearing, per the Hermes /learn prompt pattern this mirrors), followed * by the full doc body as the instructions to follow. */ export function composeSlashPrompt(commandName: string, rawArgs: string, body: string): string { return expandDeterministicCommand({ kind: "memory-slash", commandName, body }, rawArgs); } export default async function (pi: ExtensionAPI) { const docs = await discoverSlashDocs(); for (const doc of docs) { let expansion: DeterministicCommandExpansion | undefined; let loadError: unknown; try { expansion = { kind: "memory-slash", commandName: doc.commandName, body: await readMemoryDocImpl(doc.name), }; } catch (err) { loadError = err; } // pi preserves unknown registration fields on the resolved command object. // The broker discloses this serializable contract through get_commands; the // handler below consumes the SAME object and expander, so preview cannot // drift from submission. A failed/dynamic command has no expansion metadata. pi.registerCommand(doc.commandName, { description: doc.description, ...(expansion === undefined ? {} : { expansion }), handler: async (args, ctx) => { if (expansion === undefined) { ctx.ui.notify( `/${doc.commandName}: failed to read memory doc "${doc.name}": ${loadError instanceof Error ? loadError.message : String(loadError)}`, "error", ); return; } pi.sendMessage( { customType: "memory-slash-command", content: expandDeterministicCommand(expansion, args ?? ""), display: true, }, { triggerTurn: true }, ); }, } as Parameters[1] & { expansion?: DeterministicCommandExpansion }); } }