mirror-mind 0.40.1 → 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 +1 -0
- package/docs/releases/v0.41.0.md +116 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +1 -1
- package/ts/src/frontDoor/mirrorModeRoute.ts +24 -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
package/docs/releases/index.md
CHANGED
|
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
|
|
|
12
12
|
|
|
13
13
|
## Releases
|
|
14
14
|
|
|
15
|
+
- [v0.41.0 — A Persona When the Subject Asks for One](v0.41.0.md) — minor release of CR203 and CR204: a Mirror Mode turn's persona is routed by its subject, by a Decisions model through OpenRouter taken only above a threshold, else the Journey's `default_persona`, else none; a turn never inherits the session's persona, and a turn with none tells the model not to sign; every routing failure is none; one content-free `llm_calls` row per call; `mirror-mind/desktop` returns `personaName` beside the persona's id. No migration.
|
|
15
16
|
- [v0.40.1 — The Bins You Named, or Why Not](v0.40.1.md) — patch release of CR197: the plugin's hook wrappers and MCP entry use `MIRROR_BIN` only as an absolute path to a directory holding the bin they need, the rule `MIRROR_NODE` follows since v0.39.5; a relative value is never used, so a project's own `bin/mirror-hook` cannot run, and a refused value is named with its reason, once a day in `hooks.log` and on the MCP entry's stderr; the forward-slash `C:/…` form Windows machines set counts as absolute, in CI under Git Bash. Also carries CV9's re-baseline (documents only). No migration.
|
|
16
17
|
- [v0.40.0 — A Reply Stays in Its Own Conversation, and a Journey Can Go](v0.40.0.md) — minor release closing the first release train: CR151, a session's transcript fills only the conversation that session is bound to, each reply stamped with its transcript time, where it used to fill every open reply-less conversation in the transcript's window, another session's included; a transcript no session owns backfills nothing; and CR200, previewed as v0.40.0-next.1, `mirror-mind/desktop` deletes a Journey with every record that belongs to it, by digest, behind a verified backup. Update every install that has a home. No migration.
|
|
17
18
|
- [v0.40.0-next.1 — A Journey Can Go, Showing What Goes With It](v0.40.0-next.1.md) — preview under npm's `next`, read by no updater, for Nautilus for Mirror (the release train): CR200, `mirror-mind/desktop` gains `previewJourneyDeletion` and `deleteJourneyWithRecords`, deleting a Journey with every record that belongs to it after a preview that counts it, by digest, behind a verified backup; child Journeys refuse. Terminal users receive it with v0.40.0. No migration.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.41.0 routes each Mirror Mode turn's persona by what the turn is about, and lets a turn
|
|
4
|
+
have none. A Decisions model chooses among your personas and says whether the subject
|
|
5
|
+
belongs to any of them; its choice is taken only above a threshold, else the persona the
|
|
6
|
+
turn's Journey names as its default, else none. A turn no longer inherits the session's
|
|
7
|
+
persona, and a turn with none tells the model not to sign. `mirror-mind/desktop` also hands
|
|
8
|
+
its host the persona's own name beside its id. One new model call per turn, made beside
|
|
9
|
+
the embedding the turn already makes. No schema change, no migration.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# v0.41.0 — A Persona When the Subject Asks for One
|
|
13
|
+
|
|
14
|
+
**Date:** 2026-11-08
|
|
15
|
+
|
|
16
|
+
## Highlights
|
|
17
|
+
|
|
18
|
+
- **A turn's persona follows its subject, or there is none**
|
|
19
|
+
([CR203](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr203-route-a-turns-persona-by-its-subject-and-let-it-have-none.md)).
|
|
20
|
+
In Mirror Mode, on the delta of an ordinary turn and on the full load alike, the persona
|
|
21
|
+
is, in order: the one you named (`mirror load --persona`); the router's answer, a
|
|
22
|
+
Decisions model through OpenRouter's Decisions API asked which persona the subject
|
|
23
|
+
belongs to and whether it belongs to any, taken only at or above a threshold; the persona
|
|
24
|
+
the turn's Journey names as `default_persona` in its metadata; or none. It is never the
|
|
25
|
+
session's by inheritance.
|
|
26
|
+
- **None is said, not left silent.** A turn with no persona delivers
|
|
27
|
+
`persona: none` and the clause
|
|
28
|
+
`answer as the mirror; no persona this turn: do not sign, and do not answer as an earlier persona`,
|
|
29
|
+
so the model does not keep signing as the last persona it answered as.
|
|
30
|
+
- **Every routing failure is none, and the turn goes on.** No key, a timeout, a refusal, a
|
|
31
|
+
malformed answer or a persona that does not exist each give a turn with no persona. One
|
|
32
|
+
attempt, bounded like reception (`MEMORY_LLM_TIMEOUT_RECEPTION`, 10 s by default).
|
|
33
|
+
- **Two settings.** `MEMORY_PERSONA_ROUTER_MODEL` (`liquid/d1-ultra`) and
|
|
34
|
+
`MEMORY_PERSONA_ROUTER_THRESHOLD` (`0.6`), in [`REFERENCE.md`](../../REFERENCE.md); an
|
|
35
|
+
invalid value reads as unset.
|
|
36
|
+
- **The host gets the persona's name**
|
|
37
|
+
([CR204](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr204-hand-the-host-the-personas-name-with-its-id.md)).
|
|
38
|
+
`deliverMirrorContext` in `mirror-mind/desktop` returns `personaName`, the persona's own
|
|
39
|
+
`name` from its metadata, or `null`; `persona` stays the id. Additive.
|
|
40
|
+
- No schema change, no migration.
|
|
41
|
+
|
|
42
|
+
## Where We Started
|
|
43
|
+
|
|
44
|
+
A keyword count chose every turn's persona, and when no keyword scored, the session's
|
|
45
|
+
persona kept answering. Measured on the maintainer's own data over five days (550
|
|
46
|
+
deliveries), every turn had a persona, the persona changed on one turn in three, and
|
|
47
|
+
specialists from unrelated domains answered: health, customer support and QA personas on a
|
|
48
|
+
book-writing Journey. The maintainer's criterion: a wrong persona is worse than none,
|
|
49
|
+
because it puts a false specialist into the answer, while no persona only loses one
|
|
50
|
+
domain's lens and keeps identity, voice and memories.
|
|
51
|
+
|
|
52
|
+
## What Changed
|
|
53
|
+
|
|
54
|
+
**One router for both paths.** `ts/src/mirror/personaRouter.ts` owns the Decisions route,
|
|
55
|
+
which OpenRouter marks `alpha`: its request and answer shapes live in that module and a
|
|
56
|
+
contract test pins them, so a change in the route fails a test, not a turn. The doorman's
|
|
57
|
+
delta and the full load call the same rule, so the two cannot disagree. The delta asks the
|
|
58
|
+
router and embeds the prompt at the same time, so the call adds no wait of its own to the
|
|
59
|
+
delivery. Each persona is offered with its own `description`, else its generated
|
|
60
|
+
descriptor, else the start of its content.
|
|
61
|
+
|
|
62
|
+
**The session records the turn's answer.** A session's persona is now the last turn's,
|
|
63
|
+
none included. Which persona sections the conversation already holds is kept apart
|
|
64
|
+
(`doorman_personas` in the session's metadata), so a section is delivered the first time
|
|
65
|
+
its persona answers and not again; a full load starts that list over, and a compaction
|
|
66
|
+
clears it.
|
|
67
|
+
|
|
68
|
+
**Reception still runs** on a full load, for the Journey and the turn's shaping. It no
|
|
69
|
+
longer chooses the persona. Its prompt is unchanged.
|
|
70
|
+
|
|
71
|
+
**The ledger.** Each routing call writes one `llm_calls` row, `role = persona_routing`,
|
|
72
|
+
with the model, latency and cost, and an empty prompt and response in every logging mode:
|
|
73
|
+
the subject of your turn is not the ledger's to keep.
|
|
74
|
+
|
|
75
|
+
**Measured before the release.** The router was chosen by an eval of 100 labelled turns
|
|
76
|
+
from the maintainer's production data
|
|
77
|
+
([report](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr203-persona-routing-eval-2026-11-07.md)).
|
|
78
|
+
The same 100, rerun through this release's code on a copy of a home with 28 personas: 93
|
|
79
|
+
right, 3 wrong, 4 missed (the keyword router: 35 wrong), at 0.61 s median and 1.32 s at
|
|
80
|
+
p95, about US$ 0.00008 per turn. The private turns stay out of this repository; a
|
|
81
|
+
synthetic set runs in CI.
|
|
82
|
+
|
|
83
|
+
**Model-behavior gate.** This release changes no extraction model, embedding model or
|
|
84
|
+
`prompts.ts` prompt, so the gate's letter does not apply; it does add a model call and a
|
|
85
|
+
line the model reads, and the rerun above is its evidence in place of `eval --all`, which
|
|
86
|
+
does not probe routing.
|
|
87
|
+
|
|
88
|
+
## Conscious Exclusions
|
|
89
|
+
|
|
90
|
+
- **No command sets a Journey's `default_persona` yet.** The Core reads it; setting it is a
|
|
91
|
+
follow-up. Two of the rerun's three wrong picks are a Journey's own writing persona on
|
|
92
|
+
turns that asked for none, the case a default resolves.
|
|
93
|
+
- **The no-persona clause is a first wording**, one constant (`NO_PERSONA_CLAUSE`), to be
|
|
94
|
+
settled in prompt review.
|
|
95
|
+
- **`detect-persona` stays** as a command, an MCP tool and a package export. It no longer
|
|
96
|
+
decides a turn.
|
|
97
|
+
- **Reception's prompt still lists the personas**: its bytes are digest-pinned, and
|
|
98
|
+
changing it is its own decision.
|
|
99
|
+
- **A full load's worst case grows**: reception, then routing, each bounded at 10 s. A
|
|
100
|
+
delta's is unchanged.
|
|
101
|
+
- **Two personas covering one domain** remain content for their owner to merge.
|
|
102
|
+
|
|
103
|
+
## Upgrading
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
mirror runtime update
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Nothing to configure: the router uses the OpenRouter key Mirror already reads. The routing
|
|
110
|
+
calls are the `persona_routing` rows of `mirror inspect llm-calls`. Turns without a persona
|
|
111
|
+
are expected: they answer as the mirror, unsigned.
|
|
112
|
+
|
|
113
|
+
## Next Horizon
|
|
114
|
+
|
|
115
|
+
A command and a host call to set a Journey's `default_persona`; prompt review of the
|
|
116
|
+
no-persona clause; Nautilus for Mirror showing the persona's name from `personaName`.
|
package/package.json
CHANGED
|
@@ -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
|
});
|
|
@@ -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,
|
|
@@ -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
|