mirror-mind 0.38.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.
@@ -0,0 +1,190 @@
1
+ // `init <user>`: the front door route that creates a user home (DS7.US1 Slice B).
2
+ // It moved out of cli.ts when it learned to adopt a home that already exists (CR185).
3
+
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { newUserNameProblem } from "#identity/identityKey.ts";
7
+ import {
8
+ defaultUserHome,
9
+ IdentityRootExistsError,
10
+ initUserHome,
11
+ TemplatesNotFoundError,
12
+ } from "#init/init.ts";
13
+ import { configFilePath, writeConfigValue } from "#runtime/config.ts";
14
+ import { runningTreeRoot } from "#runtime/treeRoot.ts";
15
+ import { detectRuntimes, runtimeWiringLines } from "#runtime/wiring.ts";
16
+ import { isExecutable } from "#util/paths.ts";
17
+ import { PROGRAM } from "#util/program.ts";
18
+ import { dbNameForEnv, resolveMirrorHome } from "./dbPath.ts";
19
+ import { isMirrorHome } from "./mirrorHomes.ts";
20
+
21
+ /**
22
+ * Serve `init <user>` (DS7.US1 Slice B). Filesystem-only — no database is
23
+ * opened. Python's argparse defines only the `user` positional (no
24
+ * --mirror-home/--db-path), so none are stripped here.
25
+ *
26
+ * Deliberate divergence: a missing templates root is an uncaught exception in
27
+ * Python (a raw traceback, exit 1) -- not a designed message. This preserves
28
+ * the contract (exit 1, a stderr message naming the failure) without
29
+ * fabricating a fake traceback.
30
+ *
31
+ * CV22.DS10.US3 (D3): when no source configured a user, `init` writes
32
+ * `MIRROR_USER=<user>` into `~/.config/mirror/env` (0600 in 0700) and says
33
+ * where to add `OPENROUTER_API_KEY` by hand. The key is never read from argv.
34
+ * When a user IS configured -- the checkout's `.env`, or a shell running a
35
+ * second mirror -- the file is left alone and the person is told how to
36
+ * select the new home, or that it is already selected.
37
+ *
38
+ * CR185: a home that already exists is used as it is, never refused. It is
39
+ * the route the not-configured sentence names, and Getting Started's for
40
+ * moving from a clone to the package and for printing the wiring again. Nothing
41
+ * in the home is copied or changed; the configuration is written as for a new
42
+ * home, and only the steps still left are printed.
43
+ *
44
+ * Plateau 3: it ends with the wiring step for each runtime found on the PATH
45
+ * (`#runtime/wiring.ts`), with this tree's path in it, so the person is handed
46
+ * the next line rather than a page. Printing is not automating.
47
+ */
48
+ export function runInit(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): number {
49
+ const user = argv[1];
50
+ // A word that starts with `-` is a flag this command does not have, not a
51
+ // user: `init --help` once created a home named `--help`.
52
+ if (!user || user.startsWith("-")) {
53
+ console.error("Usage: init <user>");
54
+ return 2;
55
+ }
56
+ // And any other word is a key: the home's directory name and the value of
57
+ // MIRROR_USER in the wiring commands printed below. CR104's rule for slugs
58
+ // and persona ids, asked before anything is created or looked at (CR141).
59
+ const problem = newUserNameProblem(user);
60
+ if (problem !== null) {
61
+ console.error(`Error: ${problem}`);
62
+ return 2;
63
+ }
64
+ // Decided before `initUserHome` runs: its refusal of a populated identity
65
+ // stays the guard under this branch, never the way into it.
66
+ const existing = defaultUserHome(user);
67
+ if (isMirrorHome(existing)) return adoptUserHome(user, existing, env);
68
+ try {
69
+ const identityRoot = initUserHome(user);
70
+ const mirrorHome = dirname(identityRoot);
71
+ const selection = selectionFor(mirrorHome, env);
72
+ const steps: Step[] =
73
+ selection === "none"
74
+ ? [["Add OPENROUTER_API_KEY=<your key> to that file (it is created readable by you only)"]]
75
+ : selection === "other"
76
+ ? [selectThisOne(user, env)]
77
+ : [];
78
+ const prints = [
79
+ `Created user home: ${mirrorHome}`,
80
+ `Identity ready at: ${identityRoot}`,
81
+ ...configurationLine(user, selection, env),
82
+ "",
83
+ "Next steps:",
84
+ ...numbered([...steps, [`Run: ${PROGRAM} seed`]]),
85
+ "\nYour identity is ready to use. Deepen it over time with:",
86
+ ` ${PROGRAM} identity edit user identity`,
87
+ ...runtimeWiringSection(env),
88
+ ];
89
+ process.stdout.write(prints.map((line) => `${line}\n`).join(""));
90
+ return 0;
91
+ } catch (error) {
92
+ if (error instanceof IdentityRootExistsError || error instanceof TemplatesNotFoundError) {
93
+ console.error(error.message);
94
+ return 1;
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `init <user>` on a home that exists (CR185). The first line is a one-line
102
+ * summary, as for a new home: the Windows installer (PR #45) reads it. Then
103
+ * only what is left: the configuration, the key when none is set, `seed` when
104
+ * the home has no database, and the wiring.
105
+ */
106
+ function adoptUserHome(user: string, home: string, env: NodeJS.ProcessEnv): number {
107
+ const selection = selectionFor(home, env);
108
+ const config = configFilePath(env);
109
+ const steps: Step[] = [];
110
+ if (selection === "other") steps.push(selectThisOne(user, env));
111
+ else if (!env.OPENROUTER_API_KEY) {
112
+ steps.push([
113
+ `Add OPENROUTER_API_KEY=<your key> to ${config} (moving from a clone? copy the line from its .env)`,
114
+ ]);
115
+ }
116
+ if (!existsSync(join(home, dbNameForEnv(env.MEMORY_ENV || "production")))) {
117
+ steps.push([`Run: ${PROGRAM} seed`]);
118
+ }
119
+ const prints = [
120
+ `Found user home: ${home} (nothing in it was changed)`,
121
+ ...configurationLine(user, selection, env),
122
+ ...(steps.length > 0 ? ["", "Next steps:", ...numbered(steps)] : []),
123
+ ...runtimeWiringSection(env),
124
+ ];
125
+ process.stdout.write(prints.map((line) => `${line}\n`).join(""));
126
+ return 0;
127
+ }
128
+
129
+ /** Which home this machine already selects: none, this one, or another. */
130
+ type Selection = "none" | "this" | "other";
131
+
132
+ /**
133
+ * Read once, for a new home and an existing one alike. Compared as real paths,
134
+ * so a home reached through a symlink, or named by a `MIRROR_HOME` spelled
135
+ * another way, is still this one. A selection that does not resolve (a
136
+ * `MIRROR_HOME` that conflicts with `MIRROR_USER`) is not this home, and not
137
+ * `init`'s to repair.
138
+ */
139
+ function selectionFor(home: string, env: NodeJS.ProcessEnv): Selection {
140
+ if (!env.MIRROR_USER && !env.MIRROR_HOME) return "none";
141
+ try {
142
+ const selected = resolveMirrorHome(env, { warn: () => {} });
143
+ return realPath(selected) === realPath(home) ? "this" : "other";
144
+ } catch {
145
+ return "other";
146
+ }
147
+ }
148
+
149
+ function realPath(path: string): string {
150
+ try {
151
+ return realpathSync(path);
152
+ } catch {
153
+ return resolve(path);
154
+ }
155
+ }
156
+
157
+ /** The `Configuration:` line: written now, already this home, or nothing when another is selected. */
158
+ function configurationLine(user: string, selection: Selection, env: NodeJS.ProcessEnv): string[] {
159
+ if (selection === "none") {
160
+ return [
161
+ `Configuration: ${writeConfigValue("MIRROR_USER", user, { env })} (MIRROR_USER=${user})`,
162
+ ];
163
+ }
164
+ return selection === "this" ? ["Configuration: already selects this home"] : [];
165
+ }
166
+
167
+ /** One numbered step: its first line takes the number, the rest are indented under it. */
168
+ type Step = readonly string[];
169
+
170
+ function selectThisOne(user: string, env: NodeJS.ProcessEnv): Step {
171
+ return [
172
+ `This machine already selects a user; to use this one, run with MIRROR_USER=${user}`,
173
+ ` or set it in ${configFilePath(env)}`,
174
+ ];
175
+ }
176
+
177
+ function numbered(steps: readonly Step[]): string[] {
178
+ return steps.flatMap(([first, ...rest], index) => [` ${index + 1}. ${first}`, ...rest]);
179
+ }
180
+
181
+ /** The "Wire your runtime" tail of `init`, empty when no runtime is on the PATH. */
182
+ function runtimeWiringSection(env: NodeJS.ProcessEnv): string[] {
183
+ const detected = detectRuntimes(env, isExecutable);
184
+ if (detected.length === 0) return [];
185
+ const root = runningTreeRoot();
186
+ return [
187
+ "\nWire your runtime to this install (each step is one you run and can read first):",
188
+ ...detected.flatMap((runtime) => ["", ...runtimeWiringLines(runtime, root)]),
189
+ ];
190
+ }
@@ -0,0 +1,95 @@
1
+ // The homes root, and what counts as a home in it (CR185).
2
+ //
3
+ // `<home>/.mirror-minds` holds one directory per user home: the place
4
+ // `MIRROR_USER=<user>` resolves to and `init <user>` creates. The root used to
5
+ // be spelled in three places (`dbPath.ts`, `init.ts`, `runtimeRoute.ts`); it is
6
+ // spelled here. This module also owns the rule for what a home is, because two
7
+ // readers have to agree on it: the not-configured error, which names the homes
8
+ // it finds, and `init <name>`, which adopts an existing home instead of
9
+ // refusing it. If they disagreed, the error could print a command that `init`
10
+ // then answers differently.
11
+ //
12
+ // Filesystem reads only, and every one of them is allowed to fail: a root that
13
+ // is missing, a file, or unreadable has no homes. The callers are error paths
14
+ // and a bootstrap command, and neither may fail for a second reason.
15
+
16
+ import { readdirSync, statSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { newUserNameProblem } from "#identity/identityKey.ts";
20
+ import { sortByCodePoint } from "#util/pythonText.ts";
21
+
22
+ /** The homes root's name under the OS user's home directory. */
23
+ export const HOMES_DIR = ".mirror-minds";
24
+
25
+ /** `<home>/.mirror-minds`. */
26
+ export function homesRoot(home: string = homedir()): string {
27
+ return join(home, HOMES_DIR);
28
+ }
29
+
30
+ /** `<home>/.mirror-minds/<user>`: where `MIRROR_USER=<user>` resolves, and where `init <user>` creates. */
31
+ export function userHomePath(user: string, home: string = homedir()): string {
32
+ return join(homesRoot(home), user);
33
+ }
34
+
35
+ /** Every database name `dbNameForEnv` gives: `memory.db`, `memory_dev.db`, `memory_<env>.db`. */
36
+ const DATABASE_NAME = /^memory(?:_.+)?\.db$/;
37
+
38
+ /**
39
+ * Whether `path` is a Mirror home: a directory, through a symlink if need be,
40
+ * that holds a non-empty `identity/` or a database file.
41
+ *
42
+ * A directory holding only a log is not a home: an unresolved Pi session leaves
43
+ * one under the root. An empty `identity/` is not a home either, because `init`
44
+ * may still fill it. That is also the boundary `initUserHome` refuses at, so
45
+ * `init` adopts exactly what it would otherwise have to refuse, and also a home
46
+ * whose identity lives only in its database.
47
+ */
48
+ export function isMirrorHome(path: string): boolean {
49
+ if (!isDirectory(path)) return false;
50
+ const identity = join(path, "identity");
51
+ if (isDirectory(identity) && entries(identity).length > 0) return true;
52
+ return entries(path).some((name) => DATABASE_NAME.test(name) && isFile(join(path, name)));
53
+ }
54
+
55
+ /** A home found under the root. */
56
+ export interface FoundHome {
57
+ /** The directory's name. */
58
+ readonly name: string;
59
+ /** `<root>/<name>`: the entry itself, never a symlink's target. */
60
+ readonly path: string;
61
+ /** Whether `init <name>` accepts the name (CR141): only then can a message print it bare. */
62
+ readonly nameable: boolean;
63
+ }
64
+
65
+ /** The homes in `<home>/.mirror-minds`, in code-point order. Hidden entries are skipped. */
66
+ export function findMirrorHomes(home: string = homedir()): FoundHome[] {
67
+ const root = homesRoot(home);
68
+ return sortByCodePoint(entries(root).filter((name) => !name.startsWith(".")))
69
+ .map((name) => ({ name, path: join(root, name), nameable: newUserNameProblem(name) === null }))
70
+ .filter((found) => isMirrorHome(found.path));
71
+ }
72
+
73
+ function entries(path: string): string[] {
74
+ try {
75
+ return readdirSync(path);
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ function isDirectory(path: string): boolean {
82
+ try {
83
+ return statSync(path).isDirectory();
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function isFile(path: string): boolean {
90
+ try {
91
+ return statSync(path).isFile();
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
@@ -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,
@@ -28,6 +29,7 @@ import {
28
29
  extractionReasoningFindings,
29
30
  hookFailureFindings,
30
31
  hookNodeFindings,
32
+ openRouterKeyFindings,
31
33
  pluginWiringFindings,
32
34
  probeModelPins,
33
35
  renderRuntimeDiagnosis,
@@ -319,7 +321,7 @@ export async function runRuntimeReadRoute(
319
321
  : null;
320
322
  const findings = [
321
323
  ...diagnoseRuntime(report, inspectGitWorktree(report.git.repository)),
322
- ...rootStateFindings(homesRoot(env)),
324
+ ...rootStateFindings(ambientHomesRoot(env)),
323
325
  // CV22.DS10.TS5: the transition's own leftovers. A stale MIRROR_TS_*
324
326
  // gate is inert and silently so; an unresolvable Node makes every hook
325
327
  // skip; and hooks.log holds what the hooks met in their own context.
@@ -328,6 +330,9 @@ export async function runRuntimeReadRoute(
328
330
  // CV9.DS9.TS1: a reasoning setting the transport ignores rather than fail
329
331
  // on (CR178) is silently not in force unless diagnose says so.
330
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),
331
336
  ...hookFailureFindings(report),
332
337
  ...hookNodeFindings(env, (path) => {
333
338
  try {
@@ -778,8 +783,7 @@ function firstPositional(args: readonly string[]): string | null {
778
783
  return null;
779
784
  }
780
785
 
781
- /** Python's ambient `DEFAULT_USER_HOMES_DIR`: `<home>/.mirror-minds`. */
782
- function homesRoot(env: NodeJS.ProcessEnv): string {
783
- const home = env.HOME ?? env.USERPROFILE ?? "";
784
- 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 ?? "");
785
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";
@@ -184,11 +190,92 @@ export function resolveProviderConfig(
184
190
  const env = options.env ?? process.env;
185
191
  const apiKey = env.OPENROUTER_API_KEY?.trim();
186
192
  if (!apiKey) {
187
- 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}.`);
188
197
  }
189
198
  return { provider, apiKey, baseUrl: OPENROUTER_BASE_URL };
190
199
  }
191
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
+
192
279
  function rejectArgvSecrets(argv: readonly string[]): void {
193
280
  for (let index = 0; index < argv.length; index += 1) {
194
281
  const arg = argv[index] ?? "";
@@ -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) {