mirror-mind 0.36.2 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/mirror-logger.ts +293 -64
- package/.pi/skills/mm-help/SKILL.md +4 -1
- package/.pi/skills/mm-mirror/SKILL.md +25 -6
- package/README.md +3 -3
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.36.3.md +142 -0
- package/docs/releases/v0.37.0.md +221 -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/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
- package/ts/src/builder/commands.ts +9 -6
- package/ts/src/builder/load.ts +14 -2
- package/ts/src/conversation/commandTurn.ts +3 -2
- package/ts/src/conversation/logger.ts +12 -6
- package/ts/src/conversation/piSkillTurns.ts +267 -0
- package/ts/src/db/migrateOnOpen.ts +1 -1
- package/ts/src/db/migrations.ts +16 -0
- package/ts/src/db/schema.ts +0 -1
- package/ts/src/db/schemaInventorySnapshot.ts +1 -8
- package/ts/src/db/schemaState.ts +2 -0
- package/ts/src/db/schemaTsDivergence.ts +25 -4
- package/ts/src/frontDoor/cli.ts +58 -2
- package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -2
- package/ts/src/frontDoor/exploreRoute.ts +2 -2
- package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
- package/ts/src/frontDoor/routing.ts +26 -15
- package/ts/src/frontDoor/skillTurnsRoute.ts +213 -0
- package/ts/src/frontDoor/soulRoute.ts +4 -4
- package/ts/src/frontDoor/stdinSource.ts +5 -0
- package/ts/src/guards/testTempHomes.ts +92 -0
- package/ts/src/hooks/agy.ts +136 -59
- package/ts/src/hooks/claude.ts +29 -90
- package/ts/src/hooks/mirrorState.ts +10 -22
- package/ts/src/mirror/context.ts +19 -4
- package/ts/src/mirror/defaultResolution.ts +2 -1
- package/ts/src/mirror/doorman.ts +420 -0
- package/ts/src/mirror/mirrorForm.ts +4 -1
- package/ts/src/mirror/orchestration.ts +22 -3
- package/ts/src/mirror/render.ts +7 -1
- package/ts/src/mirror/runtimeSession.ts +66 -8
- package/ts/src/mode/operatingMode.ts +22 -4
- package/ts/src/providers/transport.ts +14 -0
- package/ts/src/runtime/diagnose.ts +32 -7
- package/ts/src/runtime/pluginWiring.ts +6 -4
- package/ts/src/runtime/status.ts +3 -2
- package/ts/src/runtime/update.ts +17 -1
- package/ts/src/runtime/wiring.ts +14 -1
- package/ts/src/search/memorySearch.ts +38 -18
- package/ts/src/search/ranker.ts +16 -2
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import type { Database, WritableDatabase } from "#db/database.ts";
|
|
4
|
+
import {
|
|
5
|
+
type DoormanDecision,
|
|
6
|
+
decideDoorman,
|
|
7
|
+
doormanPersonas,
|
|
8
|
+
forgetDoormanDelivery,
|
|
9
|
+
type MemoryScore,
|
|
10
|
+
readDoormanSessionState,
|
|
11
|
+
runDoorman,
|
|
12
|
+
} from "#mirror/doorman.ts";
|
|
4
13
|
import {
|
|
5
14
|
deactivateMirrorState,
|
|
6
15
|
listActiveMirrorJourneys,
|
|
7
16
|
logMirrorResponse,
|
|
8
17
|
runMirrorLoad,
|
|
9
18
|
} from "#mirror/orchestration.ts";
|
|
19
|
+
import { environmentSessionId, resolveNamedRuntimeSessionId } from "#mirror/runtimeSession.ts";
|
|
10
20
|
import {
|
|
11
21
|
activateOperatingMode,
|
|
12
22
|
deactivateOperatingMode,
|
|
@@ -16,7 +26,7 @@ import {
|
|
|
16
26
|
} from "#mode/operatingMode.ts";
|
|
17
27
|
import type { OnProviderCallOutcome } from "#observability/callOutcome.ts";
|
|
18
28
|
import { resolveFamilyProviders } from "#providers/familyProviders.ts";
|
|
19
|
-
import { MIRROR_QUERY_TRANSPORT } from "#providers/transport.ts";
|
|
29
|
+
import { MIRROR_QUERY_TRANSPORT, mirrorQueryTransportFor } from "#providers/transport.ts";
|
|
20
30
|
import { newId, nowIso } from "#util/pyGenerators.ts";
|
|
21
31
|
import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
|
|
22
32
|
|
|
@@ -28,6 +38,8 @@ import { hasOption, optionValue, stripOptionWithValue } from "./args.ts";
|
|
|
28
38
|
*/
|
|
29
39
|
export type MirrorRouteEnvironment = {
|
|
30
40
|
MIRROR_SESSION_ID?: string;
|
|
41
|
+
CLAUDE_CODE_SESSION_ID?: string;
|
|
42
|
+
CODEX_SESSION_ID?: string;
|
|
31
43
|
MEMORY_RECEPTION?: string;
|
|
32
44
|
MIRROR_TS_MIRROR_LLM_REPLAY?: string;
|
|
33
45
|
MIRROR_TS_MIRROR_EMBEDDING_REPLAY?: string;
|
|
@@ -37,14 +49,119 @@ export function isMirrorWrite(argv: readonly string[]): boolean {
|
|
|
37
49
|
return argv[0] === "mirror" && ["load", "deactivate", "log"].includes(argv[1] ?? "");
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `mirror doorman` is a write surface with its own handle discipline
|
|
54
|
+
* (CV9.DS8.TS2): it is not `isMirrorWrite`, because the front door's shared
|
|
55
|
+
* write seam opens the live-write path -- a database snapshot -- before any
|
|
56
|
+
* route runs, and a doorman turn that owes nothing must never pay that.
|
|
57
|
+
*/
|
|
58
|
+
export function isMirrorDoorman(argv: readonly string[]): boolean {
|
|
59
|
+
return argv[0] === "mirror" && argv[1] === "doorman";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DoormanRouteDeps {
|
|
63
|
+
/** The database, read-only: enough to decide. */
|
|
64
|
+
withReadOnlyDatabase: (read: (db: Database) => DoormanDecision) => DoormanDecision | number;
|
|
65
|
+
/** The database behind the pre-write snapshot: only for a turn that delivers. */
|
|
66
|
+
withLiveWriteDatabase: (
|
|
67
|
+
write: (db: WritableDatabase, dbPath: string) => Promise<number>,
|
|
68
|
+
) => Promise<number>;
|
|
69
|
+
onReceptionOutcome?: OnProviderCallOutcome;
|
|
70
|
+
/** Content-free: the turn's memory scores, hybrid and semantic, for the front-door log. */
|
|
71
|
+
onMemoryScores?: (scores: readonly MemoryScore[]) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `mirror doorman --prompt "<text>" [--session-id <id>]`: the one doorman as a
|
|
76
|
+
* command, for the Pi extension (US5). Prints the block or nothing; exit 0.
|
|
77
|
+
* `mirror doorman --forget [--session-id <id>]`: the runtime compacted the
|
|
78
|
+
* transcript; the named session's next ordinary turn is full again (US5's
|
|
79
|
+
* debt). Nothing printed; an unnamed session is nothing to mark.
|
|
80
|
+
*
|
|
81
|
+
* Two steps on purpose. The decision is read from a read-only handle; the
|
|
82
|
+
* live-write handle -- `VACUUM INTO` first, 95 ms measured on a 53 MB database
|
|
83
|
+
* -- opens only when the decision is `full` or `delta`. The ordinary turn
|
|
84
|
+
* outside Mirror Mode costs a read and nothing else, as the inject hook's does.
|
|
85
|
+
*/
|
|
86
|
+
export async function runMirrorDoormanRoute(
|
|
87
|
+
argv: readonly string[],
|
|
88
|
+
deps: DoormanRouteDeps,
|
|
89
|
+
env: MirrorRouteEnvironment = process.env,
|
|
90
|
+
): Promise<number> {
|
|
91
|
+
const args = argv.slice(2);
|
|
92
|
+
const sessionId = resolveNamedRuntimeSessionId(
|
|
93
|
+
optionValue(args, "--session-id") ?? null,
|
|
94
|
+
environmentSessionId(env),
|
|
95
|
+
);
|
|
96
|
+
if (hasOption(args, "--forget")) {
|
|
97
|
+
if (!sessionId) return 0;
|
|
98
|
+
return deps.withLiveWriteDatabase(async (db) => {
|
|
99
|
+
forgetDoormanDelivery(db, sessionId, nowIso());
|
|
100
|
+
return 0;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const prompt = optionValue(args, "--prompt");
|
|
104
|
+
if (prompt === null || prompt === undefined) {
|
|
105
|
+
console.error("mirror doorman requires --prompt");
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
const decided = deps.withReadOnlyDatabase((db) =>
|
|
109
|
+
decideDoorman({
|
|
110
|
+
prompt,
|
|
111
|
+
state: readDoormanSessionState(db, sessionId),
|
|
112
|
+
personas: doormanPersonas(db),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
if (typeof decided === "number") return decided;
|
|
116
|
+
if (decided.kind === "nothing") return 0;
|
|
117
|
+
|
|
118
|
+
// Providers only now: a turn that delivers may embed (delta) or route
|
|
119
|
+
// (full); the turn above resolved none, let alone a key.
|
|
120
|
+
const receptionEnabled = env.MEMORY_RECEPTION !== "0";
|
|
121
|
+
const family = await resolveFamilyProviders(env, mirrorQueryTransportFor(env));
|
|
122
|
+
return deps.withLiveWriteDatabase(async (db, dbPath) => {
|
|
123
|
+
const mirrorHome = dirname(dbPath);
|
|
124
|
+
const rendered = await runDoorman(db, decided, {
|
|
125
|
+
identity: basename(mirrorHome),
|
|
126
|
+
databasePath: dbPath,
|
|
127
|
+
mirrorHome,
|
|
128
|
+
user: basename(mirrorHome),
|
|
129
|
+
sessionId,
|
|
130
|
+
receptionEnabled,
|
|
131
|
+
onReceptionOutcome: deps.onReceptionOutcome,
|
|
132
|
+
llmProvider: receptionEnabled ? family.llm : undefined,
|
|
133
|
+
embeddingProvider: family.embedding,
|
|
134
|
+
onMemoryScores: deps.onMemoryScores,
|
|
135
|
+
newId,
|
|
136
|
+
nowIso,
|
|
137
|
+
});
|
|
138
|
+
process.stdout.write(rendered.stdout);
|
|
139
|
+
process.stderr.write(rendered.stderr);
|
|
140
|
+
return 0;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
40
144
|
export function isModeWrite(argv: readonly string[]): boolean {
|
|
41
145
|
if (argv[0] !== "mode") return false;
|
|
42
146
|
const args = modePositionals(argv.slice(1));
|
|
43
147
|
return args[0] === "activate" || args[0] === "deactivate";
|
|
44
148
|
}
|
|
45
149
|
|
|
46
|
-
|
|
47
|
-
|
|
150
|
+
/**
|
|
151
|
+
* `mirror deactivate` with nobody naming a session: `--session-id` absent AND
|
|
152
|
+
* no harness variable (CV9.DS8.US5 -- until then `--session-id` alone, so
|
|
153
|
+
* `/mm-new` on a Pi whose extension had named the session still refused).
|
|
154
|
+
*/
|
|
155
|
+
export function mirrorDeactivateMissingSession(
|
|
156
|
+
argv: readonly string[],
|
|
157
|
+
env: MirrorRouteEnvironment = process.env,
|
|
158
|
+
): boolean {
|
|
159
|
+
return (
|
|
160
|
+
argv[0] === "mirror" &&
|
|
161
|
+
argv[1] === "deactivate" &&
|
|
162
|
+
!optionValue(argv, "--session-id") &&
|
|
163
|
+
environmentSessionId(env) === null
|
|
164
|
+
);
|
|
48
165
|
}
|
|
49
166
|
|
|
50
167
|
export function runMirrorRead(db: Database, args: readonly string[]): number {
|
|
@@ -70,7 +187,10 @@ export async function runMirrorWriteRoute(
|
|
|
70
187
|
const args = argv.slice(2);
|
|
71
188
|
const sessionId = optionValue(args, "--session-id");
|
|
72
189
|
if (sub === "deactivate") {
|
|
73
|
-
|
|
190
|
+
// The session the caller named, else the harness's (TS2's reader): the
|
|
191
|
+
// unnamed case was refused before this route ran.
|
|
192
|
+
const named = resolveNamedRuntimeSessionId(sessionId ?? null, environmentSessionId(env));
|
|
193
|
+
deactivateMirrorState(db, named as string, nowIso());
|
|
74
194
|
console.error("Mirror Mode deactivated.");
|
|
75
195
|
return 0;
|
|
76
196
|
}
|
|
@@ -112,7 +232,11 @@ export async function runMirrorWriteRoute(
|
|
|
112
232
|
org: hasOption(args, "--org"),
|
|
113
233
|
contextOnly: hasOption(args, "--context-only"),
|
|
114
234
|
sessionId,
|
|
115
|
-
|
|
235
|
+
// The harness's variable, whichever runtime set it (CV9.DS8.TS2): the
|
|
236
|
+
// skill's own load on Claude Code or Codex names the session the hooks
|
|
237
|
+
// write. `mirror log` keeps reading `MIRROR_SESSION_ID` alone: its
|
|
238
|
+
// fallback is a guess, and widening that is a CR008 question.
|
|
239
|
+
environmentSessionId: environmentSessionId(env),
|
|
116
240
|
receptionEnabled,
|
|
117
241
|
onReceptionOutcome: options.onReceptionOutcome,
|
|
118
242
|
llmProvider: receptionEnabled ? family?.llm : undefined,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
CULTIVATION_SCAN_TRANSPORT,
|
|
9
9
|
DESCRIPTOR_TRANSPORT,
|
|
10
10
|
JOURNAL_TRANSPORT,
|
|
11
|
-
|
|
11
|
+
mirrorQueryTransportFor,
|
|
12
12
|
type ProviderTransportSpec,
|
|
13
13
|
resolveComposedProviderTransport,
|
|
14
14
|
resolveProviderTransport,
|
|
@@ -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` };
|
|
@@ -735,22 +745,23 @@ export function routeByFamily(
|
|
|
735
745
|
|
|
736
746
|
if (command === "mirror") {
|
|
737
747
|
const sub = argv[1];
|
|
738
|
-
|
|
739
|
-
|
|
748
|
+
// CV9.DS8.TS2: the doorman decides before it resolves a provider, and a
|
|
749
|
+
// `nothing` turn resolves none -- but a `full` turn is `load --query`, so
|
|
750
|
+
// the route answers for the doorman exactly as it answers for that.
|
|
751
|
+
if (sub === "load" || sub === "doorman") {
|
|
752
|
+
const hasQuery = sub === "doorman" || argv.includes("--query");
|
|
740
753
|
if (!hasQuery) {
|
|
741
754
|
return { command, engine: "ts", reason: "DS7.US4 deterministic mirror load ported to TS" };
|
|
742
755
|
}
|
|
743
|
-
// `MEMORY_RECEPTION=0` skips the classifier
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
: MIRROR_QUERY_TRANSPORT;
|
|
753
|
-
return providerRoute(command, env, spec, "load --query");
|
|
756
|
+
// `MEMORY_RECEPTION=0` skips the classifier, so the chat fixture stops
|
|
757
|
+
// being required for a replay run; the embedding half still is, because
|
|
758
|
+
// the query still searches attachments and journeys.
|
|
759
|
+
return providerRoute(
|
|
760
|
+
command,
|
|
761
|
+
env,
|
|
762
|
+
mirrorQueryTransportFor(env),
|
|
763
|
+
sub === "doorman" ? "doorman" : "load --query",
|
|
764
|
+
);
|
|
754
765
|
}
|
|
755
766
|
if (sub === "deactivate" || sub === "log" || sub === "journeys") {
|
|
756
767
|
return { command, engine: "ts", reason: `DS7.US4 mirror ${sub} ported to TS` };
|
|
@@ -942,7 +953,7 @@ const TASKS_SUBCOMMANDS = [
|
|
|
942
953
|
const WEEK_SUBCOMMANDS = ["view", "plan", "save"];
|
|
943
954
|
const CONSOLIDATE_SUBCOMMANDS = ["scan", "apply", "reject", "list"];
|
|
944
955
|
const SHADOW_SUBCOMMANDS = ["scan", "apply", "reject", "list", "show"];
|
|
945
|
-
const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys"];
|
|
956
|
+
const MIRROR_SUBCOMMANDS = ["load", "deactivate", "log", "journeys", "doorman"];
|
|
946
957
|
const MODE_SUBCOMMANDS = ["activate", "deactivate", "status"];
|
|
947
958
|
|
|
948
959
|
/** `mode`'s first positional, past the two options its parser declares; "" when none. */
|
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { Database, WritableDatabase } from "#db/database.ts";
|
|
16
16
|
import { getIdentityContent } from "#identity/identityRead.ts";
|
|
17
17
|
import { persistStickyDefaults } from "#mirror/orchestration.ts";
|
|
18
|
-
import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
|
|
18
|
+
import { environmentSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
|
|
19
19
|
import { activateOperatingMode } from "#mode/operatingMode.ts";
|
|
20
20
|
import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
|
|
21
21
|
import { generateEmbeddingSafely } from "#providers/embedding.ts";
|
|
@@ -310,7 +310,7 @@ function runLoad(
|
|
|
310
310
|
const resolvedSessionId = resolveRuntimeSessionId(
|
|
311
311
|
db,
|
|
312
312
|
sessionId,
|
|
313
|
-
process.env
|
|
313
|
+
environmentSessionId(process.env),
|
|
314
314
|
);
|
|
315
315
|
activateOperatingMode(
|
|
316
316
|
db,
|
|
@@ -355,7 +355,7 @@ function runFruit(
|
|
|
355
355
|
sessionId: string | null,
|
|
356
356
|
deps: SoulRouteDeps,
|
|
357
357
|
): number {
|
|
358
|
-
const resolved = resolveCliSoulSessionId(db, sessionId, process.env
|
|
358
|
+
const resolved = resolveCliSoulSessionId(db, sessionId, environmentSessionId(process.env));
|
|
359
359
|
if (action === "set") {
|
|
360
360
|
if (fruit === null) return usageError("soul fruit set requires a fruit");
|
|
361
361
|
const state = setFruitInMaturation(db, fruit, resolved, deps.nowIso());
|
|
@@ -384,7 +384,7 @@ async function runHarvest(
|
|
|
384
384
|
const resolved = resolveCliSoulSessionId(
|
|
385
385
|
db,
|
|
386
386
|
option("--session-id"),
|
|
387
|
-
process.env
|
|
387
|
+
environmentSessionId(process.env),
|
|
388
388
|
);
|
|
389
389
|
if (action === "set") {
|
|
390
390
|
const state = harvestFruit(db, { fruit, sessionId: resolved }, deps.nowIso());
|
|
@@ -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
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// CR163 — no test makes a temp home under a literal /tmp.
|
|
2
|
+
//
|
|
3
|
+
// The suite's temp homes are made with `tempDir` (ts/test/helpers/tempDir.ts),
|
|
4
|
+
// under os.tmpdir(), and removed when the test file's process exits. The
|
|
5
|
+
// run-level check (scripts/test_leaves_nothing.sh) proves a run leaves nothing
|
|
6
|
+
// by pointing TMPDIR at an empty directory; it sees every home only because
|
|
7
|
+
// none is made anywhere else. A home under a literal /tmp would escape it, so
|
|
8
|
+
// this guard keeps one from coming back: every `.ts` under ts/test, helpers and
|
|
9
|
+
// support included, in each form the tree has used.
|
|
10
|
+
//
|
|
11
|
+
// A line that is a comment is not a finding, nor is a /tmp that names a path
|
|
12
|
+
// no test creates (an asserted string, a golden's literal). An exception names
|
|
13
|
+
// its reason, and one that no longer matches anything is reported as stale, so
|
|
14
|
+
// it cannot outlive that reason.
|
|
15
|
+
|
|
16
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join, relative, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Files allowed a literal /tmp temp home, each with the reason it must stay. */
|
|
20
|
+
export const EXCEPTIONS: Readonly<Record<string, string>> = {
|
|
21
|
+
"ts/test/helpers/builderWorld.ts":
|
|
22
|
+
"CR164: pathsWalk sees a leaked absolute path only under a short root",
|
|
23
|
+
"ts/test/scripts/testTempHomes.test.ts": "this guard's own cases, held as strings",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Each form the tree has used to make a temp home under a literal /tmp. */
|
|
27
|
+
const FORMS: readonly RegExp[] = [
|
|
28
|
+
// mkdtempSync("/tmp/…"), a template, the async mkdtemp; the argument may open a new line.
|
|
29
|
+
/\bmkdtemp(?:Sync)?\(\s*["'`]\/tmp\//g,
|
|
30
|
+
// mkdtempSync(join("/tmp", …))
|
|
31
|
+
/\bmkdtemp(?:Sync)?\(\s*join\(\s*["'`]\/tmp["'`]/g,
|
|
32
|
+
// realpathSync("/tmp"), as in mkdtempSync(join(realpathSync("/tmp"), …))
|
|
33
|
+
/\brealpathSync\(\s*["'`]\/tmp["'`]\s*\)/g,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export interface TempHomeFinding {
|
|
37
|
+
/** Repository-relative, with forward slashes. */
|
|
38
|
+
readonly file: string;
|
|
39
|
+
readonly line: number;
|
|
40
|
+
readonly text: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TempHomeSweep {
|
|
44
|
+
readonly findings: readonly TempHomeFinding[];
|
|
45
|
+
/** Exceptions whose file no longer makes such a home. */
|
|
46
|
+
readonly staleExceptions: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isComment(line: string): boolean {
|
|
50
|
+
const text = line.trimStart();
|
|
51
|
+
return text.startsWith("//") || text.startsWith("*") || text.startsWith("/*");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function testFiles(repoRoot: string): string[] {
|
|
55
|
+
const testRoot = join(repoRoot, "ts", "test");
|
|
56
|
+
return readdirSync(testRoot, { recursive: true, withFileTypes: true })
|
|
57
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
|
58
|
+
.map((entry) => relative(repoRoot, join(entry.parentPath, entry.name)).split(sep).join("/"))
|
|
59
|
+
.sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scan(source: string): { line: number; text: string }[] {
|
|
63
|
+
const lines = source.split("\n");
|
|
64
|
+
const hits = new Map<number, string>();
|
|
65
|
+
for (const form of FORMS) {
|
|
66
|
+
for (const match of source.matchAll(form)) {
|
|
67
|
+
const line = source.slice(0, match.index).split("\n").length;
|
|
68
|
+
if (!isComment(lines[line - 1])) hits.set(line, lines[line - 1].trim());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [...hits].sort(([a], [b]) => a - b).map(([line, text]) => ({ line, text }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every temp home under a literal /tmp in `ts/test` below `repoRoot`, but the exceptions. */
|
|
75
|
+
export function findLiteralTmpHomes(
|
|
76
|
+
repoRoot: string,
|
|
77
|
+
exceptions: Readonly<Record<string, string>> = EXCEPTIONS,
|
|
78
|
+
): TempHomeSweep {
|
|
79
|
+
const findings: TempHomeFinding[] = [];
|
|
80
|
+
const excepted = new Set<string>();
|
|
81
|
+
for (const file of testFiles(repoRoot)) {
|
|
82
|
+
const hits = scan(readFileSync(join(repoRoot, file), "utf8"));
|
|
83
|
+
if (hits.length === 0) continue;
|
|
84
|
+
if (file in exceptions) {
|
|
85
|
+
excepted.add(file);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const hit of hits) findings.push({ file, ...hit });
|
|
89
|
+
}
|
|
90
|
+
const staleExceptions = Object.keys(exceptions).filter((file) => !excepted.has(file));
|
|
91
|
+
return { findings, staleExceptions };
|
|
92
|
+
}
|