kimetsu-pi 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Added
6
+
7
+ - Support for the Kimetsu 2.11.0 peer network. Pi tools `kimetsu_network_status`
8
+ and `kimetsu_network_request`, plus `/kimetsu-network-status`,
9
+ `/kimetsu-network-request`, and `/kimetsu-network-share`.
10
+ - A `kimetsu --version` check before any network command. Older binaries and
11
+ builds without the `network` feature explain the gap. Nothing listens in
12
+ that case.
13
+ - Sharing stays a terminal command. The extension never starts
14
+ `kimetsu network share`. `/kimetsu-network-share` only prints it. The relay
15
+ stays off, and only a signed, scrubbed repo pack is sent.
16
+ - Request passes `--no-mdns` unless discovery is requested, and `--map-ports`
17
+ only when asked. The process is killed on the 60-second deadline or when Pi
18
+ cancels the call.
19
+
20
+ ### Security
21
+
22
+ - Pin the development-only `undici` dependency to 8.12.4 so the audit gate
23
+ clears the high-severity advisories in 8.12.1 and earlier. Pi itself stays
24
+ on 1.2.0.
25
+
26
+ ## 0.1.5
27
+
28
+ - Pi 1.2.0 compatibility for custom-triggered turns, and integration coverage
29
+ against the Kimetsu 2.8.0 CLI.
30
+
31
+ ## 0.1.4
32
+
33
+ - Fresh memory per task, a five-minute session-save deadline, and guidance for
34
+ corrections and usefulness feedback.
package/README.md CHANGED
@@ -30,10 +30,31 @@ This package connects Kimetsu through Pi's native extension and skill interfaces
30
30
  on `PATH`, every hook silently no-ops and Pi is
31
31
  completely unaffected.
32
32
  - **Skill** (`skills/kimetsu-brain/SKILL.md`) — a Pi skill that teaches the agent
33
- to reuse injected context, correct outdated memories, and cite memories that
34
- actually helped during a task.
33
+ to reuse injected context, correct outdated memories, cite memories that
34
+ actually helped during a task, and use the peer memory network only when
35
+ asked.
35
36
 
36
- ## What's new in 0.1.4
37
+ ## What's new in 0.2.0
38
+
39
+ - Peer network tools for Kimetsu 2.11.0: `kimetsu_network_status` and
40
+ `kimetsu_network_request`, plus `/kimetsu-network-status`,
41
+ `/kimetsu-network-request`, and `/kimetsu-network-share`.
42
+ - `kimetsu --version` is checked before any network command. Older binaries,
43
+ and builds compiled without `network`, explain the gap and leave every
44
+ socket closed.
45
+ - Sharing stays a terminal command. The extension never starts it. Request
46
+ listens only while that call runs, with the relay off, discovery off unless
47
+ asked, and router port mapping off unless asked.
48
+
49
+ ## Previously in 0.1.5
50
+
51
+ - Pi 1.2.0 compatibility: custom-triggered turns expire old memory without
52
+ treating background notifications as user prompts.
53
+ - Integration tests exercise Pi's real loader, event runner, model-message
54
+ conversion, and session saving with the Kimetsu 2.8.0 CLI in isolated fixtures.
55
+ - Refreshed development dependencies and removed the source-map-js advisory.
56
+
57
+ ## Previously in 0.1.4
37
58
 
38
59
  - Fresh memory per task: older raw injections are excluded from model requests
39
60
  and new summaries, including after corrections, queued user messages, and
@@ -69,8 +90,10 @@ Install current Pi (Node.js 22.19 or newer):
69
90
  npm install -g @earendil-works/pi-coding-agent
70
91
  ```
71
92
 
72
- The `kimetsu` binary must be on `PATH`. Kimetsu 2.8.0 is the tested release for
73
- this package; the CLI integration requires at least 2.7.0. Install it with:
93
+ The `kimetsu` binary must be on `PATH`. Memory hooks work on Kimetsu 2.7.0 and
94
+ newer. This package targets Kimetsu 2.11.0 for the peer network; the extension
95
+ checks `kimetsu --version` and skips network commands on anything older.
96
+ Install Kimetsu with:
74
97
 
75
98
  ```sh
76
99
  npm install -g kimetsu-ai
@@ -109,7 +132,7 @@ pi update npm:kimetsu-pi
109
132
  For a pinned installation, select this version explicitly:
110
133
 
111
134
  ```sh
112
- pi install npm:kimetsu-pi@0.1.4
135
+ pi install npm:kimetsu-pi@0.2.0
113
136
  ```
114
137
 
115
138
  Then restart Pi or run `/reload`. Pi package updates and Kimetsu CLI updates are
@@ -151,6 +174,59 @@ missing evidence, correct existing memories, and cite only memories that
151
174
  actually helped. Run `kimetsu brain status` from the project directory to inspect
152
175
  the brain. Automatic model-based distillation requires configuration.
153
176
 
177
+ ## Peer network
178
+
179
+ Kimetsu 2.11.0 adds a peer memory network to release and npm builds. Pi has no
180
+ MCP, so this package shells out to the local `kimetsu` binary. Nothing listens
181
+ until `share` or `request` runs. The relay stays off. Packs are signed and
182
+ scrubbed, and only the repo mount is exchanged. An unknown signer is
183
+ quarantined and stays out of retrieval.
184
+
185
+ Create a network on one machine, then join it on another. These commands write
186
+ keys and do not open a socket:
187
+
188
+ ```sh
189
+ kimetsu network invite
190
+ kimetsu network join <code>
191
+ kimetsu network status
192
+ kimetsu network leave
193
+ ```
194
+
195
+ `invite` prints a join code. The code does not include the admin key. `status`
196
+ reports whether this machine has joined and always leaves listening off.
197
+
198
+ To send memories, the user runs this in a terminal from the project directory
199
+ and leaves it running until the other machine has fetched the pack:
200
+
201
+ ```sh
202
+ kimetsu network share
203
+ ```
204
+
205
+ That process listens until Ctrl-C. It prints a `peer:` line
206
+ (`endpoint-id@host:port`). The extension does not run `share` for you.
207
+ `/kimetsu-network-share` prints the same instructions and does not open a
208
+ socket. Add `--map-ports` only when you want UPnP or NAT-PMP to open a UDP
209
+ port. That stays off by default.
210
+
211
+ On the machine that should receive the pack, either ask Pi to call
212
+ `kimetsu_network_request` or run:
213
+
214
+ ```sh
215
+ /kimetsu-network-request <endpoint-id>@host:port
216
+ ```
217
+
218
+ The same fetch from a shell, with discovery and port mapping left off:
219
+
220
+ ```sh
221
+ kimetsu network request --json --no-mdns --peer <endpoint-id>@host:port
222
+ ```
223
+
224
+ `kimetsu_network_status` is the read-only check. Both tools no-op, with an
225
+ explanation, when `kimetsu --version` is older than 2.11.0 or when
226
+ `kimetsu network status --json` reports `"compiled": false`. A request is
227
+ killed if it runs longer than 60 seconds or if Pi cancels the call, so the
228
+ socket does not outlive the command.
229
+
154
230
  ## Development
155
231
 
156
232
  ```sh
@@ -160,11 +236,14 @@ npm run typecheck
160
236
  ```
161
237
 
162
238
  To run the optional CLI integration test, set `KIMETSU_TEST_BINARY` to the
163
- absolute path of a Kimetsu 2.8.0+ executable named `kimetsu` (`kimetsu.exe` on
239
+ absolute path of a Kimetsu executable named `kimetsu` (`kimetsu.exe` on
164
240
  Windows), then run `npm run test:integration`. This command fails if the binary
165
241
  is missing. It creates an isolated temporary brain, uses
166
242
  lexical retrieval without model calls, and verifies correction, context
167
- filtering, citation, and session persistence.
243
+ filtering, citation, and session persistence. It also runs the network status
244
+ and request tools. A binary older than 2.11.0 must degrade without listening.
245
+ A 2.11.0 or newer binary must report `listening: false` from
246
+ `kimetsu network status --json`.
168
247
 
169
248
  The fixture anchors a new Git repository and gives subprocesses temporary homes,
170
249
  caches, and user-brain paths. It removes inherited Git/Kimetsu overrides and
@@ -172,7 +251,10 @@ credentials before initialization. Never run integration commands against your
172
251
  normal project or home brain.
173
252
 
174
253
  CI requires the real CLI test on Linux, macOS, and Windows using checksum-pinned
175
- Kimetsu 2.8.0 binaries and the minimum supported Node version. The `required checks`
254
+ Kimetsu 2.8.0 binaries (the latest published release) and the minimum supported
255
+ Node version. Those binaries have no `network` command, so CI covers the
256
+ degradation path. Repin the workflow assets to 2.11.0 after that release is
257
+ published if the capable path should run in CI. The `required checks`
176
258
  gate also requires typechecking, tests, the dependency audit, and package dry-run.
177
259
  Release tags must match the package version and point to a commit on `main` with
178
260
  successful CI. Wait for that commit's CI before tagging; a release started too
@@ -10,12 +10,20 @@
10
10
  // the hook payload goes in on stdin, the `additionalContext` block comes back
11
11
  // on stdout, and Pi carries it into the turn as a context message.
12
12
  //
13
- // Every failure mode is a silent no-op: a missing binary, a hung binary, a
14
- // crash, unparseable output. Kimetsu is a sidecar — it must never break Pi.
13
+ // Every failure mode of a lifecycle hook is a silent no-op: a missing binary,
14
+ // a hung binary, a crash, unparseable output. Kimetsu is a sidecar — it must
15
+ // never break Pi.
16
+ //
17
+ // The peer network is separate. Pi has no MCP, so `kimetsu_network_status`
18
+ // and `kimetsu_network_request` shell out to the local binary. They require
19
+ // Kimetsu 2.11.0 and a build with the `network` feature. Anything older
20
+ // degrades with an explanation. Sharing is never started from here: it
21
+ // listens until the user stops it, and that stays a terminal command.
15
22
 
16
23
  import { spawn } from "node:child_process";
17
24
  import { randomUUID } from "node:crypto";
18
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { Type } from "typebox";
19
27
 
20
28
  /** Interactive hooks must not leave a turn waiting on a hung binary. */
21
29
  const EXEC_TIMEOUT_MS = 10000;
@@ -151,6 +159,374 @@ function workspaceArgs(ctx: any): string[] {
151
159
  return typeof cwd === "string" && cwd.trim() !== "" ? ["--workspace", cwd] : [];
152
160
  }
153
161
 
162
+ /** First Kimetsu release that ships `kimetsu network` in release builds. */
163
+ const NETWORK_MIN_VERSION: readonly [number, number, number] = [2, 11, 0];
164
+
165
+ /** `request` exchanges announcements for up to 20s, then transfers one pack. */
166
+ const NETWORK_REQUEST_TIMEOUT_MS = 60_000;
167
+
168
+ const SHARE_INSTRUCTIONS = [
169
+ "Sharing stays off until you run it yourself. In this project directory, run: kimetsu network share",
170
+ "That command listens until you press Ctrl-C. The relay stays off. Only this repo's memories are sent, after they are signed and scrubbed. Do not add --map-ports unless you want the router to open a UDP port. Nothing is listening now.",
171
+ ].join("\n");
172
+
173
+ type Capture = {
174
+ stdout: string;
175
+ stderr: string;
176
+ code: number | null;
177
+ timedOut: boolean;
178
+ aborted: boolean;
179
+ };
180
+
181
+ type StatusDetails = {
182
+ available: boolean;
183
+ compiled: boolean | null;
184
+ joined: boolean;
185
+ listening: boolean;
186
+ group: string | null;
187
+ role: string | null;
188
+ generation: number | null;
189
+ endpoint_id: string | null;
190
+ message: string;
191
+ };
192
+
193
+ type RequestDetails = {
194
+ available: boolean;
195
+ listening: boolean;
196
+ verified: boolean;
197
+ imported: number;
198
+ deduped: number;
199
+ quarantined: number;
200
+ superseded: number;
201
+ digest: string | null;
202
+ message: string;
203
+ };
204
+
205
+ type ToolResult<T> = {
206
+ content: [{ type: "text"; text: string }];
207
+ details: T;
208
+ structuredContent: T;
209
+ isError?: boolean;
210
+ };
211
+
212
+ /**
213
+ * Run `kimetsu <args>` and keep stdout, stderr, and the exit code.
214
+ * stdin is ignored. A timeout or abort kills the child so a request
215
+ * cannot keep a socket open after Pi has moved on.
216
+ */
217
+ function kimetsuCapture(args: string[], timeoutMs: number, signal?: AbortSignal): Promise<Capture> {
218
+ if (signal?.aborted) {
219
+ return Promise.resolve({ stdout: "", stderr: "", code: null, timedOut: false, aborted: true });
220
+ }
221
+ return new Promise((resolve) => {
222
+ let settled = false;
223
+ let timer: ReturnType<typeof setTimeout> | undefined;
224
+ let stdout = "";
225
+ let stderr = "";
226
+ let timedOut = false;
227
+ let aborted = false;
228
+ const finish = (code: number | null) => {
229
+ if (settled) return;
230
+ settled = true;
231
+ if (timer !== undefined) clearTimeout(timer);
232
+ if (signal !== undefined) signal.removeEventListener("abort", onAbort);
233
+ resolve({ stdout, stderr, code, timedOut, aborted });
234
+ };
235
+ const onAbort = () => {
236
+ aborted = true;
237
+ child.kill();
238
+ finish(null);
239
+ };
240
+ let child: ReturnType<typeof spawn>;
241
+ try {
242
+ child = spawn("kimetsu", args, {
243
+ stdio: ["ignore", "pipe", "pipe"],
244
+ shell: false,
245
+ windowsHide: true,
246
+ });
247
+ timer = setTimeout(() => {
248
+ timedOut = true;
249
+ child.kill();
250
+ finish(null);
251
+ }, timeoutMs);
252
+ if (typeof timer.unref === "function") timer.unref();
253
+ signal?.addEventListener("abort", onAbort, { once: true });
254
+ child.stdout?.setEncoding("utf8");
255
+ child.stderr?.setEncoding("utf8");
256
+ child.stdout?.on("data", (chunk: string) => { stdout += chunk; });
257
+ child.stderr?.on("data", (chunk: string) => { stderr += chunk; });
258
+ child.stdout?.on("error", () => {});
259
+ child.stderr?.on("error", () => {});
260
+ child.on("error", () => finish(null));
261
+ child.on("close", (code) => finish(typeof code === "number" ? code : 0));
262
+ } catch {
263
+ finish(null);
264
+ }
265
+ });
266
+ }
267
+
268
+ function parseVersion(output: string): { numbers: [number, number, number]; label: string } | undefined {
269
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)(\s*\([^)]*\))?/);
270
+ if (match === null) return undefined;
271
+ return {
272
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
273
+ label: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}`,
274
+ };
275
+ }
276
+
277
+ function versionAtLeast(version: readonly [number, number, number], min: readonly [number, number, number]): boolean {
278
+ for (let index = 0; index < 3; index += 1) {
279
+ if (version[index] !== min[index]) return version[index] > min[index];
280
+ }
281
+ return true;
282
+ }
283
+
284
+ function parseLastJson(stdout: string): Record<string, unknown> | undefined {
285
+ const lines = stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "").reverse();
286
+ for (const line of lines) {
287
+ try {
288
+ const parsed = JSON.parse(line);
289
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
290
+ } catch {
291
+ // Keep looking at earlier lines.
292
+ }
293
+ }
294
+ return undefined;
295
+ }
296
+
297
+ function textResult<T extends { message: string }>(details: T, isError = false): ToolResult<T> {
298
+ return {
299
+ content: [{ type: "text", text: details.message }],
300
+ details,
301
+ structuredContent: details,
302
+ ...(isError ? { isError: true } : {}),
303
+ };
304
+ }
305
+
306
+ function emptyStatus(message: string, compiled: boolean | null = null): StatusDetails {
307
+ return {
308
+ available: false,
309
+ compiled,
310
+ joined: false,
311
+ listening: false,
312
+ group: null,
313
+ role: null,
314
+ generation: null,
315
+ endpoint_id: null,
316
+ message,
317
+ };
318
+ }
319
+
320
+ function emptyRequest(message: string): RequestDetails {
321
+ return {
322
+ available: false,
323
+ listening: false,
324
+ verified: false,
325
+ imported: 0,
326
+ deduped: 0,
327
+ quarantined: 0,
328
+ superseded: 0,
329
+ digest: null,
330
+ message,
331
+ };
332
+ }
333
+
334
+ function commandFailure(captured: Capture, fallback: string): string {
335
+ const text = `${captured.stderr}\n${captured.stdout}`
336
+ .split("\n")
337
+ .map((line) => line.trim())
338
+ .filter((line) => line !== "")
339
+ .join(" ");
340
+ if (text === "") return fallback;
341
+ if (/nothing is listening|nothing was contacted/i.test(text)) return text;
342
+ return `${text} Nothing is listening.`;
343
+ }
344
+
345
+ function normalizePeer(peer: string): string | undefined {
346
+ const value = peer.trim();
347
+ const at = value.indexOf("@");
348
+ if (at <= 0 || at !== value.lastIndexOf("@") || at === value.length - 1) return undefined;
349
+ if (/\s/.test(value)) return undefined;
350
+ const id = value.slice(0, at);
351
+ const address = value.slice(at + 1);
352
+ if (id.startsWith("-") || address.startsWith("-")) return undefined;
353
+ return value;
354
+ }
355
+
356
+ function asNullableString(value: unknown): string | null {
357
+ return typeof value === "string" && value.trim() !== "" ? value : null;
358
+ }
359
+
360
+ function asCount(value: unknown): number {
361
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
362
+ }
363
+
364
+ type Gate =
365
+ | { ok: true; status: StatusDetails }
366
+ | { ok: false; status: StatusDetails };
367
+
368
+ /** Version gate, then `network status --json`. Status never opens a socket. */
369
+ async function networkGate(signal?: AbortSignal): Promise<Gate> {
370
+ const version = await kimetsuCapture(["--version"], EXEC_TIMEOUT_MS, signal);
371
+ if (version.aborted) {
372
+ return { ok: false, status: emptyStatus("The network check was cancelled. Nothing is listening.") };
373
+ }
374
+ if (version.timedOut) {
375
+ return { ok: false, status: emptyStatus("Kimetsu did not respond and was stopped. Nothing is listening.") };
376
+ }
377
+ if (version.code === null) {
378
+ return { ok: false, status: emptyStatus("Kimetsu is not on PATH. Nothing is listening.") };
379
+ }
380
+ const parsed = parseVersion(`${version.stdout}\n${version.stderr}`);
381
+ if (parsed !== undefined && !versionAtLeast(parsed.numbers, NETWORK_MIN_VERSION)) {
382
+ return {
383
+ ok: false,
384
+ status: emptyStatus(
385
+ `Peer network needs Kimetsu ${NETWORK_MIN_VERSION.join(".")} or newer. This binary is ${parsed.label}. Nothing is listening.`,
386
+ ),
387
+ };
388
+ }
389
+ if (version.code !== 0 && parsed === undefined) {
390
+ return { ok: false, status: emptyStatus(commandFailure(version, "Kimetsu is not on PATH. Nothing is listening.")) };
391
+ }
392
+ const status = await kimetsuCapture(["network", "status", "--json"], EXEC_TIMEOUT_MS, signal);
393
+ if (status.aborted) {
394
+ return { ok: false, status: emptyStatus("The network check was cancelled. Nothing is listening.") };
395
+ }
396
+ if (status.timedOut) {
397
+ return { ok: false, status: emptyStatus("Kimetsu did not respond and was stopped. Nothing is listening.") };
398
+ }
399
+ if (status.code === null) {
400
+ return { ok: false, status: emptyStatus("Kimetsu is not on PATH. Nothing is listening.") };
401
+ }
402
+ if (status.code !== 0) {
403
+ return {
404
+ ok: false,
405
+ status: emptyStatus(commandFailure(status, "Kimetsu network status failed. Nothing is listening.")),
406
+ };
407
+ }
408
+ const body = parseLastJson(status.stdout);
409
+ if (body === undefined) {
410
+ return { ok: false, status: emptyStatus("Kimetsu network status returned nothing readable. Nothing is listening.") };
411
+ }
412
+ if (body.compiled !== true) {
413
+ return { ok: false, status: emptyStatus("Peer network is not included in this build. Nothing is listening.", false) };
414
+ }
415
+ const details: StatusDetails = {
416
+ available: true,
417
+ compiled: true,
418
+ joined: body.joined === true,
419
+ listening: body.listening === true,
420
+ group: asNullableString(body.group),
421
+ role: asNullableString(body.role),
422
+ generation: typeof body.generation === "number" && Number.isFinite(body.generation) ? body.generation : null,
423
+ endpoint_id: asNullableString(body.endpoint_id),
424
+ message: "",
425
+ };
426
+ if (!details.joined) {
427
+ details.message = "Peer network: not joined. Nothing is listening.";
428
+ } else {
429
+ const endpoint = details.endpoint_id === null ? "" : ` Endpoint ${details.endpoint_id}.`;
430
+ details.message = `Peer network: joined "${details.group ?? "network"}" (${details.role ?? "member"}).${endpoint} Nothing is listening.`;
431
+ }
432
+ return { ok: true, status: details };
433
+ }
434
+
435
+ async function networkStatus(signal?: AbortSignal): Promise<ToolResult<StatusDetails>> {
436
+ const gate = await networkGate(signal);
437
+ return textResult(gate.status, false);
438
+ }
439
+
440
+ async function networkRequest(
441
+ params: { peer?: string; discover?: boolean; map_ports?: boolean },
442
+ ctx: { cwd?: string },
443
+ signal?: AbortSignal,
444
+ ): Promise<ToolResult<RequestDetails>> {
445
+ const discover = params.discover === true;
446
+ const mapPorts = params.map_ports === true;
447
+ if (typeof params.peer === "string" && normalizePeer(params.peer) === undefined) {
448
+ return textResult(emptyRequest(
449
+ "Peer should look like <endpoint-id>@host:port, the line `kimetsu network share` prints. Nothing was contacted.",
450
+ ), true);
451
+ }
452
+ const peer = typeof params.peer === "string" ? normalizePeer(params.peer) : undefined;
453
+ if (peer === undefined && !discover) {
454
+ return textResult(emptyRequest(
455
+ "Pass peer (endpoint-id@host:port) or set discover to true. Nothing was contacted.",
456
+ ), true);
457
+ }
458
+ const gate = await networkGate(signal);
459
+ if (!gate.ok) return textResult(emptyRequest(gate.status.message), true);
460
+ const args = ["network", "request", "--json"];
461
+ if (!discover) args.push("--no-mdns");
462
+ args.push(...workspaceArgs(ctx));
463
+ if (peer !== undefined) args.push("--peer", peer);
464
+ if (mapPorts) args.push("--map-ports");
465
+ const captured = await kimetsuCapture(args, NETWORK_REQUEST_TIMEOUT_MS, signal);
466
+ if (captured.aborted) {
467
+ return textResult(emptyRequest("The peer request was cancelled and the socket was closed. Nothing is listening."), true);
468
+ }
469
+ if (captured.timedOut) {
470
+ return textResult(emptyRequest("The peer request timed out and the socket was closed. Nothing is listening."), true);
471
+ }
472
+ if (captured.code === null) {
473
+ return textResult(emptyRequest("Kimetsu is not on PATH. Nothing is listening."), true);
474
+ }
475
+ if (captured.code !== 0) {
476
+ return textResult(emptyRequest(commandFailure(captured, "Kimetsu network request failed. Nothing is listening.")), true);
477
+ }
478
+ const body = parseLastJson(captured.stdout);
479
+ if (body === undefined || body.verified !== true) {
480
+ return textResult(emptyRequest(commandFailure(captured, "Kimetsu network request returned nothing readable. Nothing is listening.")), true);
481
+ }
482
+ const quarantined = asCount(body.quarantined);
483
+ let message = `Verified the signature. Imported ${asCount(body.imported)}, already had ${asCount(body.deduped)}, quarantined ${quarantined}, superseded ${asCount(body.superseded)}. Nothing is listening now.`;
484
+ if (quarantined > 0) {
485
+ message += " Quarantined memories stay out of retrieval until you accept them.";
486
+ }
487
+ const details: RequestDetails = {
488
+ available: true,
489
+ listening: body.listening === true,
490
+ verified: true,
491
+ imported: asCount(body.imported),
492
+ deduped: asCount(body.deduped),
493
+ quarantined,
494
+ superseded: asCount(body.superseded),
495
+ digest: asNullableString(body.digest),
496
+ message,
497
+ };
498
+ return textResult(details, false);
499
+ }
500
+
501
+ const networkNamespace = {
502
+ name: "kimetsu-network",
503
+ description: "Peer memory network. Nothing listens until a request runs. Sharing is a terminal command.",
504
+ };
505
+
506
+ const statusOutput = Type.Object({
507
+ available: Type.Boolean(),
508
+ compiled: Type.Union([Type.Boolean(), Type.Null()]),
509
+ joined: Type.Boolean(),
510
+ listening: Type.Boolean(),
511
+ group: Type.Union([Type.String(), Type.Null()]),
512
+ role: Type.Union([Type.String(), Type.Null()]),
513
+ generation: Type.Union([Type.Number(), Type.Null()]),
514
+ endpoint_id: Type.Union([Type.String(), Type.Null()]),
515
+ message: Type.String(),
516
+ });
517
+
518
+ const requestOutput = Type.Object({
519
+ available: Type.Boolean(),
520
+ listening: Type.Boolean(),
521
+ verified: Type.Boolean(),
522
+ imported: Type.Number(),
523
+ deduped: Type.Number(),
524
+ quarantined: Type.Number(),
525
+ superseded: Type.Number(),
526
+ digest: Type.Union([Type.String(), Type.Null()]),
527
+ message: Type.String(),
528
+ });
529
+
154
530
  export default function (pi: ExtensionAPI) {
155
531
  // Pi persists injected messages, including display:false messages. Only the
156
532
  // current task's injection belongs in future model calls. A unique marker
@@ -172,12 +548,17 @@ export default function (pi: ExtensionAPI) {
172
548
  // --warm-on-first-prompt folds the repo digest and episodic resume into the
173
549
  // first turn of each session.
174
550
  pi.on("before_agent_start", async (event, ctx) => {
551
+ // Pi 1.x also starts turns from custom messages. Those are not user
552
+ // prompts: expire earlier evidence without retrieving for an empty task.
553
+ // Checking the prompt field also preserves pre-1.x user events.
554
+ activeContext = undefined;
555
+ if (!("prompt" in event) || typeof event.prompt !== "string") return;
175
556
  const request = { id: randomUUID(), sessionId: sessionIdOf(ctx) };
176
557
  // Expire the last task immediately, even if retrieval is empty or fails.
177
558
  activeContext = request;
178
559
  const payload = JSON.stringify({
179
560
  session_id: request.sessionId,
180
- prompt: typeof event?.prompt === "string" ? event.prompt : "",
561
+ prompt: event.prompt,
181
562
  });
182
563
  const stdout = await kimetsuRun(
183
564
  ["brain", "context-hook", "--warm-on-first-prompt", ...workspaceArgs(ctx)],
@@ -267,4 +648,88 @@ export default function (pi: ExtensionAPI) {
267
648
  SESSION_SAVE_TIMEOUT_MS,
268
649
  );
269
650
  });
651
+
652
+ // Registered after the lifecycle hooks. Neither tool runs during a turn
653
+ // unless the model or the user calls it. Share is a command, not a tool,
654
+ // and that command does not spawn the binary.
655
+ pi.registerTool({
656
+ name: "kimetsu_network_status",
657
+ label: "Kimetsu network status",
658
+ description: "Whether this machine has joined a Kimetsu peer network. Does not listen, discover peers, or contact a relay. Sharing stays a terminal command: kimetsu network share.",
659
+ promptSnippet: "Check Kimetsu peer-network membership without listening",
660
+ promptGuidelines: [
661
+ "Does not listen, discover peers, or contact a relay.",
662
+ "Never run `kimetsu network share` unless the user explicitly asks in this turn. It listens until Ctrl-C; tell them to run it in a terminal.",
663
+ ],
664
+ parameters: Type.Object({}),
665
+ outputSchema: statusOutput,
666
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
667
+ namespace: networkNamespace,
668
+ async execute(_toolCallId, _params, signal) {
669
+ return networkStatus(signal);
670
+ },
671
+ });
672
+
673
+ pi.registerTool({
674
+ name: "kimetsu_network_request",
675
+ label: "Kimetsu network request",
676
+ description: "Fetch repo memories a peer is sharing. Verifies the pack signature before any write. Refuses packs that are not scoped to repo. Does nothing and opens no socket when this machine has not joined. Router port mapping stays off unless map_ports is true.",
677
+ promptSnippet: "Fetch a signed repo memory pack from a Kimetsu peer",
678
+ promptGuidelines: [
679
+ "Call only when the user asks to fetch memories from a peer.",
680
+ "Nothing listens until this call, and the socket closes when it finishes, times out, or Pi cancels it. The relay stays off.",
681
+ "Packs are signed and scrubbed and must be the repo mount. An unknown signer is quarantined and stays out of retrieval.",
682
+ "Leave discover false, and do not set map_ports, unless the user explicitly asks.",
683
+ "Never run `kimetsu network share` from the agent. Tell the user to run it in a terminal; it listens until they press Ctrl-C.",
684
+ ],
685
+ parameters: Type.Object({
686
+ peer: Type.Optional(Type.String({
687
+ description: "endpoint-id@host:port from kimetsu network share",
688
+ })),
689
+ discover: Type.Optional(Type.Boolean({
690
+ description: "Look for roster peers on the local network. Default false.",
691
+ })),
692
+ map_ports: Type.Optional(Type.Boolean({
693
+ description: "Ask the router to open a UDP port. Default false.",
694
+ })),
695
+ }),
696
+ outputSchema: requestOutput,
697
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
698
+ executionMode: "sequential",
699
+ namespace: networkNamespace,
700
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
701
+ return networkRequest(params, ctx, signal);
702
+ },
703
+ });
704
+
705
+ pi.registerCommand("kimetsu-network-status", {
706
+ description: "Show whether this machine has joined a Kimetsu peer network. Does not listen.",
707
+ handler: async (_args, ctx) => {
708
+ const result = await networkStatus(ctx.signal);
709
+ ctx.ui.notify(result.content[0].text, result.details.available ? "info" : "warning");
710
+ },
711
+ });
712
+
713
+ pi.registerCommand("kimetsu-network-request", {
714
+ description: "Fetch repo memories a peer is sharing. Listens only while the fetch runs.",
715
+ handler: async (args, ctx) => {
716
+ const peer = args.trim();
717
+ if (peer === "") {
718
+ ctx.ui.notify(
719
+ "Pass the peer line from kimetsu network share: /kimetsu-network-request <endpoint-id>@host:port",
720
+ "warning",
721
+ );
722
+ return;
723
+ }
724
+ const result = await networkRequest({ peer }, ctx, ctx.signal);
725
+ ctx.ui.notify(result.content[0].text, result.isError ? "error" : "info");
726
+ },
727
+ });
728
+
729
+ pi.registerCommand("kimetsu-network-share", {
730
+ description: "Show the terminal command that shares repo memories. Does not open a socket.",
731
+ handler: async (_args, ctx) => {
732
+ ctx.ui.notify(SHARE_INSTRUCTIONS, "info");
733
+ },
734
+ });
270
735
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimetsu-pi",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Kimetsu brain as a Pi.dev package — local-first, cross-session memory for the Pi coding agent that gets sharper every run.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -33,6 +33,7 @@
33
33
  "extensions/",
34
34
  "skills/",
35
35
  "README.md",
36
+ "CHANGELOG.md",
36
37
  "LICENSE"
37
38
  ],
38
39
  "engines": {
@@ -44,12 +45,15 @@
44
45
  "test:integration": "node scripts/test-integration.mjs"
45
46
  },
46
47
  "devDependencies": {
47
- "@earendil-works/pi-coding-agent": "^0.85.1",
48
+ "@earendil-works/pi-coding-agent": "1.2.0",
48
49
  "@types/node": "^26.0.0",
49
50
  "typescript": "^7.0.0",
50
- "vitest": "5.0.0"
51
+ "vitest": "5.0.3"
51
52
  },
52
53
  "peerDependencies": {
53
54
  "@earendil-works/pi-coding-agent": "*"
55
+ },
56
+ "overrides": {
57
+ "undici": "8.12.4"
54
58
  }
55
59
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kimetsu-brain
3
- description: Use when Pi tasks benefit from prior session knowledge, durable lessons, memory corrections, or feedback on helpful memories.
3
+ description: Use when Pi tasks benefit from prior session knowledge, durable lessons, memory corrections, feedback on helpful memories, or the peer memory network.
4
4
  ---
5
5
  Kimetsu is a persistent memory sidecar accessed through the `kimetsu` CLI.
6
6
  Run commands from the relevant project directory. If the binary is unavailable,
@@ -62,3 +62,38 @@ repeat credit for the same use.
62
62
  `kimetsu brain status` reports initialization, accepted memories, and pending
63
63
  proposals. Automatic session saving is separate from these deliberate actions;
64
64
  model-based lesson distillation requires a configured distiller.
65
+
66
+ ## Peer network
67
+
68
+ Kimetsu 2.11.0 adds a peer memory network. Check `kimetsu --version` before
69
+ using it. If the binary is older, or `kimetsu network status --json` reports
70
+ `"compiled": false`, say so and continue the task. Nothing listens until
71
+ `share` or `request` runs. The relay stays off. Packs are signed and scrubbed,
72
+ and only the repo mount is exchanged.
73
+
74
+ `kimetsu_network_status` and `kimetsu network status` report membership. They
75
+ do not discover peers or contact a relay.
76
+
77
+ Fetch a pack only when the user asks. Prefer `kimetsu_network_request` with
78
+ the peer line printed by `kimetsu network share` (`endpoint-id@host:port`).
79
+ That tool passes `--no-mdns` unless discovery was requested, passes
80
+ `--map-ports` only when the user asks the router to open a UDP port, and
81
+ closes the socket when the fetch finishes, times out, or Pi cancels it. The
82
+ signature is checked before any write. An unknown signer is quarantined and
83
+ stays out of retrieval. A pack that is not scoped to the repo is refused.
84
+
85
+ Do not run `kimetsu network share` unless the user explicitly asks to share
86
+ memories in this turn. Even then, do not start it in the background or from a
87
+ tool. Tell the user to run `kimetsu network share` in a terminal. It listens
88
+ until they press Ctrl-C. `/kimetsu-network-share` prints that command and does
89
+ not open a socket.
90
+
91
+ Membership commands also stay quiet until the user asks. They do not listen:
92
+
93
+ ```sh
94
+ kimetsu network invite
95
+ kimetsu network join <code>
96
+ kimetsu network leave
97
+ ```
98
+
99
+ `invite` and `join` only save keys. `leave` forgets them.