diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d442445..02e944e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,15 @@ jobs: echo "Run scripts/sync-pi-package.sh against a kimetsu-pi checkout and commit the result there." exit 1 } + - name: Diff the Pi skill against the published copy + run: | + diff -u crates/kimetsu-chat/assets/pi-skill.md \ + .kimetsu-pi/skills/kimetsu-brain/SKILL.md \ + || { + echo "::error::The canonical Pi skill and kimetsu-pi skill have drifted." + echo "Run scripts/sync-pi-package.sh against a kimetsu-pi checkout and commit the result there." + exit 1 + } sdk: name: TypeScript SDK diff --git a/crates/kimetsu-chat/assets/pi-extension.ts b/crates/kimetsu-chat/assets/pi-extension.ts index c342442..2d91dc4 100644 --- a/crates/kimetsu-chat/assets/pi-extension.ts +++ b/crates/kimetsu-chat/assets/pi-extension.ts @@ -14,11 +14,16 @@ // crash, unparseable output. Kimetsu is a sidecar — it must never break Pi. import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -/** Hard cap on any single kimetsu invocation. A hung binary must not stall a turn. */ +/** Interactive hooks must not leave a turn waiting on a hung binary. */ const EXEC_TIMEOUT_MS = 10000; +/** Session saving may distill lessons and an episode in two model calls + * (120s each by default). Leave time for both plus local persistence. */ +const SESSION_SAVE_TIMEOUT_MS = 300000; + /** Fallback session id when Pi's context does not expose one. Stable per process, * which is what the brain's per-session dedupe and refractory windows need. */ const FALLBACK_SESSION_ID = `pi-${process.pid}`; @@ -30,7 +35,7 @@ const FALLBACK_SESSION_ID = `pi-${process.pid}`; * stdout is PIPED, not ignored: the context hook communicates entirely through * it. stderr stays ignored so diagnostics never mix into the parsed payload. */ -function kimetsuRun(args: string[], input?: string): Promise { +function kimetsuRun(args: string[], input?: string, timeoutMs = EXEC_TIMEOUT_MS): Promise { return new Promise((resolve) => { let settled = false; let timer: ReturnType | undefined; @@ -52,7 +57,7 @@ function kimetsuRun(args: string[], input?: string): Promise { timer = setTimeout(() => { child.kill(); done(); - }, EXEC_TIMEOUT_MS); + }, timeoutMs); if (typeof timer.unref === "function") timer.unref(); child.stdout?.setEncoding("utf8"); @@ -147,11 +152,17 @@ function workspaceArgs(ctx: any): string[] { } export default function (pi: ExtensionAPI) { + // Pi persists injected messages, including display:false messages. Only the + // current task's injection belongs in future model calls. A unique marker + // survives Pi's message cloning without trusting content or timestamps. + let activeContext: { id: string; sessionId: string } | undefined; + // session_start fires once when Pi starts up or a new session begins. // Warming spawns the embedder daemon so the first real retrieval is semantic // rather than falling back to lexical FTS. // (`brain warm` takes no --workspace: it resolves the project from its cwd.) pi.on("session_start", async (_event, _ctx) => { + activeContext = undefined; await kimetsuRun(["brain", "warm"]); }); @@ -161,14 +172,19 @@ export default function (pi: ExtensionAPI) { // --warm-on-first-prompt folds the repo digest and episodic resume into the // first turn of each session. pi.on("before_agent_start", async (event, ctx) => { + const request = { id: randomUUID(), sessionId: sessionIdOf(ctx) }; + // Expire the last task immediately, even if retrieval is empty or fails. + activeContext = request; const payload = JSON.stringify({ - session_id: sessionIdOf(ctx), + session_id: request.sessionId, prompt: typeof event?.prompt === "string" ? event.prompt : "", }); const stdout = await kimetsuRun( ["brain", "context-hook", "--warm-on-first-prompt", ...workspaceArgs(ctx)], payload, ); + // A session switch or a newer prompt can supersede an in-flight request. + if (activeContext !== request) return; const content = parseAdditionalContext(stdout); if (content === undefined) return; // nothing relevant — zero tokens return { @@ -176,10 +192,64 @@ export default function (pi: ExtensionAPI) { customType: "kimetsu-brain", content, display: false, + details: { kimetsuContextId: request.id }, }, }; }); + pi.on("context", async (event, ctx) => { + const context = activeContext; + const currentIndex = context && context.sessionId === sessionIdOf(ctx) + ? event.messages.findIndex((message) => { + if (message.role !== "custom" || message.customType !== "kimetsu-brain") return false; + const details = message.details as { kimetsuContextId?: unknown } | undefined; + return details?.kimetsuContextId === context.id; + }) + : -1; + // Queued steering/follow-up messages bypass before_agent_start. Expire + // the old injection when a newer user message arrives; tool results alone + // do not end the current task's context. + if (currentIndex >= 0 && event.messages.some((message, index) => + index > currentIndex && message.role === "user" + )) activeContext = undefined; + + // Filter the model's copy only; preserve the persisted session history. + return { + messages: event.messages.filter((message, index) => + message.role !== "custom" || message.customType !== "kimetsu-brain" + || (activeContext !== undefined && index === currentIndex) + ), + }; + }); + + pi.on("session_before_compact", async (event) => { + // Summarization bypasses the context event. Do not turn retrieved evidence + // into a durable summary that could outlive a correction or invalidation. + event.preparation.messagesToSummarize = event.preparation.messagesToSummarize.filter( + (message) => message.role !== "custom" || message.customType !== "kimetsu-brain", + ); + event.preparation.turnPrefixMessages = event.preparation.turnPrefixMessages.filter( + (message) => message.role !== "custom" || message.customType !== "kimetsu-brain", + ); + }); + + pi.on("session_before_tree", async (event) => { + // Pi retains a reference to this temporary summary input array, so filter + // in place. The persisted session entries themselves are left untouched. + const entries = event.preparation.entriesToSummarize; + let kept = 0; + for (const entry of entries) { + if (entry.type !== "custom_message" || entry.customType !== "kimetsu-brain") { + entries[kept++] = entry; + } + } + entries.length = kept; + }); + + pi.on("session_tree", async () => { + activeContext = undefined; + }); + // agent_end fires after the LLM turn completes (maps to Kimetsu stop-hook). pi.on("agent_end", async (event, ctx) => { await kimetsuRun( @@ -190,9 +260,11 @@ export default function (pi: ExtensionAPI) { // session_shutdown fires on clean session close (maps to session-end-hook). pi.on("session_shutdown", async (_event, ctx) => { + activeContext = undefined; await kimetsuRun( ["brain", "session-end-hook", ...workspaceArgs(ctx)], lifecyclePayload(ctx), + SESSION_SAVE_TIMEOUT_MS, ); }); } diff --git a/crates/kimetsu-chat/assets/pi-skill.md b/crates/kimetsu-chat/assets/pi-skill.md new file mode 100644 index 0000000..85db798 --- /dev/null +++ b/crates/kimetsu-chat/assets/pi-skill.md @@ -0,0 +1,64 @@ +--- +name: kimetsu-brain +description: Use when Pi tasks benefit from prior session knowledge, durable lessons, memory corrections, or feedback on helpful memories. +--- +Kimetsu is a persistent memory sidecar accessed through the `kimetsu` CLI. +Run commands from the relevant project directory. If the binary is unavailable, +note the absence and continue normally. + +## Use the context already provided + +The Pi extension retrieves context before each task. Read that injection first. +When it covers the current question, proceed without repeating the same lookup. +Use `kimetsu brain context ""` when no useful context was +injected, the task changes, or a missing detail or correction needs fresh evidence. +An empty result is a reason to inspect the repository, not repeat the same query. + +Memory is evidence from earlier work. Check conflicts against current files and +the user's instructions. Respect project, environment, and version boundaries; +partial or conflicting evidence does not justify filling gaps with assumptions. + +## Record and correct durable lessons + +After verifying a reusable lesson, record it with +`kimetsu brain memory add --scope project --kind ""`. +Choose `fact`, `preference`, `convention`, `command`, or `failure_pattern`. +Include the subject and any environment/version limits in the text. + +For a correction to an existing claim, update its actual memory ID instead of +adding a contradictory duplicate: + +```sh +kimetsu brain memory edit --text "Production gateway port is 4000. Development gateway port remains 3000." +``` + +Preserve still-valid parts of the claim. Different environments or historical +versions can both be valid; a production correction does not retire development +guidance. When the entire memory is obsolete or false, use +`kimetsu brain memory invalidate --reason ""`. + +Find actual IDs with `kimetsu brain context "" --json` or +`kimetsu brain memory list --json`; match the text and scope before editing, +invalidating, or citing. For a `memory:` expansion handle, use only ``. +Never invent an ID or treat a file capsule as a memory. +The edit/invalidate commands above operate on the current workspace brain. +Listings may also include portable user-brain memories; those commands cannot +correct them. For a user-brain claim, report the correction and this limitation +instead of claiming the portable memory was updated. + +## Credit explicit usefulness + +When a particular memory materially helped, record that reliance: + +```sh +kimetsu brain cite --memory-id --query "" --note "" +``` + +Cite only memories actually used. Being injected, or having tests pass, is not +enough to credit a memory. A citation records usefulness, not proof of truth; +correct wrong claims using the commands above. Do not cite unused memories or +repeat credit for the same use. + +`kimetsu brain status` reports initialization, accepted memories, and pending +proposals. Automatic session saving is separate from these deliberate actions; +model-based lesson distillation requires a configured distiller. diff --git a/crates/kimetsu-chat/src/bridge.rs b/crates/kimetsu-chat/src/bridge.rs index 477952d..f41ce63 100644 --- a/crates/kimetsu-chat/src/bridge.rs +++ b/crates/kimetsu-chat/src/bridge.rs @@ -306,28 +306,8 @@ const PI_EXTENSION_TS: &str = include_str!("../assets/pi-extension.ts"); /// Pi skills are plain Markdown with optional YAML frontmatter. No MCP is /// available in Pi, so the skill describes the brain commands the agent can /// shell out to via `pi.exec()` or custom tools if wired. -const PI_SKILL_MD: &str = r#"--- -name: kimetsu-brain -description: Use Kimetsu brain shell commands as a persistent memory sidecar across Pi sessions. ---- -Kimetsu is a persistent brain sidecar accessible via the `kimetsu` CLI. Use it -when the task may benefit from prior session knowledge, workflow memory, or -durable cross-session context. - -Run `kimetsu brain context ` when you start a task and read the returned -capsules before deciding on a plan. An empty result means the brain held nothing -relevant and cost nothing — retrieving is cheaper than rediscovering. - -Run `kimetsu brain memory add --scope project --kind ""` once -you know something a later session would otherwise have to work out again. -Choose `fact`, `preference`, `convention`, `command`, or `failure_pattern` for -``. - -`kimetsu brain status` reports whether the brain is initialized, has accepted -memories, or has pending proposals. - -If the binary is unavailable, note the absence and continue normally. -"#; +/// The npm package vendors this same asset alongside the extension. +const PI_SKILL_MD: &str = include_str!("../assets/pi-skill.md"); #[cfg(feature = "openclaw")] /// TypeScript plugin installed at `/plugins/kimetsu/index.ts`. diff --git a/scripts/sync-pi-package.sh b/scripts/sync-pi-package.sh index 444492c..812c53f 100755 --- a/scripts/sync-pi-package.sh +++ b/scripts/sync-pi-package.sh @@ -33,4 +33,5 @@ copy() { echo "syncing $repo_root -> $target" copy "$repo_root/crates/kimetsu-chat/assets/pi-extension.ts" "$target/extensions/kimetsu.ts" +copy "$repo_root/crates/kimetsu-chat/assets/pi-skill.md" "$target/skills/kimetsu-brain/SKILL.md" echo "done. Commit the changes in $target."