Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions crates/kimetsu-chat/assets/pi-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// crash, unparseable output. Kimetsu is a sidecar — it must never break Pi.

import { spawn } from "node:child_process";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

/** Hard cap on any single kimetsu invocation. A hung binary must not stall a turn. */
const EXEC_TIMEOUT_MS = 10000;
Expand Down Expand Up @@ -98,26 +99,59 @@ function parseAdditionalContext(stdout: string): string | undefined {

/** Best-effort session id from Pi's handler context, across naming variants. */
function sessionIdOf(ctx: any): string {
// Current Pi exposes the durable id through SessionManager. Prefer it over
// historical context-field variants so /new, /resume, and /fork each get a
// distinct Kimetsu session even when they happen in the same Pi process.
const getSessionId = ctx?.sessionManager?.getSessionId;
if (typeof getSessionId === "function") {
try {
const id = getSessionId.call(ctx.sessionManager);
if (typeof id === "string" && id.trim() !== "") return id;
} catch {
// A third-party/legacy SessionManager must not break the host.
}
}
const candidates = [ctx?.sessionId, ctx?.sessionID, ctx?.session_id, ctx?.session?.id];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
}
return FALLBACK_SESSION_ID;
}

/** Current Pi's persisted JSONL transcript, when the session is not ephemeral. */
function transcriptPathOf(ctx: any): string | undefined {
const getSessionFile = ctx?.sessionManager?.getSessionFile;
if (typeof getSessionFile !== "function") return undefined;
try {
const path = getSessionFile.call(ctx.sessionManager);
return typeof path === "string" && path.trim() !== "" ? path : undefined;
} catch {
return undefined;
}
}

/** Host-neutral hook payload understood by Kimetsu v2.7. */
function lifecyclePayload(ctx: any, transcript?: unknown[]): string {
const payload: Record<string, unknown> = { session_id: sessionIdOf(ctx) };
const transcriptPath = transcriptPathOf(ctx);
if (transcriptPath !== undefined) payload.transcript_path = transcriptPath;
else if (Array.isArray(transcript)) payload.transcript = transcript;
return JSON.stringify(payload);
}

/** `--workspace <cwd>` when Pi tells us the working directory, else nothing
* (the CLI then defaults to its own cwd). */
function workspaceArgs(ctx: any): string[] {
const cwd = ctx?.cwd;
return typeof cwd === "string" && cwd.trim() !== "" ? ["--workspace", cwd] : [];
}

export default function (pi: any) {
export default function (pi: ExtensionAPI) {
// 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: any, _ctx: any) => {
pi.on("session_start", async (_event, _ctx) => {
await kimetsuRun(["brain", "warm"]);
});

Expand All @@ -126,7 +160,7 @@ export default function (pi: any) {
// context is injected. Pi has no session-start context surface, so
// --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: any, ctx: any) => {
pi.on("before_agent_start", async (event, ctx) => {
const payload = JSON.stringify({
session_id: sessionIdOf(ctx),
prompt: typeof event?.prompt === "string" ? event.prompt : "",
Expand All @@ -147,12 +181,18 @@ export default function (pi: any) {
});

// agent_end fires after the LLM turn completes (maps to Kimetsu stop-hook).
pi.on("agent_end", async (_event: any, ctx: any) => {
await kimetsuRun(["brain", "stop-hook", ...workspaceArgs(ctx)]);
pi.on("agent_end", async (event, ctx) => {
await kimetsuRun(
["brain", "stop-hook", ...workspaceArgs(ctx)],
lifecyclePayload(ctx, event.messages),
);
});

// session_shutdown fires on clean session close (maps to session-end-hook).
pi.on("session_shutdown", async (_event: any, ctx: any) => {
await kimetsuRun(["brain", "session-end-hook", ...workspaceArgs(ctx)]);
pi.on("session_shutdown", async (_event, ctx) => {
await kimetsuRun(
["brain", "session-end-hook", ...workspaceArgs(ctx)],
lifecyclePayload(ctx),
);
});
}
25 changes: 22 additions & 3 deletions crates/kimetsu-chat/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,10 @@ Run `kimetsu brain context <query>` 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 record` once you know something a later session would
otherwise have to work out again, with a concrete, actionable lesson and 2-5
domain tags.
Run `kimetsu brain memory add --scope project --kind <kind> "<lesson>"` once
you know something a later session would otherwise have to work out again.
Choose `fact`, `preference`, `convention`, `command`, or `failure_pattern` for
`<kind>`.

`kimetsu brain status` reports whether the brain is initialized, has accepted
memories, or has pending proposals.
Expand Down Expand Up @@ -5234,9 +5235,27 @@ mod tests {
"silent no-op on missing binary"
);
assert!(ts.contains("session_start"), "hooks session_start");
assert!(
ts.contains("sessionManager?.getSessionId"),
"uses current Pi session identity"
);
assert!(
ts.contains("sessionManager?.getSessionFile"),
"passes Pi's persisted transcript to lifecycle hooks"
);
assert!(ts.contains("agent_end"), "hooks agent_end");
assert!(ts.contains("session_shutdown"), "hooks session_shutdown");

let skill = fs::read_to_string(pi.join("skills/kimetsu-brain/SKILL.md")).unwrap();
assert!(
skill.contains("kimetsu brain memory add --scope project"),
"skill documents the current v2.7 memory command"
);
assert!(
!skill.contains("kimetsu brain record"),
"skill must not teach a removed command"
);

fs::remove_dir_all(ws).ok();
}

Expand Down
Loading