mirror-mind 0.40.0 → 0.41.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/docs/releases/index.md +2 -0
- package/docs/releases/v0.40.1.md +120 -0
- package/docs/releases/v0.41.0.md +116 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +2 -2
- package/plugins/mirror-mind/agy/pre-invocation.sh +24 -3
- package/plugins/mirror-mind/agy/session-start.sh +24 -3
- package/plugins/mirror-mind/agy/stop.sh +24 -3
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +24 -3
- package/plugins/mirror-mind/hooks/log-session-end.sh +24 -3
- package/plugins/mirror-mind/hooks/log-user-prompt.sh +24 -3
- package/plugins/mirror-mind/hooks/mirror-inject.sh +24 -3
- package/plugins/mirror-mind/hooks/session-start.sh +24 -3
- package/plugins/mirror-mind/mcp_config.json +1 -1
- package/plugins/mirror-mind/plugin.json +2 -2
- package/ts/src/frontDoor/mirrorModeRoute.ts +24 -2
- package/ts/src/guards/claudePlugin.ts +9 -2
- package/ts/src/mirror/defaultResolution.ts +21 -16
- package/ts/src/mirror/deliverContext.ts +35 -11
- package/ts/src/mirror/doorman.ts +86 -43
- package/ts/src/mirror/orchestration.ts +10 -1
- package/ts/src/mirror/personaRouter.ts +389 -0
- package/ts/src/providers/config.ts +25 -0
|
@@ -4,7 +4,6 @@ import type { Database, WritableDatabase } from "#db/database.ts";
|
|
|
4
4
|
import {
|
|
5
5
|
type DoormanDecision,
|
|
6
6
|
decideDoorman,
|
|
7
|
-
doormanPersonas,
|
|
8
7
|
forgetDoormanDelivery,
|
|
9
8
|
type MemoryScore,
|
|
10
9
|
readDoormanSessionState,
|
|
@@ -16,6 +15,12 @@ import {
|
|
|
16
15
|
logMirrorResponse,
|
|
17
16
|
runMirrorLoad,
|
|
18
17
|
} from "#mirror/orchestration.ts";
|
|
18
|
+
import {
|
|
19
|
+
LivePersonaRouter,
|
|
20
|
+
loadReplayPersonaRouter,
|
|
21
|
+
PERSONA_ROUTER_REPLAY_ENV,
|
|
22
|
+
type PersonaRouter,
|
|
23
|
+
} from "#mirror/personaRouter.ts";
|
|
19
24
|
import { environmentSessionId, resolveNamedRuntimeSessionId } from "#mirror/runtimeSession.ts";
|
|
20
25
|
import {
|
|
21
26
|
activateOperatingMode,
|
|
@@ -43,6 +48,7 @@ export type MirrorRouteEnvironment = {
|
|
|
43
48
|
MEMORY_RECEPTION?: string;
|
|
44
49
|
MIRROR_TS_MIRROR_LLM_REPLAY?: string;
|
|
45
50
|
MIRROR_TS_MIRROR_EMBEDDING_REPLAY?: string;
|
|
51
|
+
MIRROR_TS_PERSONA_ROUTER_REPLAY?: string;
|
|
46
52
|
};
|
|
47
53
|
|
|
48
54
|
export function isMirrorWrite(argv: readonly string[]): boolean {
|
|
@@ -109,7 +115,6 @@ export async function runMirrorDoormanRoute(
|
|
|
109
115
|
decideDoorman({
|
|
110
116
|
prompt,
|
|
111
117
|
state: readDoormanSessionState(db, sessionId),
|
|
112
|
-
personas: doormanPersonas(db),
|
|
113
118
|
}),
|
|
114
119
|
);
|
|
115
120
|
if (typeof decided === "number") return decided;
|
|
@@ -131,6 +136,7 @@ export async function runMirrorDoormanRoute(
|
|
|
131
136
|
onReceptionOutcome: deps.onReceptionOutcome,
|
|
132
137
|
llmProvider: receptionEnabled ? family.llm : undefined,
|
|
133
138
|
embeddingProvider: family.embedding,
|
|
139
|
+
personaRouter: personaRouterFor(family.mode, env),
|
|
134
140
|
onMemoryScores: deps.onMemoryScores,
|
|
135
141
|
newId,
|
|
136
142
|
nowIso,
|
|
@@ -141,6 +147,21 @@ export async function runMirrorDoormanRoute(
|
|
|
141
147
|
});
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
/**
|
|
151
|
+
* CR203: the persona router for the family's transport. Live asks the
|
|
152
|
+
* Decisions route; a replay run reaches no network, so it answers from its
|
|
153
|
+
* recorded fixture when one is named, and otherwise routes nothing: its turns
|
|
154
|
+
* have the Journey's default or none.
|
|
155
|
+
*/
|
|
156
|
+
export function personaRouterFor(
|
|
157
|
+
mode: "replay" | "live",
|
|
158
|
+
env: MirrorRouteEnvironment,
|
|
159
|
+
): PersonaRouter | undefined {
|
|
160
|
+
if (mode === "live") return new LivePersonaRouter({ env: { ...env } });
|
|
161
|
+
const fixture = env[PERSONA_ROUTER_REPLAY_ENV];
|
|
162
|
+
return fixture ? loadReplayPersonaRouter(fixture) : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
export function isModeWrite(argv: readonly string[]): boolean {
|
|
145
166
|
if (argv[0] !== "mode") return false;
|
|
146
167
|
const args = modePositionals(argv.slice(1));
|
|
@@ -241,6 +262,7 @@ export async function runMirrorWriteRoute(
|
|
|
241
262
|
onReceptionOutcome: options.onReceptionOutcome,
|
|
242
263
|
llmProvider: receptionEnabled ? family?.llm : undefined,
|
|
243
264
|
embeddingProvider: family?.embedding,
|
|
265
|
+
personaRouter: family ? personaRouterFor(family.mode, env) : undefined,
|
|
244
266
|
newId,
|
|
245
267
|
nowIso,
|
|
246
268
|
});
|
|
@@ -111,6 +111,10 @@ export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
|
|
|
111
111
|
* reason, in the Pi extension's words, and the search goes on without it. The
|
|
112
112
|
* value is read once, into `mmnode`, and printed with `printf` and its line
|
|
113
113
|
* breaks removed: dash's `echo` turns the `\n` of `\nodejs` into a newline.
|
|
114
|
+
* `MIRROR_BIN` follows the same rule (CR197): only an absolute path to a
|
|
115
|
+
* directory holding an executable `mirror`, read once, into `mmbindir`, and
|
|
116
|
+
* refused in the same words. A relative one would resolve against the project
|
|
117
|
+
* the client started the server from, and Node would run that project's file.
|
|
114
118
|
*
|
|
115
119
|
* Written for what three runtimes do to the string (the US6 spike):
|
|
116
120
|
*
|
|
@@ -119,14 +123,17 @@ export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
|
|
|
119
123
|
* runtime that did so with an unset name would blank it;
|
|
120
124
|
* * no positional parameter (`$1`, `$@`, ...) and no backtick, for the same
|
|
121
125
|
* reason, and because the text travels through two JSON encoders;
|
|
122
|
-
* * variables named `mmbin`, `mmnode`, `mmdir`: Grok substitutes SET
|
|
126
|
+
* * variables named `mmbin`, `mmbindir`, `mmnode`, `mmdir`: Grok substitutes SET
|
|
123
127
|
* environment variables by name inside MCP args at load time (`$HOME`
|
|
124
128
|
* became the path), so a script variable with a name a person exports
|
|
125
129
|
* would be replaced before the shell ever saw it.
|
|
126
130
|
*/
|
|
127
131
|
export const MCP_LOCATOR = [
|
|
128
132
|
'mmbin=""',
|
|
129
|
-
'
|
|
133
|
+
'mmbindir="$MIRROR_BIN"',
|
|
134
|
+
'mmwhy=""',
|
|
135
|
+
`[ -n "$mmbindir" ] && case "$mmbindir" in ${ABSOLUTE_PATH_CASE}) if [ ! -e "$mmbindir" ]; then mmwhy="missing"; elif [ ! -d "$mmbindir" ]; then mmwhy="not a directory"; elif [ ! -f "$mmbindir/mirror" ] || [ ! -x "$mmbindir/mirror" ]; then mmwhy="a directory without an executable mirror"; else mmbin="$mmbindir/mirror"; fi ;; *) mmwhy="relative" ;; esac`,
|
|
136
|
+
`[ -n "$mmwhy" ] && printf 'mirror-mind: MIRROR_BIN=%s is %s; searching the PATH and the usual places instead\\n' "$(printf '%s' "$mmbindir" | tr -d '\\r\\n')" "$mmwhy" >&2`,
|
|
130
137
|
'[ -n "$mmbin" ] || mmbin="$(command -v mirror 2>/dev/null)"',
|
|
131
138
|
`for mmdir in ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmbin" ] && break; [ -x "$mmdir/mirror" ] && mmbin="$mmdir/mirror"; done`,
|
|
132
139
|
'[ -n "$mmbin" ] || { echo "mirror-mind: mirror not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory." >&2; exit 1; }',
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Database } from "#db/database.ts";
|
|
2
2
|
import { descriptorsByLayer } from "#descriptor/descriptorRead.ts";
|
|
3
3
|
import type { OnProviderCallOutcome } from "#observability/callOutcome.ts";
|
|
4
|
-
import { detectPersona, type PersonaRoutingRow } from "#persona/detectPersona.ts";
|
|
5
4
|
import { type EmbeddingProvider, generateEmbeddingSafely } from "#providers/embedding.ts";
|
|
6
5
|
import type { LlmProvider, LlmResponse } from "#providers/llm.ts";
|
|
7
6
|
import { cosineSimilarity } from "#search/ranker.ts";
|
|
8
7
|
import { stripAccents } from "#util/slug.ts";
|
|
8
|
+
import { type PersonaRouter, type PersonaRouting, routeTurnPersona } from "./personaRouter.ts";
|
|
9
9
|
import { runReception, sliceCodePoints } from "./reception.ts";
|
|
10
10
|
import { GLOBAL_STICKY_DEFAULTS_SESSION_ID, getRuntimeSession } from "./runtimeSession.ts";
|
|
11
11
|
|
|
@@ -28,6 +28,10 @@ export interface ResolveMirrorDefaultsInput {
|
|
|
28
28
|
onReceptionLlmCall?: (response: LlmResponse, prompt: string) => void;
|
|
29
29
|
/** Reception's outcome, category only; see `observability/callOutcome.ts`. */
|
|
30
30
|
onReceptionOutcome?: OnProviderCallOutcome;
|
|
31
|
+
/** CR203: routes the persona when none was named; absent, no call is made. */
|
|
32
|
+
personaRouter?: PersonaRouter;
|
|
33
|
+
/** CR203: the routing call, for its ledger row; content-free. */
|
|
34
|
+
onPersonaRouting?: (routing: PersonaRouting) => void;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
interface IdentityRoutingRow {
|
|
@@ -48,7 +52,6 @@ export async function resolveMirrorDefaults(
|
|
|
48
52
|
|
|
49
53
|
const session = input.sessionId ? getRuntimeSession(db, input.sessionId) : null;
|
|
50
54
|
const global = getRuntimeSession(db, GLOBAL_STICKY_DEFAULTS_SESSION_ID);
|
|
51
|
-
const stickyPersona = session?.persona || global?.persona || null;
|
|
52
55
|
const stickyJourney = session?.journey || global?.journey || null;
|
|
53
56
|
const query = input.query ?? null;
|
|
54
57
|
|
|
@@ -73,23 +76,33 @@ export async function resolveMirrorDefaults(
|
|
|
73
76
|
input.onReceptionLlmCall,
|
|
74
77
|
input.onReceptionOutcome,
|
|
75
78
|
);
|
|
76
|
-
|
|
79
|
+
// Reception still names personas, and its prompt still offers them: the
|
|
80
|
+
// prompt is digest-pinned, and changing it is its own decision. Its pick
|
|
81
|
+
// no longer decides the turn (CR203): the router does, below.
|
|
77
82
|
if (reception.journey && journey === null) journey = reception.journey;
|
|
78
83
|
touchesIdentity = reception.touchesIdentity;
|
|
79
84
|
touchesShadow = reception.touchesShadow;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
persona ??= stickyPersona;
|
|
83
87
|
journey ??= stickyJourney;
|
|
84
88
|
|
|
85
|
-
if (persona === null && query) {
|
|
86
|
-
const matches = detectPersona(query, personaRoutingRows(db));
|
|
87
|
-
persona = matches[0]?.key ?? null;
|
|
88
|
-
}
|
|
89
89
|
if (journey === null && query) {
|
|
90
90
|
detectedJourney = await detectJourney(db, query, input.embeddingProvider);
|
|
91
91
|
journey = detectedJourney[0]?.[0] ?? null;
|
|
92
92
|
}
|
|
93
|
+
// CR203: the persona the person named, else the turn's own by its subject,
|
|
94
|
+
// else the Journey's default, else none. Never the session's or the last
|
|
95
|
+
// load's by inheritance: a wrong persona is worse than none.
|
|
96
|
+
if (persona === null) {
|
|
97
|
+
persona = (
|
|
98
|
+
await routeTurnPersona(db, {
|
|
99
|
+
prompt: query ?? "",
|
|
100
|
+
journey,
|
|
101
|
+
router: query ? input.personaRouter : undefined,
|
|
102
|
+
onRouting: input.onPersonaRouting,
|
|
103
|
+
})
|
|
104
|
+
).persona;
|
|
105
|
+
}
|
|
93
106
|
|
|
94
107
|
return { persona, journey, detectedJourney, touchesIdentity, touchesShadow };
|
|
95
108
|
}
|
|
@@ -177,14 +190,6 @@ function descriptorMap(db: Database, layer: string): Map<string, string> {
|
|
|
177
190
|
return new Map(descriptorsByLayer(db, layer).map((row) => [row.key, row.descriptor]));
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
/** Every persona's routing table, as `detectPersona` scores it. Shared with the doorman (CV9.DS8.TS2). */
|
|
181
|
-
export function personaRoutingRows(db: Database): PersonaRoutingRow[] {
|
|
182
|
-
return identityRoutingRows(db, "persona").map((row) => ({
|
|
183
|
-
key: row.key,
|
|
184
|
-
routing_keywords: routingKeywords(row.metadata),
|
|
185
|
-
}));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
193
|
function routingKeywords(metadata: string | null): string[] {
|
|
189
194
|
if (!metadata) return [];
|
|
190
195
|
try {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
import { basename, dirname } from "node:path";
|
|
37
37
|
import { isCommandTurn } from "#conversation/commandTurn.ts";
|
|
38
38
|
import { DatabaseNotCurrentError } from "#conversation/hostCalls.ts";
|
|
39
|
-
import { openDatabaseReadOnly } from "#db/database.ts";
|
|
39
|
+
import { type Database, openDatabaseReadOnly } from "#db/database.ts";
|
|
40
40
|
import { inspectMigrationState } from "#db/migrationReport.ts";
|
|
41
41
|
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
42
42
|
import type { ProviderConfig } from "#providers/config.ts";
|
|
@@ -48,11 +48,11 @@ import {
|
|
|
48
48
|
type DoormanDecision,
|
|
49
49
|
type DoormanSessionState,
|
|
50
50
|
decideDoorman,
|
|
51
|
-
doormanPersonas,
|
|
52
51
|
forgetDoormanDelivery,
|
|
53
52
|
readDoormanSessionState,
|
|
54
53
|
runDoorman,
|
|
55
54
|
} from "./doorman.ts";
|
|
55
|
+
import { LivePersonaRouter, type PersonaRouter } from "./personaRouter.ts";
|
|
56
56
|
|
|
57
57
|
export type MirrorContextRejection = "invalid_request" | "no_key";
|
|
58
58
|
|
|
@@ -81,7 +81,7 @@ export interface DeliverMirrorContextOptions {
|
|
|
81
81
|
/** Reception's classifier for the full folder; on unless turned off. */
|
|
82
82
|
receptionEnabled?: boolean;
|
|
83
83
|
/** Tests only: providers in place of the live ones the key would build. */
|
|
84
|
-
providers?: { llm?: LlmProvider; embedding?: EmbeddingProvider };
|
|
84
|
+
providers?: { llm?: LlmProvider; embedding?: EmbeddingProvider; personaRouter?: PersonaRouter };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export interface DeliveredMirrorContext {
|
|
@@ -90,6 +90,11 @@ export interface DeliveredMirrorContext {
|
|
|
90
90
|
block: string;
|
|
91
91
|
/** The session's persona and Journey after the delivery. */
|
|
92
92
|
persona: string | null;
|
|
93
|
+
/**
|
|
94
|
+
* CR204: the persona's own name, from its metadata, for a host to show; `null` when it has
|
|
95
|
+
* none or the turn has no persona. Display text only: `persona` stays the id.
|
|
96
|
+
*/
|
|
97
|
+
personaName: string | null;
|
|
93
98
|
journey: string | null;
|
|
94
99
|
}
|
|
95
100
|
|
|
@@ -97,7 +102,6 @@ export interface DeliveredMirrorContext {
|
|
|
97
102
|
export function decideHostTurn(input: {
|
|
98
103
|
prompt: string;
|
|
99
104
|
state: DoormanSessionState | null;
|
|
100
|
-
personas: Parameters<typeof decideDoorman>[0]["personas"];
|
|
101
105
|
}): DoormanDecision {
|
|
102
106
|
const decided = decideDoorman(input);
|
|
103
107
|
// In Mirror Mode the doorman answers nothing only for a command turn, which
|
|
@@ -121,8 +125,11 @@ function assertCurrent(dbPath: string): void {
|
|
|
121
125
|
/**
|
|
122
126
|
* The providers' environment for a host call: the key, one attempt per call,
|
|
123
127
|
* and the embedding bounded like reception (10 s). A full turn makes one
|
|
124
|
-
* reception call and, with attachments, one
|
|
125
|
-
*
|
|
128
|
+
* reception call, one persona-routing call (CR203) and, with attachments, one
|
|
129
|
+
* embedding; a delta, one embedding and one routing call made beside it. So a
|
|
130
|
+
* delta waits at most about 10 s on its providers and a full turn about 30 s,
|
|
131
|
+
* only when every call runs to its bound; the routing settings are the
|
|
132
|
+
* defaults, since a host passes no environment.
|
|
126
133
|
*/
|
|
127
134
|
export function hostProviderEnv(apiKey: string): Record<string, string> {
|
|
128
135
|
return {
|
|
@@ -137,11 +144,12 @@ export function hostProviderEnv(apiKey: string): Record<string, string> {
|
|
|
137
144
|
export function hostProviders(
|
|
138
145
|
apiKey: string,
|
|
139
146
|
createClient?: (config: ProviderConfig) => OpenRouterClient,
|
|
140
|
-
): { llm: LlmProvider; embedding: EmbeddingProvider } {
|
|
147
|
+
): { llm: LlmProvider; embedding: EmbeddingProvider; personaRouter: PersonaRouter } {
|
|
141
148
|
const env = hostProviderEnv(apiKey);
|
|
142
149
|
return {
|
|
143
150
|
llm: new LiveLlmProvider({ env, ...(createClient ? { createClient } : {}) }),
|
|
144
151
|
embedding: new LiveEmbeddingProvider({ env, ...(createClient ? { createClient } : {}) }),
|
|
152
|
+
personaRouter: new LivePersonaRouter({ env, ...(createClient ? { createClient } : {}) }),
|
|
145
153
|
};
|
|
146
154
|
}
|
|
147
155
|
|
|
@@ -172,18 +180,17 @@ export async function deliverMirrorContext(
|
|
|
172
180
|
decision = decideHostTurn({
|
|
173
181
|
prompt,
|
|
174
182
|
state: readDoormanSessionState(read, sessionId),
|
|
175
|
-
personas: doormanPersonas(read),
|
|
176
183
|
});
|
|
177
184
|
} finally {
|
|
178
185
|
read.close();
|
|
179
186
|
}
|
|
180
187
|
if (decision.kind === "nothing")
|
|
181
|
-
return { kind: "nothing", block: "", persona: null, journey: null };
|
|
188
|
+
return { kind: "nothing", block: "", persona: null, personaName: null, journey: null };
|
|
182
189
|
|
|
183
190
|
const apiKey = typeof options.apiKey === "string" ? options.apiKey.trim() : "";
|
|
184
191
|
if (!apiKey && !options.providers) throw new MirrorContextRejectedError("no_key");
|
|
185
192
|
const receptionEnabled = options.receptionEnabled !== false;
|
|
186
|
-
const { llm, embedding } = options.providers ?? hostProviders(apiKey);
|
|
193
|
+
const { llm, embedding, personaRouter } = options.providers ?? hostProviders(apiKey);
|
|
187
194
|
|
|
188
195
|
const db = openLiveWriteDatabase(dbPath);
|
|
189
196
|
try {
|
|
@@ -198,15 +205,18 @@ export async function deliverMirrorContext(
|
|
|
198
205
|
receptionEnabled,
|
|
199
206
|
llmProvider: receptionEnabled ? llm : undefined,
|
|
200
207
|
embeddingProvider: embedding,
|
|
208
|
+
personaRouter,
|
|
201
209
|
extensionRuntime: { environment: {} },
|
|
202
210
|
newId,
|
|
203
211
|
nowIso,
|
|
204
212
|
});
|
|
205
213
|
const state = readDoormanSessionState(db, sessionId);
|
|
214
|
+
const persona = state?.persona ?? null;
|
|
206
215
|
return {
|
|
207
216
|
kind: decision.kind,
|
|
208
217
|
block: rendered.stdout,
|
|
209
|
-
persona
|
|
218
|
+
persona,
|
|
219
|
+
personaName: persona ? personaName(db, persona) : null,
|
|
210
220
|
journey: state?.journey ?? null,
|
|
211
221
|
};
|
|
212
222
|
} finally {
|
|
@@ -214,6 +224,20 @@ export async function deliverMirrorContext(
|
|
|
214
224
|
}
|
|
215
225
|
}
|
|
216
226
|
|
|
227
|
+
/** CR204: the `name` the seed kept in a persona's metadata, when it kept one. */
|
|
228
|
+
function personaName(db: Database, key: string): string | null {
|
|
229
|
+
const row = db
|
|
230
|
+
.prepare("SELECT metadata FROM identity WHERE layer = 'persona' AND key = ?")
|
|
231
|
+
.get(key);
|
|
232
|
+
if (typeof row?.metadata !== "string") return null;
|
|
233
|
+
try {
|
|
234
|
+
const name: unknown = (JSON.parse(row.metadata) as Record<string, unknown> | null)?.name;
|
|
235
|
+
return typeof name === "string" && name.trim() ? name.trim() : null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
217
241
|
/**
|
|
218
242
|
* The host compacted the Conversation's transcript: the folder left it, so the
|
|
219
243
|
* next turn delivers it whole again. An unknown session is nothing to mark.
|
package/ts/src/mirror/doorman.ts
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
//
|
|
14
14
|
// 1. the explicit Mirror form in the prompt -> the full folder, routed from
|
|
15
15
|
// the query, before the first token; the session marked active by its id;
|
|
16
|
-
// 2. Mirror Mode active, an ordinary turn -> the
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// 2. Mirror Mode active, an ordinary turn -> the turn's persona is routed
|
|
17
|
+
// by its subject (CR203, `personaRouter.ts`): the router's answer, else
|
|
18
|
+
// the Journey's own default, else none -- never the session's by
|
|
19
|
+
// inheritance; a persona the conversation has not read yet has its
|
|
20
|
+
// section delivered; the memories and attachments relevant to the prompt
|
|
21
|
+
// follow; the identity layers are not repeated -- they already sit in
|
|
22
|
+
// the transcript;
|
|
21
23
|
// a skill invocation other than the Mirror form is not a question and
|
|
22
24
|
// gets nothing (CR160's command-turn rule);
|
|
23
25
|
// 3. Mirror Mode not active -> nothing. The model's own routing may invoke
|
|
@@ -37,7 +39,8 @@
|
|
|
37
39
|
//
|
|
38
40
|
// What a turn costs: nothing when Mirror Mode is off; on an ordinary active
|
|
39
41
|
// turn, ONE embedding, shared by the memory and attachment searches and
|
|
40
|
-
// counted once in `llm_calls` (DS8 decision 1)
|
|
42
|
+
// counted once in `llm_calls` (DS8 decision 1), and ONE persona-routing call,
|
|
43
|
+
// made beside the embedding, not after it (CR203); on the explicit turn, what
|
|
41
44
|
// `mirror load --query` costs today (reception's LLM call when enabled, the
|
|
42
45
|
// attachment embedding), plus the skill's own load until TS3 teaches it to
|
|
43
46
|
// trust this line.
|
|
@@ -48,14 +51,13 @@ import { memoriesById } from "#frontDoor/searchRoute.ts";
|
|
|
48
51
|
import { getIdentityContent } from "#identity/identityRead.ts";
|
|
49
52
|
import { getActiveOperatingMode } from "#mode/operatingMode.ts";
|
|
50
53
|
import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
|
|
51
|
-
import { detectPersona, type PersonaRoutingRow } from "#persona/detectPersona.ts";
|
|
52
54
|
import { type EmbeddingProvider, generateEmbeddingSafely } from "#providers/embedding.ts";
|
|
53
55
|
import { searchMemoriesWithStatus } from "#search/memorySearch.ts";
|
|
54
56
|
import { pythonJsonDumps } from "#util/pyGenerators.ts";
|
|
55
57
|
import { relevantAttachments } from "./context.ts";
|
|
56
|
-
import { personaRoutingRows } from "./defaultResolution.ts";
|
|
57
58
|
import { mirrorForm } from "./mirrorForm.ts";
|
|
58
59
|
import { type MirrorLoadInput, runMirrorLoad } from "./orchestration.ts";
|
|
60
|
+
import { personaRoutingLedger, routeTurnPersona } from "./personaRouter.ts";
|
|
59
61
|
import {
|
|
60
62
|
decodeMetadata,
|
|
61
63
|
getRuntimeSession,
|
|
@@ -80,8 +82,6 @@ export type DoormanDecision =
|
|
|
80
82
|
| {
|
|
81
83
|
readonly kind: "delta";
|
|
82
84
|
readonly query: string;
|
|
83
|
-
/** The persona that newly scored, or null when the session's stands. */
|
|
84
|
-
readonly persona: string | null;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
/** The stable prefix TS3's skill reads; everything after the colon varies. */
|
|
@@ -116,6 +116,15 @@ export const DOORMAN_MEMORY_SEMANTIC_THRESHOLD = 0.4;
|
|
|
116
116
|
*/
|
|
117
117
|
export const DOORMAN_DELIVERED_METADATA_KEY = "doorman_delivered";
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The personas whose section the conversation already holds (CR203). A turn
|
|
121
|
+
* may now have none, so "the session's persona" no longer says which sections
|
|
122
|
+
* the transcript carries: a writer turn, a turn with none, and a writer turn
|
|
123
|
+
* again must deliver the writer's section once. A full load starts the list
|
|
124
|
+
* over with its own persona.
|
|
125
|
+
*/
|
|
126
|
+
export const DOORMAN_PERSONAS_METADATA_KEY = "doorman_personas";
|
|
127
|
+
|
|
119
128
|
/**
|
|
120
129
|
* Set on the session when the runtime compacted the transcript (the Pi
|
|
121
130
|
* extension's `session_compact`, CV9.DS8.US5): a delta's premise -- the
|
|
@@ -139,38 +148,56 @@ export function forgetDoormanDelivery(
|
|
|
139
148
|
const metadata = decodeMetadata(session.metadata);
|
|
140
149
|
metadata[DOORMAN_REFRESH_METADATA_KEY] = true;
|
|
141
150
|
metadata[DOORMAN_DELIVERED_METADATA_KEY] = [];
|
|
151
|
+
metadata[DOORMAN_PERSONAS_METADATA_KEY] = [];
|
|
142
152
|
updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
/** How many ranked memories a delta may read through to fill its five. */
|
|
146
156
|
const DOORMAN_SEARCH_CEILING = 25;
|
|
147
157
|
|
|
148
|
-
function
|
|
158
|
+
function deliveredList(db: Database, sessionId: string | null, key: string): Set<string> {
|
|
149
159
|
if (!sessionId) return new Set();
|
|
150
|
-
const raw = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null)[
|
|
151
|
-
DOORMAN_DELIVERED_METADATA_KEY
|
|
152
|
-
];
|
|
160
|
+
const raw = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null)[key];
|
|
153
161
|
return new Set(
|
|
154
162
|
Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : [],
|
|
155
163
|
);
|
|
156
164
|
}
|
|
157
165
|
|
|
166
|
+
function deliveredMemoryIds(db: Database, sessionId: string | null): Set<string> {
|
|
167
|
+
return deliveredList(db, sessionId, DOORMAN_DELIVERED_METADATA_KEY);
|
|
168
|
+
}
|
|
169
|
+
|
|
158
170
|
function rememberDelivered(
|
|
159
171
|
db: WritableDatabase,
|
|
160
172
|
sessionId: string | null,
|
|
161
173
|
ids: readonly string[],
|
|
162
174
|
nowIso: string,
|
|
163
|
-
options: { clearRefresh?: boolean } = {},
|
|
175
|
+
options: { clearRefresh?: boolean; personas?: readonly string[] } = {},
|
|
164
176
|
): void {
|
|
165
177
|
if (!sessionId) return;
|
|
166
178
|
const session = getRuntimeSession(db, sessionId);
|
|
167
179
|
if (!session) return;
|
|
168
180
|
const metadata = decodeMetadata(session.metadata);
|
|
169
181
|
metadata[DOORMAN_DELIVERED_METADATA_KEY] = [...ids];
|
|
182
|
+
if (options.personas) metadata[DOORMAN_PERSONAS_METADATA_KEY] = [...options.personas];
|
|
170
183
|
if (options.clearRefresh) delete metadata[DOORMAN_REFRESH_METADATA_KEY];
|
|
171
184
|
updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
|
|
172
185
|
}
|
|
173
186
|
|
|
187
|
+
function rememberPersonas(
|
|
188
|
+
db: WritableDatabase,
|
|
189
|
+
sessionId: string | null,
|
|
190
|
+
personas: readonly string[],
|
|
191
|
+
nowIso: string,
|
|
192
|
+
): void {
|
|
193
|
+
if (!sessionId) return;
|
|
194
|
+
const session = getRuntimeSession(db, sessionId);
|
|
195
|
+
if (!session) return;
|
|
196
|
+
const metadata = decodeMetadata(session.metadata);
|
|
197
|
+
metadata[DOORMAN_PERSONAS_METADATA_KEY] = [...personas];
|
|
198
|
+
updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
/** One ranked memory's scores, content-free, for the front-door log. */
|
|
175
202
|
export interface MemoryScore {
|
|
176
203
|
readonly score: number;
|
|
@@ -206,7 +233,6 @@ export function readDoormanSessionState(
|
|
|
206
233
|
export function decideDoorman(input: {
|
|
207
234
|
prompt: string;
|
|
208
235
|
state: DoormanSessionState | null;
|
|
209
|
-
personas: readonly PersonaRoutingRow[];
|
|
210
236
|
}): DoormanDecision {
|
|
211
237
|
const explicit = mirrorForm(input.prompt);
|
|
212
238
|
if (explicit) return { kind: "full", query: explicit.query };
|
|
@@ -220,19 +246,29 @@ export function decideDoorman(input: {
|
|
|
220
246
|
// The runtime compacted the transcript: the folder is a summary now, so this
|
|
221
247
|
// turn delivers it whole, routed from the prompt, and the deltas resume after.
|
|
222
248
|
if (input.state.refresh) return { kind: "full", query: input.prompt };
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return { kind: "delta", query: input.prompt
|
|
249
|
+
// The persona is not decided here: routing it is a model call (CR203), and
|
|
250
|
+
// this function stays pure. `runDoorman` routes it, beside the embedding.
|
|
251
|
+
return { kind: "delta", query: input.prompt };
|
|
226
252
|
}
|
|
227
253
|
|
|
254
|
+
/**
|
|
255
|
+
* What the line says when a turn has no persona (CR203). Draft wording, for the
|
|
256
|
+
* prompt engineer to settle in review: no signature, and no earlier persona
|
|
257
|
+
* carried over.
|
|
258
|
+
*/
|
|
259
|
+
export const NO_PERSONA_CLAUSE =
|
|
260
|
+
"no persona this turn: do not sign, and do not answer as an earlier persona";
|
|
261
|
+
|
|
228
262
|
export function deliveredLine(input: {
|
|
229
263
|
kind: "full" | "delta";
|
|
230
264
|
persona: string | null;
|
|
231
265
|
journey: string | null;
|
|
232
266
|
}): string {
|
|
267
|
+
// CR203: a turn may have no persona, and the model must hear that as this
|
|
268
|
+
// turn's answer, not as silence it may fill with the last persona it signed.
|
|
233
269
|
const clause = input.persona
|
|
234
270
|
? `answer as the mirror; sign ◇ ${input.persona}`
|
|
235
|
-
:
|
|
271
|
+
: `answer as the mirror; ${NO_PERSONA_CLAUSE}`;
|
|
236
272
|
const what =
|
|
237
273
|
input.kind === "full"
|
|
238
274
|
? "full"
|
|
@@ -289,7 +325,10 @@ async function runFull(
|
|
|
289
325
|
// The conversation's context starts over with the folder: so does the
|
|
290
326
|
// list of memories the deltas have put in it, and a compaction's refresh
|
|
291
327
|
// is answered.
|
|
292
|
-
rememberDelivered(db, input.sessionId, [], input.nowIso(), {
|
|
328
|
+
rememberDelivered(db, input.sessionId, [], input.nowIso(), {
|
|
329
|
+
clearRefresh: true,
|
|
330
|
+
personas: loaded.resolved.persona ? [loaded.resolved.persona] : [],
|
|
331
|
+
});
|
|
293
332
|
}
|
|
294
333
|
const line = deliveredLine({
|
|
295
334
|
kind: "full",
|
|
@@ -305,28 +344,37 @@ async function runDelta(
|
|
|
305
344
|
input: RunDoormanInput,
|
|
306
345
|
): Promise<RenderedDoorman> {
|
|
307
346
|
const state = readDoormanSessionState(db, input.sessionId);
|
|
308
|
-
const persona = decision.persona ?? state?.persona ?? null;
|
|
309
347
|
const journey = state?.journey ?? null;
|
|
348
|
+
// The turn's persona and its embedding, side by side: neither waits on the
|
|
349
|
+
// other. One embedding for the turn, in the ledger once; both searches share
|
|
350
|
+
// it. Without a provider, or when it fails, the memory search degrades to FTS
|
|
351
|
+
// and the attachments are skipped: the delta still carries the line and the
|
|
352
|
+
// persona.
|
|
353
|
+
const [turn, queryEmbedding] = await Promise.all([
|
|
354
|
+
routeTurnPersona(db, {
|
|
355
|
+
prompt: decision.query,
|
|
356
|
+
journey,
|
|
357
|
+
router: input.personaRouter,
|
|
358
|
+
onRouting: personaRoutingLedger(db, { sessionId: input.sessionId, now: input.nowIso }),
|
|
359
|
+
}),
|
|
360
|
+
embedOnce(db, decision.query, input),
|
|
361
|
+
]);
|
|
362
|
+
const persona = turn.persona;
|
|
310
363
|
const parts: string[] = [deliveredLine({ kind: "delta", persona, journey })];
|
|
311
364
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
input.sessionId,
|
|
319
|
-
{ persona: decision.persona },
|
|
320
|
-
input.nowIso(),
|
|
321
|
-
);
|
|
365
|
+
const deliveredPersonas = deliveredList(db, input.sessionId, DOORMAN_PERSONAS_METADATA_KEY);
|
|
366
|
+
if (persona && !deliveredPersonas.has(persona)) {
|
|
367
|
+
const content = getIdentityContent(db, "persona", persona);
|
|
368
|
+
if (content) {
|
|
369
|
+
parts.push(`=== persona/${persona} ===\n${content}`);
|
|
370
|
+
rememberPersonas(db, input.sessionId, [...deliveredPersonas, persona], input.nowIso());
|
|
322
371
|
}
|
|
323
372
|
}
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const queryEmbedding = await embedOnce(db, decision.query, input);
|
|
373
|
+
// The session records the turn's answer, none included: a session's persona
|
|
374
|
+
// is the last turn's, not a default the next turn inherits.
|
|
375
|
+
if (input.sessionId && persona !== (state?.persona ?? null)) {
|
|
376
|
+
updateRuntimeSessionColumns(db, input.sessionId, { persona }, input.nowIso());
|
|
377
|
+
}
|
|
330
378
|
|
|
331
379
|
const delivered = deliveredMemoryIds(db, input.sessionId);
|
|
332
380
|
const memories = await searchMemoriesWithStatus(db, {
|
|
@@ -413,8 +461,3 @@ async function embedOnce(
|
|
|
413
461
|
return null;
|
|
414
462
|
}
|
|
415
463
|
}
|
|
416
|
-
|
|
417
|
-
/** The persona routing table the decision scores against; read once per turn. */
|
|
418
|
-
export function doormanPersonas(db: Database): PersonaRoutingRow[] {
|
|
419
|
-
return personaRoutingRows(db);
|
|
420
|
-
}
|
|
@@ -12,6 +12,7 @@ import type { EmbeddingProvider } from "#providers/embedding.ts";
|
|
|
12
12
|
import type { LlmProvider } from "#providers/llm.ts";
|
|
13
13
|
import { loadMirrorContext } from "./context.ts";
|
|
14
14
|
import { type ResolvedMirrorDefaults, resolveMirrorDefaults } from "./defaultResolution.ts";
|
|
15
|
+
import { type PersonaRouter, personaRoutingLedger } from "./personaRouter.ts";
|
|
15
16
|
import { pythonTruthy, sliceCodePoints } from "./reception.ts";
|
|
16
17
|
import {
|
|
17
18
|
renderJourneyDetected,
|
|
@@ -47,6 +48,8 @@ export interface MirrorLoadInput {
|
|
|
47
48
|
onReceptionOutcome?: OnProviderCallOutcome;
|
|
48
49
|
llmProvider?: LlmProvider;
|
|
49
50
|
embeddingProvider?: EmbeddingProvider;
|
|
51
|
+
/** CR203: routes a turn's persona by its subject; absent, the turn has the Journey's default or none. */
|
|
52
|
+
personaRouter?: PersonaRouter;
|
|
50
53
|
databasePath?: string;
|
|
51
54
|
mirrorHome?: string;
|
|
52
55
|
user?: string;
|
|
@@ -72,6 +75,10 @@ export async function runMirrorLoad(
|
|
|
72
75
|
const resolved = await resolveMirrorDefaults(db, {
|
|
73
76
|
...input,
|
|
74
77
|
onReceptionOutcome: input.onReceptionOutcome,
|
|
78
|
+
onPersonaRouting: personaRoutingLedger(db, {
|
|
79
|
+
sessionId: input.sessionId ?? null,
|
|
80
|
+
now: input.nowIso,
|
|
81
|
+
}),
|
|
75
82
|
// Priced, as Python's `build_llm_logger(role="reception")` prices it
|
|
76
83
|
// (CV22.DS8.US3). The row existed before but carried a null cost, so
|
|
77
84
|
// every Mirror Mode activation with a query would have logged live spend
|
|
@@ -289,7 +296,9 @@ function writeMirrorState(
|
|
|
289
296
|
input.sessionId,
|
|
290
297
|
{
|
|
291
298
|
mirrorActive: input.active,
|
|
292
|
-
|
|
299
|
+
// A load's persona is recorded even when it is none (CR203): the session
|
|
300
|
+
// must not keep an earlier turn's persona as if this one had chosen it.
|
|
301
|
+
...(input.persona !== undefined ? { persona: input.persona } : {}),
|
|
293
302
|
...(input.journey !== undefined && input.journey !== null ? { journey: input.journey } : {}),
|
|
294
303
|
},
|
|
295
304
|
nowIso,
|