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.
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.33.3.md +9 -1
- package/docs/releases/v0.38.1.md +152 -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/cli.ts +32 -103
- package/ts/src/frontDoor/consultRoute.ts +4 -2
- package/ts/src/frontDoor/dbPath.ts +80 -7
- package/ts/src/frontDoor/initRoute.ts +190 -0
- package/ts/src/frontDoor/mirrorHomes.ts +95 -0
- package/ts/src/frontDoor/namedRefusal.ts +29 -1
- package/ts/src/frontDoor/runtimeRoute.ts +9 -5
- package/ts/src/frontDoor/searchRoute.ts +31 -3
- package/ts/src/frontDoor/usage.ts +17 -3
- package/ts/src/init/init.ts +2 -1
- package/ts/src/providers/config.ts +88 -1
- package/ts/src/providers/embedding.ts +26 -3
- package/ts/src/providers/familyProviders.ts +43 -0
- package/ts/src/runtime/diagnose.ts +28 -1
- package/ts/src/runtime/git.ts +153 -42
- package/ts/src/runtime/status.ts +27 -17
- package/ts/src/runtime/strategies/clone.ts +76 -55
- package/ts/src/runtime/update.ts +2 -1
- package/ts/src/search/memorySearch.ts +4 -2
|
@@ -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(
|
|
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`:
|
|
782
|
-
function
|
|
783
|
-
|
|
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 ? `${
|
|
134
|
+
return degraded ? `${note}\n` : "No memories found.\n";
|
|
107
135
|
}
|
|
108
136
|
const lines: string[] = [];
|
|
109
137
|
if (degraded) {
|
|
110
|
-
lines.push(`${
|
|
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
|
-
`
|
|
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 {
|
|
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}` };
|
package/ts/src/init/init.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|