om-memory-system 6.2.0-next.243 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/config.js +3 -0
  2. package/dist/core/memorisation-operations.js +2 -0
  3. package/dist/importer/external-api-test.d.ts +4 -2
  4. package/dist/importer/external-api-test.js +3 -2
  5. package/dist/services/ai/provider-config.d.ts +1 -1
  6. package/dist/services/ai/provider-error-kind.d.ts +7 -0
  7. package/dist/services/ai/provider-error-kind.js +9 -0
  8. package/dist/services/ai/providers/anthropic-messages.js +3 -2
  9. package/dist/services/ai/providers/base-provider.d.ts +2 -0
  10. package/dist/services/ai/providers/base-provider.js +4 -0
  11. package/dist/services/ai/providers/google-gemini.js +3 -2
  12. package/dist/services/ai/providers/openai-chat-completion.js +3 -2
  13. package/dist/services/ai/providers/openai-responses.js +3 -2
  14. package/dist/services/api-handlers.d.ts +3 -24
  15. package/dist/services/api-handlers.js +4 -114
  16. package/dist/services/client.js +1 -0
  17. package/dist/services/global-config-writer.d.ts +12 -5
  18. package/dist/services/global-config-writer.js +23 -8
  19. package/dist/services/linked-search.d.ts +2 -0
  20. package/dist/services/openrouter-models.d.ts +25 -0
  21. package/dist/services/openrouter-models.js +58 -0
  22. package/dist/services/openrouter-providers.d.ts +37 -0
  23. package/dist/services/openrouter-providers.js +147 -0
  24. package/dist/services/openrouter-routes.d.ts +9 -0
  25. package/dist/services/openrouter-routes.js +77 -0
  26. package/dist/services/openrouter-routing-config.d.ts +48 -0
  27. package/dist/services/openrouter-routing-config.js +143 -0
  28. package/dist/services/retrieval-rerank.d.ts +2 -0
  29. package/dist/services/retrieval-rerank.js +4 -1
  30. package/dist/services/settings-snapshot.d.ts +1 -0
  31. package/dist/services/settings-snapshot.js +5 -0
  32. package/dist/services/tag-migration.d.ts +48 -0
  33. package/dist/services/tag-migration.js +183 -0
  34. package/dist/services/web-route-modules.js +4 -0
  35. package/dist/web/assets/index-4epIq1xy.js +171 -0
  36. package/dist/web/assets/index-Tc6q6ju1.css +2 -0
  37. package/dist/web/assets/react-SIfiwpqq.js +1 -0
  38. package/dist/web/index.html +3 -2
  39. package/package.json +1 -1
  40. package/dist/web/assets/index-CoYuPYCS.css +0 -2
  41. package/dist/web/assets/index-jbZuE9HV.js +0 -171
package/dist/config.js CHANGED
@@ -11,6 +11,7 @@ import { parseIgnoredDirectories, parseImportPathMaps } from "./importer/import-
11
11
  import { normaliseSkillConfig, projectSkillOverride, } from "./services/skill-learning/skill-config.js";
12
12
  import * as visibleRecall from "./services/visible-recall-config.js";
13
13
  import * as injectionOptions from "./services/injection-options-config.js";
14
+ import { keepGlobalProvider } from "./services/openrouter-routing-config.js";
14
15
  import { checkedMemoryLimit, MEMORY_LIMITS } from "./utils/memory-limits.js";
15
16
  import { defaultSimilarityThreshold } from "./utils/threshold-default.js";
16
17
  import { RERANKER_TIMEOUT_DEFAULT_MS } from "./services/request-timeouts.js";
@@ -539,6 +540,8 @@ export function initConfig(directory, options = {}) {
539
540
  delete projectOverrides.claudeConfigDir;
540
541
  delete projectOverrides.webServerAutoStart;
541
542
  delete projectOverrides.webServerEnabled;
543
+ // The routing choice of the OpenRouter card is global, like the endpoint and the key.
544
+ keepGlobalProvider(globalConfig, projectOverrides, (message) => log(message, { directory }));
542
545
  const merged = { ...globalConfig, ...projectOverrides };
543
546
  // The active memory space picks the storage folder unless the project sets its own (ADR-041).
544
547
  const globalStoragePath = storagePathFrom(globalConfig);
@@ -121,6 +121,8 @@ export async function runRecall(args, linked, rerank = false) {
121
121
  includeLinked: true,
122
122
  kind: "memorisation",
123
123
  reranker: rerank,
124
+ // A reranked recall still answers "no memorisation matched" when nothing clears the threshold.
125
+ thresholdAfterRerank: true,
124
126
  ...(wanted.length > 0 ? { limit: LABEL_SEARCH_POOL } : {}),
125
127
  });
126
128
  if (!result.success)
@@ -1,4 +1,4 @@
1
- import { CONFIG } from "../config.js";
1
+ import type { MemoryProviderRuntimeConfig } from "../services/ai/provider-config.js";
2
2
  export type ExternalApiTestResult = {
3
3
  ok: true;
4
4
  model: string;
@@ -10,7 +10,9 @@ export type ExternalApiTestResult = {
10
10
  * One small call to the saved external API from this process, with a small
11
11
  * output limit. The error never contains the key.
12
12
  */
13
- export declare function testExternalApi(config?: typeof CONFIG, createProvider?: (provider: string, providerConfig: Record<string, unknown>) => {
13
+ export declare function testExternalApi(given?: MemoryProviderRuntimeConfig & {
14
+ memoryProvider: string;
15
+ }, createProvider?: (provider: string, providerConfig: Record<string, unknown>) => {
14
16
  executeToolCall: (...args: any[]) => Promise<{
15
17
  success: boolean;
16
18
  error?: string;
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { CONFIG } from "../config.js";
3
2
  import { safeHealthError } from "../services/safe-health-error.js";
4
3
  const OUTPUT_LIMIT = 64;
5
4
  const TIMEOUT_MS = 20_000;
@@ -15,7 +14,9 @@ function outputLimitParams(provider) {
15
14
  * One small call to the saved external API from this process, with a small
16
15
  * output limit. The error never contains the key.
17
16
  */
18
- export async function testExternalApi(config = CONFIG, createProvider) {
17
+ export async function testExternalApi(given, createProvider) {
18
+ // The real config loads only when no config is passed, so tests never read it.
19
+ const config = given ?? (await import("../config.js")).CONFIG;
19
20
  const secrets = [config.memoryApiKey];
20
21
  try {
21
22
  const { buildMemoryProviderConfig } = await import("../services/ai/provider-config.js");
@@ -1,5 +1,5 @@
1
1
  import type { ProviderConfig } from "./providers/base-provider.js";
2
- interface MemoryProviderRuntimeConfig {
2
+ export interface MemoryProviderRuntimeConfig {
3
3
  memoryProvider?: string;
4
4
  memoryModel?: string;
5
5
  memoryApiUrl?: string;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Sorts a memory-model error into a kind the web page can explain in words.
3
+ * `no_credit` and `auth` fail the same way for every memory, so a run that
4
+ * hits one stops instead of calling the model again for each memory.
5
+ */
6
+ export type ProviderErrorKind = "no_credit" | "auth" | "other";
7
+ export declare function providerErrorKind(message: string): ProviderErrorKind;
@@ -0,0 +1,9 @@
1
+ const NO_CREDIT = /\b402\b|insufficient[_ ](balance|credits?|funds|quota)|out of credits?|credit balance/i;
2
+ const AUTH = /\b401\b|\b403\b|invalid[_ ]api[_ ]key|unauthori[sz]ed|incorrect api key/i;
3
+ export function providerErrorKind(message) {
4
+ if (NO_CREDIT.test(message))
5
+ return "no_credit";
6
+ if (AUTH.test(message))
7
+ return "auth";
8
+ return "other";
9
+ }
@@ -1,4 +1,4 @@
1
- import { applySafeExtraParams, BaseAIProvider, describeValidationError, isTransportError, } from "./base-provider.js";
1
+ import { applySafeExtraParams, BaseAIProvider, describeValidationError, isTransportError, redactApiKey, } from "./base-provider.js";
2
2
  import { AISessionManager } from "../session/ai-session-manager.js";
3
3
  import { ToolSchemaConverter } from "../tools/tool-schema.js";
4
4
  import { log } from "../../logger.js";
@@ -113,7 +113,8 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
113
113
  });
114
114
  clearTimeout(timeout);
115
115
  if (!response.ok) {
116
- const errorText = await response.text().catch(() => response.statusText);
116
+ const rawError = await response.text().catch(() => response.statusText);
117
+ const errorText = redactApiKey(rawError, this.config.apiKey);
117
118
  log(this.apiErrorLogLabel(), {
118
119
  provider: this.getProviderName(),
119
120
  model: this.config.model,
@@ -54,6 +54,8 @@ export declare function validationLogFields(error: unknown, stopReason: string |
54
54
  };
55
55
  /** The error a failed tool reply returns. It says when the reply was cut off. */
56
56
  export declare function validationFailureMessage(error: unknown, stopReason: string | undefined): string;
57
+ /** An error body from the API with the configured key removed, so it is safe to log and return. */
58
+ export declare function redactApiKey(text: string, apiKey: string | undefined): string;
57
59
  export declare function applySafeExtraParams(requestBody: Record<string, any>, extraParams: Record<string, unknown>): void;
58
60
  export declare abstract class BaseAIProvider {
59
61
  protected config: ProviderConfig;
@@ -55,6 +55,10 @@ export function validationFailureMessage(error, stopReason) {
55
55
  const cutOff = stopReason === "length" ? " (the reply was cut off)" : "";
56
56
  return `Validation failed: ${describeValidationError(error)}${cutOff}`;
57
57
  }
58
+ /** An error body from the API with the configured key removed, so it is safe to log and return. */
59
+ export function redactApiKey(text, apiKey) {
60
+ return apiKey ? text.split(apiKey).join("[redacted]") : text;
61
+ }
58
62
  const PROTECTED_KEYS = new Set([
59
63
  "model",
60
64
  "messages",
@@ -1,4 +1,4 @@
1
- import { BaseAIProvider, describeValidationError, isTransportError, } from "./base-provider.js";
1
+ import { BaseAIProvider, describeValidationError, isTransportError, redactApiKey, } from "./base-provider.js";
2
2
  import { AISessionManager } from "../session/ai-session-manager.js";
3
3
  import { log } from "../../logger.js";
4
4
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
@@ -147,7 +147,8 @@ export class GoogleGeminiProvider extends BaseAIProvider {
147
147
  });
148
148
  clearTimeout(timeout);
149
149
  if (!response.ok) {
150
- const errorText = await response.text().catch(() => response.statusText);
150
+ const rawError = await response.text().catch(() => response.statusText);
151
+ const errorText = redactApiKey(rawError, this.config.apiKey);
151
152
  log("Gemini API error", {
152
153
  provider: this.getProviderName(),
153
154
  model: this.config.model,
@@ -1,4 +1,4 @@
1
- import { BaseAIProvider, applySafeExtraParams, validationFailureMessage, validationLogFields, isTransportError, } from "./base-provider.js";
1
+ import { BaseAIProvider, applySafeExtraParams, validationFailureMessage, validationLogFields, isTransportError, redactApiKey, } from "./base-provider.js";
2
2
  import { log } from "../../logger.js";
3
3
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
4
4
  import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
@@ -205,7 +205,8 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
205
205
  });
206
206
  clearTimeout(timeout);
207
207
  if (!response.ok) {
208
- const errorText = await response.text().catch(() => response.statusText);
208
+ const rawError = await response.text().catch(() => response.statusText);
209
+ const errorText = redactApiKey(rawError, this.config.apiKey);
209
210
  log("OpenAI Chat Completion API error", {
210
211
  provider: this.getProviderName(),
211
212
  model: this.config.model,
@@ -1,4 +1,4 @@
1
- import { BaseAIProvider, applySafeExtraParams, isTransportError, } from "./base-provider.js";
1
+ import { BaseAIProvider, applySafeExtraParams, isTransportError, redactApiKey, } from "./base-provider.js";
2
2
  import { AISessionManager } from "../session/ai-session-manager.js";
3
3
  import { ToolSchemaConverter } from "../tools/tool-schema.js";
4
4
  import { log } from "../../logger.js";
@@ -62,7 +62,8 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
62
62
  });
63
63
  clearTimeout(timeout);
64
64
  if (!response.ok) {
65
- const errorText = await response.text().catch(() => response.statusText);
65
+ const rawError = await response.text().catch(() => response.statusText);
66
+ const errorText = redactApiKey(rawError, this.config.apiKey);
66
67
  log("OpenAI Responses API error", {
67
68
  provider: this.getProviderName(),
68
69
  model: this.config.model,
@@ -155,27 +155,6 @@ export declare function handleCategoryCleanupStatus(userId?: string): Promise<Ap
155
155
  export declare function handleCategoryCleanupApply(userId?: string, body?: any, deps?: CategoryCleanupDeps): Promise<ApiResponse<any>>;
156
156
  export declare function handleCategoryCleanupUndo(userId?: string, deps?: CategoryCleanupDeps): Promise<ApiResponse<any>>;
157
157
  export declare function handleUpdateProfileItem(body?: any): Promise<ApiResponse<any>>;
158
- export declare function handleDetectTagMigration(): Promise<ApiResponse<{
159
- needsMigration: boolean;
160
- count: number;
161
- }>>;
162
- interface MigrationProgress {
163
- processed: number;
164
- total: number;
165
- currentBatch: number;
166
- totalBatches: number;
167
- isComplete: boolean;
168
- errors: string[];
169
- }
170
- export declare function handleGetTagMigrationProgress(): Promise<ApiResponse<MigrationProgress>>;
171
- /**
172
- * Tag the memories that have no tags, a few per request. Only untagged
173
- * memories are touched: tagged ones keep their vectors. A memory that fails
174
- * is recorded and passed over, so one bad row cannot stall the run.
175
- */
176
- export declare function handleRunTagMigrationBatch(batchSize?: number): Promise<ApiResponse<{
177
- processed: number;
178
- total: number;
179
- hasMore: boolean;
180
- }>>;
181
- export {};
158
+ export { handleDetectTagMigration, handleGetTagMigrationProgress } from "./tag-migration.js";
159
+ /** Tags untagged memories with the memory model. The work lives in tag-migration.ts. */
160
+ export declare function handleRunTagMigrationBatch(batchSize?: number): Promise<import("./tag-migration.js").ApiResponse<import("./tag-migration.js").TagMigrationBatch>>;
@@ -12,7 +12,7 @@ import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
12
12
  import { saveProfile } from "./user-profile/merge-and-save.js";
13
13
  import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
14
14
  import { resolveWebProfileUserId } from "./profile-identity.js";
15
- import { UNTAGGED_NORMAL_PREDICATE } from "./memory-kind.js";
15
+ import { runTagMigrationBatch } from "./tag-migration.js";
16
16
  import { loadMemoryRows, memorisationIdsAmong, MEMORISATION_EDIT_REFUSAL, MEMORISATION_TYPE_REFUSAL, webUpdateRefusal, } from "./web-memory-rows.js";
17
17
  async function getAllMemoryShards() {
18
18
  await ensureTursoReady();
@@ -996,118 +996,8 @@ export async function handleUpdateProfileItem(body) {
996
996
  return { success: false, error: String(error) };
997
997
  }
998
998
  }
999
- export async function handleDetectTagMigration() {
1000
- try {
1001
- await ensureTursoReady();
1002
- let untaggedCount = 0;
1003
- for (const shard of await tursoShardManager.getAllShards("project", "")) {
1004
- const db = await tursoConnectionManager.getConnection(shard.dbPath);
1005
- const row = await db.get(`SELECT COUNT(*) as count FROM memories WHERE ${UNTAGGED_NORMAL_PREDICATE}`);
1006
- untaggedCount += Number(row?.count ?? 0);
1007
- }
1008
- return { success: true, data: { needsMigration: untaggedCount > 0, count: untaggedCount } };
1009
- }
1010
- catch (error) {
1011
- return { success: false, error: String(error) };
1012
- }
1013
- }
1014
- const idleMigration = () => ({
1015
- processed: 0,
1016
- total: 0,
1017
- currentBatch: 0,
1018
- totalBatches: 0,
1019
- isComplete: true,
1020
- errors: [],
1021
- });
1022
- let migrationProgress = idleMigration();
1023
- /** The untagged memories this migration run covers, fixed when the run starts. */
1024
- let migrationQueue = [];
1025
- export async function handleGetTagMigrationProgress() {
1026
- return { success: true, data: migrationProgress };
1027
- }
1028
- /**
1029
- * Tag the memories that have no tags, a few per request. Only untagged
1030
- * memories are touched: tagged ones keep their vectors. A memory that fails
1031
- * is recorded and passed over, so one bad row cannot stall the run.
1032
- */
999
+ export { handleDetectTagMigration, handleGetTagMigrationProgress } from "./tag-migration.js";
1000
+ /** Tags untagged memories with the memory model. The work lives in tag-migration.ts. */
1033
1001
  export async function handleRunTagMigrationBatch(batchSize = 5) {
1034
- try {
1035
- await ensureTursoReady();
1036
- const { AIProviderFactory } = await import("./ai/ai-provider-factory.js");
1037
- const { buildMemoryProviderConfig } = await import("./ai/provider-config.js");
1038
- const providerConfig = buildMemoryProviderConfig(CONFIG, {
1039
- maxIterations: 1,
1040
- iterationTimeout: 30000,
1041
- });
1042
- const provider = AIProviderFactory.createProvider(CONFIG.memoryProvider, providerConfig);
1043
- if (migrationProgress.isComplete) {
1044
- migrationQueue = [];
1045
- for (const shard of await tursoShardManager.getAllShards("project", "")) {
1046
- const db = await tursoConnectionManager.getConnection(shard.dbPath);
1047
- const rows = await db.all(`SELECT id FROM memories WHERE ${UNTAGGED_NORMAL_PREDICATE} ORDER BY id`);
1048
- for (const row of rows)
1049
- migrationQueue.push({ id: String(row.id), dbPath: shard.dbPath });
1050
- }
1051
- migrationProgress = {
1052
- ...idleMigration(),
1053
- total: migrationQueue.length,
1054
- totalBatches: Math.ceil(migrationQueue.length / batchSize),
1055
- isComplete: migrationQueue.length === 0,
1056
- };
1057
- }
1058
- const batch = migrationQueue.slice(migrationProgress.processed, migrationProgress.processed + batchSize);
1059
- for (const item of batch) {
1060
- try {
1061
- const db = await tursoConnectionManager.getConnection(item.dbPath);
1062
- const m = await db.get("SELECT * FROM memories WHERE id = ?", [item.id]);
1063
- // Tagged or deleted since the run started: nothing to do.
1064
- if (!m || (m.tags && String(m.tags).trim()))
1065
- continue;
1066
- const prompt = `Generate 2-4 short technical tags for this memory content:\n\n${m.content}\n\nReturn ONLY a comma-separated list of tags.`;
1067
- const result = await provider.executeToolCall("You are a technical tagger.", prompt, {
1068
- type: "function",
1069
- function: {
1070
- name: "save_tags",
1071
- description: "Save generated tags",
1072
- parameters: {
1073
- type: "object",
1074
- properties: { tags: { type: "array", items: { type: "string" } } },
1075
- required: ["tags"],
1076
- },
1077
- },
1078
- }, `migration_${m.id}`);
1079
- const tags = Array.isArray(result.data?.tags)
1080
- ? result.data.tags.map((t) => String(t).trim().toLowerCase()).filter(Boolean)
1081
- : [];
1082
- if (!result.success || tags.length === 0) {
1083
- throw new Error(result.error ?? "The model returned no tags");
1084
- }
1085
- const vector = await embeddingService.embedWithTimeout(m.content, { task: "document" });
1086
- const tagsVector = await embeddingService.embedWithTimeout(formatTagsForEmbedding(tags), {
1087
- task: "document",
1088
- });
1089
- // One statement, so a memory never ends up tagged with stale vectors.
1090
- await tursoVectorSearch.updateVector(db, m.id, vector, tagsVector, tags.join(","));
1091
- }
1092
- catch (e) {
1093
- const errorMsg = String(e);
1094
- migrationProgress.errors.push(errorMsg);
1095
- log("Migration error for memory", { id: item.id, error: errorMsg }, "error");
1096
- }
1097
- finally {
1098
- migrationProgress.processed++;
1099
- }
1100
- }
1101
- migrationProgress.currentBatch++;
1102
- const hasMore = migrationProgress.processed < migrationProgress.total;
1103
- if (!hasMore)
1104
- migrationProgress.isComplete = true;
1105
- return {
1106
- success: true,
1107
- data: { processed: migrationProgress.processed, total: migrationProgress.total, hasMore },
1108
- };
1109
- }
1110
- catch (error) {
1111
- return { success: false, error: String(error) };
1112
- }
1002
+ return runTagMigrationBatch(CONFIG, batchSize);
1113
1003
  }
@@ -101,6 +101,7 @@ export class LocalMemoryClient {
101
101
  maxMemories,
102
102
  similarityThreshold: CONFIG.similarityThreshold,
103
103
  kind: options.kind ?? "normal",
104
+ thresholdAfterRerank: options.thresholdAfterRerank,
104
105
  }, reranker, {
105
106
  search: (shardList, vector, tag, limit, threshold, queryText, kind) => tursoVectorSearch.searchAcrossShards(shardList, vector, tag, limit, threshold, queryText, keywordBoost, "boost", undefined, kind),
106
107
  log: (message, meta) => log(message, meta),
@@ -12,12 +12,19 @@ export declare class ConfigConflictError extends Error {
12
12
  constructor();
13
13
  }
14
14
  export declare function readGlobalConfigRevision(): string;
15
- /** Write only page-editable keys; reject edits based on a stale config revision. */
16
- export declare function writeGlobalConfigKeys(edits: Record<string, unknown>, expectedRevision: string,
17
- /** A route that owns a separate set of keys passes it; the general save may not touch them. */
18
- options?: {
15
+ type WriteOptions = {
16
+ /** A route that owns a separate set of keys passes it; the general save may not touch them. */
19
17
  only?: Set<string>;
20
- }): Promise<{
18
+ /**
19
+ * Builds the edits from the config as read inside the save queue, against the
20
+ * checked revision, so a read-modify-write cannot lose another save. Returning
21
+ * no edits writes nothing. `edits` is then ignored.
22
+ */
23
+ derive?: (current: Record<string, unknown>) => Record<string, unknown>;
24
+ };
25
+ /** Write only page-editable keys; reject edits based on a stale config revision. */
26
+ export declare function writeGlobalConfigKeys(edits: Record<string, unknown>, expectedRevision: string, options?: WriteOptions): Promise<{
21
27
  revision: string;
22
28
  migratedLegacy: boolean;
23
29
  }>;
30
+ export {};
@@ -103,6 +103,10 @@ const RESETTABLE_KEYS = [
103
103
  ];
104
104
  const QUERY_RULE = `openingContextQuery must be text of ${OPENING_CONTEXT_QUERY_MAX_LENGTH} characters or fewer`;
105
105
  function isValidEdit(key, value) {
106
+ // Written only by the OpenRouter routing route; `undefined` removes the key.
107
+ if (key === "memoryExtraParams" || key === "memoryExtraParams.provider") {
108
+ return (value === undefined || (typeof value === "object" && value !== null && !Array.isArray(value)));
109
+ }
106
110
  // `undefined` removes the key from the file. `null` is the wire form of
107
111
  // "reset to default", accepted for the memory limits and the optional reranker keys.
108
112
  const memoryLimit = getMemoryLimitRule(key);
@@ -229,12 +233,8 @@ let queue = Promise.resolve();
229
233
  let pending = 0;
230
234
  let lastBaseRevision;
231
235
  let lastWrittenRevision;
232
- /** Write only page-editable keys; reject edits based on a stale config revision. */
233
- export async function writeGlobalConfigKeys(edits, expectedRevision,
234
- /** A route that owns a separate set of keys passes it; the general save may not touch them. */
235
- options = {}) {
236
- if (!edits || !Object.keys(edits).length)
237
- throw new Error("No settings to save");
236
+ /** Refuse an edit to a key the caller may not write, or a value the key does not accept. */
237
+ function checkEdits(edits, options) {
238
238
  for (const [key, value] of Object.entries(edits)) {
239
239
  const allowed = options.only ? options.only.has(key) : keys.has(key);
240
240
  if (!allowed)
@@ -265,6 +265,14 @@ options = {}) {
265
265
  if (missing !== null)
266
266
  throw new Error(`Skill folder not found: ${missing}`);
267
267
  }
268
+ }
269
+ /** Write only page-editable keys; reject edits based on a stale config revision. */
270
+ export async function writeGlobalConfigKeys(edits, expectedRevision, options = {}) {
271
+ if (!options.derive) {
272
+ if (!edits || !Object.keys(edits).length)
273
+ throw new Error("No settings to save");
274
+ checkEdits(edits, options);
275
+ }
268
276
  const wasQueued = pending > 0;
269
277
  pending++;
270
278
  const save = queue.then(async () => {
@@ -285,8 +293,15 @@ options = {}) {
285
293
  if (errors.length || !current || typeof current !== "object" || Array.isArray(current)) {
286
294
  throw new Error("Config contains invalid JSONC");
287
295
  }
288
- checkMemorisationSettingsEdits(edits, memorisationSettingsFrom(current));
289
- for (const [key, value] of Object.entries(edits)) {
296
+ const applied = options.derive ? options.derive(current) : edits;
297
+ if (options.derive) {
298
+ if (!Object.keys(applied).length) {
299
+ return { revision: original.revision, migratedLegacy: false };
300
+ }
301
+ checkEdits(applied, options);
302
+ }
303
+ checkMemorisationSettingsEdits(applied, memorisationSettingsFrom(current));
304
+ for (const [key, value] of Object.entries(applied)) {
290
305
  // A nested identifier maps to its JSONC leaf path, so compaction.enabled
291
306
  // writes the leaf, never a literal dotted key.
292
307
  const jsoncPath = (getMemoryLimitRule(key)?.path ?? key.split("."));
@@ -21,6 +21,8 @@ export interface MemoryReadOptions {
21
21
  limit?: number;
22
22
  /** Search only: rerank this read whatever the general `rerankerEnabled` switch says. */
23
23
  reranker?: boolean;
24
+ /** Search only: with the reranker on, still drop hits below `similarityThreshold`. */
25
+ thresholdAfterRerank?: boolean;
24
26
  }
25
27
  /** The settings a linked search needs; the caller snapshots them from CONFIG. */
26
28
  export interface LinkedSearchSettings {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The OpenRouter model list, reduced to ID and name for the Model field
3
+ * suggestions. It reads one public OpenRouter list without an API key. `fetch`
4
+ * and the clock come in as arguments, so the module needs no config and tests
5
+ * need no network.
6
+ */
7
+ export type ModelRow = {
8
+ id: string;
9
+ name: string;
10
+ };
11
+ export type ModelsResult = {
12
+ ok: true;
13
+ models: ModelRow[];
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
19
+ export declare function createOpenRouterModels(deps: {
20
+ fetch: FetchLike;
21
+ now: () => number;
22
+ timeoutMs?: number;
23
+ }): {
24
+ list: () => Promise<ModelsResult>;
25
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The OpenRouter model list, reduced to ID and name for the Model field
3
+ * suggestions. It reads one public OpenRouter list without an API key. `fetch`
4
+ * and the clock come in as arguments, so the module needs no config and tests
5
+ * need no network.
6
+ */
7
+ const URL_MODELS = "https://openrouter.ai/api/v1/models";
8
+ const CACHE_TTL_MS = 60 * 60 * 1000;
9
+ const FETCH_TIMEOUT_MS = 10_000;
10
+ const text = (value) => typeof value === "string" && value.trim() ? value : undefined;
11
+ export function createOpenRouterModels(deps) {
12
+ const timeoutMs = deps.timeoutMs ?? FETCH_TIMEOUT_MS;
13
+ let cache;
14
+ async function list() {
15
+ if (cache && deps.now() - cache.at < CACHE_TTL_MS)
16
+ return { ok: true, models: cache.models };
17
+ let response;
18
+ try {
19
+ response = await deps.fetch(URL_MODELS, {
20
+ method: "GET",
21
+ headers: { Accept: "application/json" },
22
+ signal: AbortSignal.timeout(timeoutMs),
23
+ });
24
+ }
25
+ catch (error) {
26
+ const name = error instanceof Error ? error.name : "";
27
+ if (name === "TimeoutError" || name === "AbortError") {
28
+ return { ok: false, error: "OpenRouter did not answer within 10 seconds." };
29
+ }
30
+ return { ok: false, error: "OpenRouter could not be reached." };
31
+ }
32
+ if (!response.ok) {
33
+ return { ok: false, error: `OpenRouter answered with status ${response.status}.` };
34
+ }
35
+ let body;
36
+ try {
37
+ body = await response.json();
38
+ }
39
+ catch {
40
+ return { ok: false, error: "OpenRouter sent a reply that could not be read." };
41
+ }
42
+ const rows = body?.data;
43
+ if (!Array.isArray(rows)) {
44
+ return { ok: false, error: "OpenRouter sent a model list in an unknown form." };
45
+ }
46
+ const models = [];
47
+ for (const row of rows) {
48
+ const id = text(row?.id);
49
+ if (id)
50
+ models.push({ id, name: text(row.name) ?? id });
51
+ }
52
+ models.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
53
+ // Only a good answer is cached, so a failed fetch is tried again on the next request.
54
+ cache = { at: deps.now(), models };
55
+ return { ok: true, models };
56
+ }
57
+ return { list };
58
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The providers that serve one OpenRouter model, each with a data policy label.
3
+ * It reads two public OpenRouter lists without an API key: the model's endpoints
4
+ * and the zero data retention (ZDR) endpoints. `fetch` and the clock come in as
5
+ * arguments, so the module needs no config and tests need no network.
6
+ */
7
+ export type ProviderPolicy = "zdr" | "safety" | "unknown";
8
+ /** US dollars per 1M tokens. Only set when both values are known. */
9
+ export type ProviderPrice = {
10
+ input: number;
11
+ output: number;
12
+ };
13
+ export type ProviderRow = {
14
+ slug: string;
15
+ name: string;
16
+ policy: ProviderPolicy;
17
+ price?: ProviderPrice;
18
+ };
19
+ export type ProviderFailure = "unknown-model" | "timeout" | "unreachable";
20
+ export type ProvidersResult = {
21
+ ok: true;
22
+ providers: ProviderRow[];
23
+ } | {
24
+ ok: false;
25
+ reason: ProviderFailure;
26
+ error: string;
27
+ };
28
+ /** True for `author/slug` model IDs. Dot-only parts are refused so the URL cannot climb out of /models/. */
29
+ export declare function isModelId(value: unknown): value is string;
30
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
31
+ export declare function createOpenRouterProviders(deps: {
32
+ fetch: FetchLike;
33
+ now: () => number;
34
+ timeoutMs?: number;
35
+ }): {
36
+ list: (model: string) => Promise<ProvidersResult>;
37
+ };