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.
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.3.md +9 -1
- package/docs/releases/v0.38.0.md +215 -0
- 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/conversation/closeTail.ts +7 -3
- package/ts/src/conversation/extraction.ts +1 -11
- package/ts/src/conversation/userName.ts +44 -0
- package/ts/src/extraction/conversationMetadata.ts +53 -8
- package/ts/src/extraction/prompts.ts +8 -3
- 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 +13 -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 +193 -2
- package/ts/src/providers/cost.ts +5 -1
- package/ts/src/providers/embedding.ts +26 -3
- package/ts/src/providers/familyProviders.ts +43 -0
- package/ts/src/providers/llm.ts +40 -0
- package/ts/src/runtime/diagnose.ts +59 -0
- package/ts/src/runtime/git.ts +153 -42
- package/ts/src/runtime/status.ts +47 -18
- 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
|
@@ -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(
|
|
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`:
|
|
778
|
-
function
|
|
779
|
-
|
|
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 ? `${
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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] ?? "";
|
package/ts/src/providers/cost.ts
CHANGED
|
@@ -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 --
|
|
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 {
|
|
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) {
|
|
@@ -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,
|
package/ts/src/providers/llm.ts
CHANGED
|
@@ -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 }),
|