om-memory-system 4.23.0-next.144 → 4.25.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.
- package/dist/adapters/opencode/user-prompt.js +6 -1
- package/dist/config-template.d.ts +6 -0
- package/dist/config-template.js +385 -0
- package/dist/config.d.ts +27 -1
- package/dist/config.js +29 -382
- package/dist/core/retrieval.js +1 -1
- package/dist/services/chat/chat-answer.d.ts +56 -0
- package/dist/services/chat/chat-answer.js +88 -0
- package/dist/services/chat/chat-plan.d.ts +60 -0
- package/dist/services/chat/chat-plan.js +92 -0
- package/dist/services/chat/chat-retrieve.d.ts +36 -0
- package/dist/services/chat/chat-retrieve.js +82 -0
- package/dist/services/chat/chat-routes.d.ts +12 -0
- package/dist/services/chat/chat-routes.js +145 -0
- package/dist/services/chat/chat-runtime.d.ts +21 -0
- package/dist/services/chat/chat-runtime.js +104 -0
- package/dist/services/chat/chat-service.d.ts +42 -0
- package/dist/services/chat/chat-service.js +130 -0
- package/dist/services/chat/chat-status.d.ts +16 -0
- package/dist/services/chat/chat-status.js +23 -0
- package/dist/services/chat/chat-store.d.ts +60 -0
- package/dist/services/chat/chat-store.js +352 -0
- package/dist/services/chat/chat-types.d.ts +59 -0
- package/dist/services/chat/chat-types.js +8 -0
- package/dist/services/client.d.ts +1 -0
- package/dist/services/client.js +38 -5
- package/dist/services/context.d.ts +3 -1
- package/dist/services/context.js +4 -2
- package/dist/services/global-config-writer.js +61 -7
- package/dist/services/privacy.js +2 -2
- package/dist/services/request-timeouts.d.ts +5 -0
- package/dist/services/request-timeouts.js +8 -0
- package/dist/services/reranker-test.d.ts +1 -0
- package/dist/services/reranker-test.js +94 -0
- package/dist/services/reranker.d.ts +45 -0
- package/dist/services/reranker.js +133 -0
- package/dist/services/retrieval-rerank.d.ts +43 -0
- package/dist/services/retrieval-rerank.js +42 -0
- package/dist/services/settings-snapshot.js +26 -4
- package/dist/services/turso/exact-scan.d.ts +6 -0
- package/dist/services/turso/exact-scan.js +23 -0
- package/dist/services/turso/keyword-search.d.ts +35 -0
- package/dist/services/turso/keyword-search.js +153 -0
- package/dist/services/turso/rank-fusion.d.ts +34 -0
- package/dist/services/turso/rank-fusion.js +45 -0
- package/dist/services/turso/tag-match.d.ts +7 -0
- package/dist/services/turso/tag-match.js +29 -0
- package/dist/services/turso/types.d.ts +2 -0
- package/dist/services/turso/vector-search.d.ts +3 -3
- package/dist/services/turso/vector-search.js +23 -39
- package/dist/services/user-profile/profile-context.d.ts +6 -1
- package/dist/services/user-profile/profile-context.js +43 -7
- package/dist/services/user-profile/profile-relevance.d.ts +37 -0
- package/dist/services/user-profile/profile-relevance.js +75 -0
- package/dist/services/web-route-modules.d.ts +8 -0
- package/dist/services/web-route-modules.js +15 -0
- package/dist/services/web-server.js +8 -4
- package/dist/utils/keyword-boost.d.ts +17 -0
- package/dist/utils/keyword-boost.js +32 -0
- package/dist/utils/memory-limits.d.ts +7 -8
- package/dist/utils/memory-limits.js +10 -4
- package/dist/utils/reranker-settings.d.ts +13 -0
- package/dist/utils/reranker-settings.js +48 -0
- package/dist/utils/threshold-default.d.ts +12 -0
- package/dist/utils/threshold-default.js +22 -0
- package/dist/web/assets/index-DWk9C9Sm.css +2 -0
- package/dist/web/assets/index-rHcTKCkC.js +126 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-7385lY75.css +0 -2
- package/dist/web/assets/index-C9y78EH_.js +0 -127
|
@@ -2,9 +2,12 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { promises as fs, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
5
|
-
import {
|
|
5
|
+
import { getGlobalConfigSourcePath, getGlobalConfigWritePath, validateGlobalConfig, } from "../config.js";
|
|
6
|
+
import { CONFIG_TEMPLATE } from "../config-template.js";
|
|
6
7
|
import { isValidClaudeConfigDir } from "./claude-folder.js";
|
|
8
|
+
import { KEYWORD_MODES } from "../utils/keyword-boost.js";
|
|
7
9
|
import { getMemoryLimitRule } from "../utils/memory-limits.js";
|
|
10
|
+
import { RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS } from "./request-timeouts.js";
|
|
8
11
|
const keys = new Set([
|
|
9
12
|
"opencodeProvider",
|
|
10
13
|
"opencodeModel",
|
|
@@ -33,6 +36,13 @@ const keys = new Set([
|
|
|
33
36
|
"autoCaptureMaxContextBytes",
|
|
34
37
|
"userProfileMaxContextBytes",
|
|
35
38
|
"retrievalMaxTokens",
|
|
39
|
+
"similarityThreshold",
|
|
40
|
+
"keywordMode",
|
|
41
|
+
"rerankerEnabled",
|
|
42
|
+
"rerankerApiUrl",
|
|
43
|
+
"rerankerModel",
|
|
44
|
+
"rerankerApiKey",
|
|
45
|
+
"rerankerTimeoutMs",
|
|
36
46
|
]);
|
|
37
47
|
/**
|
|
38
48
|
* Written only by the embedding apply route, together, after a passing test.
|
|
@@ -58,8 +68,37 @@ const MEMORY_PROVIDERS = [
|
|
|
58
68
|
export function isSecretReference(value) {
|
|
59
69
|
return (typeof value === "string" && /^(env:\/\/[A-Za-z_][A-Za-z0-9_]*|file:\/\/.+)$/.test(value.trim()));
|
|
60
70
|
}
|
|
71
|
+
/** Reranker keys that a null edit removes, the same wire form as a limit reset. */
|
|
72
|
+
const OPTIONAL_RERANKER_KEYS = ["rerankerApiUrl", "rerankerModel", "rerankerApiKey"];
|
|
61
73
|
function isValidEdit(key, value) {
|
|
62
|
-
// `undefined` removes the key from the file.
|
|
74
|
+
// `undefined` removes the key from the file. `null` is the wire form of
|
|
75
|
+
// "reset to default", accepted for the six memory limits and the optional reranker keys.
|
|
76
|
+
const memoryLimit = getMemoryLimitRule(key);
|
|
77
|
+
if (memoryLimit && value === null)
|
|
78
|
+
return true;
|
|
79
|
+
if (OPTIONAL_RERANKER_KEYS.includes(key) && value === null)
|
|
80
|
+
return true;
|
|
81
|
+
// keywordMode: null removes the key, so the mode follows the default again.
|
|
82
|
+
if (key === "keywordMode")
|
|
83
|
+
return value === null || KEYWORD_MODES.includes(value);
|
|
84
|
+
// The five opt-in reranker keys (openspec retrieval-threshold-and-reranker).
|
|
85
|
+
if (key === "rerankerEnabled")
|
|
86
|
+
return typeof value === "boolean";
|
|
87
|
+
if (key === "rerankerApiUrl") {
|
|
88
|
+
return value === undefined || (typeof value === "string" && /^https?:\/\/\S+$/.test(value));
|
|
89
|
+
}
|
|
90
|
+
if (key === "rerankerModel") {
|
|
91
|
+
return value === undefined || (typeof value === "string" && value.trim().length > 0);
|
|
92
|
+
}
|
|
93
|
+
if (key === "rerankerApiKey") {
|
|
94
|
+
return value === undefined || isSecretReference(value);
|
|
95
|
+
}
|
|
96
|
+
if (key === "rerankerTimeoutMs") {
|
|
97
|
+
return (typeof value === "number" &&
|
|
98
|
+
Number.isSafeInteger(value) &&
|
|
99
|
+
value >= RERANKER_TIMEOUT_MIN_MS &&
|
|
100
|
+
value <= RERANKER_TIMEOUT_MAX_MS);
|
|
101
|
+
}
|
|
63
102
|
if (key === "embeddingApiUrl") {
|
|
64
103
|
return value === undefined || (typeof value === "string" && /^https?:\/\/\S+$/.test(value));
|
|
65
104
|
}
|
|
@@ -98,8 +137,8 @@ function isValidEdit(key, value) {
|
|
|
98
137
|
// Entries are checked by the startup validation below.
|
|
99
138
|
if (key === "importPathMaps" || key === "importIgnoredDirectories")
|
|
100
139
|
return Array.isArray(value);
|
|
101
|
-
// The five memory limits share one validation rule set
|
|
102
|
-
|
|
140
|
+
// The five memory limits share one validation rule set (six with
|
|
141
|
+
// similarityThreshold; null means reset, handled above).
|
|
103
142
|
if (memoryLimit)
|
|
104
143
|
return memoryLimit.isValid(value);
|
|
105
144
|
// An empty value clears the field.
|
|
@@ -147,11 +186,20 @@ options = {}) {
|
|
|
147
186
|
if (!isValidEdit(key, value)) {
|
|
148
187
|
// Never echo the value: a rejected memoryApiKey may be a literal key.
|
|
149
188
|
const memoryRule = getMemoryLimitRule(key);
|
|
150
|
-
|
|
189
|
+
const rerankerAccepted = {
|
|
190
|
+
rerankerEnabled: "boolean",
|
|
191
|
+
rerankerApiUrl: "an http:// or https:// URL",
|
|
192
|
+
rerankerModel: "a non-empty string",
|
|
193
|
+
rerankerTimeoutMs: `integers from ${RERANKER_TIMEOUT_MIN_MS} to ${RERANKER_TIMEOUT_MAX_MS}`,
|
|
194
|
+
keywordMode: KEYWORD_MODES.join(" or "),
|
|
195
|
+
};
|
|
196
|
+
throw new Error(["memoryApiKey", "embeddingApiKey", "webServerAuthPassword", "rerankerApiKey"].includes(key)
|
|
151
197
|
? `${key} must be an env:// or file:// reference`
|
|
152
198
|
: memoryRule
|
|
153
199
|
? `Invalid ${key} setting: accepted ${memoryRule.accepted}`
|
|
154
|
-
:
|
|
200
|
+
: rerankerAccepted[key]
|
|
201
|
+
? `Invalid ${key} setting: accepted ${rerankerAccepted[key]}`
|
|
202
|
+
: `Invalid ${key} setting`);
|
|
155
203
|
}
|
|
156
204
|
}
|
|
157
205
|
const wasQueued = pending > 0;
|
|
@@ -173,7 +221,13 @@ options = {}) {
|
|
|
173
221
|
// A nested limit identifier maps to its JSONC leaf path, so
|
|
174
222
|
// chatMessage.maxMemories writes the leaf, never a literal dotted key.
|
|
175
223
|
const jsoncPath = (getMemoryLimitRule(key)?.path ?? [key]);
|
|
176
|
-
|
|
224
|
+
// null is the wire form of "reset to default": remove the key so the
|
|
225
|
+
// value follows the default again (undefined removes in jsonc modify).
|
|
226
|
+
const jsoncValue = value === null &&
|
|
227
|
+
(getMemoryLimitRule(key) || OPTIONAL_RERANKER_KEYS.includes(key) || key === "keywordMode")
|
|
228
|
+
? undefined
|
|
229
|
+
: value;
|
|
230
|
+
content = applyEdits(content, modify(content, jsoncPath, jsoncValue, {
|
|
177
231
|
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
|
178
232
|
}));
|
|
179
233
|
}
|
package/dist/services/privacy.js
CHANGED
|
@@ -64,6 +64,6 @@ export function stripPrivateContent(content) {
|
|
|
64
64
|
* than storing a message that is private in its entirety.
|
|
65
65
|
*/
|
|
66
66
|
export function isFullyPrivate(content) {
|
|
67
|
-
|
|
68
|
-
return
|
|
67
|
+
// Several private regions leave several markers, so remove them all.
|
|
68
|
+
return stripPrivateContent(content).split(REDACTED).join("").trim() === "";
|
|
69
69
|
}
|
|
@@ -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,11 @@ const editable = [
|
|
|
29
29
|
"importIgnoredDirectories",
|
|
30
30
|
"claudeConfigDir",
|
|
31
31
|
"autoCaptureLanguage",
|
|
32
|
+
"rerankerEnabled",
|
|
33
|
+
"rerankerApiUrl",
|
|
34
|
+
"rerankerModel",
|
|
35
|
+
"rerankerTimeoutMs",
|
|
36
|
+
"keywordMode",
|
|
32
37
|
...MEMORY_LIMIT_SETTINGS,
|
|
33
38
|
];
|
|
34
39
|
/** Keys a project config cannot override, so the page always shows the global value. */
|
|
@@ -45,6 +50,11 @@ const globalOnly = [
|
|
|
45
50
|
"importPathMaps",
|
|
46
51
|
"importIgnoredDirectories",
|
|
47
52
|
"claudeConfigDir",
|
|
53
|
+
"rerankerEnabled",
|
|
54
|
+
"rerankerApiUrl",
|
|
55
|
+
"rerankerModel",
|
|
56
|
+
"rerankerApiKey",
|
|
57
|
+
"rerankerTimeoutMs",
|
|
48
58
|
];
|
|
49
59
|
function readSettingsFile(path) {
|
|
50
60
|
if (!path)
|
|
@@ -86,6 +96,8 @@ function nestedMemorySetting(rule, global, project) {
|
|
|
86
96
|
source: hasValue ? (projectHasParent ? "project" : "global") : "default",
|
|
87
97
|
globalValue: hasGlobal ? globalContainer[leaf] : rule.default,
|
|
88
98
|
default: rule.default,
|
|
99
|
+
// Whether the leaf exists in the global file: the Reset action needs it.
|
|
100
|
+
hasGlobal,
|
|
89
101
|
};
|
|
90
102
|
}
|
|
91
103
|
export function getSettingsSnapshot(directory) {
|
|
@@ -109,20 +121,30 @@ export function getSettingsSnapshot(directory) {
|
|
|
109
121
|
? "global"
|
|
110
122
|
: "default";
|
|
111
123
|
const value = projectOverrides ? project[key] : (global[key] ?? runtime[key]);
|
|
112
|
-
// The
|
|
124
|
+
// The six memory limits also carry their documented default for the UI;
|
|
125
|
+
// similarityThreshold's default follows the active embedder. hasGlobal
|
|
126
|
+
// tells the Reset action whether the key exists in the global file.
|
|
127
|
+
const ruleDefault = limitRule?.defaultFor?.(CONFIG.embeddingModel) ?? limitRule?.default;
|
|
113
128
|
return [
|
|
114
129
|
key,
|
|
115
130
|
limitRule
|
|
116
131
|
? {
|
|
117
132
|
value,
|
|
118
133
|
source,
|
|
119
|
-
globalValue: global[key] ??
|
|
120
|
-
default:
|
|
134
|
+
globalValue: global[key] ?? ruleDefault,
|
|
135
|
+
default: ruleDefault,
|
|
136
|
+
hasGlobal: Object.hasOwn(global, key),
|
|
121
137
|
}
|
|
122
138
|
: { value, source, globalValue: global[key] ?? runtime[key] },
|
|
123
139
|
];
|
|
124
140
|
}));
|
|
125
|
-
const secrets = Object.fromEntries([
|
|
141
|
+
const secrets = Object.fromEntries([
|
|
142
|
+
"memoryApiKey",
|
|
143
|
+
"embeddingApiKey",
|
|
144
|
+
"webServerApiToken",
|
|
145
|
+
"webServerAuthPassword",
|
|
146
|
+
"rerankerApiKey",
|
|
147
|
+
].map((key) => {
|
|
126
148
|
const raw = global[key];
|
|
127
149
|
const source = typeof raw === "string"
|
|
128
150
|
? raw.startsWith("env://")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TursoDb } from "./turso-db.js";
|
|
2
|
+
/** Exact (non-indexed) nearest-neighbour scan, used when `vector_top_k` fails. */
|
|
3
|
+
export declare function exactScanKind(db: TursoDb, queryJson: string, k: number, containerTag: string, columnName: string): Promise<Array<{
|
|
4
|
+
id: string;
|
|
5
|
+
similarity: number;
|
|
6
|
+
}>>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { distanceToSimilarity } from "./vector-utils.js";
|
|
2
|
+
/** Exact (non-indexed) nearest-neighbour scan, used when `vector_top_k` fails. */
|
|
3
|
+
export async function exactScanKind(db, queryJson, k, containerTag, columnName) {
|
|
4
|
+
const rows = await db.all(containerTag === ""
|
|
5
|
+
? `
|
|
6
|
+
SELECT m.id AS id, vector_distance_cos(m.${columnName}, vector32(?)) AS dist
|
|
7
|
+
FROM memories m
|
|
8
|
+
WHERE m.${columnName} IS NOT NULL
|
|
9
|
+
ORDER BY dist ASC
|
|
10
|
+
LIMIT ?
|
|
11
|
+
`
|
|
12
|
+
: `
|
|
13
|
+
SELECT m.id AS id, vector_distance_cos(m.${columnName}, vector32(?)) AS dist
|
|
14
|
+
FROM memories m
|
|
15
|
+
WHERE m.${columnName} IS NOT NULL AND m.container_tag = ?
|
|
16
|
+
ORDER BY dist ASC
|
|
17
|
+
LIMIT ?
|
|
18
|
+
`, containerTag === "" ? [queryJson, k] : [queryJson, containerTag, k]);
|
|
19
|
+
return rows.map((row) => ({
|
|
20
|
+
id: String(row.id),
|
|
21
|
+
similarity: distanceToSimilarity(Number(row.dist)),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { KeywordMode } from "../../utils/keyword-boost.js";
|
|
2
|
+
import type { TursoDb } from "./turso-db.js";
|
|
3
|
+
/**
|
|
4
|
+
* FTS5 query for the words of a prompt: lower-case terms from letters and
|
|
5
|
+
* digits only ("ADR-023" gives `adr` and `023`), each in quotes, joined by OR.
|
|
6
|
+
* Quoting and the character filter keep prompt text from becoming index
|
|
7
|
+
* syntax. Returns null when no usable term is left.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildKeywordQuery(queryText: string | undefined): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Similarity after the keyword boost: `base + keywordBoost × score ÷ best`,
|
|
12
|
+
* where `score` is the memory's match strength and `best` the strongest in the
|
|
13
|
+
* same search. The share is capped at 1 so the boost never passes
|
|
14
|
+
* `keywordBoost`, and the result never passes 1.
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyKeywordBoost(base: number, score: number, bestScore: number, keywordBoost: number): number;
|
|
17
|
+
/** Create or rebuild the keyword index of a shard. Idempotent. */
|
|
18
|
+
export declare function ensureKeywordIndex(db: TursoDb): Promise<void>;
|
|
19
|
+
export interface KeywordHits {
|
|
20
|
+
/** Match strength per memory id; higher is stronger. */
|
|
21
|
+
scores: Map<string, number>;
|
|
22
|
+
best: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Memories of one shard that contain a word of the prompt, strongest first.
|
|
26
|
+
* Returns null when there is nothing to match or the index fails: search then
|
|
27
|
+
* continues with vector and tag results. The log holds a code and an error
|
|
28
|
+
* name only, never the prompt or memory text.
|
|
29
|
+
*/
|
|
30
|
+
export declare function searchKeywords(db: TursoDb, queryText: string | undefined, containerTag: string, limit: number): Promise<KeywordHits | null>;
|
|
31
|
+
/**
|
|
32
|
+
* `base` with the boost for memory `id`; `base` itself when it has no keyword hit
|
|
33
|
+
* or the mode is `rrf` (rank fusion keeps each memory's own similarity).
|
|
34
|
+
*/
|
|
35
|
+
export declare function boostedSimilarity(base: number, hits: KeywordHits | null, id: string, keywordBoost: number, mode?: KeywordMode): number;
|