kimetsu-pi 0.1.5 → 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,23 @@ 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.5
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
37
50
 
38
51
  - Pi 1.2.0 compatibility: custom-triggered turns expire old memory without
39
52
  treating background notifications as user prompts.
@@ -77,8 +90,10 @@ Install current Pi (Node.js 22.19 or newer):
77
90
  npm install -g @earendil-works/pi-coding-agent
78
91
  ```
79
92
 
80
- The `kimetsu` binary must be on `PATH`. Kimetsu 2.8.0 is the tested release for
81
- 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:
82
97
 
83
98
  ```sh
84
99
  npm install -g kimetsu-ai
@@ -117,7 +132,7 @@ pi update npm:kimetsu-pi
117
132
  For a pinned installation, select this version explicitly:
118
133
 
119
134
  ```sh
120
- pi install npm:kimetsu-pi@0.1.4
135
+ pi install npm:kimetsu-pi@0.2.0
121
136
  ```
122
137
 
123
138
  Then restart Pi or run `/reload`. Pi package updates and Kimetsu CLI updates are
@@ -159,6 +174,59 @@ missing evidence, correct existing memories, and cite only memories that
159
174
  actually helped. Run `kimetsu brain status` from the project directory to inspect
160
175
  the brain. Automatic model-based distillation requires configuration.
161
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
+
162
230
  ## Development
163
231
 
164
232
  ```sh
@@ -168,11 +236,14 @@ npm run typecheck
168
236
  ```
169
237
 
170
238
  To run the optional CLI integration test, set `KIMETSU_TEST_BINARY` to the
171
- 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
172
240
  Windows), then run `npm run test:integration`. This command fails if the binary
173
241
  is missing. It creates an isolated temporary brain, uses
174
242
  lexical retrieval without model calls, and verifies correction, context
175
- 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`.
176
247
 
177
248
  The fixture anchors a new Git repository and gives subprocesses temporary homes,
178
249
  caches, and user-brain paths. It removes inherited Git/Kimetsu overrides and
@@ -180,7 +251,10 @@ credentials before initialization. Never run integration commands against your
180
251
  normal project or home brain.
181
252
 
182
253
  CI requires the real CLI test on Linux, macOS, and Windows using checksum-pinned
183
- 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`
184
258
  gate also requires typechecking, tests, the dependency audit, and package dry-run.
185
259
  Release tags must match the package version and point to a commit on `main` with
186
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
@@ -272,4 +648,88 @@ export default function (pi: ExtensionAPI) {
272
648
  SESSION_SAVE_TIMEOUT_MS,
273
649
  );
274
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
+ });
275
735
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimetsu-pi",
3
- "version": "0.1.5",
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": {
@@ -47,9 +48,12 @@
47
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.