mirror-mind 0.36.3 → 0.38.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 +173 -46
- package/.pi/skills/mm-mirror/SKILL.md +25 -6
- package/README.md +2 -2
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.37.0.md +221 -0
- package/docs/releases/v0.38.0.md +215 -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/closeTail.ts +7 -3
- package/ts/src/conversation/extraction.ts +1 -11
- package/ts/src/conversation/logger.ts +12 -6
- package/ts/src/conversation/userName.ts +44 -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/extraction/conversationMetadata.ts +53 -8
- package/ts/src/extraction/prompts.ts +8 -3
- package/ts/src/frontDoor/cli.ts +42 -2
- package/ts/src/frontDoor/exploreRoute.ts +2 -2
- package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
- package/ts/src/frontDoor/routing.ts +16 -15
- package/ts/src/frontDoor/runtimeRoute.ts +4 -0
- package/ts/src/frontDoor/soulRoute.ts +4 -4
- 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/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/config.ts +105 -1
- package/ts/src/providers/cost.ts +5 -1
- package/ts/src/providers/llm.ts +40 -0
- package/ts/src/providers/transport.ts +14 -0
- package/ts/src/runtime/diagnose.ts +64 -7
- package/ts/src/runtime/pluginWiring.ts +6 -4
- package/ts/src/runtime/status.ts +24 -4
- 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
package/ts/src/hooks/agy.ts
CHANGED
|
@@ -1,45 +1,49 @@
|
|
|
1
|
-
// Antigravity hook entries (CV21.DS7.TS2
|
|
1
|
+
// Antigravity hook entries (CV21.DS7.TS2; through the one doorman since
|
|
2
|
+
// CV9.DS8.TS6).
|
|
2
3
|
//
|
|
3
4
|
// agy runs the plugin's `hooks.json` handlers with camelCase protojson on
|
|
4
5
|
// stdin and reads JSON from stdout (`injectSteps`; a `systemMessage` step
|
|
5
6
|
// nests its text, an `ephemeralMessage` is a plain string). The contracts
|
|
6
|
-
// are committed bytes in CV21.DS7.US1's `evidence/` (agy 1.4.2)
|
|
7
|
+
// are committed bytes in CV21.DS7.US1's `evidence/` (agy 1.4.2) and in
|
|
8
|
+
// TS6's `evidence/probe-20261025.md` (agy 1.4.7).
|
|
7
9
|
//
|
|
8
|
-
//
|
|
10
|
+
// Three handlers:
|
|
9
11
|
//
|
|
10
|
-
// * `agy:session-start` — turn conversation logging on,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// (US1), unless TS2's live route shows otherwise — the measured
|
|
14
|
-
// fallback would add an `injectSteps` `systemMessage` in this handler.
|
|
12
|
+
// * `agy:session-start` — turn conversation logging on, turn Mirror Mode
|
|
13
|
+
// on for the conversation, and answer `{}`. The Operating Instructions
|
|
14
|
+
// are NOT delivered here: `rules/AGENTS.md` carries them (US1).
|
|
15
15
|
//
|
|
16
|
-
// * `agy:pre-invocation` —
|
|
17
|
-
// `PreInvocation` fires before EVERY model call
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// adapter re-injects per turn, so a persistent step would accumulate;
|
|
24
|
-
// and the content is identity and memory, so ephemeral is the
|
|
25
|
-
// data-minimizing choice against agy's stored trajectory.
|
|
16
|
+
// * `agy:pre-invocation` — put Mirror's context before the model once per
|
|
17
|
+
// user turn. `PreInvocation` fires before EVERY model call, with
|
|
18
|
+
// `invocationNum` resetting to 0 at each user turn; protojson omits
|
|
19
|
+
// zero-valued fields, so an ABSENT invocationNum is 0. The generated
|
|
20
|
+
// wrapper already gates on this before Node is spawned; the gate here is
|
|
21
|
+
// defense in depth. At invocation 0 the hook hands the turn to the one
|
|
22
|
+
// doorman and answers its block as ONE persistent `systemMessage` step.
|
|
26
23
|
//
|
|
27
|
-
// agy
|
|
28
|
-
// and the explicit Mirror form reaches the model through the skill (L4),
|
|
29
|
-
// never through these hooks. The quiet session — a `conversationId` no
|
|
30
|
-
// runtime session binds (every session until TS3 lands), or one bound with
|
|
31
|
-
// Mirror Mode off — answers `{}`: Mirror on agy is invisible until invoked.
|
|
24
|
+
// * `agy:stop` — log the turns from the transcript (agy has no SessionEnd).
|
|
32
25
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// payload
|
|
26
|
+
// Where the prompt comes from. On agy 1.4.2 no payload carried the user's
|
|
27
|
+
// text (DS12.TS3 L7), so this hook could only re-inject the whole folder on
|
|
28
|
+
// every turn, routed from nothing. agy 1.4.7 carries it in every payload, as
|
|
29
|
+
// `lastUserInput`, verbatim -- a skill turn in its slash form (TS6's probe) --
|
|
30
|
+
// so the hook reads the prompt from the payload and never from a transcript.
|
|
31
|
+
// A payload without it (agy before 1.4.7) keeps the pre-TS6 injection,
|
|
32
|
+
// unchanged: Navigator decision (b), 2026-10-25, removable when the runtime
|
|
33
|
+
// matrix's floor for agy reaches 1.4.7.
|
|
34
|
+
//
|
|
35
|
+
// Payload fields stay inert: `conversationId` and the prompt flow only
|
|
36
|
+
// through array argv, never a shell, and failures recorded in hooks.log are
|
|
37
|
+
// facts, never payload text.
|
|
36
38
|
|
|
37
39
|
import { readAgyTurns } from "#conversation/agyTranscript.ts";
|
|
38
40
|
import { isCommandTurn } from "#conversation/commandTurn.ts";
|
|
39
41
|
import { logAssistantMessage, logUserMessage } from "#conversation/logger.ts";
|
|
40
42
|
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
41
43
|
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
44
|
+
import { forgetDoormanDelivery } from "#mirror/doorman.ts";
|
|
42
45
|
import { decodeMetadata, getRuntimeSession, upsertRuntimeSession } from "#mirror/runtimeSession.ts";
|
|
46
|
+
import { activateOperatingMode } from "#mode/operatingMode.ts";
|
|
43
47
|
import { newId, nowIso } from "#util/pyGenerators.ts";
|
|
44
48
|
import { readMirrorState } from "./mirrorState.ts";
|
|
45
49
|
import { noteHookFailure, runFrontDoor, runFrontDoorQuietly } from "./runtime.ts";
|
|
@@ -51,8 +55,21 @@ interface AgyPayload {
|
|
|
51
55
|
readonly conversationId: string;
|
|
52
56
|
readonly invocationNum: number;
|
|
53
57
|
readonly transcriptPath: string;
|
|
58
|
+
/**
|
|
59
|
+
* The turn's prompt, verbatim (agy 1.4.7, every payload), or null: absent
|
|
60
|
+
* before 1.4.7, and protojson omits an empty string, so absent and empty
|
|
61
|
+
* are one case.
|
|
62
|
+
*/
|
|
63
|
+
readonly lastUserInput: string | null;
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
const EMPTY_PAYLOAD: AgyPayload = {
|
|
67
|
+
conversationId: "",
|
|
68
|
+
invocationNum: 0,
|
|
69
|
+
transcriptPath: "",
|
|
70
|
+
lastUserInput: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
56
73
|
/** The key under which the Stop handler stores its high-water step_index. */
|
|
57
74
|
const HIGH_WATER_KEY = "agy_last_step_index";
|
|
58
75
|
|
|
@@ -63,35 +80,45 @@ const HIGH_WATER_KEY = "agy_last_step_index";
|
|
|
63
80
|
export function parseAgyPayload(stdin: string): AgyPayload {
|
|
64
81
|
try {
|
|
65
82
|
const raw: unknown = JSON.parse(stdin);
|
|
66
|
-
if (typeof raw !== "object" || raw === null)
|
|
67
|
-
return { conversationId: "", invocationNum: 0, transcriptPath: "" };
|
|
68
|
-
}
|
|
83
|
+
if (typeof raw !== "object" || raw === null) return EMPTY_PAYLOAD;
|
|
69
84
|
const record = raw as Record<string, unknown>;
|
|
70
85
|
return {
|
|
71
86
|
conversationId: typeof record.conversationId === "string" ? record.conversationId : "",
|
|
72
87
|
// protojson omits zero-valued fields: absent IS 0.
|
|
73
88
|
invocationNum: typeof record.invocationNum === "number" ? record.invocationNum : 0,
|
|
74
89
|
transcriptPath: typeof record.transcriptPath === "string" ? record.transcriptPath : "",
|
|
90
|
+
lastUserInput:
|
|
91
|
+
typeof record.lastUserInput === "string" && record.lastUserInput.trim()
|
|
92
|
+
? record.lastUserInput
|
|
93
|
+
: null,
|
|
75
94
|
};
|
|
76
95
|
} catch {
|
|
77
|
-
return
|
|
96
|
+
return EMPTY_PAYLOAD;
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
/**
|
|
82
|
-
* `SessionStart`: logging on,
|
|
83
|
-
* the contract.
|
|
101
|
+
* `SessionStart`: logging on, turn Mirror Mode on for the conversation,
|
|
102
|
+
* answer the contract.
|
|
103
|
+
*
|
|
104
|
+
* Mirror Mode activation (CV21.DS7.US4 scope change). The `/mirror-mind:mm:*`
|
|
105
|
+
* skill runs `mirror load` as an agent command that cannot name the agy
|
|
106
|
+
* session, and on agy 1.4.2 no hook carried the prompt to detect the Mirror
|
|
107
|
+
* form by. So SessionStart — the one hook that carries the `conversationId`
|
|
108
|
+
* at the start of a conversation — turns Mirror Mode on, always: Gemini CLI's
|
|
109
|
+
* always-on per-turn injection, the runtime Antigravity replaces.
|
|
84
110
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* the
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
111
|
+
* Three writes since CV9.DS8.TS6, because the doorman reads more than the
|
|
112
|
+
* flag. `mirror_active`, as before. The session's OWN operating-mode stamp,
|
|
113
|
+
* Mirror Mode: the doorman treats a session as in Mirror Mode only while its
|
|
114
|
+
* mode says so -- its own stamp, else the global one -- so without it a new
|
|
115
|
+
* conversation would answer to whatever mode was last stamped globally, and a
|
|
116
|
+
* home last switched to Builder would leave agy silent. And the doorman's
|
|
117
|
+
* refresh (`forgetDoormanDelivery`): nothing is in this conversation's
|
|
118
|
+
* transcript yet, so its first ordinary turn is the full folder, routed from
|
|
119
|
+
* that turn's prompt, and the deltas follow. agy fires SessionStart for a new
|
|
120
|
+
* conversation, not a resumed one (TS6's probe): a resumed conversation keeps
|
|
121
|
+
* the folder its persistent steps already hold.
|
|
95
122
|
*/
|
|
96
123
|
export async function agySessionStart(stdin: string): Promise<number> {
|
|
97
124
|
await runFrontDoorQuietly("agy:session-start", ["conversation-logger", "session-start"]);
|
|
@@ -102,12 +129,15 @@ export async function agySessionStart(stdin: string): Promise<number> {
|
|
|
102
129
|
const dbPath = resolveDbPath([], process.env);
|
|
103
130
|
const db = openLiveWriteDatabase(dbPath);
|
|
104
131
|
try {
|
|
132
|
+
const now = nowIso();
|
|
105
133
|
upsertRuntimeSession(
|
|
106
134
|
db,
|
|
107
135
|
conversationId,
|
|
108
136
|
{ interface: "antigravity", mirrorActive: true },
|
|
109
|
-
|
|
137
|
+
now,
|
|
110
138
|
);
|
|
139
|
+
activateOperatingMode(db, { mode: "Mirror Mode", sessionId: conversationId }, now);
|
|
140
|
+
forgetDoormanDelivery(db, conversationId, now);
|
|
111
141
|
} finally {
|
|
112
142
|
db.close();
|
|
113
143
|
}
|
|
@@ -190,40 +220,87 @@ export async function agyStop(stdin: string): Promise<number> {
|
|
|
190
220
|
return 0;
|
|
191
221
|
}
|
|
192
222
|
|
|
193
|
-
/**
|
|
223
|
+
/** One step of agy's `injectSteps`: the persistent kind, or the ephemeral one. */
|
|
224
|
+
type AgyStep = { systemMessage: { systemMessage: string } } | { ephemeralMessage: string };
|
|
225
|
+
|
|
226
|
+
const PRE_INVOCATION = "agy:pre-invocation";
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `PreInvocation`: at a user turn's first model call, Mirror's context as ONE
|
|
230
|
+
* step, when one is owed.
|
|
231
|
+
*/
|
|
194
232
|
export async function agyPreInvocation(stdin: string): Promise<number> {
|
|
195
233
|
const payload = parseAgyPayload(stdin);
|
|
196
234
|
|
|
197
235
|
// Defense in depth behind the wrapper's shell gate: only the turn's first
|
|
198
|
-
// invocation injects; the
|
|
236
|
+
// invocation injects; the others answer instantly and touch nothing.
|
|
199
237
|
if (payload.invocationNum !== 0 || !payload.conversationId) {
|
|
200
238
|
process.stdout.write(EMPTY);
|
|
201
239
|
return 0;
|
|
202
240
|
}
|
|
203
241
|
|
|
242
|
+
const step =
|
|
243
|
+
payload.lastUserInput === null
|
|
244
|
+
? await contextWithoutPrompt(payload.conversationId)
|
|
245
|
+
: await doormanStep(payload.conversationId, payload.lastUserInput);
|
|
246
|
+
process.stdout.write(step ? `${JSON.stringify({ injectSteps: [step] })}\n` : EMPTY);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The turn through the one doorman (CV9.DS8.TS6), handed over as
|
|
252
|
+
* `claudeInject` hands Claude's: in-process, through the front door, the hook
|
|
253
|
+
* deciding nothing. `mirror doorman` answers the full folder on the explicit
|
|
254
|
+
* form -- even in a conversation no SessionStart activated -- a delta on an
|
|
255
|
+
* ordinary turn in Mirror Mode, and nothing on a skill turn or outside it; the
|
|
256
|
+
* session writes, the one embedding per delta turn, and the handle discipline
|
|
257
|
+
* are its own. The hook reads no transcript: the prompt goes to the doorman
|
|
258
|
+
* and nowhere else.
|
|
259
|
+
*
|
|
260
|
+
* The block rides in a `systemMessage` step, which agy keeps before the model
|
|
261
|
+
* on later turns; the pre-TS6 `ephemeralMessage` would drop the folder after
|
|
262
|
+
* this one, and every delta stands on the folder still being there. Ephemeral
|
|
263
|
+
* never kept identity off disk either: agy writes the step into the stored
|
|
264
|
+
* transcript all the same (TS6's probe, TS2's observation).
|
|
265
|
+
*/
|
|
266
|
+
async function doormanStep(conversationId: string, prompt: string): Promise<AgyStep | null> {
|
|
267
|
+
// `--session-id` before `--prompt`: the route reads a flag's FIRST
|
|
268
|
+
// occurrence, so a prompt that is exactly `--session-id` stays the prompt.
|
|
269
|
+
const argv = ["mirror", "doorman", "--session-id", conversationId, "--prompt", prompt];
|
|
270
|
+
try {
|
|
271
|
+
const result = await runFrontDoor(argv);
|
|
272
|
+
if (result.code !== 0)
|
|
273
|
+
noteHookFailure(PRE_INVOCATION, `\`mirror doorman\` exited ${result.code}`);
|
|
274
|
+
return result.stdout.trim() ? { systemMessage: { systemMessage: result.stdout } } : null;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
noteHookFailure(PRE_INVOCATION, error instanceof Error ? error.message : String(error));
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* agy before 1.4.7, whose payload carries no prompt: the injection this hook
|
|
283
|
+
* made before TS6, unchanged (Navigator decision (b)). The quiet session --
|
|
284
|
+
* unbound, or bound with Mirror Mode off -- gets nothing; an active one gets
|
|
285
|
+
* the whole folder with its session's persona and journey, re-injected each
|
|
286
|
+
* turn as an `ephemeralMessage`. Removable when the runtime matrix's floor
|
|
287
|
+
* for agy reaches 1.4.7.
|
|
288
|
+
*/
|
|
289
|
+
async function contextWithoutPrompt(conversationId: string): Promise<AgyStep | null> {
|
|
204
290
|
let dbPath: string;
|
|
205
291
|
try {
|
|
206
292
|
dbPath = resolveDbPath([], process.env);
|
|
207
293
|
} catch {
|
|
208
|
-
|
|
209
|
-
return 0;
|
|
294
|
+
return null;
|
|
210
295
|
}
|
|
211
296
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!state.active) {
|
|
215
|
-
process.stdout.write(EMPTY);
|
|
216
|
-
return 0;
|
|
217
|
-
}
|
|
297
|
+
const state = readMirrorState(dbPath, conversationId);
|
|
298
|
+
if (!state.active) return null;
|
|
218
299
|
|
|
219
|
-
const argv = ["mirror", "load", "--context-only", "--session-id",
|
|
300
|
+
const argv = ["mirror", "load", "--context-only", "--session-id", conversationId];
|
|
220
301
|
if (state.persona) argv.push("--persona", state.persona);
|
|
221
302
|
if (state.journey) argv.push("--journey", state.journey);
|
|
222
303
|
|
|
223
304
|
const result = await runFrontDoor(argv);
|
|
224
|
-
|
|
225
|
-
process.stdout.write(
|
|
226
|
-
context ? `${JSON.stringify({ injectSteps: [{ ephemeralMessage: context }] })}\n` : EMPTY,
|
|
227
|
-
);
|
|
228
|
-
return 0;
|
|
305
|
+
return result.stdout.trim() ? { ephemeralMessage: result.stdout } : null;
|
|
229
306
|
}
|
package/ts/src/hooks/claude.ts
CHANGED
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
// loud, and every failure is still non-fatal to the user's turn.
|
|
13
13
|
|
|
14
14
|
import { runtimeReadsPromptContext } from "#conversation/runtimePolicy.ts";
|
|
15
|
-
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
16
|
-
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
17
|
-
import { mirrorForm } from "#mirror/mirrorForm.ts";
|
|
18
|
-
import { markInjected, needsInject, readMirrorState } from "./mirrorState.ts";
|
|
19
15
|
import { parseHookPayload, readStdin } from "./payload.ts";
|
|
20
16
|
import {
|
|
21
17
|
noteHookFailure,
|
|
@@ -108,105 +104,48 @@ export async function claudeSessionEnd(stdin: string): Promise<number> {
|
|
|
108
104
|
}
|
|
109
105
|
|
|
110
106
|
/**
|
|
111
|
-
* `mirror-inject.sh`:
|
|
107
|
+
* `mirror-inject.sh`: put Mirror's context before the model, by the one
|
|
108
|
+
* doorman (CV9.DS8.US4).
|
|
112
109
|
*
|
|
113
|
-
*
|
|
110
|
+
* Until US4 this hook carried a policy of its own -- the explicit Mirror form
|
|
111
|
+
* loaded the folder; a session in Mirror Mode was loaded once more and marked
|
|
112
|
+
* `hook_injected` -- and neither block carried the line the `mm-mirror` skill
|
|
113
|
+
* reads (TS3), so every explicit turn loaded twice, and once the one injection
|
|
114
|
+
* was spent a follow-up in another domain re-routed nothing (CR165).
|
|
114
115
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
116
|
+
* Now the hook decides nothing. It hands the prompt and the session to
|
|
117
|
+
* `mirror doorman` (TS2), in-process through the front door, and writes back
|
|
118
|
+
* what it delivers: the full folder on the explicit form, opened by the
|
|
119
|
+
* delivered line; a delta -- the newly routed persona, the memories and
|
|
120
|
+
* attachments for this prompt -- on an ordinary turn while the session is in
|
|
121
|
+
* Mirror Mode; nothing otherwise. The session writes, the one embedding per
|
|
122
|
+
* delta turn, the memory-score log, and the handle discipline (a quiet turn
|
|
123
|
+
* opens no writable handle) are the doorman's. The Pi extension reaches the
|
|
124
|
+
* same route (US5).
|
|
121
125
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
126
|
+
* Two things from before are kept on purpose. CR143: on a runtime that
|
|
127
|
+
* discards what this hook returns (Grok), return before any load. CR100: a
|
|
128
|
+
* payload with no session id is reported on stderr rather than passed over in
|
|
129
|
+
* silence -- it is the difference between "Mirror Mode is off" and "this hook
|
|
130
|
+
* is misconfigured" -- and the doorman still runs, so an explicit turn delivers
|
|
131
|
+
* its folder unnamed, as `mirror doorman` does without `--session-id`.
|
|
125
132
|
*/
|
|
126
133
|
export async function claudeInject(stdin: string): Promise<number> {
|
|
127
134
|
const payload = parseHookPayload(stdin);
|
|
128
|
-
// CR143: on a runtime that discards what this hook returns (Grok), neither
|
|
129
|
-
// case is worth its load — nor the state read below, nor the "injected"
|
|
130
|
-
// mark, which would say the model received what it did not. Detected the
|
|
131
|
-
// way the sibling hooks detect it, from the payload and the environment.
|
|
132
135
|
if (!runtimeReadsPromptContext(detectHookRuntime(payload.raw))) return 0;
|
|
133
|
-
const sessionId = payload.sessionId;
|
|
134
|
-
|
|
135
|
-
// Case 1: the explicit invocation.
|
|
136
|
-
const explicit = mirrorForm(payload.prompt);
|
|
137
|
-
if (explicit) {
|
|
138
|
-
const argv = ["mirror", "load", "--context-only", "--query", explicit.query];
|
|
139
|
-
if (sessionId) argv.push("--session-id", sessionId);
|
|
140
|
-
const result = await runFrontDoor(argv);
|
|
141
|
-
if (result.stdout.trim()) process.stdout.write(result.stdout);
|
|
142
|
-
return 0;
|
|
143
|
-
}
|
|
144
136
|
|
|
145
|
-
|
|
137
|
+
const sessionId = payload.sessionId;
|
|
146
138
|
if (!sessionId) {
|
|
147
|
-
// Python's `mirror_state` warned on stderr rather than no-op'ing silently,
|
|
148
|
-
// so a hook wired without a session id is visible. Kept: it is the
|
|
149
|
-
// difference between "Mirror Mode is off" and "this hook is misconfigured".
|
|
150
139
|
process.stderr.write("warning: mirror inject hook received no session_id; no state read.\n");
|
|
151
|
-
return 0;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let dbPath: string;
|
|
155
|
-
try {
|
|
156
|
-
dbPath = resolveDbPath([], process.env);
|
|
157
|
-
} catch (error) {
|
|
158
|
-
noteHookFailure("claude:inject", error instanceof Error ? error.message : String(error));
|
|
159
|
-
return 0;
|
|
160
140
|
}
|
|
161
141
|
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
const argv = [
|
|
166
|
-
"mirror",
|
|
167
|
-
"load",
|
|
168
|
-
"--context-only",
|
|
169
|
-
"--query",
|
|
170
|
-
payload.prompt,
|
|
171
|
-
"--session-id",
|
|
172
|
-
sessionId,
|
|
173
|
-
];
|
|
174
|
-
if (state.persona) argv.push("--persona", state.persona);
|
|
175
|
-
if (state.journey) argv.push("--journey", state.journey);
|
|
176
|
-
|
|
177
|
-
const result = await runFrontDoor(argv);
|
|
178
|
-
if (!result.stdout.trim()) return 0;
|
|
179
|
-
|
|
180
|
-
process.stdout.write(result.stdout);
|
|
181
|
-
// Only after the context actually reached the model: the shell marked it
|
|
182
|
-
// inside `if [ -n "$CONTEXT" ]` for the same reason. Marking first would
|
|
183
|
-
// lose the injection whenever the load produced nothing.
|
|
142
|
+
const argv = ["mirror", "doorman", "--prompt", payload.prompt];
|
|
143
|
+
if (sessionId) argv.push("--session-id", sessionId);
|
|
184
144
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// that the old hook did not pay. Measured on a real 53 MB database:
|
|
190
|
-
// **95 ms**, once per Mirror Mode session -- only the single prompt that
|
|
191
|
-
// owes an injection reaches this line, and that turn is already loading
|
|
192
|
-
// identity and memories. The ordinary turn returns above without opening
|
|
193
|
-
// a writable handle at all (114 ms end to end, nothing written).
|
|
194
|
-
//
|
|
195
|
-
// Paying the front door's own safety contract for a real write to a real
|
|
196
|
-
// user database beat inventing a second narrowed write handle inside a
|
|
197
|
-
// deletion story. Recorded with the number so the trade is visible.
|
|
198
|
-
//
|
|
199
|
-
// This hook runs AT THE SAME TIME as `log-user-prompt`, which writes too.
|
|
200
|
-
// Until finding F21 the two snapshots collided on one fixed file and one
|
|
201
|
-
// write was lost -- in the Navigator walk, two injections in three
|
|
202
|
-
// prompts. `openLiveWriteDatabase` gives each writer its own staging
|
|
203
|
-
// snapshot, so concurrent hooks no longer race.
|
|
204
|
-
const db = openLiveWriteDatabase(dbPath);
|
|
205
|
-
try {
|
|
206
|
-
markInjected(db, sessionId, new Date().toISOString());
|
|
207
|
-
} finally {
|
|
208
|
-
db.close();
|
|
209
|
-
}
|
|
145
|
+
const result = await runFrontDoor(argv);
|
|
146
|
+
if (result.code !== 0)
|
|
147
|
+
noteHookFailure("claude:inject", `\`mirror doorman\` exited ${result.code}`);
|
|
148
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
210
149
|
} catch (error) {
|
|
211
150
|
noteHookFailure("claude:inject", error instanceof Error ? error.message : String(error));
|
|
212
151
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// Mirror Mode state for one runtime session, as
|
|
1
|
+
// Mirror Mode state for one runtime session, as Antigravity's PreInvocation
|
|
2
|
+
// hook needs it on agy before 1.4.7, whose payload carries no prompt. The
|
|
3
|
+
// Claude-family inject hook stopped reading it in CV9.DS8.US4, and agy's hook
|
|
4
|
+
// stopped on 1.4.7 in CV9.DS8.TS6: both hand the turn to `mirror doorman`,
|
|
5
|
+
// which reads for itself.
|
|
2
6
|
//
|
|
3
7
|
// The TypeScript replacement for the Python hook module `mirror_state` (144
|
|
4
8
|
// lines, deleted with the core at CV22.DS10.TS5), which
|
|
@@ -11,24 +15,21 @@
|
|
|
11
15
|
// adding two commands whose sole consumer is a hook would widen the command
|
|
12
16
|
// surface this story exists to shrink.
|
|
13
17
|
//
|
|
14
|
-
//
|
|
15
|
-
// a
|
|
16
|
-
//
|
|
17
|
-
// look like a user who simply has Mirror Mode off.
|
|
18
|
+
// `needs_inject` and `mark_injected` -- the once-injection the inject hook
|
|
19
|
+
// used to owe a Mirror Mode session -- left with US4, and the `hook_injected`
|
|
20
|
+
// column they read left with US5 (migration 018).
|
|
18
21
|
|
|
19
|
-
import { openDatabaseReadOnly
|
|
20
|
-
import { getRuntimeSession
|
|
22
|
+
import { openDatabaseReadOnly } from "#db/database.ts";
|
|
23
|
+
import { getRuntimeSession } from "#mirror/runtimeSession.ts";
|
|
21
24
|
|
|
22
25
|
export interface MirrorHookState {
|
|
23
26
|
readonly active: boolean;
|
|
24
|
-
readonly hookInjected: boolean;
|
|
25
27
|
readonly persona: string;
|
|
26
28
|
readonly journey: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const NO_STATE: MirrorHookState = {
|
|
30
32
|
active: false,
|
|
31
|
-
hookInjected: false,
|
|
32
33
|
persona: "",
|
|
33
34
|
journey: "",
|
|
34
35
|
};
|
|
@@ -47,7 +48,6 @@ export function readMirrorState(dbPath: string, sessionId: string): MirrorHookSt
|
|
|
47
48
|
if (!session) return NO_STATE;
|
|
48
49
|
return {
|
|
49
50
|
active: session.mirrorActive,
|
|
50
|
-
hookInjected: session.hookInjected,
|
|
51
51
|
persona: session.persona ?? "",
|
|
52
52
|
journey: session.journey ?? "",
|
|
53
53
|
};
|
|
@@ -57,15 +57,3 @@ export function readMirrorState(dbPath: string, sessionId: string): MirrorHookSt
|
|
|
57
57
|
db.close();
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
/** Python `needs_inject`: Mirror Mode is on and context has not been injected. */
|
|
62
|
-
export function needsInject(state: MirrorHookState): boolean {
|
|
63
|
-
return state.active && !state.hookInjected;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Python `mark_injected`, over an already-open writable connection. */
|
|
67
|
-
export function markInjected(db: WritableDatabase, sessionId: string, nowIso: string): void {
|
|
68
|
-
if (!sessionId) return;
|
|
69
|
-
if (!getRuntimeSession(db, sessionId)) return;
|
|
70
|
-
upsertRuntimeSession(db, sessionId, { hookInjected: true }, nowIso);
|
|
71
|
-
}
|
package/ts/src/mirror/context.ts
CHANGED
|
@@ -116,15 +116,19 @@ export async function relevantAttachments(
|
|
|
116
116
|
journey: string | null,
|
|
117
117
|
provider?: EmbeddingProvider,
|
|
118
118
|
embeddingLedger?: (info: EmbeddingAttemptInfo) => void,
|
|
119
|
+
/**
|
|
120
|
+
* The query's embedding, already generated (CV9.DS8.TS2): the doorman embeds
|
|
121
|
+
* a turn once and shares the vector with the memory search. When given, the
|
|
122
|
+
* provider is not called and no ledger row is written here.
|
|
123
|
+
*/
|
|
124
|
+
precomputedEmbedding?: readonly number[],
|
|
119
125
|
): Promise<{ row: AttachmentRow; score: number }[]> {
|
|
120
126
|
const rows = attachmentRows(db, journey);
|
|
121
127
|
// No attachments, no embedding: the early return is why a query against a database
|
|
122
128
|
// without attachments records no spend -- there is none.
|
|
123
129
|
if (rows.length === 0) return [];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
...(embeddingLedger ? { onAttempt: embeddingLedger } : {}),
|
|
127
|
-
});
|
|
130
|
+
const queryEmbedding =
|
|
131
|
+
precomputedEmbedding ?? (await embedQuery(query, provider, embeddingLedger));
|
|
128
132
|
const queryTokens = (query.match(/[\p{L}\p{N}_]+/gu) ?? [])
|
|
129
133
|
.filter((token) => [...token].length >= 2 || /^\p{N}+$/u.test(token))
|
|
130
134
|
.map((token) => stripAccents(token.toLowerCase()));
|
|
@@ -146,6 +150,17 @@ export async function relevantAttachments(
|
|
|
146
150
|
return scored.slice(0, journey ? 5 : 8).filter((item) => item.score > 0.4);
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
async function embedQuery(
|
|
154
|
+
query: string,
|
|
155
|
+
provider: EmbeddingProvider | undefined,
|
|
156
|
+
embeddingLedger: ((info: EmbeddingAttemptInfo) => void) | undefined,
|
|
157
|
+
): Promise<readonly number[]> {
|
|
158
|
+
if (!provider) throw new Error("attachment embedding replay provider is required");
|
|
159
|
+
return generateEmbeddingSafely(provider, query, {
|
|
160
|
+
...(embeddingLedger ? { onAttempt: embeddingLedger } : {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
149
164
|
function attachmentRows(db: Database, journey: string | null): AttachmentRow[] {
|
|
150
165
|
const sql = journey
|
|
151
166
|
? "SELECT journey_id, name, description, content, embedding FROM attachments WHERE journey_id = ? AND embedding IS NOT NULL ORDER BY created_at"
|
|
@@ -177,7 +177,8 @@ function descriptorMap(db: Database, layer: string): Map<string, string> {
|
|
|
177
177
|
return new Map(descriptorsByLayer(db, layer).map((row) => [row.key, row.descriptor]));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
/** Every persona's routing table, as `detectPersona` scores it. Shared with the doorman (CV9.DS8.TS2). */
|
|
181
|
+
export function personaRoutingRows(db: Database): PersonaRoutingRow[] {
|
|
181
182
|
return identityRoutingRows(db, "persona").map((row) => ({
|
|
182
183
|
key: row.key,
|
|
183
184
|
routing_keywords: routingKeywords(row.metadata),
|