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.
- package/.pi/extensions/mirror-logger.ts +128 -26
- package/.pi/skills/mm-help/SKILL.md +4 -1
- package/README.md +1 -1
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.36.3.md +142 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +1 -1
- package/ts/src/conversation/commandTurn.ts +3 -2
- package/ts/src/conversation/piSkillTurns.ts +267 -0
- package/ts/src/frontDoor/cli.ts +16 -0
- package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -2
- package/ts/src/frontDoor/routing.ts +10 -0
- package/ts/src/frontDoor/skillTurnsRoute.ts +213 -0
- package/ts/src/frontDoor/stdinSource.ts +5 -0
- package/ts/src/mirror/mirrorForm.ts +4 -1
|
@@ -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
|
-
* -
|
|
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 →
|
|
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.
|
|
619
|
+
// --- 2. input → what the person typed, for the run it starts (CR160) ---
|
|
562
620
|
|
|
563
|
-
pi.on("
|
|
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
|
-
|
|
568
|
-
if (!prompt || prompt.startsWith("/")) return;
|
|
631
|
+
// --- 3. before_agent_start → the turn to the core's prompt rule ---
|
|
569
632
|
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
"
|
|
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
|
-
// ---
|
|
667
|
+
// --- 4. agent_end → the run's assistant text, kept for the prompt's reply ---
|
|
584
668
|
//
|
|
585
|
-
// agent_end fires once per
|
|
586
|
-
//
|
|
587
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
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
|
|
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
|
package/docs/releases/index.md
CHANGED
|
@@ -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
|
@@ -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)
|
|
33
|
-
//
|
|
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
|
+
}
|
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -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 = /^([/$]
|
|
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 {
|