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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// CR203: a turn's persona, by its subject, or none.
|
|
2
|
+
//
|
|
3
|
+
// Until CR203 a turn always had a persona: a keyword count chose one, and when
|
|
4
|
+
// nothing scored the session's kept answering. Measured on production data, a
|
|
5
|
+
// third of consecutive turns switched persona and specialists from unrelated
|
|
6
|
+
// domains answered. The Navigator's criterion: a wrong persona is worse than
|
|
7
|
+
// none, because it puts a false specialist into the answer, while no persona
|
|
8
|
+
// only loses one domain's lens.
|
|
9
|
+
//
|
|
10
|
+
// So a turn's persona is, in order:
|
|
11
|
+
//
|
|
12
|
+
// 1. the router's answer: a Decisions model through OpenRouter's Decisions
|
|
13
|
+
// API, asked one Choice (which persona) and one Noul (does the subject
|
|
14
|
+
// belong to any of them) in one request; the Choice is taken only when
|
|
15
|
+
// the Noul is at or above the threshold and it names a known persona;
|
|
16
|
+
// 2. else the persona the turn's Journey names as its own default
|
|
17
|
+
// (`default_persona` in the Journey's metadata): the Journey's word, not
|
|
18
|
+
// an inference;
|
|
19
|
+
// 3. else none.
|
|
20
|
+
//
|
|
21
|
+
// Every failure lands on none: no key, a timeout, a refusal, a malformed
|
|
22
|
+
// answer, an unknown persona. The router never throws into a turn. Both the
|
|
23
|
+
// doorman's delta and the full load call `routeTurnPersona`, so the two paths
|
|
24
|
+
// cannot disagree.
|
|
25
|
+
//
|
|
26
|
+
// The route is `alpha`. Its request and answer shapes live here and nowhere
|
|
27
|
+
// else, pinned by a contract test, so a change in the shape fails a test, not
|
|
28
|
+
// a turn.
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import type { Database, StatementRunner } from "#db/database.ts";
|
|
32
|
+
import { descriptorsByLayer } from "#descriptor/descriptorRead.ts";
|
|
33
|
+
import { logLlmCall } from "#observability/llmCalls.ts";
|
|
34
|
+
import {
|
|
35
|
+
DEFAULT_PERSONA_ROUTER_THRESHOLD,
|
|
36
|
+
hasOpenRouterKey,
|
|
37
|
+
type ProviderConfig,
|
|
38
|
+
resolveLlmTimeoutMs,
|
|
39
|
+
resolvePersonaRouterModel,
|
|
40
|
+
resolvePersonaRouterThreshold,
|
|
41
|
+
resolveProviderConfig,
|
|
42
|
+
} from "#providers/config.ts";
|
|
43
|
+
import { createOpenRouterClient, type OpenRouterClient } from "#providers/openrouter.ts";
|
|
44
|
+
import { sliceCodePoints } from "./reception.ts";
|
|
45
|
+
|
|
46
|
+
/** A persona as the router offers it: its key and what it is for. */
|
|
47
|
+
export interface RoutablePersona {
|
|
48
|
+
readonly key: string;
|
|
49
|
+
readonly description: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PersonaRouteInput {
|
|
53
|
+
readonly prompt: string;
|
|
54
|
+
readonly journey: string | null;
|
|
55
|
+
readonly personas: readonly RoutablePersona[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Why the router answered what it did; content-free. */
|
|
59
|
+
export type PersonaRoutingOutcome =
|
|
60
|
+
| "chosen"
|
|
61
|
+
| "below_threshold"
|
|
62
|
+
| "unknown_persona"
|
|
63
|
+
| "malformed"
|
|
64
|
+
| "failed"
|
|
65
|
+
| "no_key"
|
|
66
|
+
| "no_personas";
|
|
67
|
+
|
|
68
|
+
export interface PersonaRouting {
|
|
69
|
+
readonly persona: string | null;
|
|
70
|
+
readonly outcome: PersonaRoutingOutcome;
|
|
71
|
+
/** The model asked; absent when no call was made. */
|
|
72
|
+
readonly model?: string;
|
|
73
|
+
readonly noul?: number;
|
|
74
|
+
readonly latencyMs?: number;
|
|
75
|
+
readonly costUsd?: number | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PersonaRouter {
|
|
79
|
+
route(input: PersonaRouteInput): Promise<PersonaRouting>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The Decisions route, under `/api`, not the chat client's `/api/v1`. */
|
|
83
|
+
export const DECISIONS_BASE_URL = "https://openrouter.ai/api";
|
|
84
|
+
export const DECISIONS_PATH = "/alpha/decisions";
|
|
85
|
+
|
|
86
|
+
/** The bounds the eval measured with: what the model reads of each part. */
|
|
87
|
+
const REQUEST_CHARS = 2000;
|
|
88
|
+
const DESCRIPTION_CHARS = 280;
|
|
89
|
+
const NOUL_DESCRIPTION_CHARS = 90;
|
|
90
|
+
|
|
91
|
+
/** The request body, as the eval of 2026-11-07 sent it. Pure. */
|
|
92
|
+
export function decisionsRequest(model: string, input: PersonaRouteInput): unknown {
|
|
93
|
+
const criteria = Object.fromEntries(
|
|
94
|
+
input.personas.map((persona) => [
|
|
95
|
+
persona.key,
|
|
96
|
+
sliceCodePoints(persona.description, DESCRIPTION_CHARS),
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
const listed = input.personas
|
|
100
|
+
.map(
|
|
101
|
+
(persona) =>
|
|
102
|
+
`${persona.key}: ${sliceCodePoints(persona.description, NOUL_DESCRIPTION_CHARS)}`,
|
|
103
|
+
)
|
|
104
|
+
.join("; ");
|
|
105
|
+
return {
|
|
106
|
+
model,
|
|
107
|
+
state: { journey: input.journey, request: sliceCodePoints(input.prompt, REQUEST_CHARS) },
|
|
108
|
+
questions: {
|
|
109
|
+
persona: {
|
|
110
|
+
type: "choice",
|
|
111
|
+
instructions:
|
|
112
|
+
"Which specialist should answer this request, judging only by the subject of `request`?",
|
|
113
|
+
criteria,
|
|
114
|
+
},
|
|
115
|
+
fits: {
|
|
116
|
+
type: "noul",
|
|
117
|
+
instructions: `Does the subject of \`request\` clearly belong to the domain of one specific specialist from this list: ${listed}?`,
|
|
118
|
+
criteria: {
|
|
119
|
+
true: "The request asks for expertise that one listed specialist clearly owns.",
|
|
120
|
+
false:
|
|
121
|
+
"The request is a plain instruction, a confirmation, a general question, or its subject belongs to no listed specialist.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
130
|
+
? (value as Record<string, unknown>)
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The answer read against the personas offered and the threshold. Pure. */
|
|
135
|
+
export function readDecisionsAnswer(
|
|
136
|
+
body: unknown,
|
|
137
|
+
personas: readonly RoutablePersona[],
|
|
138
|
+
threshold: number,
|
|
139
|
+
): PersonaRouting {
|
|
140
|
+
const answers = record(record(body)?.answers);
|
|
141
|
+
const choice = record(answers?.persona)?.choice;
|
|
142
|
+
const noul = record(answers?.fits)?.noul;
|
|
143
|
+
const cost = record(record(body)?.usage)?.cost;
|
|
144
|
+
const costUsd = typeof cost === "number" && Number.isFinite(cost) ? cost : null;
|
|
145
|
+
if (typeof choice !== "string" || typeof noul !== "number" || !(noul >= 0 && noul <= 1)) {
|
|
146
|
+
return { persona: null, outcome: "malformed", costUsd };
|
|
147
|
+
}
|
|
148
|
+
if (noul < threshold) return { persona: null, outcome: "below_threshold", noul, costUsd };
|
|
149
|
+
if (!personas.some((persona) => persona.key === choice)) {
|
|
150
|
+
return { persona: null, outcome: "unknown_persona", noul, costUsd };
|
|
151
|
+
}
|
|
152
|
+
return { persona: choice, outcome: "chosen", noul, costUsd };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface LivePersonaRouterOptions {
|
|
156
|
+
/** Where the key and the settings are read; a host passes its own. */
|
|
157
|
+
env?: Record<string, string | undefined>;
|
|
158
|
+
/** Tests only: a client in place of the live one. */
|
|
159
|
+
createClient?: (config: ProviderConfig) => OpenRouterClient;
|
|
160
|
+
/** Tests only: the clock latency is measured with. */
|
|
161
|
+
clock?: () => number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The router over OpenRouter's Decisions API: one attempt, bounded like
|
|
166
|
+
* reception (10 s), since a person's turn waits on it. The eval's p95 was 1.1 s.
|
|
167
|
+
*/
|
|
168
|
+
export class LivePersonaRouter implements PersonaRouter {
|
|
169
|
+
readonly #env: Record<string, string | undefined>;
|
|
170
|
+
readonly #createClient: (config: ProviderConfig) => OpenRouterClient;
|
|
171
|
+
readonly #clock: () => number;
|
|
172
|
+
|
|
173
|
+
constructor(options: LivePersonaRouterOptions = {}) {
|
|
174
|
+
this.#env = options.env ?? process.env;
|
|
175
|
+
this.#createClient = options.createClient ?? ((config) => createOpenRouterClient(config));
|
|
176
|
+
this.#clock = options.clock ?? (() => performance.now());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async route(input: PersonaRouteInput): Promise<PersonaRouting> {
|
|
180
|
+
if (input.personas.length === 0) return { persona: null, outcome: "no_personas" };
|
|
181
|
+
if (!hasOpenRouterKey(this.#env)) return { persona: null, outcome: "no_key" };
|
|
182
|
+
const env = this.#env;
|
|
183
|
+
const model = resolvePersonaRouterModel({ env });
|
|
184
|
+
const started = this.#clock();
|
|
185
|
+
try {
|
|
186
|
+
const client = this.#createClient({
|
|
187
|
+
...resolveProviderConfig("openrouter", { env }),
|
|
188
|
+
baseUrl: DECISIONS_BASE_URL,
|
|
189
|
+
});
|
|
190
|
+
const body = await client.postJson(DECISIONS_PATH, decisionsRequest(model, input), {
|
|
191
|
+
timeoutMs: resolveLlmTimeoutMs("reception", { env }),
|
|
192
|
+
maxRetries: 0,
|
|
193
|
+
});
|
|
194
|
+
const latencyMs = Math.round(this.#clock() - started);
|
|
195
|
+
return {
|
|
196
|
+
...readDecisionsAnswer(body, input.personas, resolvePersonaRouterThreshold({ env })),
|
|
197
|
+
model,
|
|
198
|
+
latencyMs,
|
|
199
|
+
};
|
|
200
|
+
} catch {
|
|
201
|
+
return {
|
|
202
|
+
persona: null,
|
|
203
|
+
outcome: "failed",
|
|
204
|
+
model,
|
|
205
|
+
latencyMs: Math.round(this.#clock() - started),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Where a replay run finds its router's recorded answers. A family's replay
|
|
213
|
+
* reaches no network, and without this its turns could only ever have none:
|
|
214
|
+
* the route from the command to the session row would go untested.
|
|
215
|
+
*/
|
|
216
|
+
export const PERSONA_ROUTER_REPLAY_ENV = "MIRROR_TS_PERSONA_ROUTER_REPLAY";
|
|
217
|
+
|
|
218
|
+
export interface PersonaRouterReplayFixture {
|
|
219
|
+
readonly kind: "persona_router";
|
|
220
|
+
/** The first answer whose `contains` is in the prompt, case aside, is the route's body. */
|
|
221
|
+
readonly answers: readonly { readonly contains: string; readonly body: unknown }[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Recorded Decisions bodies, read through the same `readDecisionsAnswer` the
|
|
226
|
+
* live route's are. A prompt no answer matches is below the threshold. No
|
|
227
|
+
* model is named, so a replay writes no ledger row.
|
|
228
|
+
*/
|
|
229
|
+
export class ReplayPersonaRouter implements PersonaRouter {
|
|
230
|
+
readonly #fixture: PersonaRouterReplayFixture;
|
|
231
|
+
readonly #threshold: number;
|
|
232
|
+
|
|
233
|
+
constructor(fixture: PersonaRouterReplayFixture, threshold = DEFAULT_PERSONA_ROUTER_THRESHOLD) {
|
|
234
|
+
this.#fixture = fixture;
|
|
235
|
+
this.#threshold = threshold;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async route(input: PersonaRouteInput): Promise<PersonaRouting> {
|
|
239
|
+
const prompt = input.prompt.toLowerCase();
|
|
240
|
+
const answer = this.#fixture.answers.find((candidate) =>
|
|
241
|
+
prompt.includes(candidate.contains.toLowerCase()),
|
|
242
|
+
);
|
|
243
|
+
if (!answer) return { persona: null, outcome: "below_threshold" };
|
|
244
|
+
const {
|
|
245
|
+
model: _model,
|
|
246
|
+
latencyMs: _latency,
|
|
247
|
+
...read
|
|
248
|
+
} = readDecisionsAnswer(answer.body, input.personas, this.#threshold);
|
|
249
|
+
return read;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function loadReplayPersonaRouter(path: string): ReplayPersonaRouter {
|
|
254
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
255
|
+
const fixture = record(parsed);
|
|
256
|
+
if (fixture?.kind !== "persona_router" || !Array.isArray(fixture.answers)) {
|
|
257
|
+
throw new Error(`${PERSONA_ROUTER_REPLAY_ENV} is not a persona_router fixture: ${path}`);
|
|
258
|
+
}
|
|
259
|
+
return new ReplayPersonaRouter(fixture as unknown as PersonaRouterReplayFixture);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Every persona with what it is for: its own `description` (seeded from its
|
|
264
|
+
* YAML), else its generated descriptor, else the start of its content. The
|
|
265
|
+
* description comes first because it is the persona's declared purpose and
|
|
266
|
+
* what the eval measured with; the rerun on the Core's code with descriptors
|
|
267
|
+
* first doubled the wrong picks, since 11 of 28 production personas had none
|
|
268
|
+
* and fell back to a heading.
|
|
269
|
+
*/
|
|
270
|
+
export function routablePersonas(db: Database): RoutablePersona[] {
|
|
271
|
+
const descriptors = new Map(
|
|
272
|
+
descriptorsByLayer(db, "persona").map((row) => [row.key, row.descriptor]),
|
|
273
|
+
);
|
|
274
|
+
return db
|
|
275
|
+
.prepare("SELECT key, content, metadata FROM identity WHERE layer = 'persona' ORDER BY key")
|
|
276
|
+
.all()
|
|
277
|
+
.map((row) => {
|
|
278
|
+
const key = String(row.key);
|
|
279
|
+
return {
|
|
280
|
+
key,
|
|
281
|
+
description:
|
|
282
|
+
declaredDescription(row.metadata) ||
|
|
283
|
+
descriptors.get(key)?.trim() ||
|
|
284
|
+
sliceCodePoints(String(row.content), 200),
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function declaredDescription(metadata: unknown): string {
|
|
290
|
+
if (typeof metadata !== "string") return "";
|
|
291
|
+
try {
|
|
292
|
+
const description = record(JSON.parse(metadata))?.description;
|
|
293
|
+
return typeof description === "string" ? description.trim() : "";
|
|
294
|
+
} catch {
|
|
295
|
+
return "";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The persona a Journey names as its own default, when it names one that
|
|
301
|
+
* exists. Read from the Journey's metadata (`default_persona`).
|
|
302
|
+
*/
|
|
303
|
+
export function journeyDefaultPersona(db: Database, journey: string | null): string | null {
|
|
304
|
+
if (!journey) return null;
|
|
305
|
+
const row = db
|
|
306
|
+
.prepare("SELECT metadata FROM identity WHERE layer = 'journey' AND key = ?")
|
|
307
|
+
.get(journey);
|
|
308
|
+
if (typeof row?.metadata !== "string") return null;
|
|
309
|
+
let named: unknown;
|
|
310
|
+
try {
|
|
311
|
+
named = record(JSON.parse(row.metadata))?.default_persona;
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (typeof named !== "string" || !named.trim()) return null;
|
|
316
|
+
const exists = db
|
|
317
|
+
.prepare("SELECT 1 AS found FROM identity WHERE layer = 'persona' AND key = ?")
|
|
318
|
+
.get(named.trim());
|
|
319
|
+
return exists ? named.trim() : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface TurnPersona {
|
|
323
|
+
readonly persona: string | null;
|
|
324
|
+
/** Where the persona came from; null when the turn has none. */
|
|
325
|
+
readonly source: "router" | "journey" | null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface RouteTurnPersonaInput {
|
|
329
|
+
readonly prompt: string;
|
|
330
|
+
readonly journey: string | null;
|
|
331
|
+
/** Absent: no call is made, and the turn falls to the Journey's default or none. */
|
|
332
|
+
readonly router?: PersonaRouter;
|
|
333
|
+
/** Where the call's ledger row goes; content-free. */
|
|
334
|
+
readonly onRouting?: (routing: PersonaRouting) => void;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The turn's persona: the router's answer, else the Journey's default, else none. */
|
|
338
|
+
export async function routeTurnPersona(
|
|
339
|
+
db: Database,
|
|
340
|
+
input: RouteTurnPersonaInput,
|
|
341
|
+
): Promise<TurnPersona> {
|
|
342
|
+
if (input.router) {
|
|
343
|
+
const personas = routablePersonas(db);
|
|
344
|
+
let routing: PersonaRouting;
|
|
345
|
+
try {
|
|
346
|
+
routing = await input.router.route({
|
|
347
|
+
prompt: input.prompt,
|
|
348
|
+
journey: input.journey,
|
|
349
|
+
personas,
|
|
350
|
+
});
|
|
351
|
+
} catch {
|
|
352
|
+
routing = { persona: null, outcome: "failed" };
|
|
353
|
+
}
|
|
354
|
+
input.onRouting?.(routing);
|
|
355
|
+
const chosen = routing.persona;
|
|
356
|
+
if (chosen !== null && personas.some((persona) => persona.key === chosen)) {
|
|
357
|
+
return { persona: chosen, source: "router" };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const fallback = journeyDefaultPersona(db, input.journey);
|
|
361
|
+
return fallback ? { persona: fallback, source: "journey" } : { persona: null, source: null };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* One `llm_calls` row for a call that was made, without the prompt or the
|
|
366
|
+
* answer in any logging mode: the subject of a person's turn is not the
|
|
367
|
+
* ledger's to keep.
|
|
368
|
+
*/
|
|
369
|
+
export function personaRoutingLedger(
|
|
370
|
+
db: StatementRunner,
|
|
371
|
+
context: { sessionId?: string | null; now?: () => string } = {},
|
|
372
|
+
): (routing: PersonaRouting) => void {
|
|
373
|
+
return (routing) => {
|
|
374
|
+
if (!routing.model) return;
|
|
375
|
+
logLlmCall(
|
|
376
|
+
db,
|
|
377
|
+
{
|
|
378
|
+
role: "persona_routing",
|
|
379
|
+
model: routing.model,
|
|
380
|
+
prompt: "",
|
|
381
|
+
response: "",
|
|
382
|
+
latencyMs: routing.latencyMs ?? null,
|
|
383
|
+
costUsd: routing.costUsd ?? null,
|
|
384
|
+
sessionId: context.sessionId ?? null,
|
|
385
|
+
},
|
|
386
|
+
context.now ? { now: context.now } : {},
|
|
387
|
+
);
|
|
388
|
+
};
|
|
389
|
+
}
|
|
@@ -161,6 +161,31 @@ export function resolveExtractionReasoning(
|
|
|
161
161
|
return { model, effort: null, source: null, invalid: setting.invalid };
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* CR203: the Decisions model that routes a turn's persona, and the Noul score
|
|
166
|
+
* at or above which its choice is taken. The eval of 2026-11-07 chose the pair
|
|
167
|
+
* (`docs/project/refinement/rs010-cv22-oracle-and-port-hygiene/`); they are
|
|
168
|
+
* settings so a later model replaces it without a code change, measured again
|
|
169
|
+
* on a labelled set first. An empty or invalid value reads as unset: a typo
|
|
170
|
+
* must not turn routing off, or on for every turn.
|
|
171
|
+
*/
|
|
172
|
+
export const DEFAULT_PERSONA_ROUTER_MODEL = "liquid/d1-ultra";
|
|
173
|
+
export const DEFAULT_PERSONA_ROUTER_THRESHOLD = 0.6;
|
|
174
|
+
|
|
175
|
+
export function resolvePersonaRouterModel(options: ModelPinOptions = {}): string {
|
|
176
|
+
const env = options.env ?? process.env;
|
|
177
|
+
return env.MEMORY_PERSONA_ROUTER_MODEL?.trim() || DEFAULT_PERSONA_ROUTER_MODEL;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resolvePersonaRouterThreshold(options: ModelPinOptions = {}): number {
|
|
181
|
+
const env = options.env ?? process.env;
|
|
182
|
+
const raw = env.MEMORY_PERSONA_ROUTER_THRESHOLD?.trim() ?? "";
|
|
183
|
+
const parsed = Number(raw);
|
|
184
|
+
return raw !== "" && Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
|
|
185
|
+
? parsed
|
|
186
|
+
: DEFAULT_PERSONA_ROUTER_THRESHOLD;
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
// Observability (AI-09, CV9.E2.S13): mirrors Python's MEMORY_LOG_LLM_CALLS
|
|
165
190
|
// resolution exactly -- off | metadata | full, default metadata (never a
|
|
166
191
|
// silent full -- bodies are opt-in only). Legacy "1" maps to "full" for
|