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
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
development:
dependency-type: development
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
59 changes: 55 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,72 @@ on:
permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: test (${{ matrix.os }})
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
asset: kimetsu-2.8.0-x86_64-unknown-linux-gnu-lean.tar.gz
sha256: 08bba882de824bebfc33cdc33313459b086ba5b96f245cfa0159f0411084710b
- os: macos-15
asset: kimetsu-2.8.0-aarch64-apple-darwin-lean.tar.gz
sha256: 84e85f39efe8304254dbc9c2808520c4bb7466fced19d849bfe06f6857ff918e
- os: windows-latest
asset: kimetsu-2.8.0-x86_64-pc-windows-msvc-lean.zip
sha256: 4b6461139e2a4df9771a0ae99b3ac184259d4e42dcc100a2ee623edfa76d59aa
runs-on: ${{ matrix.os }}
env:
KIMETSU_INTEGRATION_REQUIRED: "1"
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
persist-credentials: false
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: "22"
node-version: "22.19.0"
cache: npm
- run: npm ci
- name: Install checksum-pinned Kimetsu CLI in runner temp
shell: pwsh
env:
CLI_ASSET: ${{ matrix.asset }}
CLI_SHA256: ${{ matrix.sha256 }}
run: |
$cliDir = Join-Path $env:RUNNER_TEMP "kimetsu-cli"
New-Item -ItemType Directory -Path $cliDir | Out-Null
$archive = Join-Path $cliDir $env:CLI_ASSET
Invoke-WebRequest "https://github.com/RodCor/kimetsu/releases/download/v2.8.0/$env:CLI_ASSET" -OutFile $archive
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $env:CLI_SHA256) { throw "Kimetsu archive checksum mismatch" }
if ($archive.EndsWith('.zip')) {
Expand-Archive -LiteralPath $archive -DestinationPath $cliDir
} else {
tar -xzf $archive -C $cliDir
if ($LASTEXITCODE -ne 0) { throw "Kimetsu archive extraction failed" }
}
$binaries = @(Get-ChildItem -LiteralPath $cliDir -Recurse -File | Where-Object { $_.Name -in @('kimetsu', 'kimetsu.exe') })
if ($binaries.Count -ne 1) { throw "Expected exactly one Kimetsu executable" }
"KIMETSU_TEST_BINARY=$($binaries[0].FullName)" >> $env:GITHUB_ENV
- run: npm run typecheck
- run: npm test
- run: npm audit --audit-level=high
- run: npm pack --dry-run

required-checks:
name: required checks
if: always()
needs: [test]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require every platform to pass
env:
TEST_RESULT: ${{ needs.test.result }}
run: test "$TEST_RESULT" = success
42 changes: 36 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,50 @@ on:
tags: ["v*"]
permissions:
contents: read
id-token: write

concurrency:
group: publish
cancel-in-progress: false

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
- name: Require a matching version from main with successful CI
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
shell: bash
run: |
test "$GITHUB_REF_NAME" = "v$(node -p "require('./package.json').version")"
commit=$(git rev-parse HEAD)
git merge-base --is-ancestor "$commit" origin/main
passed=$(gh api "repos/$GH_REPO/actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=$commit&per_page=100" --jq '[.workflow_runs[] | select(.conclusion == "success")] | length')
test "$passed" -gt 0 || { echo "::error::The tagged commit must have successful main-branch CI before publishing."; exit 1; }
publish:
needs: [validate]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
persist-credentials: false
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- run: npm ci
- name: Verify tag matches package version
shell: bash
run: test "$GITHUB_REF_NAME" = "v$(node -p "require('./package.json').version")"
- run: npm run typecheck
- run: npm test
- run: npm audit --audit-level=high
Expand Down
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Repository safeguards

- Keep `extensions/kimetsu.ts` and `skills/kimetsu-brain/SKILL.md` synchronized with
the canonical assets in `RodCor/kimetsu`. Changes affecting both need paired PRs.
- Tests that run the real Kimetsu CLI must use `tests/helpers/sandbox.ts`. Create
and verify an independent Git root before running any Kimetsu command. Use the
fixture environment for every subprocess, including subprocesses the extension
starts. Never run test `init`, config, memory, or migration commands against a
developer checkout or home brain.
- Keep test homes, user brains, caches, and transcripts under the fixture root.
Do not inherit credentials, Git redirection, or Kimetsu configuration overrides.
Check resolved cleanup paths before recursive deletion. Do not globally install
or upgrade Kimetsu to run tests.
- Before merging, run typecheck, the full suite including the real CLI integration,
the dependency audit, and package dry-run. CI must pass on all three platforms.
`npm run test:integration` fails if `KIMETSU_TEST_BINARY` is missing.
- Use PRs and the required checks for `main`. Do not bypass or weaken repository
protection to land a change. Keep Actions pinned to full commit SHAs and tokens
scoped to the jobs that need them. A release needs matching package/tag versions
and successful CI for the tagged commit on `main`.
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ extension points:
Pi lifecycle events (`session_start`, `before_agent_start`, `agent_end`,
`session_shutdown`) and shells
out to the `kimetsu` binary to warm, load, and persist brain context around each
session. Each call is capped by a timeout, so a slow or hung binary never stalls
Pi. If the binary is not on `PATH`, every hook silently no-ops and Pi is
session. Interactive hooks have a 10-second timeout; session saving has a
separate five-minute limit for model-based distillation. If the binary is not
on `PATH`, every hook silently no-ops and Pi is
completely unaffected.
- **Skill** (`skills/kimetsu-brain/SKILL.md`) — a Pi skill that teaches the agent
when and how to consult and record memories during a task.
Expand Down Expand Up @@ -86,6 +87,9 @@ pi install npm:kimetsu-pi
| --- | --- |
| `session_start` | `kimetsu brain warm` |
| `before_agent_start` | `kimetsu brain context-hook --warm-on-first-prompt` |
| `context` | Filters earlier Kimetsu injections out of each model request |
| `session_before_compact`, `session_before_tree` | Excludes Kimetsu injections from new summaries |
| `session_tree` | Expires the previous branch's active injection |
| `agent_end` | `kimetsu brain stop-hook` |
| `session_shutdown` | `kimetsu brain session-end-hook` |

Expand All @@ -94,14 +98,52 @@ The extension uses Pi's `SessionManager` identity, so `/new`, `/resume`, and
Pi's persisted JSONL transcript to the stop and session-end hooks, enabling
Kimetsu's configured distiller and automatic work-episode capture.

Only the current task's Kimetsu injection is sent to the model. Older injections
remain in the saved transcript but are filtered from subsequent model requests,
including when retrieval returns no context. Ordinary conversation and other
extensions' messages are preserved. Queued user messages expire the previous
injection; tool continuations keep it. New compaction and branch summaries omit
raw Kimetsu injections. This does not rewrite earlier assistant answers or
existing summaries.

Session saving can make two model calls, so it may take longer than retrieval.
Pi awaits it when closing or switching a session, for up to five minutes; it
normally finishes sooner. The longer deadline applies only to
`session-end-hook`. Prompt retrieval, warm-up, and `stop-hook` retain their
10-second limits. Saving remains best-effort and needs a clean shutdown.

The bundled skill teaches the agent to reuse relevant injected context, fetch
missing evidence, correct existing memories, and cite only memories that
actually helped. Run `kimetsu brain status` from the project directory to inspect
the brain. Automatic model-based distillation requires configuration.

## Development

```sh
npm install
npm ci
npm test
npm run typecheck
```

To run the optional CLI integration test, set `KIMETSU_TEST_BINARY` to the
absolute path of a Kimetsu 2.8.0+ executable named `kimetsu` (`kimetsu.exe` on
Windows), then run `npm run test:integration`. This command fails if the binary
is missing. It creates an isolated temporary brain, uses
lexical retrieval without model calls, and verifies correction, context
filtering, citation, and session persistence.

The fixture anchors a new Git repository and gives subprocesses temporary homes,
caches, and user-brain paths. It removes inherited Git/Kimetsu overrides and
credentials before initialization. Never run integration commands against your
normal project or home brain.

CI requires the real CLI test on Linux, macOS, and Windows using checksum-pinned
Kimetsu 2.8.0 binaries and the minimum supported Node version. The `required checks`
gate also requires typechecking, tests, the dependency audit, and package dry-run.
Release tags must match the package version and point to a commit on `main` with
successful CI. Wait for that commit's CI before tagging; a release started too
early must be rerun after CI passes.

## Links

- **Website & docs:** [kimetsu.dev](https://kimetsu.dev)
Expand Down
80 changes: 76 additions & 4 deletions extensions/kimetsu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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<string> {
function kimetsuRun(args: string[], input?: string, timeoutMs = EXEC_TIMEOUT_MS): Promise<string> {
return new Promise((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
Expand All @@ -52,7 +57,7 @@ function kimetsuRun(args: string[], input?: string): Promise<string> {
timer = setTimeout(() => {
child.kill();
done();
}, EXEC_TIMEOUT_MS);
}, timeoutMs);
if (typeof timer.unref === "function") timer.unref();

child.stdout?.setEncoding("utf8");
Expand Down Expand Up @@ -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"]);
});

Expand All @@ -161,25 +172,84 @@ 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 {
message: {
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(
Expand All @@ -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,
);
});
}
Loading