From 682c7646bacfa0ea6fae733bd2c9e27b51bc0a73 Mon Sep 17 00:00:00 2001 From: RodCor Date: Thu, 10 Sep 2026 14:02:21 -0300 Subject: [PATCH 1/3] Refresh memory context and harden tests, CI, and publishing --- .github/dependabot.yml | 15 ++ .github/workflows/ci.yml | 59 +++++- .github/workflows/publish.yml | 42 ++++- AGENTS.md | 20 ++ README.md | 48 ++++- extensions/kimetsu.ts | 80 +++++++- package-lock.json | 300 +++++++++++++++++------------- package.json | 41 +++- scripts/test-integration.mjs | 8 + skills/kimetsu-brain/SKILL.md | 79 ++++++-- tests/extension.test.ts | 169 ++++++++++++++++- tests/helpers/sandbox.ts | 44 +++++ tests/kimetsu-integration.test.ts | 76 ++++++++ tests/pi-runtime.test.ts | 73 ++++++-- tests/sandbox.test.ts | 41 ++++ 15 files changed, 909 insertions(+), 186 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 AGENTS.md create mode 100644 scripts/test-integration.mjs create mode 100644 tests/helpers/sandbox.ts create mode 100644 tests/kimetsu-integration.test.ts create mode 100644 tests/sandbox.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..aa0851b --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e5bd51..4ff197c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 270aee2..8663aa2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5946994 --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index 627c6ee..2de110f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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` | @@ -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) diff --git a/extensions/kimetsu.ts b/extensions/kimetsu.ts index c342442..2d91dc4 100644 --- a/extensions/kimetsu.ts +++ b/extensions/kimetsu.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/package-lock.json b/package-lock.json index 2aeab0b..0c8f5a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@earendil-works/pi-coding-agent": "^0.84.2", "@types/node": "^26.0.0", "typescript": "^7.0.0", - "vitest": "^4.1.10" + "vitest": "4.1.11" }, "engines": { "node": ">=22.19.0" @@ -1974,26 +1974,43 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", - "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", - "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -2008,9 +2025,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -2025,9 +2042,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", - "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -2042,9 +2059,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", - "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -2059,9 +2076,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", - "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -2076,13 +2093,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", - "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2093,13 +2113,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", - "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2110,13 +2133,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", - "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2127,13 +2153,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", - "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2144,13 +2173,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", - "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2161,13 +2193,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", - "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2178,9 +2213,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", - "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -2195,9 +2230,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", - "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -2212,9 +2247,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", - "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -2618,16 +2653,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2636,13 +2671,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2663,9 +2698,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2676,13 +2711,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2690,14 +2725,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2706,9 +2741,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2716,13 +2751,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2970,6 +3005,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2991,6 +3029,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3012,6 +3053,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3033,6 +3077,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3146,9 +3193,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3159,9 +3206,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -3179,7 +3226,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3188,13 +3235,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", - "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.144.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3204,20 +3251,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.4", - "@rolldown/binding-darwin-arm64": "1.2.4", - "@rolldown/binding-darwin-x64": "1.2.4", - "@rolldown/binding-freebsd-x64": "1.2.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", - "@rolldown/binding-linux-arm64-gnu": "1.2.4", - "@rolldown/binding-linux-arm64-musl": "1.2.4", - "@rolldown/binding-linux-ppc64-gnu": "1.2.4", - "@rolldown/binding-linux-s390x-gnu": "1.2.4", - "@rolldown/binding-linux-x64-gnu": "1.2.4", - "@rolldown/binding-linux-x64-musl": "1.2.4", - "@rolldown/binding-openharmony-arm64": "1.2.4", - "@rolldown/binding-win32-arm64-msvc": "1.2.4", - "@rolldown/binding-win32-x64-msvc": "1.2.4" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/siginfo": { @@ -3338,16 +3386,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -3364,7 +3412,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3416,19 +3464,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3456,12 +3504,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index c25d366..4c657bd 100644 --- a/package.json +++ b/package.json @@ -2,27 +2,52 @@ "name": "kimetsu-pi", "version": "0.1.3", "description": "Kimetsu brain as a Pi.dev package — local-first, cross-session memory for the Pi coding agent that gets sharper every run.", - "keywords": ["pi-package", "pi", "kimetsu", "memory", "brain", "rag", "mcp", "extension", "skill"], + "keywords": [ + "pi-package", + "pi", + "kimetsu", + "memory", + "brain", + "rag", + "mcp", + "extension", + "skill" + ], "homepage": "https://kimetsu.dev", - "repository": { "type": "git", "url": "git+https://github.com/RodCor/kimetsu-pi.git" }, + "repository": { + "type": "git", + "url": "git+https://github.com/RodCor/kimetsu-pi.git" + }, "license": "MIT OR Apache-2.0", "type": "module", "pi": { - "extensions": ["./extensions"], - "skills": ["./skills"], + "extensions": [ + "./extensions" + ], + "skills": [ + "./skills" + ], "image": "https://raw.githubusercontent.com/RodCor/kimetsu/main/docs/assets/kimetsu-logo.png" }, - "files": ["extensions/", "skills/", "README.md", "LICENSE"], - "engines": { "node": ">=22.19.0" }, + "files": [ + "extensions/", + "skills/", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22.19.0" + }, "scripts": { "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:integration": "node scripts/test-integration.mjs" }, "devDependencies": { "@earendil-works/pi-coding-agent": "^0.84.2", "@types/node": "^26.0.0", "typescript": "^7.0.0", - "vitest": "^4.1.10" + "vitest": "4.1.11" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*" diff --git a/scripts/test-integration.mjs b/scripts/test-integration.mjs new file mode 100644 index 0000000..3698c07 --- /dev/null +++ b/scripts/test-integration.mjs @@ -0,0 +1,8 @@ +import { spawnSync } from "node:child_process"; + +const result = spawnSync(process.execPath, ["node_modules/vitest/vitest.mjs", "run", "tests/kimetsu-integration.test.ts"], { + stdio: "inherit", + env: { ...process.env, KIMETSU_INTEGRATION_REQUIRED: "1" }, +}); +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/skills/kimetsu-brain/SKILL.md b/skills/kimetsu-brain/SKILL.md index 48970b0..85db798 100644 --- a/skills/kimetsu-brain/SKILL.md +++ b/skills/kimetsu-brain/SKILL.md @@ -1,21 +1,64 @@ --- name: kimetsu-brain -description: Use Kimetsu brain shell commands as a persistent memory sidecar across Pi sessions. +description: Use when Pi tasks benefit from prior session knowledge, durable lessons, memory corrections, or feedback on helpful memories. --- -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. - -Brain-first workflow: -1. Before planning or editing broad coding, review, debugging, or setup tasks, - run `kimetsu brain context ` and read the returned capsules as working - context before deciding on a plan. -2. After solving a non-obvious problem, run - `kimetsu brain memory add --scope project --kind ""` with a - concrete, actionable lesson. Choose `fact`, `preference`, `convention`, - `command`, or `failure_pattern` for ``. -3. Run `kimetsu brain status` when you need to know whether the brain is - initialized, has accepted memories, or has pending proposals. - -Optional mode: Kimetsu brain context is a preferred first step for non-trivial -work. If the binary is unavailable, note the absence and continue normally. +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/tests/extension.test.ts b/tests/extension.test.ts index 7fb6a01..3322297 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Controllable spawn mock: each test sets the child's behaviour. const spawnMock = vi.fn(); @@ -59,6 +59,16 @@ function register() { return cbs; } +// Pi leaves messages unchanged when no context handler is registered. +async function modelMessages(cbs: ReturnType, messages: any[], ctx: any = {}) { + const result: any = await cbs["context"]?.({ messages }, ctx); + return result?.messages ?? messages; +} + +function persistedMessage(result: any) { + return { role: "custom", ...result.message, timestamp: 1 }; +} + const HOOK_OUTPUT = JSON.stringify({ continue: true, hookSpecificOutput: { @@ -71,8 +81,12 @@ beforeEach(() => { spawnMock.mockReset(); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("kimetsu pi extension", () => { - it("registers the four lifecycle handlers", () => { + it("registers lifecycle and context handlers", () => { spawnMock.mockImplementation(() => makeChild()); const registered: string[] = []; const pi = { on: (ev: string, _cb: unknown) => registered.push(ev) }; @@ -80,6 +94,10 @@ describe("kimetsu pi extension", () => { expect(registered).toEqual([ "session_start", "before_agent_start", + "context", + "session_before_compact", + "session_before_tree", + "session_tree", "agent_end", "session_shutdown", ]); @@ -162,6 +180,153 @@ describe("kimetsu pi extension", () => { expect(result?.message?.content).toContain("use thiserror"); }); + it("replaces earlier injections while preserving conversation and other extensions", async () => { + spawnMock.mockImplementationOnce(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const old = persistedMessage(await cbs["before_agent_start"]({ prompt: "old build guidance" }, {})); + spawnMock.mockImplementationOnce(() => respondingChild(HOOK_OUTPUT.replace("use thiserror", "use anyhow"))); + const current = persistedMessage(await cbs["before_agent_start"]({ prompt: "corrected build guidance" }, {})); + const user = { role: "user", content: "fix the build", timestamp: 2 }; + const other = { role: "custom", customType: "another-extension", content: "keep this", display: false, timestamp: 3 }; + const legacy = { role: "custom", customType: "kimetsu-brain", content: "legacy memory", display: false, timestamp: 0 }; + const history = [legacy, old, user, other, current]; + + // Pi passes a deep copy on every model call, including tool continuations. + expect(await modelMessages(cbs, structuredClone(history))).toEqual([user, other, current]); + expect(await modelMessages(cbs, structuredClone(history))).toEqual([user, other, current]); + expect(history).toEqual([legacy, old, user, other, current]); + }); + + it.each(["", "not hook JSON"])("removes old injections when the next retrieval returns %j", async (stdout) => { + spawnMock.mockImplementationOnce(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const old = persistedMessage(await cbs["before_agent_start"]({ prompt: "old build guidance" }, {})); + spawnMock.mockImplementationOnce(() => respondingChild(stdout)); + await cbs["before_agent_start"]({ prompt: "a different task" }, {}); + expect(await modelMessages(cbs, [old])).toEqual([]); + }); + + it("does not reactivate a prior injection when a session is resumed", async () => { + spawnMock.mockImplementation(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const ctx = { sessionId: "resumed-session" }; + const old = persistedMessage(await cbs["before_agent_start"]({ prompt: "old build guidance" }, ctx)); + await cbs["session_start"]({ reason: "resume" }, ctx); + expect(await modelMessages(cbs, [old], ctx)).toEqual([]); + }); + + it("does not carry an active injection into another session", async () => { + spawnMock.mockImplementation(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const old = persistedMessage(await cbs["before_agent_start"]({ prompt: "old build guidance" }, { sessionId: "one" })); + expect(await modelMessages(cbs, [old], { sessionId: "two" })).toEqual([]); + }); + + it("discards a pending retrieval after the session changes", async () => { + const pending = makeChild(); + spawnMock.mockImplementationOnce(() => pending).mockImplementationOnce(() => respondingChild("")); + const cbs = register(); + const result = cbs["before_agent_start"]({ prompt: "old build guidance" }, { sessionId: "one" }); + await cbs["session_start"]({ reason: "new" }, { sessionId: "two" }); + pending.stdoutHandlers["data"]?.(HOOK_OUTPUT); + pending.handlers["close"]?.(0); + await expect(result).resolves.toBeUndefined(); + }); + + it("expires the injection when a queued user message arrives between tool calls", async () => { + spawnMock.mockImplementation(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const current = persistedMessage(await cbs["before_agent_start"]({ prompt: "old build guidance" }, {})); + const tool = { role: "toolResult", toolCallId: "build", toolName: "bash", content: [{ type: "text", text: "passed" }], isError: false, timestamp: 2 }; + const correction = { role: "user", content: "That guidance is obsolete; use anyhow.", timestamp: 3 }; + expect(await modelMessages(cbs, [current, tool])).toEqual([current, tool]); + expect(await modelMessages(cbs, [current, tool, correction])).toEqual([tool, correction]); + // Expiry survives subsequent context trimming or an automatic retry. + expect(await modelMessages(cbs, [current, tool])).toEqual([tool]); + }); + + it("excludes memory injections from new compaction summaries without rewriting history", async () => { + spawnMock.mockImplementation(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const current = persistedMessage(await cbs["before_agent_start"]({ prompt: "build guidance" }, {})); + const user = { role: "user", content: "fix the build", timestamp: 2 }; + const other = { role: "custom", customType: "other-extension", content: "keep", display: false, timestamp: 3 }; + const history = [current, user, other]; + const preparation = { + messagesToSummarize: history, + turnPrefixMessages: [current, user], + previousSummary: "An existing summary is left intact.", + firstKeptEntryId: "kept-boundary", + }; + await cbs["session_before_compact"]?.({ preparation }, {}); + expect(preparation.messagesToSummarize).toEqual([user, other]); + expect(preparation.turnPrefixMessages).toEqual([user]); + expect(preparation.firstKeptEntryId).toBe("kept-boundary"); + expect(preparation.previousSummary).toBe("An existing summary is left intact."); + expect(history).toEqual([current, user, other]); + }); + + it("excludes injections from branch summaries and expires context on tree navigation", async () => { + spawnMock.mockImplementation(() => respondingChild(HOOK_OUTPUT)); + const cbs = register(); + const current = persistedMessage(await cbs["before_agent_start"]({ prompt: "build guidance" }, {})); + const memoryEntry = { type: "custom_message", id: "injection", customType: "kimetsu-brain", content: current.content }; + const userEntry = { type: "message", id: "user", message: { role: "user", content: "fix build" } }; + const otherEntry = { type: "custom_message", id: "other", customType: "other-extension", content: "keep" }; + const entries = [memoryEntry, userEntry, otherEntry]; + const preparation = { entriesToSummarize: [...entries], targetId: "user" }; + const summaryInput = preparation.entriesToSummarize; + await cbs["session_before_tree"]?.({ preparation }, {}); + expect(preparation.entriesToSummarize).toEqual([userEntry, otherEntry]); + expect(summaryInput).toEqual([userEntry, otherEntry]); + expect(entries).toEqual([memoryEntry, userEntry, otherEntry]); + await cbs["session_tree"]?.({ newLeafId: "user" }, {}); + expect(await modelMessages(cbs, [current])).toEqual([]); + }); + + it("allows session saving to finish after the retrieval deadline", async () => { + vi.useFakeTimers(); + const child = makeChild(); + spawnMock.mockImplementation(() => child); + const cbs = register(); + let completed = false; + const saving = cbs["session_shutdown"]({}, {}).then(() => { completed = true; }); + await vi.advanceTimersByTimeAsync(15_000); + expect(completed).toBe(false); + expect(child.kill).not.toHaveBeenCalled(); + child.handlers["close"]?.(0); + await saving; + expect(completed).toBe(true); + await vi.advanceTimersByTimeAsync(300_000); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("still bounds a hung session save", async () => { + vi.useFakeTimers(); + const child = makeChild(); + spawnMock.mockImplementation(() => child); + const cbs = register(); + let completed = false; + const saving = cbs["session_shutdown"]({}, {}).then(() => { completed = true; }); + await vi.advanceTimersByTimeAsync(299_999); + expect(completed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await saving; + expect(completed).toBe(true); + expect(child.kill).toHaveBeenCalledTimes(1); + }); + + it("keeps the short deadline for prompt retrieval", async () => { + vi.useFakeTimers(); + const child = makeChild(); + spawnMock.mockImplementation(() => child); + const cbs = register(); + const retrieving = cbs["before_agent_start"]({ prompt: "why is the build red" }, {}); + await vi.advanceTimersByTimeAsync(10_000); + await expect(retrieving).resolves.toBeUndefined(); + expect(child.kill).toHaveBeenCalledTimes(1); + }); + it("requests the first-turn warm start and passes the workspace", async () => { spawnMock.mockImplementation(() => respondingChild(HOOK_OUTPUT)); const cbs = register(); diff --git a/tests/helpers/sandbox.ts b/tests/helpers/sandbox.ts new file mode 100644 index 0000000..325ee6c --- /dev/null +++ b/tests/helpers/sandbox.ts @@ -0,0 +1,44 @@ +import { execFileSync } from "node:child_process"; +import { lstatSync, mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; + +export function createTestSandbox(parent: NodeJS.ProcessEnv = process.env) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "kimetsu-pi-test-"))); + const workspace = join(root, "project"); + mkdirSync(workspace); + // Allow only executable lookup and Windows process essentials. In particular, + // never inherit Git redirection, Kimetsu overrides, or provider credentials. + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(parent)) { + if (/^(PATH|SYSTEMROOT|WINDIR|COMSPEC|PATHEXT)$/i.test(key)) env[key.toUpperCase()] = value; + } + const home = join(root, "home"); + const temp = join(root, "tmp"); + for (const dir of [home, temp]) mkdirSync(dir); + Object.assign(env, { + HOME: home, USERPROFILE: home, + APPDATA: join(home, "AppData", "Roaming"), LOCALAPPDATA: join(home, "AppData", "Local"), + XDG_CONFIG_HOME: join(home, "config"), XDG_CACHE_HOME: join(home, "cache"), XDG_DATA_HOME: join(home, "data"), + TMPDIR: temp, TMP: temp, TEMP: temp, + GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(home, ".gitconfig"), GIT_CEILING_DIRECTORIES: root, + KIMETSU_USER_BRAIN_DIR: join(root, "user-brain"), KIMETSU_USER_BRAIN: "0", KIMETSU_EMBED_DAEMON: "0", + }); + const cleanup = () => { + if (!root.startsWith(realpathSync(tmpdir()) + sep) || lstatSync(root).isSymbolicLink()) throw new Error("Unsafe test cleanup path"); + rmSync(root, { recursive: true, force: true }); + }; + const assertWorkspace = () => { + if (realpathSync(workspace) !== workspace || !lstatSync(join(workspace, ".git")).isDirectory() || lstatSync(join(workspace, ".git")).isSymbolicLink()) throw new Error("Unsafe test workspace"); + const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: workspace, env, encoding: "utf8", windowsHide: true }).trim(); + if (realpathSync(gitRoot) !== realpathSync(workspace)) throw new Error("Unsafe test workspace"); + }; + try { + execFileSync("git", ["init", "--quiet", "--template=", workspace], { env, windowsHide: true }); + assertWorkspace(); + return { root, workspace, env, assertWorkspace, cleanup }; + } catch (error) { + cleanup(); + throw error; + } +} diff --git a/tests/kimetsu-integration.test.ts b/tests/kimetsu-integration.test.ts new file mode 100644 index 0000000..1cc3ae7 --- /dev/null +++ b/tests/kimetsu-integration.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { convertToLlm, SessionManager } from "@earendil-works/pi-coding-agent"; +import extension from "../extensions/kimetsu.ts"; +import { createTestSandbox } from "./helpers/sandbox.ts"; + +const binary = process.env.KIMETSU_TEST_BINARY; +if (process.env.KIMETSU_INTEGRATION_REQUIRED === "1" && !binary) { + throw new Error("KIMETSU_TEST_BINARY is required for the CLI integration gate"); +} +if (binary && !isAbsolute(binary)) throw new Error("KIMETSU_TEST_BINARY must be an absolute executable path"); +let sandbox: ReturnType | undefined; + +afterEach(() => { + vi.unstubAllEnvs(); + sandbox?.cleanup(); + sandbox = undefined; +}); + +describe.skipIf(!binary)("real Kimetsu CLI", () => { + it("retrieves a corrected memory and saves a Pi work episode in an isolated brain", async () => { + sandbox = createTestSandbox(); + const { root: scratch, workspace, env } = sandbox; + env.PATH = dirname(resolve(binary!)) + delimiter + env.PATH; + // The extension spawns the CLI itself. Isolate its inherited environment + // too; Vitest restores every key before fixture cleanup. + for (const key of new Set([...Object.keys(process.env), ...Object.keys(env)])) vi.stubEnv(key, env[key]); + const cli = (...args: string[]) => { + sandbox!.assertWorkspace(); + return execFileSync(binary!, args, { cwd: workspace, env, encoding: "utf8", timeout: 30_000, windowsHide: true }); + }; + cli("init"); + cli("config", "set", "embedder.enabled", "false"); + const oldText = "Orchid production gateway port is 3000."; + const correctedText = "Orchid production gateway port is 4000."; + cli("brain", "memory", "add", "--scope", "project", "--kind", "fact", oldText); + const rows = JSON.parse(cli("brain", "memory", "list", "--json")); + const memoryId = rows.find((row: { text: string }) => row.text === oldText)?.memory_id; + expect(memoryId).toBeTruthy(); + + const session = SessionManager.create(workspace, join(scratch, "sessions")); + const ctx = { cwd: workspace, sessionManager: session }; + const handlers: Record Promise> = {}; + extension({ on: (name: string, handler: any) => { handlers[name] = handler; } } as any); + const prompt = "What is the Orchid production gateway port?"; + session.appendMessage({ role: "user", content: prompt, timestamp: 1 }); + const first = await handlers.before_agent_start({ prompt }, ctx); + expect(first?.message?.content).toContain("3000"); + session.appendCustomMessageEntry(first.message.customType, first.message.content, false, first.message.details); + + cli("brain", "memory", "edit", memoryId, "--text", correctedText); + session.appendMessage({ role: "user", content: "The production configuration was corrected. " + prompt, timestamp: 2 }); + const second = await handlers.before_agent_start({ prompt }, ctx); + expect(second?.message?.content).toContain("4000"); + session.appendCustomMessageEntry(second.message.customType, second.message.content, false, second.message.details); + const history = session.buildSessionContext().messages; + const filtered = await handlers.context({ messages: structuredClone(history) }, ctx); + const modelInput = JSON.stringify(convertToLlm(filtered.messages)); + expect(modelInput).toContain("4000"); + expect(modelInput).not.toContain("3000"); + expect(JSON.stringify(history)).toContain("3000"); + + // Flush an actual Pi JSONL transcript for the session-end hook to consume. + session.appendMessage({ + role: "assistant", content: [{ type: "text", text: "Verified Orchid production gateway port is 4000." }], + api: "openai-responses", provider: "test", model: "test", stopReason: "stop", timestamp: 3, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + }); + cli("brain", "cite", "--memory-id", memoryId, "--query", prompt, "--note", "The corrected memory answered the port question."); + await handlers.session_shutdown({}, ctx); + const resume = cli("resume", "--task-id", session.getSessionId()); + expect(resume).toContain("Saved:"); + expect(resume).toContain("4000"); + }, 60_000); +}); diff --git a/tests/pi-runtime.test.ts b/tests/pi-runtime.test.ts index 345cc1f..ad2513f 100644 --- a/tests/pi-runtime.test.ts +++ b/tests/pi-runtime.test.ts @@ -1,31 +1,74 @@ import { describe, expect, it } from "vitest"; import { resolve } from "node:path"; -import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; +import { compact, DefaultResourceLoader, ExtensionRunner, SessionManager, type ModelRegistry } from "@earendil-works/pi-coding-agent"; + +async function loadExtension() { + const loader = new DefaultResourceLoader({ + cwd: process.cwd(), + agentDir: process.cwd(), + additionalExtensionPaths: [resolve("extensions/kimetsu.ts")], + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); + await loader.reload(); + return loader.getExtensions(); +} describe("current Pi runtime", () => { it("loads the extension and recognizes every lifecycle hook", async () => { - const extensionPath = resolve("extensions/kimetsu.ts"); - const loader = new DefaultResourceLoader({ - cwd: process.cwd(), - agentDir: process.cwd(), - additionalExtensionPaths: [extensionPath], - noExtensions: true, - noSkills: true, - noPromptTemplates: true, - noThemes: true, - noContextFiles: true, - }); - - await loader.reload(); - const loaded = loader.getExtensions(); + const loaded = await loadExtension(); expect(loaded.errors).toEqual([]); expect(loaded.extensions).toHaveLength(1); expect([...loaded.extensions[0].handlers.keys()]).toEqual([ "session_start", "before_agent_start", + "context", + "session_before_compact", + "session_before_tree", + "session_tree", "agent_end", "session_shutdown", ]); }); + + it("filters the real compaction preparation before Pi sends summary requests", async () => { + const loaded = await loadExtension(); + const session = SessionManager.inMemory(process.cwd()); + const runner = new ExtensionRunner(loaded.extensions, loaded.runtime, process.cwd(), session, {} as ModelRegistry); + const errors: unknown[] = []; + runner.onError((error) => errors.push(error)); + const oldMemory = { role: "custom" as const, customType: "kimetsu-brain", content: "OBSOLETE_PORT_3000", display: false, timestamp: 1 }; + const preparation: Parameters[0] = { + firstKeptEntryId: "keep-this-boundary", + messagesToSummarize: [oldMemory, { role: "user", content: "Verified production port is 4000.", timestamp: 2 }], + turnPrefixMessages: [oldMemory, { role: "user", content: "Keep working on the verified configuration.", timestamp: 3 }], + isSplitTurn: true, + tokensBefore: 4000, + fileOps: { read: new Set(["config.toml"]), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 1024, keepRecentTokens: 1024 }, + }; + await runner.emit({ type: "session_before_compact", preparation, branchEntries: [], reason: "manual", willRetry: false, signal: new AbortController().signal }); + + // Only the external model is replaced. Pi builds both real summary requests. + const requests: string[] = []; + const usage = { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }; + const streamFn = async (_model: unknown, context: { messages: unknown[] }) => { + requests.push(JSON.stringify(context.messages)); + const response = { role: "assistant", content: [{ type: "text", text: "Verified configuration summary." }], stopReason: "stop", timestamp: 4, usage }; + return { async *[Symbol.asyncIterator]() { yield { type: "done" }; }, result: async () => response }; + }; + const model = { id: "test", provider: "test", api: "openai-responses", maxTokens: 1024 } as Parameters[1]; + const result = await compact(preparation, model, "", undefined, undefined, undefined, undefined, streamFn as unknown as Parameters[7]); + expect(errors).toEqual([]); + expect(requests).toHaveLength(2); + expect(requests.join("\n")).not.toContain("OBSOLETE_PORT_3000"); + expect(requests[0]).toContain("Verified production port is 4000."); + expect(requests[1]).toContain("Keep working on the verified configuration."); + expect(result.firstKeptEntryId).toBe("keep-this-boundary"); + expect(result.summary).toContain("config.toml"); + }); }); diff --git a/tests/sandbox.test.ts b/tests/sandbox.test.ts new file mode 100644 index 0000000..ec147a5 --- /dev/null +++ b/tests/sandbox.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, it } from "vitest"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { createTestSandbox } from "./helpers/sandbox.ts"; + +const fixtures: ReturnType[] = []; +afterEach(() => { for (const fixture of fixtures.splice(0).reverse()) fixture.cleanup(); }); +const fixture = (parent?: NodeJS.ProcessEnv) => { + const sandbox = createTestSandbox(parent); + fixtures.push(sandbox); + return sandbox; +}; + +it("keeps subprocess homes, caches, Git configuration and credentials inside the test boundary", () => { + const sandbox = fixture({ PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, OPENAI_API_KEY: "test-secret", KIMETSU_TEST_OVERRIDE: "outside" }); + expect(sandbox.env.OPENAI_API_KEY).toBeUndefined(); + expect(sandbox.env.KIMETSU_TEST_OVERRIDE).toBeUndefined(); + for (const key of ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "KIMETSU_USER_BRAIN_DIR"]) { + expect(sandbox.env[key]).toContain(sandbox.root); + } +}); + +it("ignores inherited Git redirection before initialization and leaves the other repository intact", () => { + const outside = fixture(); + const config = join(outside.workspace, ".git", "config"); + const before = readFileSync(config); + const sentinel = join(outside.workspace, "keep.txt"); + writeFileSync(sentinel, "original"); + const sandbox = fixture({ ...process.env, GIT_DIR: join(outside.workspace, ".git"), GIT_WORK_TREE: outside.workspace }); + expect(existsSync(join(sandbox.workspace, ".git", "config"))).toBe(true); + expect(readFileSync(config)).toEqual(before); + expect(readFileSync(sentinel, "utf8")).toBe("original"); +}); + +it("refuses ancestor repository discovery if the workspace Git anchor disappears", () => { + const sandbox = fixture(); + execFileSync("git", ["init", "--quiet", sandbox.root], { env: sandbox.env, windowsHide: true }); + renameSync(join(sandbox.workspace, ".git"), join(sandbox.workspace, "git-backup")); + expect(() => sandbox.assertWorkspace()).toThrow(); +}); From 0454ede8b0abd884fe458a62891984b4ad0d1d2a Mon Sep 17 00:00:00 2001 From: RodCor Date: Thu, 10 Sep 2026 14:05:20 -0300 Subject: [PATCH 2/3] Keep CLI fixtures portable and finish maintenance before cleanup --- tests/helpers/sandbox.ts | 6 ++++-- tests/kimetsu-integration.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/helpers/sandbox.ts b/tests/helpers/sandbox.ts index 325ee6c..47fdf9e 100644 --- a/tests/helpers/sandbox.ts +++ b/tests/helpers/sandbox.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { lstatSync, mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, sep } from "node:path"; +import { join, relative, sep } from "node:path"; export function createTestSandbox(parent: NodeJS.ProcessEnv = process.env) { const root = realpathSync(mkdtempSync(join(tmpdir(), "kimetsu-pi-test-"))); @@ -31,7 +31,9 @@ export function createTestSandbox(parent: NodeJS.ProcessEnv = process.env) { const assertWorkspace = () => { if (realpathSync(workspace) !== workspace || !lstatSync(join(workspace, ".git")).isDirectory() || lstatSync(join(workspace, ".git")).isSymbolicLink()) throw new Error("Unsafe test workspace"); const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: workspace, env, encoding: "utf8", windowsHide: true }).trim(); - if (realpathSync(gitRoot) !== realpathSync(workspace)) throw new Error("Unsafe test workspace"); + // Git and Node may disagree on the drive letter's case on Windows. + // path.relative compares paths using the current platform's semantics. + if (relative(realpathSync(gitRoot), realpathSync(workspace)) !== "") throw new Error("Unsafe test workspace"); }; try { execFileSync("git", ["init", "--quiet", "--template=", workspace], { env, windowsHide: true }); diff --git a/tests/kimetsu-integration.test.ts b/tests/kimetsu-integration.test.ts index 1cc3ae7..36732c2 100644 --- a/tests/kimetsu-integration.test.ts +++ b/tests/kimetsu-integration.test.ts @@ -32,6 +32,11 @@ describe.skipIf(!binary)("real Kimetsu CLI", () => { }; cli("init"); cli("config", "set", "embedder.enabled", "false"); + // Session-end launches overdue upkeep detached. Run it synchronously first + // so no background writer can race fixture cleanup after the hook exits. + const upkeep = JSON.parse(cli("brain", "maintain", "--json")); + expect(upkeep.length).toBeGreaterThan(0); + expect(upkeep.every((pass: { ok: boolean }) => pass.ok)).toBe(true); const oldText = "Orchid production gateway port is 3000."; const correctedText = "Orchid production gateway port is 4000."; cli("brain", "memory", "add", "--scope", "project", "--kind", "fact", oldText); @@ -68,6 +73,8 @@ describe.skipIf(!binary)("real Kimetsu CLI", () => { usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, }); cli("brain", "cite", "--memory-id", memoryId, "--query", prompt, "--note", "The corrected memory answered the port question."); + const schedule = JSON.parse(cli("brain", "maintain", "--status", "--json")); + expect(schedule.every((pass: { due: boolean }) => !pass.due)).toBe(true); await handlers.session_shutdown({}, ctx); const resume = cli("resume", "--task-id", session.getSessionId()); expect(resume).toContain("Saved:"); From 4b077484929002d5c70f9df75ee1551e94c37b89 Mon Sep 17 00:00:00 2001 From: RodCor Date: Thu, 10 Sep 2026 14:07:47 -0300 Subject: [PATCH 3/3] Resolve Windows short path aliases before fixture checks --- tests/helpers/sandbox.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/helpers/sandbox.ts b/tests/helpers/sandbox.ts index 47fdf9e..3ae85f6 100644 --- a/tests/helpers/sandbox.ts +++ b/tests/helpers/sandbox.ts @@ -4,7 +4,9 @@ import { tmpdir } from "node:os"; import { join, relative, sep } from "node:path"; export function createTestSandbox(parent: NodeJS.ProcessEnv = process.env) { - const root = realpathSync(mkdtempSync(join(tmpdir(), "kimetsu-pi-test-"))); + // The native Windows resolver expands 8.3 aliases (e.g. RUNNER~1) too. + const realPath = realpathSync.native; + const root = realPath(mkdtempSync(join(tmpdir(), "kimetsu-pi-test-"))); const workspace = join(root, "project"); mkdirSync(workspace); // Allow only executable lookup and Windows process essentials. In particular, @@ -25,15 +27,15 @@ export function createTestSandbox(parent: NodeJS.ProcessEnv = process.env) { KIMETSU_USER_BRAIN_DIR: join(root, "user-brain"), KIMETSU_USER_BRAIN: "0", KIMETSU_EMBED_DAEMON: "0", }); const cleanup = () => { - if (!root.startsWith(realpathSync(tmpdir()) + sep) || lstatSync(root).isSymbolicLink()) throw new Error("Unsafe test cleanup path"); + if (!root.startsWith(realPath(tmpdir()) + sep) || lstatSync(root).isSymbolicLink()) throw new Error("Unsafe test cleanup path"); rmSync(root, { recursive: true, force: true }); }; const assertWorkspace = () => { - if (realpathSync(workspace) !== workspace || !lstatSync(join(workspace, ".git")).isDirectory() || lstatSync(join(workspace, ".git")).isSymbolicLink()) throw new Error("Unsafe test workspace"); + if (realPath(workspace) !== workspace || !lstatSync(join(workspace, ".git")).isDirectory() || lstatSync(join(workspace, ".git")).isSymbolicLink()) throw new Error("Unsafe test workspace"); const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: workspace, env, encoding: "utf8", windowsHide: true }).trim(); // Git and Node may disagree on the drive letter's case on Windows. // path.relative compares paths using the current platform's semantics. - if (relative(realpathSync(gitRoot), realpathSync(workspace)) !== "") throw new Error("Unsafe test workspace"); + if (relative(realPath(gitRoot), realPath(workspace)) !== "") throw new Error(`Unsafe test workspace: Git resolved ${gitRoot}, expected ${workspace}`); }; try { execFileSync("git", ["init", "--quiet", "--template=", workspace], { env, windowsHide: true });