mirror-mind 0.36.2 → 0.36.3

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.
@@ -6,9 +6,12 @@
6
6
  * Events handled:
7
7
  * - session_start → unmute + welcome card + status line, in ONE front-door
8
8
  * process (CV9.E2.S32); maintenance detached
9
- * - before_agent_start → log user prompt, NOT awaited: queued on a per-session
9
+ * - input → what the person typed, kept for the run it starts (CR160)
10
+ * - before_agent_start → the turn to the core's prompt rule (`user-prompt`), with a
11
+ * turn id minted for it, NOT awaited: queued on a per-session
10
12
  * chain so the turn starts at once (CV9.E2.S32)
11
- * - agent_end → log assistant response, after the chain settles
13
+ * - agent_end → the run's assistant text, kept for the prompt's reply
14
+ * - agent_settled → the prompt's whole reply to `stop`, after the chain settles
12
15
  * - session_shutdown → close conversation (after the chain settles) + backup
13
16
  *
14
17
  * All heavy logic lives in the TypeScript core, reached through the `mirror`
@@ -35,6 +38,7 @@ import { homedir } from "node:os";
35
38
  import { delimiter, dirname, join } from "node:path";
36
39
  import { fileURLToPath } from "node:url";
37
40
  import { spawn } from "node:child_process";
41
+ import { randomUUID } from "node:crypto";
38
42
 
39
43
  /** The tree this extension ships in: `.pi/extensions/` sits two below the root. */
40
44
  const TREE_ROOT = fileURLToPath(new URL("../../", import.meta.url)).replace(/\/$/, "");
@@ -360,6 +364,60 @@ export default function (pi: ExtensionAPI) {
360
364
  await (pendingUserLog.get(sessionId) ?? Promise.resolve());
361
365
  }
362
366
 
367
+ /**
368
+ * CR160: Pi's turns reach the core's prompt rule, the one the hooks use
369
+ * (`user-prompt`, `stop`): a skill invocation and its reply are not
370
+ * conversation, a Mirror question is (`commandTurn.ts`, `mirrorForm.ts`).
371
+ *
372
+ * The rule needs what the person typed, and Pi hands `before_agent_start` the
373
+ * prompt after it expanded a skill into its body. Its documented `input` event
374
+ * carries the text as sent, so that text is kept here for the run it starts --
375
+ * only when it starts one: a message queued while Pi streams joins a run
376
+ * already going. The payload travels as `--payload`: `pi.exec` cannot write
377
+ * stdin (`conversationLoggerRoute.ts`).
378
+ */
379
+ const typedInput = new Map<string, string>();
380
+
381
+ /** The prompt in flight and the assistant text its runs have produced so far. */
382
+ interface PendingReply {
383
+ turnId: string;
384
+ texts: string[];
385
+ }
386
+ const pendingReply = new Map<string, PendingReply>();
387
+
388
+ /**
389
+ * Pi's expansion of `/skill:<name> args`: `<skill name="<name>" location="…">`,
390
+ * `References are relative to ….`, the body, `</skill>`, then the arguments.
391
+ * With no typed text kept for a run, the wrapper is read back to the command
392
+ * it came from, so the rule still sees a command (CR160, A3).
393
+ */
394
+ const PI_SKILL_WRAPPER =
395
+ /^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]*))?$/;
396
+
397
+ function typedFormOf(prompt: string): string {
398
+ const match = PI_SKILL_WRAPPER.exec(prompt);
399
+ if (!match) return prompt;
400
+ const args = (match[2] ?? "").trim();
401
+ return args ? `/skill:${match[1]} ${args}` : `/skill:${match[1]}`;
402
+ }
403
+
404
+ /** The `stop` argv for a prompt's reply, or null when it produced no text. */
405
+ function takeStopArgs(sessionId: string): string[] | null {
406
+ const reply = pendingReply.get(sessionId);
407
+ pendingReply.delete(sessionId);
408
+ if (!reply || reply.texts.length === 0) return null;
409
+ const content = truncate(reply.texts.join("\n\n---\n\n"));
410
+ log("INFO", `stop (turn ${reply.turnId}): ${reply.texts.length} block(s), ${content.length} chars`);
411
+ return [
412
+ "conversation-logger",
413
+ "stop",
414
+ "--interface",
415
+ "pi",
416
+ "--payload",
417
+ JSON.stringify({ session_id: sessionId, turn_id: reply.turnId, last_assistant_message: content }),
418
+ ];
419
+ }
420
+
363
421
  /** Extract readable text from a content blocks array or plain string. */
364
422
  function extractText(content: unknown): string {
365
423
  if (typeof content === "string") return content;
@@ -558,33 +616,60 @@ export default function (pi: ExtensionAPI) {
558
616
  runMirrorBackground(["conversation-logger", "session-maintenance"], "session-maintenance");
559
617
  });
560
618
 
561
- // --- 2. before_agent_start → log user prompt with explicit session id ---
619
+ // --- 2. input → what the person typed, for the run it starts (CR160) ---
562
620
 
563
- pi.on("before_agent_start", async (event, ctx) => {
621
+ pi.on("input", async (event, ctx) => {
564
622
  const sessionId = ctx.sessionManager.getSessionFile() ?? null;
565
623
  if (!sessionId) return;
624
+ // The fields are Pi's (`InputEvent`); the types this directory pins predate one.
625
+ const { text, streamingBehavior } = event as { text: string; streamingBehavior?: string };
626
+ // Queued while Pi streams: it joins a run already going and starts none.
627
+ if (streamingBehavior) return;
628
+ typedInput.set(sessionId, text);
629
+ });
566
630
 
567
- const prompt = event.prompt ?? "";
568
- if (!prompt || prompt.startsWith("/")) return;
631
+ // --- 3. before_agent_start → the turn to the core's prompt rule ---
569
632
 
570
- log("INFO", `log-user: ${prompt.slice(0, 80)}...`);
571
- // Not awaited (CV9.E2.S32): the turn starts now; `agent_end` and
633
+ pi.on("before_agent_start", async (event, ctx) => {
634
+ const sessionId = ctx.sessionManager.getSessionFile() ?? null;
635
+ if (!sessionId) return;
636
+ // A run a custom message started answers no prompt of the person's. Pi's
637
+ // field (`messageType`); the types this directory pins predate it.
638
+ const { messageType } = event as { messageType?: string };
639
+ if (messageType !== undefined && messageType !== "user") return;
640
+
641
+ const typed = typedInput.get(sessionId);
642
+ typedInput.delete(sessionId);
643
+ const prompt = typed ?? typedFormOf(event.prompt ?? "");
644
+
645
+ // A reply still unsent belongs to the prompt before this one: a Pi without
646
+ // `agent_settled` (before 0.80.4) never sent it. It goes first, on the chain.
647
+ const earlier = takeStopArgs(sessionId);
648
+ if (earlier) enqueueUserLog(sessionId, earlier);
649
+
650
+ const turnId = randomUUID();
651
+ pendingReply.set(sessionId, { turnId, texts: [] });
652
+ if (!prompt) return;
653
+
654
+ log("INFO", `user-prompt (turn ${turnId}): ${prompt.slice(0, 80)}...`);
655
+ // Not awaited (CV9.E2.S32): the turn starts now; `agent_settled` and
572
656
  // `session_shutdown` settle the chain before anything that must follow.
573
657
  enqueueUserLog(sessionId, [
574
658
  "conversation-logger",
575
- "log-user",
576
- sessionId,
577
- truncate(prompt),
659
+ "user-prompt",
578
660
  "--interface",
579
661
  "pi",
662
+ "--payload",
663
+ JSON.stringify({ session_id: sessionId, prompt: truncate(prompt), turn_id: turnId }),
580
664
  ]);
581
665
  });
582
666
 
583
- // --- 3. agent_end → log assistant response ---
667
+ // --- 4. agent_end → the run's assistant text, kept for the prompt's reply ---
584
668
  //
585
- // agent_end fires once per user prompt, with ALL messages in the cycle
586
- // (assistant + tool calls + tool results). Extract only assistant text
587
- // and log as a single consolidated message.
669
+ // agent_end fires once per run, with ALL messages in it (assistant + tool
670
+ // calls + tool results), and a prompt can take more than one run. The
671
+ // assistant text is kept until the prompt settles: the hooks' `stop` takes
672
+ // one answer per turn (CR160, A2).
588
673
 
589
674
  pi.on("agent_end", async (event, ctx) => {
590
675
  const sessionId = ctx.sessionManager.getSessionFile() ?? null;
@@ -610,21 +695,34 @@ export default function (pi: ExtensionAPI) {
610
695
 
611
696
  if (assistantTexts.length === 0) return;
612
697
 
613
- log("INFO", `log-assistant: ${assistantTexts.length} block(s), ${assistantTexts.join("").length} chars`);
698
+ // No prompt in flight: a run a custom message started, after the last
699
+ // prompt settled. It answers nothing the person asked, and `stop` would
700
+ // find nothing waiting, as on the other runtimes.
701
+ pendingReply.get(sessionId)?.texts.push(...assistantTexts);
702
+ await refreshMirrorStatus(ctx);
703
+ });
704
+
705
+ // --- 5. agent_settled → the prompt's whole reply to `stop` ---
614
706
 
615
- const combined = assistantTexts.join("\n\n---\n\n");
616
- const content = truncate(combined);
707
+ // `agent_settled` arrived in Pi 0.80.4, after the Pi types this directory pins
708
+ // (`.pi/package.json`), so it is registered by its name alone.
709
+ const onEvent = pi.on.bind(pi) as unknown as (
710
+ event: string,
711
+ handler: (event: unknown, ctx: { sessionManager: { getSessionFile(): string | undefined } }) => Promise<void>,
712
+ ) => void;
617
713
 
618
- // The user message's process has exited before this one is spawned.
714
+ onEvent("agent_settled", async (_event, ctx) => {
715
+ const sessionId = ctx.sessionManager.getSessionFile() ?? null;
716
+ if (!sessionId) return;
717
+ const args = takeStopArgs(sessionId);
718
+ if (!args) return;
719
+ // The prompt's process has exited before this one is spawned; detached, so
720
+ // the write survives Pi exiting right after the answer.
619
721
  await settleUserLog(sessionId);
620
- runMirrorBackground(
621
- ["conversation-logger", "log-assistant", sessionId, content, "--interface", "pi"],
622
- "log-assistant",
623
- );
624
- await refreshMirrorStatus(ctx);
722
+ runMirrorBackground(args, "stop");
625
723
  });
626
724
 
627
- // --- 4. session_shutdown → close conversation + backup ---
725
+ // --- 6. session_shutdown → close conversation + backup ---
628
726
  //
629
727
  // Uses extract=False because extraction calls the LLM and can take 30s+.
630
728
  // Extraction happens at the next session_start via extract_pending.
@@ -633,7 +731,11 @@ export default function (pi: ExtensionAPI) {
633
731
  const sessionId = ctx.sessionManager.getSessionFile() ?? null;
634
732
 
635
733
  if (sessionId) {
636
- // A prompt still in flight must land before the conversation closes.
734
+ // A reply still unsent (a Pi without `agent_settled`) lands before the
735
+ // conversation closes, after its prompt; so does a prompt in flight.
736
+ const reply = takeStopArgs(sessionId);
737
+ if (reply) enqueueUserLog(sessionId, reply);
738
+ typedInput.delete(sessionId);
637
739
  await settleUserLog(sessionId);
638
740
  pendingUserLog.delete(sessionId);
639
741
  await runMirror(["conversation-logger", "session-end-pi", sessionId]);
@@ -6,7 +6,10 @@ user-invocable: true
6
6
 
7
7
  # Help
8
8
 
9
- Show the user the available commands:
9
+ Show the user the available commands, starting with this line:
10
+
11
+ > In Pi, type `/mm-` and pick a command from the menu, which lists each one as
12
+ > `skill:mm-<name>`.
10
13
 
11
14
  ## Operating Modes
12
15
 
package/README.md CHANGED
@@ -114,7 +114,7 @@ this repository, turning a personal experiment into a reusable framework:
114
114
  - **Multi-user** — independent mirrors for multiple users on the same machine
115
115
  - **Multi-session** — concurrent sessions tracked safely in a database-backed session registry
116
116
  - **Intelligence depth** — hybrid semantic and lexical search, honest reinforcement, two-pass extraction, memory consolidation, and shadow cultivation
117
- - **Extension system** — a stable contract for adding user-specific capabilities outside the core; extensions own their own SQLite tables, CLI subcommands, and Mirror Mode context providers without touching the framework
117
+ - **Extension system** — a stable contract for adding user-specific capabilities outside the core; extensions own their own SQLite tables, CLI subcommands, and Mirror Mode context providers without touching the framework, and reach every runtime: Pi through the home's catalog, Claude Code, Codex, Antigravity, and Grok Build through a plugin each home generates
118
118
 
119
119
  The move toward Pi as the preferred runtime was inspired by
120
120
  [Henrique Bastos](https://github.com/henriquebastos) and his early work in
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.36.3 — A Skill You Invoke Is Not Something You Said](v0.36.3.md) — patch release of one Change Request, CR160, with CR159's docs: on Pi, a skill picked from the menu or typed is no longer stored as the person's words, and neither is its reply; a question asked with `/mm-mirror` is stored as the question. Pi's turns go through the rule the other runtimes have. `mirror conversations skill-turns` repairs what earlier versions stored, only on `--apply` and after a verified backup. No migration.
15
16
  - [v0.36.2 — Clearing Out What Another Home Copied](v0.36.2.md) — patch release of one Change Request, CR154, finishing what v0.36.1 began: `mirror conversations imported` lists the Pi conversations an earlier version imported, by the folder each session came from and beside how many sessions the home logged live there, and `remove` deletes the ones you name, only on `--apply` and after a verified backup. `discard-current` no longer fails on a conversation a Soul Mode integration names. No migration.
16
17
  - [v0.36.1 — A Home Keeps Only Its Own Pi Sessions](v0.36.1.md) — patch release of one Change Request, CR153: session maintenance imports no Pi session, so the Mirror homes on one machine no longer copy each other's Pi conversations; a home's Pi conversations are the ones the Pi extension logged into it. `PI_SESSIONS_DIR` is inert. Conversations an earlier version imported stay, and the note shows how to list them. Update every install that has a home. No migration.
17
18
  - [v0.36.0 — Five Runtimes, and a Home's Extensions on Every One](v0.36.0.md) — minor release closing CV21.DS10: Grok Build is the fifth runtime, at its honest L3 through the canonical plugin in two printed steps, measured live on grok 1.0.62. With it, CV21.DS5.US2: a home's installed extensions reach Claude Code, Codex, Antigravity, and Grok Build as one generated plugin per home, chosen by measurement on all four. Three Change Requests found by use: an extension's settings file (`extensions/<id>.env`, merged under the environment for the one spawned process), a quiet exit on a closed pipe, and a `bind` that refuses what it cannot bind. No migration.
@@ -0,0 +1,142 @@
1
+ ---
2
+ digest: >
3
+ v0.36.3 is a patch release of one Change Request, CR160, with CR159's docs.
4
+ On Pi, a skill picked from the command menu, or typed as `/skill:<name>`,
5
+ reached Mirror after Pi had expanded it into the skill's whole body, and
6
+ Mirror stored that body as something the person said. Pi's turns now go
7
+ through the rule every other runtime has: a skill invocation and its reply
8
+ are not recorded, and a question asked with `/mm-mirror` is recorded as the
9
+ question. `mirror conversations skill-turns` repairs what earlier versions
10
+ stored, only on `--apply` and only after a verified backup. No migration.
11
+ ---
12
+
13
+ # v0.36.3 — A Skill You Invoke Is Not Something You Said
14
+
15
+ **Date:** 2026-10-23
16
+
17
+ ## Highlights
18
+
19
+ - **On Pi, a skill you invoke is not recorded as your words**
20
+ ([CR160](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr160-a-pi-skill-invoked-as-skill-is-logged-as-its-body.md)),
21
+ whether you pick it from the menu or type it, and neither is its answer. A question
22
+ you ask with `/mm-mirror` is recorded as the question, with one reply. A prompt has
23
+ one reply row.
24
+ - **`mirror conversations skill-turns` repairs what an earlier version stored.** It
25
+ shows, by skill, what it would change, and each conversation it would delete because
26
+ no user turn would be left; on `--apply` it takes a dated backup, verifies it, and
27
+ repairs in one transaction. Mirror questions keep their question; other skill turns
28
+ go; a conversation left with no user turn goes, its memories kept, unlinked; a title
29
+ that came from a skill's body is rebuilt from the first remaining user turn.
30
+ - **Pi joins the rule the other runtimes already had.** Mirror's Pi extension sends
31
+ what you typed, read before Pi expands a skill, to the same prompt and reply handlers
32
+ Claude Code, Codex, and Grok Build use.
33
+ - **The docs say how Pi invokes a skill**
34
+ ([CR159](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr159-pi-skills-are-spelled-by-their-bare-name-and-step-7a-lost-two-dashes.md)):
35
+ typing `/mm-` opens Pi's command menu, which lists each skill as `skill:mm-<skill>`;
36
+ pick it there.
37
+
38
+ ## Where We Started
39
+
40
+ Writing down how extensions reach each runtime (CV21.DS5.TS4), the docs called Pi's
41
+ skill commands `/mm-<skill>`, as if Pi registered them. CR159 measured Pi 1.4.0: Pi
42
+ registers each skill as `/skill:<name>`, its menu offers it under that name, and it
43
+ expands the skill into its whole body before any extension sees the prompt. Mirror's
44
+ Pi extension skipped a prompt only when it opened with `/`. An expanded skill opens
45
+ with `<skill`, so it was stored as the person's turn. On the maintainer's two homes,
46
+ 225 and 185 such turns, from April to October: conversations titled
47
+ `<skill name="mm-build" location=…`, and extraction reading a skill's instructions as
48
+ something the person said.
49
+
50
+ ## What Changed
51
+
52
+ **One rule, in one place.** The core already held the rule: what you say to the mirror
53
+ is remembered, a skill you invoke is not. Claude Code, Codex, and Grok Build reach it
54
+ through the hooks' prompt and reply handlers; Pi's extension kept a check of its own,
55
+ and Pi's expansion slipped past it. Rather than teach the extension Pi's wrapper, Pi
56
+ now uses the same handlers: the extension reads what you typed from Pi's documented
57
+ `input` event, gives each run a turn id, and sends the prompt's whole reply once, when
58
+ Pi settles. The payload travels as an argument, because Pi's extension API cannot
59
+ write a process's input; Pi's prompts already travelled that way.
60
+
61
+ **Measured before and after.** A route committed with the Change Request runs the same
62
+ four prompts on Pi 1.4.0 against the tree before the fix and after it. Before, a skill
63
+ picked from the menu or typed as `/skill:` was stored as the person's turn, a question
64
+ typed with `/mm-mirror` was lost, and every skill's reply was kept. After, a skill
65
+ invocation leaves nothing, and a Mirror question leaves the question and one reply.
66
+
67
+ **A repair, careful by construction.** Shaped as v0.36.2's `remove`: a dry run first,
68
+ with the exact command that applies it; on `--apply`, the set decided read-only, then a
69
+ dated archive, verified, then one transaction that decides again and refuses if
70
+ anything moved. It matches only Pi's whole skill block, and the one the old logger cut
71
+ at its 50,000-character limit, and only in Pi conversations: a person may paste
72
+ `<skill name=` anywhere. On copies of the maintainer's two homes it kept 20 Mirror
73
+ questions in each, removed 205 and 165 skill turns, deleted 31 and 24 conversations
74
+ left with no user turn, and a second dry run found nothing.
75
+
76
+ **A review before any code.** Five personas reviewed the plan. The engineer found that
77
+ the hooks already had the rule and a tested handler for each side of a turn, so Pi
78
+ joined them rather than the core learning a second copy. The devops engineer found that
79
+ a running Pi session keeps the extension it loaded, so the old logging commands stay
80
+ for one more release, and the repair is run after every session has restarted.
81
+
82
+ ### What you will notice
83
+
84
+ - In Pi, a skill you pick or type no longer appears in your conversations as something
85
+ you said, and neither does its answer.
86
+ - A question you ask with `/mm-mirror` is recorded as the question; typed in full, it
87
+ used to be lost.
88
+ - A new command, `mirror conversations skill-turns`.
89
+
90
+ ## Upgrading
91
+
92
+ ```bash
93
+ mirror runtime update # or: npm install -g mirror-mind
94
+ ```
95
+
96
+ There is no database migration, and no runtime wiring changes. Then, for every home
97
+ you use with Pi:
98
+
99
+ 1. Restart every Pi session on that home. A session keeps the extension it loaded and,
100
+ until it restarts, stores a skill's body as before.
101
+ 2. See what the repair would change, then repair:
102
+
103
+ ```bash
104
+ mirror conversations skill-turns
105
+ mirror conversations skill-turns --apply
106
+ ```
107
+
108
+ The dry run lists each conversation it would delete, with its message count. Running it
109
+ again later repairs anything a session that had not restarted stored meanwhile. The
110
+ dated backup it takes, and the pre-write backup, keep copies until they age out (30
111
+ days for an archive).
112
+
113
+ ## Conscious Exclusions
114
+
115
+ - **Past replies to skipped turns.** A reply to a skill typed as `/mm-<skill>` was
116
+ recorded before this release, and cannot be told from a reply to an ordinary turn,
117
+ since one prompt can record more than one reply. Those replies stay.
118
+ - **Summaries, their embeddings, and memories** of affected conversations stay: they
119
+ describe a session in which a skill was invoked.
120
+ - **Pi's own session files** are not read or touched.
121
+ - **Older weak titles.** Startup maintenance improves only the titles of the most
122
+ recently ended conversations; the rest wait for
123
+ [CR162](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr162-maintenance-retitles-only-the-latest-conversations.md).
124
+ - **A tool for models.** No MCP tool and no skill runs the repair: a person does.
125
+
126
+ ## What We Learned
127
+
128
+ A rule kept in two places drifts at the edges. The hooks' rule and the Pi extension's
129
+ check agreed for months, until what Pi hands an extension stopped opening with `/`.
130
+ The second copy kept the letter of the rule after its input changed shape; one copy,
131
+ reached by every runtime, cannot.
132
+
133
+ The data that says a thing is wrong is often already there. Four hundred rows titled
134
+ `<skill name=…` sat in two homes for six months, read by every title, summary, and
135
+ extraction pass, and named by none of them.
136
+
137
+ ## Next Horizon
138
+
139
+ CV21.DS11, the contract and docs refresh with the parity matrix, then CV21's own
140
+ release. Captured and open: CR161, the Pi extension's types and the check it lacks;
141
+ CR162, the titles maintenance never reaches. `log-user` and `log-assistant`, kept here
142
+ for sessions that have not restarted, retire in the release after this one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.2",
3
+ "version": "0.36.3",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.2",
3
+ "version": "0.36.3",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.36.2",
4
+ "version": "0.36.3",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -29,8 +29,9 @@
29
29
  // One rule for every runtime: a Claude Code user who types `$mm-journeys`,
30
30
  // which Claude Code does not understand, is still invoking a skill, and that
31
31
  // turn is not conversation either. The rule has one home for every hook that
32
- // runs in our process (the Claude-family logger, agy's Stop); the Pi
33
- // extension, a separate process in Pi's file, keeps its own `/` check.
32
+ // runs in our process (the Claude-family logger, agy's Stop), and since CR160
33
+ // for Pi as well: its extension sends what the person typed, read before Pi
34
+ // expands a skill into its body, to the same `user-prompt` and `stop` handlers.
34
35
 
35
36
  const SKILL_SEGMENT = "[a-z][a-z0-9_-]*";
36
37
  const DOLLAR_SKILL_FORM = new RegExp(`^\\$${SKILL_SEGMENT}(?::${SKILL_SEGMENT})*(?=\\s|$)`);
@@ -0,0 +1,267 @@
1
+ // The Pi user turns an earlier version stored as a skill's body, and their repair
2
+ // (CR160).
3
+ //
4
+ // Pi turns a prompt that opens with `/skill:<name>`, which is also what its command
5
+ // menu sends, into the skill's whole body before its extensions see it. Until CR160
6
+ // Pi's extension logged that prompt, so a skill invocation was stored as
7
+ // `<skill name="…" location="…">` and the body, as if the person had said it; every
8
+ // later reader -- the title, the embedding, extraction -- read it so.
9
+ //
10
+ // The repair applies the rule the core logs by (`commandTurn.ts`): a Mirror
11
+ // question keeps its question, without the block (`mirrorForm.ts`); any other skill
12
+ // invocation is not conversation, and neither are its arguments (a `mm-build`
13
+ // slug), so its row goes. A conversation the repair leaves with no user turn goes
14
+ // too, through the shared deletion, which keeps its memories unlinked (the
15
+ // Navigator's decision, A7). A conversation titled from a block, and not titled by
16
+ // hand, gets the title every conversation gets from its first user turn, marked
17
+ // provisional so the title paths can improve it. Replies stay: they are what the
18
+ // mirror said, and a reply to a skipped turn cannot be told from another. Summaries
19
+ // and their embeddings stay: they describe a session in which a skill was invoked
20
+ // (A6).
21
+ //
22
+ // Only Pi's whole wrapper, only in Pi conversations (A8): a person may type or
23
+ // paste `<skill name=` at the start of a message anywhere. One more shape counts:
24
+ // the wrapper the old logger cut at its size limit, whose ending only it writes.
25
+
26
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
27
+ import { deleteConversation } from "#conversation/conversationDeletion.ts";
28
+ import { generateTitle, setProvisionalTitle } from "#conversation/logger.ts";
29
+ import { type Database, type WritableDatabase, withTransaction } from "#db/database.ts";
30
+
31
+ /**
32
+ * Pi's expansion of `/skill:<name> args`, whole: the opening tag, Pi's line naming
33
+ * where the skill's references resolve, the body, the closing tag, and the
34
+ * arguments after a blank line when there are any (Pi 1.3.0 and 1.4.0,
35
+ * `_expandSkillCommand`). Pi's own extension reads the same shape back
36
+ * (`.pi/extensions/mirror-logger.ts`); `piSkillTurns.test.ts` holds the two alike.
37
+ */
38
+ export const PI_SKILL_WRAPPER =
39
+ /^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]*))?$/;
40
+
41
+ /**
42
+ * The same block when the old logger cut it: the extension kept the first 50,000
43
+ * characters and added its own marker (`truncate`, `.pi/extensions/mirror-logger.ts`),
44
+ * so the closing tag never arrived. Pi's opening lines, then an ending only the
45
+ * logger writes: no person types that. Whatever followed the block was cut with it.
46
+ */
47
+ const PI_SKILL_WRAPPER_CUT =
48
+ /^<skill name="([^"]+)" location="[^"]*">\nReferences are relative to [^\n]*\n\n[\s\S]*\n\[… truncated\]$/;
49
+
50
+ /** The one skill whose turn is conversation: its question is the turn. */
51
+ const MIRROR_SKILL = "mm-mirror";
52
+
53
+ export interface StoredSkillTurn {
54
+ messageId: string;
55
+ conversationId: string;
56
+ skill: string;
57
+ /** What followed the block, trimmed; "" when nothing did. */
58
+ tail: string;
59
+ /** The Mirror question the row keeps, or null when the row goes. */
60
+ question: string | null;
61
+ }
62
+
63
+ export interface ConversationToDelete {
64
+ id: string;
65
+ title: string | null;
66
+ messageCount: number;
67
+ startedAt: string;
68
+ }
69
+
70
+ export interface ConversationToRetitle {
71
+ id: string;
72
+ title: string;
73
+ newTitle: string;
74
+ }
75
+
76
+ export interface SkillTurnRepairPlan {
77
+ /** Every stored skill turn, in the order the rows were written. */
78
+ turns: StoredSkillTurn[];
79
+ /** The Mirror questions: each row keeps its question. */
80
+ kept: StoredSkillTurn[];
81
+ /** Every other skill turn: each row goes. */
82
+ removed: StoredSkillTurn[];
83
+ conversationsToDelete: ConversationToDelete[];
84
+ conversationsToRetitle: ConversationToRetitle[];
85
+ }
86
+
87
+ export interface SkillTurnRepairResult {
88
+ kept: number;
89
+ removed: number;
90
+ conversationsDeleted: number;
91
+ /** Messages that went with the deleted conversations, beyond the removed turns. */
92
+ messagesDeletedWithConversations: number;
93
+ conversationsRetitled: number;
94
+ }
95
+
96
+ /** The set the transaction found is not the set the person was shown. */
97
+ export class SkillTurnRepairSetMovedError extends Error {
98
+ constructor() {
99
+ super("the stored skill turns changed since they were listed");
100
+ this.name = "SkillTurnRepairSetMovedError";
101
+ }
102
+ }
103
+
104
+ /** The skill turn a stored row holds, or null when the row is not Pi's whole wrapper. */
105
+ export function readSkillTurn(content: string): { skill: string; tail: string } | null {
106
+ const match = PI_SKILL_WRAPPER.exec(content);
107
+ if (match) return { skill: match[1] as string, tail: (match[2] ?? "").trim() };
108
+ const cut = PI_SKILL_WRAPPER_CUT.exec(content);
109
+ if (cut) return { skill: cut[1] as string, tail: "" };
110
+ return null;
111
+ }
112
+
113
+ function isManualTitle(rawMetadata: unknown): boolean {
114
+ if (typeof rawMetadata !== "string") return false;
115
+ try {
116
+ const metadata = JSON.parse(rawMetadata) as Record<string, unknown>;
117
+ return metadata.title_status === "manual" || metadata.title_source === "manual";
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * What the repair would change, decided once for the dry run and again inside the
125
+ * repair's transaction.
126
+ */
127
+ export function planSkillTurnRepair(db: Database): SkillTurnRepairPlan {
128
+ // SQL narrows to the prefix; `readSkillTurn` decides on the whole wrapper.
129
+ const candidates = db
130
+ .prepare(
131
+ `SELECT m.id, m.conversation_id, m.content
132
+ FROM messages m JOIN conversations c ON c.id = m.conversation_id
133
+ WHERE c.interface = 'pi' AND m.role = 'user' AND substr(m.content, 1, 13) = '<skill name="'
134
+ ORDER BY m.created_at, m.rowid`,
135
+ )
136
+ .all();
137
+ const turns: StoredSkillTurn[] = [];
138
+ for (const row of candidates) {
139
+ const read = readSkillTurn(String(row.content));
140
+ if (read === null) continue;
141
+ const question = read.skill === MIRROR_SKILL && read.tail !== "" ? read.tail : null;
142
+ turns.push({
143
+ messageId: String(row.id),
144
+ conversationId: String(row.conversation_id),
145
+ skill: read.skill,
146
+ tail: read.tail,
147
+ question,
148
+ });
149
+ }
150
+ const kept = turns.filter((turn) => turn.question !== null);
151
+ const removed = turns.filter((turn) => turn.question === null);
152
+ const removedIds = new Set(removed.map((turn) => turn.messageId));
153
+ const keptQuestion = new Map(kept.map((turn) => [turn.messageId, turn.question as string]));
154
+
155
+ // A conversation the repair leaves with no user turn goes. Otherwise a block-born
156
+ // title is rebuilt from its first remaining user turn that is not a command: a
157
+ // command is not conversation (`commandTurn.ts`), so it names one only when
158
+ // nothing else remains.
159
+ const touched = [...new Set(turns.map((turn) => turn.conversationId))];
160
+ const userRows = db.prepare(
161
+ `SELECT id, content FROM messages WHERE conversation_id = ? AND role = 'user'
162
+ ORDER BY created_at, rowid`,
163
+ );
164
+ const remainingUserTurns = (conversationId: string, removing: ReadonlySet<string>): string[] =>
165
+ userRows
166
+ .all(conversationId)
167
+ .filter((row) => !removing.has(String(row.id)))
168
+ .map((row) => keptQuestion.get(String(row.id)) ?? String(row.content))
169
+ .filter((content) => content.trim() !== "");
170
+ const titleSource = (remaining: readonly string[]): string | null =>
171
+ remaining.find((content) => !isCommandTurn(content)) ?? remaining[0] ?? null;
172
+ const remainingFirstUser = new Map<string, string | null>();
173
+ for (const conversationId of touched) {
174
+ remainingFirstUser.set(
175
+ conversationId,
176
+ titleSource(remainingUserTurns(conversationId, removedIds)),
177
+ );
178
+ }
179
+
180
+ const conversationRow = db.prepare(
181
+ `SELECT id, title, started_at, metadata,
182
+ (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count
183
+ FROM conversations c WHERE id = ?`,
184
+ );
185
+ const conversationsToDelete: ConversationToDelete[] = [];
186
+ for (const conversationId of touched) {
187
+ if (remainingFirstUser.get(conversationId) !== null) continue;
188
+ const row = conversationRow.get(conversationId);
189
+ if (!row) continue;
190
+ conversationsToDelete.push({
191
+ id: conversationId,
192
+ title: typeof row.title === "string" ? row.title : null,
193
+ messageCount: Number(row.message_count),
194
+ startedAt: String(row.started_at),
195
+ });
196
+ }
197
+ const deleting = new Set(conversationsToDelete.map((item) => item.id));
198
+
199
+ // Titles born of a block, in any Pi conversation that stays, unless set by hand.
200
+ const titled = db
201
+ .prepare(
202
+ `SELECT id, title, metadata FROM conversations
203
+ WHERE interface = 'pi' AND lower(substr(trim(title), 1, 6)) = '<skill'
204
+ ORDER BY started_at, id`,
205
+ )
206
+ .all();
207
+ const conversationsToRetitle: ConversationToRetitle[] = [];
208
+ for (const row of titled) {
209
+ const id = String(row.id);
210
+ if (deleting.has(id) || isManualTitle(row.metadata)) continue;
211
+ const source = remainingFirstUser.has(id)
212
+ ? remainingFirstUser.get(id)
213
+ : titleSource(remainingUserTurns(id, new Set()));
214
+ if (!source || readSkillTurn(source) !== null) continue;
215
+ const newTitle = generateTitle(source).trim();
216
+ if (newTitle === "") continue;
217
+ conversationsToRetitle.push({ id, title: String(row.title), newTitle });
218
+ }
219
+
220
+ return { turns, kept, removed, conversationsToDelete, conversationsToRetitle };
221
+ }
222
+
223
+ /** One string that names everything a plan changes, to compare a plan with the one shown. */
224
+ export function skillTurnRepairKey(plan: SkillTurnRepairPlan): string {
225
+ const sorted = (values: readonly string[]) => [...values].sort().join(",");
226
+ return [
227
+ sorted(plan.kept.map((turn) => turn.messageId)),
228
+ sorted(plan.removed.map((turn) => turn.messageId)),
229
+ sorted(plan.conversationsToDelete.map((item) => item.id)),
230
+ sorted(plan.conversationsToRetitle.map((item) => `${item.id}=${item.newTitle}`)),
231
+ ].join("|");
232
+ }
233
+
234
+ /**
235
+ * Repair what the plan names, in one transaction: all or nothing. The plan is
236
+ * decided again inside the transaction and must be the one the person was shown
237
+ * (`expectedKey`); if it moved, nothing changes.
238
+ */
239
+ export function applySkillTurnRepair(
240
+ db: WritableDatabase,
241
+ expectedKey: string,
242
+ ): SkillTurnRepairResult {
243
+ return withTransaction(db, () => {
244
+ const plan = planSkillTurnRepair(db);
245
+ if (skillTurnRepairKey(plan) !== expectedKey) throw new SkillTurnRepairSetMovedError();
246
+ const keepQuestion = db.prepare("UPDATE messages SET content = ? WHERE id = ?");
247
+ for (const turn of plan.kept) keepQuestion.run(turn.question as string, turn.messageId);
248
+ const removeTurn = db.prepare("DELETE FROM messages WHERE id = ?");
249
+ for (const turn of plan.removed) removeTurn.run(turn.messageId);
250
+ const remainingMessages = db.prepare(
251
+ "SELECT COUNT(*) AS n FROM messages WHERE conversation_id = ?",
252
+ );
253
+ let messagesDeletedWithConversations = 0;
254
+ for (const item of plan.conversationsToDelete) {
255
+ messagesDeletedWithConversations += Number(remainingMessages.get(item.id)?.n ?? 0);
256
+ deleteConversation(db, item.id);
257
+ }
258
+ for (const item of plan.conversationsToRetitle) setProvisionalTitle(db, item.id, item.newTitle);
259
+ return {
260
+ kept: plan.kept.length,
261
+ removed: plan.removed.length,
262
+ conversationsDeleted: plan.conversationsToDelete.length,
263
+ messagesDeletedWithConversations,
264
+ conversationsRetitled: plan.conversationsToRetitle.length,
265
+ };
266
+ });
267
+ }
@@ -172,6 +172,7 @@ import { runRuntimeReadRoute, runWelcomeRoute } from "./runtimeRoute.ts";
172
172
  import { runMemorySearchRoute } from "./searchRoute.ts";
173
173
  import { resolveSeedPaths } from "./seedPaths.ts";
174
174
  import { isSessionStartWelcome, runSessionStartWelcomeRoute } from "./sessionStartWelcomeRoute.ts";
175
+ import { isSkillTurnsCommand, runSkillTurnsRoute } from "./skillTurnsRoute.ts";
175
176
  import { runSoulRoute } from "./soulRoute.ts";
176
177
  import {
177
178
  applyTasksImport,
@@ -1781,6 +1782,21 @@ async function dispatchTs(argv: readonly string[]): Promise<number | DispatchOut
1781
1782
  verifyBackup: (archive) => verifyBackupArchive(archive),
1782
1783
  });
1783
1784
  }
1785
+ // CR160: the same shape -- the dry run reads; `--apply` writes behind the
1786
+ // pre-write backup, after its own dated, verified archive.
1787
+ if (isSkillTurnsCommand(argv)) {
1788
+ return runSkillTurnsRoute(argv, {
1789
+ withReadOnlyDatabase: (read) => withMirrorReadDb(argv, read),
1790
+ withLiveWriteDatabase: (write) => {
1791
+ const dbPath = resolveDbPathForCli(argv.slice(1));
1792
+ if (dbPath === null) return 2;
1793
+ return withLiveWriteDbAt(dbPath, "conversations", (db) => write(db, dbPath));
1794
+ },
1795
+ backup: (dbPath) =>
1796
+ createZipBackup({ dbPath, mirrorHome: dirname(dbPath), backupDir: null, silent: true }),
1797
+ verifyBackup: (archive) => verifyBackupArchive(archive),
1798
+ });
1799
+ }
1784
1800
  // CV22.DS7.TS1: the DB safety tools. `backup` never opens or bootstraps the
1785
1801
  // database; `repair-encoding --apply` rides the live-write seam.
1786
1802
  if (argv[0] === "backup") {
@@ -35,6 +35,24 @@ function subcommandOf(args: readonly string[]): string {
35
35
  */
36
36
  const readStdin = takeStdin;
37
37
 
38
+ /**
39
+ * `--payload <json>`: a hook subcommand's payload in argv rather than on stdin
40
+ * (CR160). Pi's extension reaches the core through `pi.exec`, which cannot write
41
+ * a child's stdin, and since CR160 it sends Pi's turns to `user-prompt` and
42
+ * `stop`. Pi's prompt has travelled in argv since CV9, as `log-user`'s argument,
43
+ * so this is the exposure Pi always had, no wider (`stdinSource.ts`). The option
44
+ * is taken out of argv before the subcommand sees it, and a route given one
45
+ * never reads fd 0: a stdin pipe nobody closes cannot hang it.
46
+ */
47
+ function takePayloadOption(args: readonly string[]): { payload: string | null; rest: string[] } {
48
+ const index = args.indexOf("--payload");
49
+ if (index === -1 || index + 1 >= args.length) return { payload: null, rest: [...args] };
50
+ return {
51
+ payload: args[index + 1] as string,
52
+ rest: [...args.slice(0, index), ...args.slice(index + 2)],
53
+ };
54
+ }
55
+
38
56
  /**
39
57
  * Where the route's lines go. The default is the process's own streams; the
40
58
  * `session-start --welcome` composite (CV9.E2.S32) captures them instead, so
@@ -51,7 +69,7 @@ export async function runConversationLoggerRoute(
51
69
  argv: readonly string[],
52
70
  io: ConversationLoggerRouteIo = {},
53
71
  ): Promise<number> {
54
- const args = argv.slice(1);
72
+ const { payload, rest: args } = takePayloadOption(argv.slice(1));
55
73
  const subcommand = subcommandOf(args);
56
74
  const runtime = createLoggerRuntime({
57
75
  db,
@@ -73,7 +91,7 @@ export async function runConversationLoggerRoute(
73
91
  }),
74
92
  });
75
93
  const result = await runConversationLoggerCommand(db, args, runtime, {
76
- stdin: STDIN_SUBCOMMANDS.has(subcommand) ? readStdin() : undefined,
94
+ stdin: STDIN_SUBCOMMANDS.has(subcommand) ? (payload ?? readStdin()) : undefined,
77
95
  });
78
96
  const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
79
97
  const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
@@ -591,6 +591,16 @@ export function routeByFamily(
591
591
  : "CR154 conversations imported",
592
592
  };
593
593
  }
594
+ // CR160: allowlisted by name like CR154's, its apply named apart.
595
+ if (argv[1] === "skill-turns") {
596
+ return {
597
+ command,
598
+ engine: "ts",
599
+ reason: argv.includes("--apply")
600
+ ? "CR160 conversations skill-turns --apply"
601
+ : "CR160 conversations skill-turns",
602
+ };
603
+ }
594
604
  const writeFlag = TS4_LIFECYCLE_WRITE_FLAGS.find((flag) => argv.includes(flag));
595
605
  if (writeFlag) {
596
606
  return { command, engine: "ts", reason: `DS7.TS4 ${writeFlag} ported to TS` };
@@ -0,0 +1,213 @@
1
+ // `mirror conversations skill-turns` (CR160): see the Pi user turns an earlier
2
+ // version stored as a skill's body, and repair them.
3
+ //
4
+ // conversations skill-turns [--apply] [--mirror-home PATH] [--db-path PATH]
5
+ //
6
+ // A dry run until `--apply`. The dry run names what the repair would change: by
7
+ // skill, how many rows keep their Mirror question and how many go; each
8
+ // conversation it would delete because it leaves it with no user turn, with its
9
+ // message count (A7); how many titles it would rebuild. `--apply` decides first
10
+ // (nothing to repair stops there, with no backup), takes the dated backup and
11
+ // verifies it, and only then repairs, in one transaction that decides again and
12
+ // refuses if anything moved. Every option is declared: a typo is refused before
13
+ // anything opens. Shaped as CR154's `conversations imported remove`.
14
+ //
15
+ // The module that decides what a stored skill turn is, and repairs it, is
16
+ // `#conversation/piSkillTurns.ts`; this file parses, opens, and prints. Nothing
17
+ // here is offered to a model: no MCP tool, no skill.
18
+
19
+ import { RETENTION_DAYS } from "#backup/zipBackup.ts";
20
+ import {
21
+ applySkillTurnRepair,
22
+ planSkillTurnRepair,
23
+ type SkillTurnRepairPlan,
24
+ type SkillTurnRepairResult,
25
+ SkillTurnRepairSetMovedError,
26
+ skillTurnRepairKey,
27
+ } from "#conversation/piSkillTurns.ts";
28
+ import type { Database, WritableDatabase } from "#db/database.ts";
29
+ import { PROGRAM } from "#util/program.ts";
30
+ import { shellWord } from "#util/shellWord.ts";
31
+ import { parseArgs } from "#util/strictArgs.ts";
32
+
33
+ const USAGE = `usage: ${PROGRAM} conversations skill-turns [--apply] [--mirror-home PATH] [--db-path PATH]`;
34
+
35
+ /** How argparse names the program in an error line. */
36
+ const PROG = `${PROGRAM} conversations skill-turns`;
37
+
38
+ const WHAT_A_SKILL_TURN_IS =
39
+ "Until CR160, Mirror recorded the skill Pi expanded, instead of what was typed.";
40
+
41
+ const HOME_OPTIONS = ["--mirror-home", "--db-path"] as const;
42
+
43
+ export interface SkillTurnsIo {
44
+ /** The home's database, read-only: the dry run, and the decision before `--apply`. */
45
+ withReadOnlyDatabase: (read: (db: Database) => number) => number;
46
+ /** The home's database behind the front door's pre-write backup: `--apply` only. */
47
+ withLiveWriteDatabase: (write: (db: WritableDatabase, dbPath: string) => number) => number;
48
+ /** The dated archive; its path, or null on failure. */
49
+ backup: (dbPath: string) => string | null;
50
+ verifyBackup: (archivePath: string) => { valid: boolean; note: string | null };
51
+ stdout?: (line: string) => void;
52
+ stderr?: (line: string) => void;
53
+ }
54
+
55
+ export function isSkillTurnsCommand(argv: readonly string[]): boolean {
56
+ return argv[0] === "conversations" && argv[1] === "skill-turns";
57
+ }
58
+
59
+ function plural(count: number, word: string): string {
60
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
61
+ }
62
+
63
+ function isEmpty(plan: SkillTurnRepairPlan): boolean {
64
+ return plan.turns.length === 0 && plan.conversationsToRetitle.length === 0;
65
+ }
66
+
67
+ export function runSkillTurnsRoute(argv: readonly string[], io: SkillTurnsIo): number {
68
+ const out = io.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
69
+ const err = io.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
70
+
71
+ const rest = argv.slice(2);
72
+ if (rest.includes("-h") || rest.includes("--help")) {
73
+ out(USAGE);
74
+ return 0;
75
+ }
76
+ const parsed = parseArgs(rest, { flags: ["--apply"], valued: [...HOME_OPTIONS] });
77
+ if (parsed.error) {
78
+ err(USAGE);
79
+ err(`${PROG}: error: ${parsed.error}`);
80
+ return 2;
81
+ }
82
+ if (!parsed.flags.has("--apply")) {
83
+ // The command that applies this dry run, to the same home, every value quoted.
84
+ const words = ["conversations", "skill-turns"];
85
+ for (const option of HOME_OPTIONS) {
86
+ const value = parsed.values.get(option);
87
+ if (value !== undefined) words.push(option, value);
88
+ }
89
+ words.push("--apply");
90
+ return dryRun(io, out, [PROGRAM, ...words.map(shellWord)].join(" "));
91
+ }
92
+ return apply(io, out, err);
93
+ }
94
+
95
+ function dryRun(io: SkillTurnsIo, out: (line: string) => void, applyCommand: string): number {
96
+ return io.withReadOnlyDatabase((db) => {
97
+ const plan = planSkillTurnRepair(db);
98
+ if (isEmpty(plan)) {
99
+ out("No Pi user turns stored as a skill's body in this home.");
100
+ return 0;
101
+ }
102
+ if (plan.turns.length > 0) {
103
+ const conversations = new Set(plan.turns.map((turn) => turn.conversationId)).size;
104
+ out(
105
+ `Pi user turns stored as a skill's body: ${plan.turns.length}, in ${plural(conversations, "conversation")}.`,
106
+ );
107
+ out(WHAT_A_SKILL_TURN_IS);
108
+ out("");
109
+ out(" question kept removed skill");
110
+ for (const [skill, kept, removed] of bySkill(plan)) {
111
+ out(`${String(kept).padStart(15)}${String(removed).padStart(9)} ${skill}`);
112
+ }
113
+ out("");
114
+ }
115
+ if (plan.conversationsToDelete.length > 0) {
116
+ out(
117
+ `Would delete ${plural(plan.conversationsToDelete.length, "conversation")} left with no user turn:`,
118
+ );
119
+ for (const item of plan.conversationsToDelete) {
120
+ out(
121
+ ` ${item.startedAt.slice(0, 10)} ${item.id.slice(0, 8)} ${plural(item.messageCount, "message")} ${item.title ?? "(untitled)"}`,
122
+ );
123
+ }
124
+ }
125
+ const retitles = plan.conversationsToRetitle.length;
126
+ if (retitles > 0) {
127
+ out(
128
+ `Would retitle ${plural(retitles, "conversation")} from ${retitles === 1 ? "its" : "their"} first remaining user turn.`,
129
+ );
130
+ }
131
+ out("Replies, summaries, and memories stay.");
132
+ out("Nothing was changed. To repair them, run:");
133
+ out(` ${applyCommand}`);
134
+ return 0;
135
+ });
136
+ }
137
+
138
+ /** By skill: [skill, rows that keep their question, rows that go]; most first, then by name. */
139
+ function bySkill(plan: SkillTurnRepairPlan): [string, number, number][] {
140
+ const counts = new Map<string, [number, number]>();
141
+ for (const turn of plan.turns) {
142
+ const [kept, removed] = counts.get(turn.skill) ?? [0, 0];
143
+ counts.set(turn.skill, turn.question !== null ? [kept + 1, removed] : [kept, removed + 1]);
144
+ }
145
+ return [...counts.entries()]
146
+ .map(([skill, [kept, removed]]): [string, number, number] => [skill, kept, removed])
147
+ .sort((a, b) => b[1] + b[2] - (a[1] + a[2]) || a[0].localeCompare(b[0]));
148
+ }
149
+
150
+ function apply(io: SkillTurnsIo, out: (line: string) => void, err: (line: string) => void): number {
151
+ // Decide first, read-only: nothing to repair stops here, and takes no backup. A
152
+ // holder rather than a variable: it is assigned inside a callback.
153
+ const decided: { key: string | null } = { key: null };
154
+ const decidedCode = io.withReadOnlyDatabase((db) => {
155
+ const plan = planSkillTurnRepair(db);
156
+ if (isEmpty(plan)) {
157
+ out("Nothing to repair.");
158
+ return 0;
159
+ }
160
+ decided.key = skillTurnRepairKey(plan);
161
+ return 0;
162
+ });
163
+ const expectedKey = decided.key;
164
+ if (decidedCode !== 0 || expectedKey === null) return decidedCode;
165
+
166
+ return io.withLiveWriteDatabase((db, dbPath) => {
167
+ let archive: string | null;
168
+ try {
169
+ archive = io.backup(dbPath);
170
+ } catch {
171
+ archive = null;
172
+ }
173
+ if (archive === null) {
174
+ err("The backup failed, so nothing was changed.");
175
+ return 1;
176
+ }
177
+ const verification = io.verifyBackup(archive);
178
+ if (!verification.valid) {
179
+ err(
180
+ `The backup ${archive} did not verify (${verification.note ?? "no reason given"}), so nothing was changed.`,
181
+ );
182
+ return 1;
183
+ }
184
+ let result: SkillTurnRepairResult;
185
+ try {
186
+ result = applySkillTurnRepair(db, expectedKey);
187
+ } catch (error) {
188
+ if (error instanceof SkillTurnRepairSetMovedError) {
189
+ err(
190
+ "The stored skill turns changed while the backup was taken, so nothing was changed. Run it again.",
191
+ );
192
+ return 1;
193
+ }
194
+ throw error;
195
+ }
196
+ out(`Backup: ${archive} (verified).`);
197
+ out(
198
+ `Kept ${plural(result.kept, "Mirror question")}; removed ${plural(result.removed, "skill turn")}.`,
199
+ );
200
+ if (result.conversationsDeleted > 0) {
201
+ out(
202
+ `Deleted ${plural(result.conversationsDeleted, "conversation")} left with no user turn, ` +
203
+ `with ${plural(result.messagesDeletedWithConversations, "more message")}.`,
204
+ );
205
+ out("Memories and provider-call records that named them were kept, unlinked.");
206
+ }
207
+ if (result.conversationsRetitled > 0) {
208
+ out(`Retitled ${plural(result.conversationsRetitled, "conversation")}.`);
209
+ }
210
+ out(`Copies remain in this home's backups until they age out (${RETENTION_DAYS} days).`);
211
+ return 0;
212
+ });
213
+ }
@@ -12,6 +12,11 @@
12
12
  // caller uses, and a payload does not belong in argv: argv is what `ps`
13
13
  // shows, and what the Gemini CLI hooks (retired by CV21.DS8.US1) put a prompt
14
14
  // into is the exception this seam avoids repeating.
15
+ //
16
+ // One exception stands, and is no wider than before: Pi's extension reaches the
17
+ // core through `pi.exec`, which cannot write a child's stdin, so it passes the
18
+ // payload as `--payload` (CR160, `conversationLoggerRoute.ts`). Pi's prompt has
19
+ // travelled in argv since CV9, as `log-user`'s argument.
15
20
 
16
21
  import { readFileSync } from "node:fs";
17
22
 
@@ -13,11 +13,14 @@
13
13
  // $mirror-mind:mm:mirror … the Codex plugin (TS2 L10)
14
14
  // /mm-mirror … Grok (TS4 L5)
15
15
  // $mm-mirror … Codex's own skill form
16
+ // /skill:mm-mirror … Pi's command, which its menu sends (CR160)
16
17
  //
17
18
  // Three choices, each made by the runtime, not the person: the sigil (`/` or
18
19
  // `$`), whether the plugin's name prefixes the skill, and whether the skill's
19
20
  // `name: "mm:mirror"` or its directory `mm-mirror` is used. The grammar is
20
21
  // their product, so a runtime that combines them differently is covered too.
22
+ // Pi is the exception: it registers every skill as the command `skill:<name>`
23
+ // (CR159 measured it on Pi 1.4.0), so its one form is spelled out whole.
21
24
  //
22
25
  // The form must open the prompt and end at whitespace or the end of it: a
23
26
  // longer skill name (`/mm-mirrors`) is another skill, and a form quoted in the
@@ -32,7 +35,7 @@ export interface MirrorForm {
32
35
  readonly query: string;
33
36
  }
34
37
 
35
- const FORM = /^([/$])(mirror-mind:)?mm[:-]mirror(?=\s|$)/;
38
+ const FORM = /^(?:\/skill:mm-mirror|[/$](?:mirror-mind:)?mm[:-]mirror)(?=\s|$)/;
36
39
 
37
40
  /** The explicit Mirror Mode form at the start of a prompt, or null. */
38
41
  export function mirrorForm(prompt: string): MirrorForm | null {