om-memory-system 4.22.0 → 4.24.0-next.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,3 +9,8 @@ export declare const NODE_HTTP_IDLE_TIMEOUT_MS: number;
9
9
  * without memory rather than stalling the agent.
10
10
  */
11
11
  export declare const V2_RETRIEVAL_TIMEOUT_MS = 5000;
12
+ export declare const RERANKER_TIMEOUT_DEFAULT_MS = 4000;
13
+ export declare const RERANKER_TIMEOUT_MIN_MS = 500;
14
+ export declare const RERANKER_TIMEOUT_MAX_MS = 30000;
15
+ /** Candidates the reranker sees per search. A constant until evidence says otherwise. */
16
+ export declare const RERANKER_POOL_SIZE = 50;
@@ -11,3 +11,11 @@ export const NODE_HTTP_IDLE_TIMEOUT_MS = Math.max(EXTERNAL_PROFILE_CLEANUP_TIMEO
11
11
  * without memory rather than stalling the agent.
12
12
  */
13
13
  export const V2_RETRIEVAL_TIMEOUT_MS = 5_000;
14
+ // Opt-in reranker (openspec change retrieval-threshold-and-reranker). The
15
+ // default keeps total search time inside OpenCode V2's 5-second wait; a user
16
+ // who sets more than 5000 accepts that V2 may send the request with no memory.
17
+ export const RERANKER_TIMEOUT_DEFAULT_MS = 4_000;
18
+ export const RERANKER_TIMEOUT_MIN_MS = 500;
19
+ export const RERANKER_TIMEOUT_MAX_MS = 30_000;
20
+ /** Candidates the reranker sees per search. A constant until evidence says otherwise. */
21
+ export const RERANKER_POOL_SIZE = 50;
@@ -0,0 +1 @@
1
+ export declare function testReranker(req: Request, method: string, remoteAddress: string | undefined): Promise<Response>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The Reranker card's Test route (openspec change
3
+ * retrieval-threshold-and-reranker, task 6.1). Sends two fixed sample
4
+ * documents, never real memory text, through the same rerankDocuments the
5
+ * search uses, and reports success or a failure code only. Same guard rules
6
+ * as the embedding test route: loopback caller, authorised API request,
7
+ * JSON body.
8
+ */
9
+ import { isAuthorizedApiRequest } from "./auth-token.js";
10
+ import { isLoopbackAddress } from "./web-api-auth.js";
11
+ import { resolveSecretValue } from "./secret-resolver.js";
12
+ import { rerankDocuments, RerankError } from "./reranker.js";
13
+ import { RERANKER_TIMEOUT_DEFAULT_MS, RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS, } from "./request-timeouts.js";
14
+ // Fixed samples: they carry no user content, so the test never leaks memory
15
+ // text to an unverified server beyond these two strings.
16
+ const TEST_QUERY = "Which document is about the omms reranker test?";
17
+ const TEST_DOCUMENTS = [
18
+ { id: "sample-1", memory: "This document describes the omms reranker connectivity test." },
19
+ { id: "sample-2", memory: "This document describes an unrelated topic for comparison." },
20
+ ];
21
+ function reject(message) {
22
+ return Response.json({ error: message }, { status: 400 });
23
+ }
24
+ /** Draft validation mirrors the save route's rules; values are never echoed. */
25
+ function draftSettings(draft) {
26
+ const url = typeof draft.rerankerApiUrl === "string" ? draft.rerankerApiUrl.trim() : "";
27
+ if (!/^https?:\/\/\S+$/.test(url)) {
28
+ return reject("Invalid rerankerApiUrl setting: accepted an http:// or https:// URL");
29
+ }
30
+ const model = typeof draft.rerankerModel === "string" ? draft.rerankerModel.trim() : "";
31
+ if (!model) {
32
+ return reject("Invalid rerankerModel setting: accepted a non-empty string");
33
+ }
34
+ let apiKey;
35
+ if (draft.rerankerApiKey !== undefined && draft.rerankerApiKey !== "") {
36
+ const key = draft.rerankerApiKey;
37
+ if (typeof key !== "string" ||
38
+ !/^(env:\/\/[A-Za-z_][A-Za-z0-9_]*|file:\/\/.+)$/.test(key.trim())) {
39
+ return reject("rerankerApiKey must be an env:// or file:// reference");
40
+ }
41
+ try {
42
+ apiKey = resolveSecretValue(key);
43
+ }
44
+ catch {
45
+ return reject("rerankerApiKey could not be resolved");
46
+ }
47
+ }
48
+ let timeoutMs = RERANKER_TIMEOUT_DEFAULT_MS;
49
+ if (draft.rerankerTimeoutMs !== undefined && draft.rerankerTimeoutMs !== null) {
50
+ const value = draft.rerankerTimeoutMs;
51
+ if (typeof value !== "number" ||
52
+ !Number.isSafeInteger(value) ||
53
+ value < RERANKER_TIMEOUT_MIN_MS ||
54
+ value > RERANKER_TIMEOUT_MAX_MS) {
55
+ return reject(`Invalid rerankerTimeoutMs setting: accepted integers from ${RERANKER_TIMEOUT_MIN_MS} to ${RERANKER_TIMEOUT_MAX_MS}`);
56
+ }
57
+ timeoutMs = value;
58
+ }
59
+ return { apiUrl: url.replace(/\/+$/, ""), model, apiKey, timeoutMs };
60
+ }
61
+ export async function testReranker(req, method, remoteAddress) {
62
+ if (method !== "POST") {
63
+ return Response.json({ error: "Not found" }, { status: 404 });
64
+ }
65
+ if (!isLoopbackAddress(remoteAddress) || !isAuthorizedApiRequest(req)) {
66
+ return Response.json({ error: "Only this machine can test a reranker" }, { status: 403 });
67
+ }
68
+ const body = (await req.json().catch(() => null));
69
+ if (!body || typeof body !== "object") {
70
+ return reject("A JSON body with the drafted reranker settings is required");
71
+ }
72
+ const settings = draftSettings(body);
73
+ if (settings instanceof Response)
74
+ return settings;
75
+ const started = Date.now();
76
+ try {
77
+ const { ordered } = await rerankDocuments(settings, TEST_QUERY, TEST_DOCUMENTS);
78
+ // The spec passes the test only when the relevant sample ranks first.
79
+ if (ordered[0]?.id !== "sample-1") {
80
+ return Response.json({
81
+ success: false,
82
+ code: "reranker_wrong_order",
83
+ elapsedMs: Date.now() - started,
84
+ });
85
+ }
86
+ return Response.json({ success: true });
87
+ }
88
+ catch (error) {
89
+ // Success or a failure code only; never prompt or memory text.
90
+ const code = error instanceof RerankError ? error.code : "reranker_unreachable";
91
+ const elapsedMs = error instanceof RerankError ? error.elapsedMs : 0;
92
+ return Response.json({ success: false, code, elapsedMs });
93
+ }
94
+ }
@@ -0,0 +1,45 @@
1
+ export interface RerankerSettings {
2
+ apiUrl: string;
3
+ model: string;
4
+ apiKey?: string;
5
+ timeoutMs: number;
6
+ }
7
+ /** A rerank candidate: the memory text plus the id used to break score ties. */
8
+ export interface RerankCandidate {
9
+ id: string;
10
+ memory: string;
11
+ }
12
+ /** Structural input so rerankSettingsFrom works with any config-shaped object. */
13
+ export interface RerankerConfigSlice {
14
+ rerankerEnabled?: boolean;
15
+ rerankerApiUrl?: string;
16
+ rerankerModel?: string;
17
+ rerankerApiKey?: string;
18
+ rerankerTimeoutMs?: number;
19
+ }
20
+ /** Codes are metadata-only: safe to log, unlike the query or memory text. */
21
+ export declare class RerankError extends Error {
22
+ readonly code: "reranker_unreachable" | "reranker_timeout" | "reranker_http_error" | "reranker_invalid_reply";
23
+ readonly elapsedMs: number;
24
+ readonly status?: number | undefined;
25
+ constructor(code: "reranker_unreachable" | "reranker_timeout" | "reranker_http_error" | "reranker_invalid_reply", elapsedMs: number, status?: number | undefined, detail?: string);
26
+ }
27
+ /**
28
+ * Map a config to reranker settings. Undefined means "off": the switch is
29
+ * off, or the URL or model is missing (an incomplete setup is treated as
30
+ * off, never as a broken search). A key reference that does not resolve in
31
+ * this process sends no Authorization header; the server then answers 401
32
+ * and the caller falls back.
33
+ */
34
+ export declare function rerankSettingsFrom(config: RerankerConfigSlice): RerankerSettings | undefined;
35
+ /**
36
+ * Ask the reranker to order the documents. Throws RerankError on any
37
+ * failure; the caller falls back to the threshold result. A reply is valid
38
+ * only when it has one entry per document, each index is an integer inside
39
+ * the document range and appears once, and each relevance_score is finite.
40
+ * Partial reranking is never used.
41
+ */
42
+ export declare function rerankDocuments<T extends RerankCandidate>(settings: RerankerSettings, query: string, documents: T[], fetchImpl?: typeof fetch): Promise<{
43
+ ordered: T[];
44
+ scores: number[];
45
+ }>;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pure client for the opt-in reranker (openspec change
3
+ * retrieval-threshold-and-reranker). Never imports CONFIG: callers pass the
4
+ * settings so tests and the search path share one module without stubbing
5
+ * src/config.js. The endpoint contract matches llama-server --reranking
6
+ * (path /v1/rerank), Jina, and Cohere-style servers: POST <url>/rerank with
7
+ * {model, query, documents, top_n}; reply {results: [{index, relevance_score}]}.
8
+ */
9
+ import { resolveSecretValue } from "./secret-resolver.js";
10
+ import { RERANKER_TIMEOUT_DEFAULT_MS } from "./request-timeouts.js";
11
+ /** Codes are metadata-only: safe to log, unlike the query or memory text. */
12
+ export class RerankError extends Error {
13
+ code;
14
+ elapsedMs;
15
+ status;
16
+ constructor(code, elapsedMs, status, detail) {
17
+ // The detail can quote transport text; callers redact the key first.
18
+ super(detail ? `${code}: ${detail}` : code);
19
+ this.code = code;
20
+ this.elapsedMs = elapsedMs;
21
+ this.status = status;
22
+ this.name = "RerankError";
23
+ }
24
+ }
25
+ function redactKey(text, key) {
26
+ if (!key)
27
+ return text;
28
+ return text.split(key).join("[redacted]");
29
+ }
30
+ /** Documents are cut so one long memory cannot dominate the request. */
31
+ const MAX_DOCUMENT_CHARS = 4000;
32
+ /**
33
+ * Map a config to reranker settings. Undefined means "off": the switch is
34
+ * off, or the URL or model is missing (an incomplete setup is treated as
35
+ * off, never as a broken search). A key reference that does not resolve in
36
+ * this process sends no Authorization header; the server then answers 401
37
+ * and the caller falls back.
38
+ */
39
+ export function rerankSettingsFrom(config) {
40
+ if (!config.rerankerEnabled)
41
+ return undefined;
42
+ const apiUrl = config.rerankerApiUrl?.trim();
43
+ const model = config.rerankerModel?.trim();
44
+ if (!apiUrl || !model)
45
+ return undefined;
46
+ let apiKey;
47
+ if (config.rerankerApiKey) {
48
+ try {
49
+ apiKey = resolveSecretValue(config.rerankerApiKey);
50
+ }
51
+ catch {
52
+ apiKey = undefined;
53
+ }
54
+ }
55
+ return {
56
+ apiUrl: apiUrl.replace(/\/+$/, ""),
57
+ model,
58
+ apiKey,
59
+ timeoutMs: config.rerankerTimeoutMs ?? RERANKER_TIMEOUT_DEFAULT_MS,
60
+ };
61
+ }
62
+ /**
63
+ * Ask the reranker to order the documents. Throws RerankError on any
64
+ * failure; the caller falls back to the threshold result. A reply is valid
65
+ * only when it has one entry per document, each index is an integer inside
66
+ * the document range and appears once, and each relevance_score is finite.
67
+ * Partial reranking is never used.
68
+ */
69
+ export async function rerankDocuments(settings, query, documents, fetchImpl = fetch) {
70
+ const started = Date.now();
71
+ const elapsed = () => Date.now() - started;
72
+ const documentsText = documents.map((doc) => doc.memory.slice(0, MAX_DOCUMENT_CHARS));
73
+ const headers = { "Content-Type": "application/json" };
74
+ if (settings.apiKey)
75
+ headers.Authorization = `Bearer ${settings.apiKey}`;
76
+ let response;
77
+ try {
78
+ response = await fetchImpl(`${settings.apiUrl}/rerank`, {
79
+ method: "POST",
80
+ headers,
81
+ body: JSON.stringify({
82
+ model: settings.model,
83
+ query,
84
+ documents: documentsText,
85
+ top_n: documents.length,
86
+ }),
87
+ signal: AbortSignal.timeout(settings.timeoutMs),
88
+ });
89
+ }
90
+ catch (error) {
91
+ const name = error?.name;
92
+ const detail = error instanceof Error ? error.message : String(error);
93
+ if (name === "AbortError" || name === "TimeoutError" || /abort/i.test(detail)) {
94
+ throw new RerankError("reranker_timeout", elapsed(), undefined, "timed out");
95
+ }
96
+ throw new RerankError("reranker_unreachable", elapsed(), undefined, redactKey(detail, settings.apiKey));
97
+ }
98
+ if (!response.ok) {
99
+ throw new RerankError("reranker_http_error", elapsed(), response.status);
100
+ }
101
+ let results;
102
+ try {
103
+ const body = (await response.json());
104
+ results = body.results;
105
+ }
106
+ catch {
107
+ throw new RerankError("reranker_invalid_reply", elapsed());
108
+ }
109
+ if (!Array.isArray(results) || results.length !== documents.length) {
110
+ throw new RerankError("reranker_invalid_reply", elapsed());
111
+ }
112
+ const seen = new Set();
113
+ const scored = results.map((entry) => {
114
+ const index = entry?.index;
115
+ const score = entry?.relevance_score;
116
+ const indexOk = typeof index === "number" &&
117
+ Number.isInteger(index) &&
118
+ index >= 0 &&
119
+ index < documents.length;
120
+ const scoreOk = typeof score === "number" && Number.isFinite(score);
121
+ if (!indexOk || !scoreOk || seen.has(index)) {
122
+ throw new RerankError("reranker_invalid_reply", elapsed());
123
+ }
124
+ seen.add(index);
125
+ return { index: index, score: score };
126
+ });
127
+ // Highest relevance first; ties break by memory id so the order is stable.
128
+ scored.sort((a, b) => b.score - a.score || documents[a.index].id.localeCompare(documents[b.index].id));
129
+ return {
130
+ ordered: scored.map((entry) => documents[entry.index]),
131
+ scores: scored.map((entry) => entry.score),
132
+ };
133
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The reranker stage of memory search (openspec change
3
+ * retrieval-threshold-and-reranker, design decision 7). vector-search.ts
4
+ * sits at its file-size ceiling, so this module wraps the existing shard
5
+ * search instead of changing it: one shard read with no threshold returns
6
+ * the best candidates; the reranker reorders them; any failure falls back
7
+ * to the normal threshold result. Pure plumbing only — the settings and the
8
+ * search/fetch/log implementations are passed in, never imported from
9
+ * CONFIG, so tests run without a store or a server.
10
+ */
11
+ import type { SearchResult, ShardInfo } from "./turso/types.js";
12
+ import { type RerankerSettings } from "./reranker.js";
13
+ /** The shape of tursoVectorSearch.searchAcrossShards, injectable for tests. */
14
+ export type SearchAcrossShards = (shards: ShardInfo[], queryVector: Float32Array, containerTag: string, limit: number, similarityThreshold: number, queryText?: string) => Promise<{
15
+ results: SearchResult[];
16
+ warnings: string[];
17
+ }>;
18
+ export interface RerankedSearchOptions {
19
+ shards: ShardInfo[];
20
+ queryVector: Float32Array;
21
+ containerTag: string;
22
+ queryText: string;
23
+ maxMemories: number;
24
+ similarityThreshold: number;
25
+ }
26
+ export interface RerankedSearchDeps {
27
+ search: SearchAcrossShards;
28
+ fetchImpl?: typeof fetch;
29
+ /** Defaults to silence; the real caller passes the shared logger. */
30
+ log?: (message: string, meta: Record<string, unknown>) => void;
31
+ }
32
+ /**
33
+ * Search with an optional reranker. The shard search always runs once with a
34
+ * threshold of 0 and a limit of max(maxMemories, pool size), so the same
35
+ * read serves the reranked path and the fallback. The candidates come back
36
+ * sorted by similarity, so applying the threshold and the limit to the same
37
+ * list reproduces the no-reranker result exactly.
38
+ */
39
+ export declare function rerankedSearch(options: RerankedSearchOptions, settings: RerankerSettings, deps: RerankedSearchDeps): Promise<{
40
+ results: SearchResult[];
41
+ warnings: string[];
42
+ reranked: boolean;
43
+ }>;
@@ -0,0 +1,42 @@
1
+ import { RERANKER_POOL_SIZE } from "./request-timeouts.js";
2
+ import { rerankDocuments } from "./reranker.js";
3
+ /**
4
+ * Search with an optional reranker. The shard search always runs once with a
5
+ * threshold of 0 and a limit of max(maxMemories, pool size), so the same
6
+ * read serves the reranked path and the fallback. The candidates come back
7
+ * sorted by similarity, so applying the threshold and the limit to the same
8
+ * list reproduces the no-reranker result exactly.
9
+ */
10
+ export async function rerankedSearch(options, settings, deps) {
11
+ const { search } = deps;
12
+ const { results: candidates, warnings } = await search(options.shards, options.queryVector, options.containerTag, Math.max(options.maxMemories, RERANKER_POOL_SIZE), 0, options.queryText);
13
+ // Nothing to reorder: skip the server round trip for an empty search.
14
+ if (candidates.length === 0)
15
+ return { results: [], warnings, reranked: false };
16
+ try {
17
+ const { ordered } = await rerankDocuments(settings, options.queryText, candidates, deps.fetchImpl);
18
+ return { results: ordered.slice(0, options.maxMemories), warnings, reranked: true };
19
+ }
20
+ catch (error) {
21
+ // Metadata only: the code, the elapsed time, and the candidate count.
22
+ // The query and any memory text never reach the log.
23
+ const code = typeof error?.code === "string"
24
+ ? error.code
25
+ : "reranker_unreachable";
26
+ const elapsedMs = typeof error?.elapsedMs === "number"
27
+ ? error.elapsedMs
28
+ : 0;
29
+ deps.log?.("Reranker failed; returning the threshold result", {
30
+ code,
31
+ elapsedMs,
32
+ candidates: candidates.length,
33
+ });
34
+ return {
35
+ results: candidates
36
+ .filter((result) => result.similarity >= options.similarityThreshold)
37
+ .slice(0, options.maxMemories),
38
+ warnings,
39
+ reranked: false,
40
+ };
41
+ }
42
+ }
@@ -29,6 +29,10 @@ const editable = [
29
29
  "importIgnoredDirectories",
30
30
  "claudeConfigDir",
31
31
  "autoCaptureLanguage",
32
+ "rerankerEnabled",
33
+ "rerankerApiUrl",
34
+ "rerankerModel",
35
+ "rerankerTimeoutMs",
32
36
  ...MEMORY_LIMIT_SETTINGS,
33
37
  ];
34
38
  /** Keys a project config cannot override, so the page always shows the global value. */
@@ -45,6 +49,11 @@ const globalOnly = [
45
49
  "importPathMaps",
46
50
  "importIgnoredDirectories",
47
51
  "claudeConfigDir",
52
+ "rerankerEnabled",
53
+ "rerankerApiUrl",
54
+ "rerankerModel",
55
+ "rerankerApiKey",
56
+ "rerankerTimeoutMs",
48
57
  ];
49
58
  function readSettingsFile(path) {
50
59
  if (!path)
@@ -86,6 +95,8 @@ function nestedMemorySetting(rule, global, project) {
86
95
  source: hasValue ? (projectHasParent ? "project" : "global") : "default",
87
96
  globalValue: hasGlobal ? globalContainer[leaf] : rule.default,
88
97
  default: rule.default,
98
+ // Whether the leaf exists in the global file: the Reset action needs it.
99
+ hasGlobal,
89
100
  };
90
101
  }
91
102
  export function getSettingsSnapshot(directory) {
@@ -109,20 +120,30 @@ export function getSettingsSnapshot(directory) {
109
120
  ? "global"
110
121
  : "default";
111
122
  const value = projectOverrides ? project[key] : (global[key] ?? runtime[key]);
112
- // The five memory limits also carry their documented default for the UI.
123
+ // The six memory limits also carry their documented default for the UI;
124
+ // similarityThreshold's default follows the active embedder. hasGlobal
125
+ // tells the Reset action whether the key exists in the global file.
126
+ const ruleDefault = limitRule?.defaultFor?.(CONFIG.embeddingModel) ?? limitRule?.default;
113
127
  return [
114
128
  key,
115
129
  limitRule
116
130
  ? {
117
131
  value,
118
132
  source,
119
- globalValue: global[key] ?? limitRule.default,
120
- default: limitRule.default,
133
+ globalValue: global[key] ?? ruleDefault,
134
+ default: ruleDefault,
135
+ hasGlobal: Object.hasOwn(global, key),
121
136
  }
122
137
  : { value, source, globalValue: global[key] ?? runtime[key] },
123
138
  ];
124
139
  }));
125
- const secrets = Object.fromEntries(["memoryApiKey", "embeddingApiKey", "webServerApiToken", "webServerAuthPassword"].map((key) => {
140
+ const secrets = Object.fromEntries([
141
+ "memoryApiKey",
142
+ "embeddingApiKey",
143
+ "webServerApiToken",
144
+ "webServerAuthPassword",
145
+ "rerankerApiKey",
146
+ ].map((key) => {
126
147
  const raw = global[key];
127
148
  const source = typeof raw === "string"
128
149
  ? raw.startsWith("env://")
@@ -12,6 +12,7 @@ import { isOlderVersion } from "./version-compare.js";
12
12
  import { AUTH_HEADER, getOrCreateAuthToken, isAuthorizedApiRequest } from "./auth-token.js";
13
13
  import { WebAuth } from "./web-auth.js";
14
14
  import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
15
+ import { testReranker } from "./reranker-test.js";
15
16
  import { handleListTags, handleListKeywords, handleListMemories, handleAddMemory, handleDeleteMemory, handleBulkDelete, handleUpdateMemory, handleSearch, handleStats, handlePinMemory, handleUnpinMemory, handleRunCleanup, handleRunDeduplication, handleDetectMigration, handleRunMigration, handleDetectTagMigration, handleRunTagMigrationBatch, handleGetTagMigrationProgress, handleDeletePrompt, handleBulkDeletePrompts, handleGetUserProfile, handleGetProfileChangelog, handleGetProfileSnapshot, handleRefreshProfile, handleCategoryCleanupApply, handleCategoryCleanupStart, handleCategoryCleanupStatus, handleCategoryCleanupUndo, handleUpdateProfileItem, } from "./api-handlers.js";
16
17
  import { routeProfileCleanup } from "./profile-cleanup-routes.js";
17
18
  const isBun = typeof globalThis.Bun !== "undefined";
@@ -1043,6 +1044,8 @@ export class WebServer {
1043
1044
  if (response)
1044
1045
  return response;
1045
1046
  }
1047
+ if (path === "/api/settings/reranker/test")
1048
+ return testReranker(req, method, remoteAddress);
1046
1049
  if (path === "/api/settings/external-api/test" && method === "POST") {
1047
1050
  const { testExternalApi } = await import("../importer/web-import-api.js");
1048
1051
  return this.jsonResponse(await testExternalApi());
@@ -1,16 +1,14 @@
1
- /**
2
- * Shared validation rules for the five memory-limit settings
3
- * (openspec change memory-context-controls). Pure functions only: values
4
- * are passed in, so config.ts, the settings writer, and the settings
5
- * snapshot share one rule set without importing CONFIG. Do not import
6
- * CONFIG here: many tests stub src/config.js with a partial module.
7
- */
8
1
  export interface MemoryLimitRule {
9
2
  /** Settings identifier used by files, requests, and the UI. Nested settings use one dot. */
10
3
  readonly setting: string;
11
4
  /** JSONC leaf path. A nested setting maps to its leaf, never a literal dotted key. */
12
5
  readonly path: readonly string[];
13
6
  readonly default: number;
7
+ /**
8
+ * Embedder-aware default when the file omits the key. Only
9
+ * similarityThreshold needs it; the other limits keep `default`.
10
+ */
11
+ readonly defaultFor?: (embeddingModel: string | undefined) => number;
14
12
  readonly unit: string;
15
13
  /** Accepted-values text for error messages and help text. */
16
14
  readonly accepted: string;
@@ -22,8 +20,9 @@ export declare const MEMORY_LIMITS: {
22
20
  readonly autoCaptureMaxContextBytes: MemoryLimitRule;
23
21
  readonly userProfileMaxContextBytes: MemoryLimitRule;
24
22
  readonly retrievalMaxTokens: MemoryLimitRule;
23
+ readonly similarityThreshold: MemoryLimitRule;
25
24
  };
26
- /** The five settings identifiers, in Memory card order. */
25
+ /** The six settings identifiers, in Memory card order. */
27
26
  export declare const MEMORY_LIMIT_SETTINGS: readonly string[];
28
27
  export declare function getMemoryLimitRule(setting: string): MemoryLimitRule | undefined;
29
28
  /**
@@ -1,18 +1,23 @@
1
1
  /**
2
- * Shared validation rules for the five memory-limit settings
2
+ * Shared validation rules for the six memory-limit settings
3
3
  * (openspec change memory-context-controls). Pure functions only: values
4
4
  * are passed in, so config.ts, the settings writer, and the settings
5
5
  * snapshot share one rule set without importing CONFIG. Do not import
6
6
  * CONFIG here: many tests stub src/config.js with a partial module.
7
7
  */
8
+ import { defaultSimilarityThreshold } from "./threshold-default.js";
8
9
  function isPositiveSafeInteger(value) {
9
10
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
10
11
  }
11
12
  function integerInRange(min, max) {
12
13
  return (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
13
14
  }
14
- function rule(setting, path, defaultValue, unit, accepted, isValid) {
15
- return { setting, path, default: defaultValue, unit, accepted, isValid };
15
+ /** Any finite number from 0 to 1 inclusive (similarityThreshold). */
16
+ function decimalFrom0To1(value) {
17
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
18
+ }
19
+ function rule(setting, path, defaultValue, unit, accepted, isValid, defaultFor) {
20
+ return { setting, path, default: defaultValue, defaultFor, unit, accepted, isValid };
16
21
  }
17
22
  export const MEMORY_LIMITS = {
18
23
  maxMemories: rule("maxMemories", ["maxMemories"], 10, "results", "positive safe integers", isPositiveSafeInteger),
@@ -20,8 +25,9 @@ export const MEMORY_LIMITS = {
20
25
  autoCaptureMaxContextBytes: rule("autoCaptureMaxContextBytes", ["autoCaptureMaxContextBytes"], 131072, "UTF-8 bytes", "integers from 16384 to 16777216", integerInRange(16384, 16777216)),
21
26
  userProfileMaxContextBytes: rule("userProfileMaxContextBytes", ["userProfileMaxContextBytes"], 32768, "UTF-8 bytes", "integers from 1024 to 16777216", integerInRange(1024, 16777216)),
22
27
  retrievalMaxTokens: rule("retrievalMaxTokens", ["retrievalMaxTokens"], 2000, "approximate tokens", "integers from 256 to 65536", integerInRange(256, 65536)),
28
+ similarityThreshold: rule("similarityThreshold", ["similarityThreshold"], 0.6, "similarity score", "numbers from 0 to 1", decimalFrom0To1, defaultSimilarityThreshold),
23
29
  };
24
- /** The five settings identifiers, in Memory card order. */
30
+ /** The six settings identifiers, in Memory card order. */
25
31
  export const MEMORY_LIMIT_SETTINGS = Object.keys(MEMORY_LIMITS);
26
32
  const RULES = MEMORY_LIMITS;
27
33
  export function getMemoryLimitRule(setting) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The embedder-dependent default for similarityThreshold (openspec change
3
+ * retrieval-threshold-and-reranker). Pure function: callers pass the model
4
+ * name, so config.ts, the settings writer, and the settings snapshot share
5
+ * one table without importing CONFIG. Do not import CONFIG here: many tests
6
+ * stub src/config.js with a partial module.
7
+ *
8
+ * Evidence: 0.5 was measured with qwen3-embedding:0.6b only (Experiments 24
9
+ * to 26). Other embedders keep the historical 0.6 until someone measures
10
+ * them and adds a line to the list below.
11
+ */
12
+ export declare function defaultSimilarityThreshold(embeddingModel: string | undefined): number;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The embedder-dependent default for similarityThreshold (openspec change
3
+ * retrieval-threshold-and-reranker). Pure function: callers pass the model
4
+ * name, so config.ts, the settings writer, and the settings snapshot share
5
+ * one table without importing CONFIG. Do not import CONFIG here: many tests
6
+ * stub src/config.js with a partial module.
7
+ *
8
+ * Evidence: 0.5 was measured with qwen3-embedding:0.6b only (Experiments 24
9
+ * to 26). Other embedders keep the historical 0.6 until someone measures
10
+ * them and adds a line to the list below.
11
+ */
12
+ const MEASURED_THRESHOLD_0_5 = new Set([
13
+ "qwen3-embedding:0.6b",
14
+ "qwen/qwen3-embedding-0.6b",
15
+ "qwen/qwen3-embedding-0.6b-gguf:q8_0",
16
+ ]);
17
+ const UNMEASURED_THRESHOLD = 0.6;
18
+ export function defaultSimilarityThreshold(embeddingModel) {
19
+ if (!embeddingModel)
20
+ return UNMEASURED_THRESHOLD;
21
+ return MEASURED_THRESHOLD_0_5.has(embeddingModel.toLowerCase()) ? 0.5 : UNMEASURED_THRESHOLD;
22
+ }