mirror-mind 0.37.0 → 0.38.1

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.
@@ -9,12 +9,20 @@
9
9
  // which had no replay transport and would have called the live provider;
10
10
  // * a new journey slug or persona id outside the grammar, which the identity
11
11
  // store refuses for any route that did not ask first (CR104). Today every route
12
- // asks first, so this answers the next route that creates a key.
12
+ // asks first, so this answers the next route that creates a key;
13
+ // * a missing OpenRouter key (CR187), refused up front by the commands that
14
+ // cannot work without one and lazily, at a first model call, by the rest --
15
+ // and a key OpenRouter refused (HTTP 401 or 403), whether a chat call or an
16
+ // embedding call met it. A configuration failure, so exit 2, as a missing
17
+ // home is.
13
18
  //
14
19
  // Like every `Error:` line, the answer claims nothing about what changed before
15
20
  // the refusal was raised.
16
21
 
17
22
  import { InvalidIdentityKeyError } from "#identity/identityKey.ts";
23
+ import { keyRefusedMessage, ProviderConfigError } from "#providers/config.ts";
24
+ import { EmbeddingError } from "#providers/embedding.ts";
25
+ import { LlmTransportError } from "#providers/openrouter.ts";
18
26
  import { ReplayFixtureIncompleteError } from "#providers/transport.ts";
19
27
 
20
28
  export interface NamedRefusal {
@@ -31,5 +39,25 @@ export function namedRefusal(error: unknown): NamedRefusal | null {
31
39
  if (error instanceof InvalidIdentityKeyError) {
32
40
  return { line: `Error: ${error.message}`, exitCode: 1 };
33
41
  }
42
+ if (error instanceof ProviderConfigError) {
43
+ return { line: `Error: ${error.message}`, exitCode: 2 };
44
+ }
45
+ const refusedStatus = refusedKeyStatus(error);
46
+ if (refusedStatus !== undefined) {
47
+ return { line: `Error: ${keyRefusedMessage(refusedStatus)}`, exitCode: 2 };
48
+ }
34
49
  return null;
35
50
  }
51
+
52
+ /**
53
+ * The HTTP status of a key OpenRouter refused, null when it was not kept, or
54
+ * undefined when `error` is not a refused key. Only the classification is read:
55
+ * the transport keeps nothing the provider sent back, and neither does this.
56
+ */
57
+ function refusedKeyStatus(error: unknown): number | null | undefined {
58
+ if (error instanceof LlmTransportError && error.kind === "auth") return error.status;
59
+ if (error instanceof EmbeddingError && error.transport?.kind === "auth") {
60
+ return error.transport.status;
61
+ }
62
+ return undefined;
63
+ }
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { createZipBackup } from "#backup/zipBackup.ts";
16
16
  import { dbNameForEnv, resolveMirrorHome } from "#frontDoor/dbPath.ts";
17
17
  import { frontDoorLogPath, logFrontDoor } from "#frontDoor/frontDoorLog.ts";
18
+ import { homesRoot } from "#frontDoor/mirrorHomes.ts";
18
19
  import { renderUnknownSubcommand } from "#frontDoor/usage.ts";
19
20
  import {
20
21
  renderBackupVerification,
@@ -25,8 +26,10 @@ import { channelFor, channelForReport, renderChannel, setChannel } from "#runtim
25
26
  import {
26
27
  diagnoseRuntime,
27
28
  extensionPluginFindings,
29
+ extractionReasoningFindings,
28
30
  hookFailureFindings,
29
31
  hookNodeFindings,
32
+ openRouterKeyFindings,
30
33
  pluginWiringFindings,
31
34
  probeModelPins,
32
35
  renderRuntimeDiagnosis,
@@ -318,12 +321,18 @@ export async function runRuntimeReadRoute(
318
321
  : null;
319
322
  const findings = [
320
323
  ...diagnoseRuntime(report, inspectGitWorktree(report.git.repository)),
321
- ...rootStateFindings(homesRoot(env)),
324
+ ...rootStateFindings(ambientHomesRoot(env)),
322
325
  // CV22.DS10.TS5: the transition's own leftovers. A stale MIRROR_TS_*
323
326
  // gate is inert and silently so; an unresolvable Node makes every hook
324
327
  // skip; and hooks.log holds what the hooks met in their own context.
325
328
  // All three are invisible without being asked for.
326
329
  ...staleRevertGateFindings(env),
330
+ // CV9.DS9.TS1: a reasoning setting the transport ignores rather than fail
331
+ // on (CR178) is silently not in force unless diagnose says so.
332
+ ...extractionReasoningFindings(env),
333
+ // CR187: with no key, memory extraction, embeddings, and consult are off,
334
+ // and nothing else on this report would say so.
335
+ ...openRouterKeyFindings(env),
327
336
  ...hookFailureFindings(report),
328
337
  ...hookNodeFindings(env, (path) => {
329
338
  try {
@@ -774,8 +783,7 @@ function firstPositional(args: readonly string[]): string | null {
774
783
  return null;
775
784
  }
776
785
 
777
- /** Python's ambient `DEFAULT_USER_HOMES_DIR`: `<home>/.mirror-minds`. */
778
- function homesRoot(env: NodeJS.ProcessEnv): string {
779
- const home = env.HOME ?? env.USERPROFILE ?? "";
780
- return join(home, ".mirror-minds");
786
+ /** Python's ambient `DEFAULT_USER_HOMES_DIR`: the homes root under the environment's home. */
787
+ function ambientHomesRoot(env: NodeJS.ProcessEnv): string {
788
+ return homesRoot(env.HOME ?? env.USERPROFILE ?? "");
781
789
  }
@@ -1,5 +1,6 @@
1
1
  import type { WritableDatabase } from "#db/database.ts";
2
2
  import { requireString } from "#db/rowDecode.ts";
3
+ import { keyFileTarget } from "#providers/config.ts";
3
4
  import type { EmbeddingProvider } from "#providers/embedding.ts";
4
5
  import { resolveFamilyProviders } from "#providers/familyProviders.ts";
5
6
  import { SEARCH_TRANSPORT } from "#providers/transport.ts";
@@ -14,6 +15,32 @@ import { tagsText } from "./render/memories.ts";
14
15
  const DEGRADED_NOTE =
15
16
  "⚠ Degraded: lexical-only search (embedding unavailable — offline or no API key).";
16
17
 
18
+ /**
19
+ * The degraded note for the cause the search reported (CR187). The general
20
+ * note guesses "offline or no API key"; for the two causes the person can fix
21
+ * themselves, the note says which one, and where.
22
+ *
23
+ * `config` means a missing key here: the search's embedding provider is the
24
+ * live one, and nothing else in its path raises `ProviderConfigError`. `auth`
25
+ * is a key OpenRouter refused, kept through the embedding wrapper.
26
+ */
27
+ export function degradedNoteFor(
28
+ kind: string | undefined,
29
+ env: Record<string, string | undefined> = process.env,
30
+ home?: string,
31
+ ): string {
32
+ if (kind === "config") {
33
+ return (
34
+ "⚠ Degraded: lexical-only search (OPENROUTER_API_KEY is not configured: " +
35
+ `add it to ${keyFileTarget(env, home)}).`
36
+ );
37
+ }
38
+ if (kind === "auth") {
39
+ return "⚠ Degraded: lexical-only search (OpenRouter refused OPENROUTER_API_KEY: check the key where it is set).";
40
+ }
41
+ return DEGRADED_NOTE;
42
+ }
43
+
17
44
  export interface SearchMemoryRow {
18
45
  id: string;
19
46
  memory_type: string;
@@ -87,7 +114,7 @@ export async function runMemorySearchRoute(
87
114
  db,
88
115
  results.map((result) => result.id),
89
116
  );
90
- return formatSearchResults(query, results, rows, degraded);
117
+ return formatSearchResults(query, results, rows, degraded, degradedNoteFor(degradedKind));
91
118
  }
92
119
 
93
120
  /**
@@ -101,13 +128,14 @@ export function formatSearchResults(
101
128
  results: readonly FreshSearchResult[],
102
129
  rows: ReadonlyMap<string, SearchMemoryRow>,
103
130
  degraded: boolean,
131
+ note: string = DEGRADED_NOTE,
104
132
  ): string {
105
133
  if (results.length === 0) {
106
- return degraded ? `${DEGRADED_NOTE}\n` : "No memories found.\n";
134
+ return degraded ? `${note}\n` : "No memories found.\n";
107
135
  }
108
136
  const lines: string[] = [];
109
137
  if (degraded) {
110
- lines.push(`${DEGRADED_NOTE} Ranked by keyword match.`, "");
138
+ lines.push(`${note} Ranked by keyword match.`, "");
111
139
  }
112
140
  lines.push(`🔍 Search: "${query}" (${results.length} results)`, "");
113
141
  for (const result of results) {
@@ -131,6 +131,11 @@ export function renderUnknownSubcommand(
131
131
  export interface UsageContext {
132
132
  readonly userConfigured: boolean;
133
133
  readonly configFile: string;
134
+ /**
135
+ * "To use the home at <path>, run: mirror init <name>" when a home `init` can
136
+ * name is on disk and nothing selects it (CR185), else absent.
137
+ */
138
+ readonly useHome?: string | null;
134
139
  }
135
140
 
136
141
  /**
@@ -139,11 +144,16 @@ export interface UsageContext {
139
144
  * usage dump -- three lines that say what is missing, what to run, and where
140
145
  * the configuration will live. With a user configured, bare `mirror` is the
141
146
  * usage block, as before.
147
+ *
148
+ * CR185: when a home is on disk that nothing selects -- a move from a clone to
149
+ * the package -- what to run is the command that uses it, the sentence the
150
+ * not-configured error gives.
142
151
  */
143
- export function renderOrientation(configFile: string): string {
152
+ export function renderOrientation(configFile: string, useHome: string | null = null): string {
153
+ const create = ` Run: ${PROGRAM} init <your-name>`;
144
154
  return [
145
155
  "No user configured yet.",
146
- ` Run: ${PROGRAM} init <your-name>`,
156
+ useHome === null ? create : ` ${useHome}`,
147
157
  ` Configuration will live at: ${configFile}`,
148
158
  "",
149
159
  ].join("\n");
@@ -156,7 +166,11 @@ export function answerUsage(request: UsageRequest, context?: UsageContext): Usag
156
166
  return { stream: "stdout", exitCode: 0, text: USAGE };
157
167
  }
158
168
  if (request.given === null && context !== undefined && !context.userConfigured) {
159
- return { stream: "stdout", exitCode: 1, text: renderOrientation(context.configFile) };
169
+ return {
170
+ stream: "stdout",
171
+ exitCode: 1,
172
+ text: renderOrientation(context.configFile, context.useHome ?? null),
173
+ };
160
174
  }
161
175
  const unknown = request.given === null ? "" : `Unknown command: ${request.given}\n\n`;
162
176
  return { stream: "stdout", exitCode: 1, text: `${unknown}${USAGE}` };
@@ -15,6 +15,7 @@
15
15
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
18
+ import { userHomePath } from "#frontDoor/mirrorHomes.ts";
18
19
  import { runningTreeRoot } from "#runtime/treeRoot.ts";
19
20
 
20
21
  /** Port of Python's uncaught `FileNotFoundError` for a missing templates root. */
@@ -51,7 +52,7 @@ export function templatesIdentityRoot(treeRoot: string = runningTreeRoot()): str
51
52
  * make every later command print the legacy-path warning (D14).
52
53
  */
53
54
  export function defaultUserHome(user: string, home: string = homedir()): string {
54
- return join(home, ".mirror-minds", user);
55
+ return userHomePath(user, home);
55
56
  }
56
57
 
57
58
  /** Every `.yaml` file under `root`, recursively (mirrors `Path.rglob("*.yaml")`). */
@@ -1,3 +1,7 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { configFilePath } from "#runtime/config.ts";
4
+
1
5
  export type ProviderName = "openrouter";
2
6
 
3
7
  export interface ProviderConfig {
@@ -9,6 +13,8 @@ export interface ProviderConfig {
9
13
  export interface ProviderConfigOptions {
10
14
  env?: Record<string, string | undefined>;
11
15
  argv?: readonly string[];
16
+ /** The OS user's home, where the config file is named from (tests only). */
17
+ home?: string;
12
18
  }
13
19
 
14
20
  const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
@@ -18,7 +24,14 @@ const ARG_SECRET_PATTERN =
18
24
  // Model pins (AI-06, CV9.E2.S12): a deprecated pinned model would fail every
19
25
  // extraction/embedding call, and those paths fail soft -- so the pins must be
20
26
  // env-overridable to repoint a deployed instance without a release.
21
- export const DEFAULT_EXTRACTION_MODEL = "google/gemini-2.5-flash-lite";
27
+ //
28
+ // The extraction pin moved from `google/gemini-2.5-flash-lite` to Claude Haiku 5.5
29
+ // on 2026-10-28 (CV9.DS9.US3), on DS9's measurement: the Navigator's own blind
30
+ // rankings on his conversations, the suite at reasoning off, and Flash Lite's
31
+ // upstream rate limits. A pin change is a migration: the pin, its entry in
32
+ // MODEL_DEFAULT_REASONING, and MODEL_PRICES (cost.ts) move in one commit, behind
33
+ // a green `eval --all` (the development guide's model upgrade playbook).
34
+ export const DEFAULT_EXTRACTION_MODEL = "anthropic/claude-haiku-5.5";
22
35
  export const DEFAULT_EMBEDDING_MODEL = "openai/text-embedding-3-small";
23
36
 
24
37
  export interface ModelPinOptions {
@@ -51,6 +64,103 @@ export function resolveEmbeddingModel(options: ModelPinOptions = {}): string {
51
64
  return env.MEMORY_EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL;
52
65
  }
53
66
 
67
+ /**
68
+ * The reasoning efforts OpenRouter's catalogue lists across its models (checked
69
+ * 2026-10-26, CV9.DS9.TS1). `none` turns reasoning off. Whether a given model
70
+ * honours a given effort is the operator's choice; this list is the vocabulary.
71
+ */
72
+ export const REASONING_EFFORTS = [
73
+ "none",
74
+ "minimal",
75
+ "low",
76
+ "medium",
77
+ "high",
78
+ "xhigh",
79
+ "max",
80
+ ] as const;
81
+
82
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
83
+
84
+ /** What `MEMORY_EXTRACTION_REASONING` says: an effort to send, or a value that is not one. */
85
+ export interface ExtractionReasoning {
86
+ /** The effort to send as `reasoning.effort`, or null to send no reasoning field. */
87
+ effort: ReasoningEffort | null;
88
+ /** The value as written when it is not an effort; null otherwise. */
89
+ invalid: string | null;
90
+ }
91
+
92
+ /**
93
+ * Read `MEMORY_EXTRACTION_REASONING` (CV9.DS9.TS1): the reasoning effort every
94
+ * extraction-family call sends. Absent or blank sends nothing, which is the request
95
+ * as it was before the setting existed.
96
+ *
97
+ * Unlike the numeric resolvers below, this never throws. They fail loudly, but they
98
+ * are read inside the session-end hooks, where a throw becomes an extraction attempt
99
+ * and three attempts quarantine a conversation for good (CR178). So a value that is
100
+ * not an effort sends nothing too, and comes back as `invalid` for `runtime status`
101
+ * and `runtime diagnose` to show.
102
+ */
103
+ export function readExtractionReasoning(options: ModelPinOptions = {}): ExtractionReasoning {
104
+ const env = options.env ?? process.env;
105
+ const raw = env.MEMORY_EXTRACTION_REASONING ?? "";
106
+ const value = raw.trim().toLowerCase();
107
+ if (value === "") return { effort: null, invalid: null };
108
+ const effort = REASONING_EFFORTS.find((candidate) => candidate === value);
109
+ return effort ? { effort, invalid: null } : { effort: null, invalid: raw.trim() };
110
+ }
111
+
112
+ /**
113
+ * The reasoning effort a model sends when `MEMORY_EXTRACTION_REASONING` is unset
114
+ * (CV9.DS9.US3). A property of the model, because what DS9 measured is: Haiku
115
+ * reasons by default and spent the title call's 40-token budget, so it was
116
+ * measured and chosen at `none`; Flash Lite was measured with no reasoning field,
117
+ * and a model absent from this table gets none, which is the request as it was
118
+ * before the setting existed. So the playbook's rollback, `MEMORY_EXTRACTION_MODEL`
119
+ * set to the old pin, reproduces exactly the request the old pin was measured with,
120
+ * and naming the default pin explicitly sends the same request as leaving it unset.
121
+ */
122
+ export const MODEL_DEFAULT_REASONING: Readonly<Record<string, ReasoningEffort>> = {
123
+ "anthropic/claude-haiku-5.5": "none",
124
+ };
125
+
126
+ /** Where the effective effort came from: the setting, the model's default, or nowhere. */
127
+ export type ReasoningSource = "setting" | "model_default";
128
+
129
+ /** The effective pair every extraction-family call sends: the model and its effort. */
130
+ export interface EffectiveExtractionReasoning {
131
+ /** The model the call names: `MEMORY_EXTRACTION_MODEL` or the default pin. */
132
+ model: string;
133
+ /** The effort to send as `reasoning.effort`, or null to send no reasoning field. */
134
+ effort: ReasoningEffort | null;
135
+ /** What decided `effort`; null when no field is sent. */
136
+ source: ReasoningSource | null;
137
+ /** `MEMORY_EXTRACTION_REASONING` as written when it is not an effort; null otherwise. */
138
+ invalid: string | null;
139
+ }
140
+
141
+ /**
142
+ * Resolve the model and the reasoning effort an extraction-family call sends
143
+ * (CV9.DS9.US3): the model first, then the effort: the setting when it names an
144
+ * effort, else the model's entry in `MODEL_DEFAULT_REASONING`, else no field. An
145
+ * invalid setting reads as unset, so the default pin still sends its model default;
146
+ * `invalid` carries the value for `runtime status` and `runtime diagnose`. The
147
+ * transport, the eval harness, and `runtime status` all read this one resolver.
148
+ */
149
+ export function resolveExtractionReasoning(
150
+ options: ModelPinOptions = {},
151
+ ): EffectiveExtractionReasoning {
152
+ const model = resolveExtractionModel(options);
153
+ const setting = readExtractionReasoning(options);
154
+ if (setting.effort !== null) {
155
+ return { model, effort: setting.effort, source: "setting", invalid: null };
156
+ }
157
+ const modelDefault = MODEL_DEFAULT_REASONING[model];
158
+ if (modelDefault !== undefined) {
159
+ return { model, effort: modelDefault, source: "model_default", invalid: setting.invalid };
160
+ }
161
+ return { model, effort: null, source: null, invalid: setting.invalid };
162
+ }
163
+
54
164
  // Observability (AI-09, CV9.E2.S13): mirrors Python's MEMORY_LOG_LLM_CALLS
55
165
  // resolution exactly -- off | metadata | full, default metadata (never a
56
166
  // silent full -- bodies are opt-in only). Legacy "1" maps to "full" for
@@ -80,11 +190,92 @@ export function resolveProviderConfig(
80
190
  const env = options.env ?? process.env;
81
191
  const apiKey = env.OPENROUTER_API_KEY?.trim();
82
192
  if (!apiKey) {
83
- throw new ProviderConfigError("OPENROUTER_API_KEY is not configured.");
193
+ // Reached lazily, at a command's first model call, so it claims nothing
194
+ // about what the command did before it (CR187).
195
+ const where = keyFileInstruction(env, options.home);
196
+ throw new ProviderConfigError(`OPENROUTER_API_KEY is not configured. Add ${where}.`);
84
197
  }
85
198
  return { provider, apiKey, baseUrl: OPENROUTER_BASE_URL };
86
199
  }
87
200
 
201
+ // --- CR187: the key's presence, and the sentences that say where it goes ---
202
+ //
203
+ // A missing key reached Node as a stack trace that named no file, and a key
204
+ // OpenRouter refused did the same. Every sentence about the key is built here,
205
+ // from one predicate, so the refusal, the status line, and the diagnose finding
206
+ // cannot disagree about what "configured" means. None of them carries the key.
207
+
208
+ /** Whether a key is configured: present and not blank, trimmed as the provider reads it. */
209
+ export function hasOpenRouterKey(env: Record<string, string | undefined>): boolean {
210
+ return Boolean(env.OPENROUTER_API_KEY?.trim());
211
+ }
212
+
213
+ /**
214
+ * "OPENROUTER_API_KEY=<your key> to <config>", naming the file this install
215
+ * reads (`XDG_CONFIG_HOME` honoured). When that file does not exist yet, the
216
+ * person is about to create it by hand, where the usual umask makes it 0644 --
217
+ * the key readable by every account on the machine -- so the instruction says
218
+ * to make it owner-only. A file `init` created is 0600 already.
219
+ */
220
+ export function keyFileInstruction(
221
+ env: Record<string, string | undefined>,
222
+ home: string = homedir(),
223
+ ): string {
224
+ return `OPENROUTER_API_KEY=<your key> to ${keyFileTarget(env, home)}`;
225
+ }
226
+
227
+ /** The config file to add the key to, with the owner-only clause when it is new. */
228
+ export function keyFileTarget(
229
+ env: Record<string, string | undefined>,
230
+ home: string = homedir(),
231
+ ): string {
232
+ const config = configFilePath({ XDG_CONFIG_HOME: env.XDG_CONFIG_HOME }, home);
233
+ const created = existsSync(config)
234
+ ? ""
235
+ : ` (a new file; make it readable only by you: chmod 600 ${config})`;
236
+ return `${config}${created}`;
237
+ }
238
+
239
+ /**
240
+ * Refuse, before a command does anything, when the key it cannot work without
241
+ * is missing. The sentence says first what was not done, then how to fix it:
242
+ * the person's first question is whether their entry, plan, or question went
243
+ * anywhere.
244
+ */
245
+ export function requireOpenRouterKey(
246
+ env: Record<string, string | undefined>,
247
+ options: { consequence?: string; home?: string } = {},
248
+ ): void {
249
+ if (hasOpenRouterKey(env)) return;
250
+ const consequence = options.consequence ?? "nothing was done";
251
+ throw new ProviderConfigError(
252
+ `${consequence}, because OPENROUTER_API_KEY is not configured. ` +
253
+ `Add ${keyFileInstruction(env, options.home)}, then run the same command again.`,
254
+ );
255
+ }
256
+
257
+ /**
258
+ * A key OpenRouter refused (HTTP 401 or 403), named by status and by where a
259
+ * key can be set -- never by value, and never with anything the provider sent
260
+ * back. The key may come from the shell or a clone's `.env` as well as the
261
+ * config file, so all three are named, as diagnose does for its own settings.
262
+ */
263
+ export function keyRefusedMessage(
264
+ status: number | null,
265
+ options: { env?: Record<string, string | undefined>; home?: string } = {},
266
+ ): string {
267
+ const env = options.env ?? process.env;
268
+ const config = configFilePath(
269
+ { XDG_CONFIG_HOME: env.XDG_CONFIG_HOME },
270
+ options.home ?? homedir(),
271
+ );
272
+ const code = status === null ? "" : ` (HTTP ${status})`;
273
+ return (
274
+ `OpenRouter refused OPENROUTER_API_KEY${code}. Check the key where it is set: ` +
275
+ `the environment, the clone's .env, or ${config}.`
276
+ );
277
+ }
278
+
88
279
  function rejectArgvSecrets(argv: readonly string[]): void {
89
280
  for (let index = 0; index < argv.length; index += 1) {
90
281
  const arg = argv[index] ?? "";
@@ -29,7 +29,11 @@ export interface ModelPrice {
29
29
 
30
30
  /** Estimated OpenRouter list prices (USD / 1K tokens). Verify on every pin change. */
31
31
  export const MODEL_PRICES: Readonly<Record<string, ModelPrice>> = {
32
- // Extraction pin -- google/gemini-2.5-flash-lite: ~$0.10/M in, ~$0.40/M out.
32
+ // Extraction pin since CV9.DS9.US3 -- anthropic/claude-haiku-5.5: $0.10/M in,
33
+ // $0.50/M out, read from OpenRouter's catalogue on 2026-10-28.
34
+ "anthropic/claude-haiku-5.5": { promptPer1k: 0.0001, completionPer1k: 0.0005 },
35
+ // The pin before it, kept so a rolled-back install (`MEMORY_EXTRACTION_MODEL`)
36
+ // keeps pricing its calls -- google/gemini-2.5-flash-lite: ~$0.10/M in, ~$0.40/M out.
33
37
  "google/gemini-2.5-flash-lite": { promptPer1k: 0.0001, completionPer1k: 0.0004 },
34
38
  // Embedding pin -- openai/text-embedding-3-small: ~$0.02/M, no completion side.
35
39
  "openai/text-embedding-3-small": { promptPer1k: 0.00002, completionPer1k: 0.0 },
@@ -6,7 +6,12 @@ import {
6
6
  resolveLlmTimeoutMs,
7
7
  resolveProviderConfig,
8
8
  } from "./config.ts";
9
- import { createOpenRouterClient, LlmTransportError, type OpenRouterClient } from "./openrouter.ts";
9
+ import {
10
+ createOpenRouterClient,
11
+ LlmTransportError,
12
+ type LlmTransportErrorKind,
13
+ type OpenRouterClient,
14
+ } from "./openrouter.ts";
10
15
  import { loadReplayFixture } from "./replay.ts";
11
16
 
12
17
  /**
@@ -33,12 +38,28 @@ export const EMBEDDING_DIMENSIONS = 1536;
33
38
  export const DEFAULT_EMBEDDING_ATTEMPTS = 3;
34
39
  export const DEFAULT_EMBEDDING_RETRY_BACKOFF_MS = 500;
35
40
 
41
+ /** The classification a wrapped transport failure carried: nothing else of it (CR187). */
42
+ export interface WrappedTransport {
43
+ readonly kind: LlmTransportErrorKind;
44
+ readonly status: number | null;
45
+ }
46
+
36
47
  export class EmbeddingError extends Error {
37
48
  readonly permanent: boolean;
38
- constructor(message: string, options: { permanent?: boolean } = {}) {
49
+ /**
50
+ * The transport failure this wraps, by kind and status, or null. Kept so a key
51
+ * OpenRouter refused on an embedding call can be named like one refused on a
52
+ * chat call (CR187); a message or a cause would carry what the provider sent.
53
+ */
54
+ readonly transport: WrappedTransport | null;
55
+ constructor(
56
+ message: string,
57
+ options: { permanent?: boolean; transport?: WrappedTransport | null } = {},
58
+ ) {
39
59
  super(message);
40
60
  this.name = "EmbeddingError";
41
61
  this.permanent = options.permanent ?? false;
62
+ this.transport = options.transport ?? null;
42
63
  }
43
64
  }
44
65
 
@@ -116,7 +137,9 @@ export async function generateEmbeddingSafely(
116
137
  // Provider/transport exception: terminal at this layer, not retried.
117
138
  options.onAttempt?.({ text, vector: null, latencyMs: Date.now() - t0, promptTokens: null });
118
139
  const detail = error instanceof Error ? error.message : String(error);
119
- throw new EmbeddingError(`Embedding provider call failed: ${detail}`);
140
+ const transport =
141
+ error instanceof LlmTransportError ? { kind: error.kind, status: error.status } : null;
142
+ throw new EmbeddingError(`Embedding provider call failed: ${detail}`, { transport });
120
143
  }
121
144
  const { vector, promptTokens } = result;
122
145
  if (!vector || vector.length === 0) {
@@ -15,6 +15,7 @@
15
15
  * are required to replay" and "these are the providers this family uses".
16
16
  */
17
17
 
18
+ import { requireOpenRouterKey } from "#providers/config.ts";
18
19
  import {
19
20
  type CreditProvider,
20
21
  LiveCreditProvider,
@@ -110,6 +111,48 @@ export async function resolveFamilyProviders(
110
111
  : liveProviders(env, owner, decision, overrides);
111
112
  }
112
113
 
114
+ /**
115
+ * `resolveFamilyProviders` for a command that cannot do its job without the
116
+ * key (CR187): `consult`, `journal`, `week plan`, `descriptor generate`,
117
+ * `consolidate scan` and `apply`, and `shadow scan`.
118
+ *
119
+ * The contract is the call site's: it is the route's first statement, before
120
+ * the database is opened or anything is read, so the refusal's "nothing was
121
+ * done" -- or the `consequence` a route names instead -- is true. Live mode only:
122
+ * a replay fixture needs no key. The family returned is the one
123
+ * `resolveFamilyProviders` builds.
124
+ *
125
+ * A command that can still answer without the key (search, `build load`,
126
+ * `mirror load`) keeps calling `resolveFamilyProviders`, and degrades.
127
+ */
128
+ export async function requireFamilyProviders(
129
+ env: ProviderTransportEnv,
130
+ spec: FamilyTransport,
131
+ options: { consequence?: string; overrides?: FamilyProviderOverrides } = {},
132
+ ): Promise<FamilyProviders> {
133
+ requireKeyForFamily(env, spec, options);
134
+ return resolveFamilyProviders(env, spec, options.overrides);
135
+ }
136
+
137
+ /**
138
+ * The key check alone, from the transport decision, with no provider built. For
139
+ * a route that must refuse before a step that comes ahead of its providers:
140
+ * `consult` ask opens its ledger database first, and that open takes the
141
+ * pre-write snapshot. Half a replay fixture is left to `resolveFamilyProviders`,
142
+ * which refuses it by name.
143
+ */
144
+ export function requireKeyForFamily(
145
+ env: ProviderTransportEnv,
146
+ spec: FamilyTransport,
147
+ options: { consequence?: string } = {},
148
+ ): void {
149
+ const decision = isComposed(spec)
150
+ ? resolveComposedProviderTransport(env, spec)
151
+ : resolveProviderTransport(env, spec);
152
+ if (decision.mode !== "live") return;
153
+ requireOpenRouterKey(env, options.consequence === undefined ? {} : options);
154
+ }
155
+
113
156
  async function replayProviders(
114
157
  decision: ProviderTransportDecision,
115
158
  overrides: FamilyProviderOverrides,
@@ -4,6 +4,7 @@ import {
4
4
  type LlmTimeoutRole,
5
5
  type ProviderConfig,
6
6
  resolveExtractionModel,
7
+ resolveExtractionReasoning,
7
8
  resolveLlmMaxRetries,
8
9
  resolveLlmTimeoutMs,
9
10
  resolveProviderConfig,
@@ -76,6 +77,33 @@ const LLM_TIMEOUT_TIER: Readonly<Record<LlmRole, LlmTimeoutRole>> = {
76
77
  descriptor: "extraction",
77
78
  };
78
79
 
80
+ /**
81
+ * Which roles carry `MEMORY_EXTRACTION_REASONING` (CV9.DS9.TS1): every role that
82
+ * runs on the extraction pin. `consult` picks its own model on every call, so a
83
+ * setting meant for the extraction pin must not reach it.
84
+ *
85
+ * A full `Record<LlmRole, boolean>` for the reason `LLM_TIMEOUT_TIER` is one: a
86
+ * role added to `LLM_ROLES` cannot compile without declaring it. A check against
87
+ * `"consult"` would let the next role that names its own model inherit the
88
+ * setting silently.
89
+ */
90
+ export const CARRIES_EXTRACTION_REASONING: Readonly<Record<LlmRole, boolean>> = {
91
+ extraction: true,
92
+ task_extraction: true,
93
+ summary: true,
94
+ curation: true,
95
+ consult: false,
96
+ reception: true,
97
+ consolidation: true,
98
+ shadow_scan: true,
99
+ conversation_title: true,
100
+ conversation_tags: true,
101
+ conversation_summary: true,
102
+ journal_classification: true,
103
+ week_plan: true,
104
+ descriptor: true,
105
+ };
106
+
79
107
  /**
80
108
  * The roles a message in Python's envelope can carry.
81
109
  *
@@ -273,6 +301,16 @@ export class LiveLlmProvider implements LlmProvider {
273
301
  async complete(request: LlmRequest): Promise<LlmResponse> {
274
302
  const client = this.resolveClient();
275
303
  const model = request.model ?? resolveExtractionModel({ env: this.env });
304
+ // CV9.DS9.TS1: the one added field; US3: the setting, else the model's own
305
+ // default (Haiku sends `none`; a model outside the table sends nothing). An
306
+ // invalid value reads as unset, so it fails nothing (a failed call would
307
+ // become an extraction attempt, CR178); `runtime status` and `diagnose`
308
+ // report it.
309
+ const { effort } = resolveExtractionReasoning({ env: this.env });
310
+ const reasoning =
311
+ effort !== null && CARRIES_EXTRACTION_REASONING[request.role]
312
+ ? { reasoning: { effort } }
313
+ : {};
276
314
  const startedAt = Date.now();
277
315
  const body = await client.postJson(
278
316
  "/chat/completions",
@@ -286,6 +324,8 @@ export class LiveLlmProvider implements LlmProvider {
286
324
  : [{ role: "user", content: request.prompt }],
287
325
  temperature: request.temperature ?? DEFAULT_TEMPERATURE,
288
326
  max_tokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
327
+ // Unset, this adds nothing: the body is the one it was before the setting.
328
+ ...reasoning,
289
329
  },
290
330
  {
291
331
  timeoutMs: resolveLlmTimeoutMs(LLM_TIMEOUT_TIER[request.role], { env: this.env }),