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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/adapters/opencode/user-prompt.js +6 -1
  2. package/dist/config.d.ts +9 -0
  3. package/dist/config.js +5 -44
  4. package/dist/core/retrieval.js +1 -1
  5. package/dist/services/chat/chat-answer.d.ts +56 -0
  6. package/dist/services/chat/chat-answer.js +88 -0
  7. package/dist/services/chat/chat-plan.d.ts +60 -0
  8. package/dist/services/chat/chat-plan.js +92 -0
  9. package/dist/services/chat/chat-retrieve.d.ts +36 -0
  10. package/dist/services/chat/chat-retrieve.js +82 -0
  11. package/dist/services/chat/chat-routes.d.ts +12 -0
  12. package/dist/services/chat/chat-routes.js +145 -0
  13. package/dist/services/chat/chat-runtime.d.ts +21 -0
  14. package/dist/services/chat/chat-runtime.js +104 -0
  15. package/dist/services/chat/chat-service.d.ts +42 -0
  16. package/dist/services/chat/chat-service.js +130 -0
  17. package/dist/services/chat/chat-status.d.ts +16 -0
  18. package/dist/services/chat/chat-status.js +23 -0
  19. package/dist/services/chat/chat-store.d.ts +60 -0
  20. package/dist/services/chat/chat-store.js +352 -0
  21. package/dist/services/chat/chat-types.d.ts +59 -0
  22. package/dist/services/chat/chat-types.js +8 -0
  23. package/dist/services/client.d.ts +1 -0
  24. package/dist/services/client.js +8 -3
  25. package/dist/services/context.d.ts +3 -1
  26. package/dist/services/context.js +4 -2
  27. package/dist/services/global-config-writer.js +8 -1
  28. package/dist/services/privacy.js +2 -2
  29. package/dist/services/settings-snapshot.js +1 -0
  30. package/dist/services/turso/exact-scan.d.ts +6 -0
  31. package/dist/services/turso/exact-scan.js +23 -0
  32. package/dist/services/turso/keyword-search.d.ts +35 -0
  33. package/dist/services/turso/keyword-search.js +153 -0
  34. package/dist/services/turso/rank-fusion.d.ts +34 -0
  35. package/dist/services/turso/rank-fusion.js +45 -0
  36. package/dist/services/turso/tag-match.d.ts +7 -0
  37. package/dist/services/turso/tag-match.js +29 -0
  38. package/dist/services/turso/types.d.ts +2 -0
  39. package/dist/services/turso/vector-search.d.ts +3 -3
  40. package/dist/services/turso/vector-search.js +23 -39
  41. package/dist/services/user-profile/profile-context.d.ts +6 -1
  42. package/dist/services/user-profile/profile-context.js +43 -7
  43. package/dist/services/user-profile/profile-relevance.d.ts +37 -0
  44. package/dist/services/user-profile/profile-relevance.js +75 -0
  45. package/dist/services/web-route-modules.d.ts +8 -0
  46. package/dist/services/web-route-modules.js +15 -0
  47. package/dist/services/web-server.js +5 -4
  48. package/dist/utils/keyword-boost.d.ts +17 -0
  49. package/dist/utils/keyword-boost.js +32 -0
  50. package/dist/utils/reranker-settings.d.ts +13 -0
  51. package/dist/utils/reranker-settings.js +48 -0
  52. package/dist/web/assets/index-CjfE2byA.js +126 -0
  53. package/dist/web/assets/index-SK0gAreR.css +2 -0
  54. package/dist/web/index.html +2 -2
  55. package/package.json +1 -1
  56. package/dist/web/assets/index-B_2GCxJJ.js +0 -127
  57. package/dist/web/assets/index-JAZ-QY-Z.css +0 -2
@@ -0,0 +1,352 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { tursoConnectionManager } from "../turso/connection-manager.js";
3
+ import { ChatStoreError, } from "./chat-types.js";
4
+ /** Bump when the tables change, and add the step to `migrate`. */
5
+ export const CHAT_SCHEMA_VERSION = 1;
6
+ export const FOLDER_NAME_MAX = 80;
7
+ export const BULK_DELETE_MAX = 500;
8
+ // The file is copied between computers by hand (ADR-032), so it keeps the
9
+ // default rollback journal: committed data lives in chats.db alone.
10
+ const SCHEMA = [
11
+ `CREATE TABLE IF NOT EXISTS chat_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
12
+ `CREATE TABLE IF NOT EXISTS chat_folders (
13
+ id TEXT PRIMARY KEY,
14
+ name TEXT NOT NULL,
15
+ name_key TEXT NOT NULL UNIQUE,
16
+ created_at INTEGER NOT NULL,
17
+ updated_at INTEGER NOT NULL
18
+ )`,
19
+ `CREATE TABLE IF NOT EXISTS chat_conversations (
20
+ id TEXT PRIMARY KEY,
21
+ folder_id TEXT REFERENCES chat_folders(id) ON DELETE CASCADE,
22
+ title TEXT NOT NULL,
23
+ scope_tag TEXT,
24
+ created_at INTEGER NOT NULL,
25
+ updated_at INTEGER NOT NULL
26
+ )`,
27
+ `CREATE TABLE IF NOT EXISTS chat_messages (
28
+ id TEXT PRIMARY KEY,
29
+ conversation_id TEXT NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE,
30
+ role TEXT NOT NULL,
31
+ content TEXT NOT NULL,
32
+ sources_json TEXT,
33
+ plan_json TEXT,
34
+ model TEXT,
35
+ error_code TEXT,
36
+ notice TEXT,
37
+ created_at INTEGER NOT NULL
38
+ )`,
39
+ `CREATE INDEX IF NOT EXISTS chat_messages_conversation ON chat_messages(conversation_id, created_at)`,
40
+ `CREATE INDEX IF NOT EXISTS chat_conversations_folder ON chat_conversations(folder_id)`,
41
+ ];
42
+ // Only these fields are written, so a source never carries a folder path or an email.
43
+ const SOURCE_FIELDS = [
44
+ "label",
45
+ "kind",
46
+ "id",
47
+ "text",
48
+ "projectName",
49
+ "projectTag",
50
+ "createdAt",
51
+ "tags",
52
+ "score",
53
+ "section",
54
+ "category",
55
+ "evidence",
56
+ ];
57
+ function snapshot(source) {
58
+ const out = {};
59
+ for (const field of SOURCE_FIELDS) {
60
+ if (source[field] !== undefined)
61
+ out[field] = source[field];
62
+ }
63
+ return out;
64
+ }
65
+ function parseJson(value, fallback) {
66
+ if (typeof value !== "string" || !value)
67
+ return fallback;
68
+ try {
69
+ return JSON.parse(value);
70
+ }
71
+ catch {
72
+ return fallback;
73
+ }
74
+ }
75
+ function toConversation(row) {
76
+ return {
77
+ id: String(row.id),
78
+ folderId: row.folder_id == null ? null : String(row.folder_id),
79
+ title: String(row.title),
80
+ scopeTag: row.scope_tag == null ? null : String(row.scope_tag),
81
+ createdAt: Number(row.created_at),
82
+ updatedAt: Number(row.updated_at),
83
+ };
84
+ }
85
+ function toMessage(row) {
86
+ return {
87
+ id: String(row.id),
88
+ conversationId: String(row.conversation_id),
89
+ role: String(row.role),
90
+ content: String(row.content),
91
+ sources: parseJson(row.sources_json, []),
92
+ plan: parseJson(row.plan_json, null),
93
+ model: row.model == null ? null : String(row.model),
94
+ errorCode: row.error_code == null ? null : String(row.error_code),
95
+ notice: row.notice == null ? null : String(row.notice),
96
+ createdAt: Number(row.created_at),
97
+ };
98
+ }
99
+ function folderName(name) {
100
+ const trimmed = name.trim();
101
+ if (!trimmed || trimmed.length > FOLDER_NAME_MAX) {
102
+ throw new ChatStoreError(`Folder names have 1 to ${FOLDER_NAME_MAX} characters`, "invalid");
103
+ }
104
+ return { name: trimmed, key: trimmed.toLowerCase() };
105
+ }
106
+ /**
107
+ * Conversations, messages, and folders in `chats.db`. One instance per file;
108
+ * every write checks the schema version so a file from a newer OMMS stays
109
+ * untouched.
110
+ */
111
+ export class ChatStore {
112
+ dbPath;
113
+ connect;
114
+ readOnly = false;
115
+ // Strictly increasing timestamps keep "newest first" stable within one millisecond.
116
+ lastStamp = 0;
117
+ constructor(dbPath, connect = (path) => tursoConnectionManager.getConnection(path)) {
118
+ this.dbPath = dbPath;
119
+ this.connect = connect;
120
+ }
121
+ db() {
122
+ return this.connect(this.dbPath);
123
+ }
124
+ now() {
125
+ this.lastStamp = Math.max(Date.now(), this.lastStamp + 1);
126
+ return this.lastStamp;
127
+ }
128
+ async init() {
129
+ const db = await this.db();
130
+ await db.execute(SCHEMA[0]);
131
+ const row = await db.get("SELECT value FROM chat_meta WHERE key = 'schema_version'");
132
+ const version = row ? Number(row.value) : 0;
133
+ if (version > CHAT_SCHEMA_VERSION) {
134
+ this.readOnly = true;
135
+ return;
136
+ }
137
+ this.readOnly = false;
138
+ await db.batch(SCHEMA.slice(1).map((sql) => ({ sql })));
139
+ if (version < CHAT_SCHEMA_VERSION) {
140
+ await db.run("INSERT INTO chat_meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [String(CHAT_SCHEMA_VERSION)]);
141
+ }
142
+ }
143
+ isReadOnly() {
144
+ return this.readOnly;
145
+ }
146
+ writable() {
147
+ if (this.readOnly) {
148
+ throw new ChatStoreError("chats.db is from a newer OMMS and is read-only", "read_only");
149
+ }
150
+ }
151
+ async listConversations() {
152
+ const db = await this.db();
153
+ const rows = await db.all("SELECT * FROM chat_conversations ORDER BY updated_at DESC, created_at DESC");
154
+ return rows.map(toConversation);
155
+ }
156
+ async getConversation(id) {
157
+ const db = await this.db();
158
+ const row = await db.get("SELECT * FROM chat_conversations WHERE id = ?", [id]);
159
+ if (!row)
160
+ return null;
161
+ const messages = await db.all("SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at, rowid", [id]);
162
+ return { conversation: toConversation(row), messages: messages.map(toMessage) };
163
+ }
164
+ async createConversation(input) {
165
+ this.writable();
166
+ const db = await this.db();
167
+ if (input.folderId)
168
+ await this.requireFolder(input.folderId);
169
+ const stamp = this.now();
170
+ const conversation = {
171
+ id: randomUUID(),
172
+ folderId: input.folderId ?? null,
173
+ title: input.title.trim() || "New chat",
174
+ scopeTag: input.scopeTag,
175
+ createdAt: stamp,
176
+ updatedAt: stamp,
177
+ };
178
+ await db.run("INSERT INTO chat_conversations (id, folder_id, title, scope_tag, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", [
179
+ conversation.id,
180
+ conversation.folderId,
181
+ conversation.title,
182
+ conversation.scopeTag,
183
+ stamp,
184
+ stamp,
185
+ ]);
186
+ return conversation;
187
+ }
188
+ async updateConversation(id, set, args) {
189
+ this.writable();
190
+ const db = await this.db();
191
+ const changed = await db.run(`UPDATE chat_conversations SET ${set}, updated_at = ? WHERE id = ?`, [...args, this.now(), id]);
192
+ if (changed === 0)
193
+ throw new ChatStoreError("Conversation not found", "not_found");
194
+ }
195
+ async renameConversation(id, title) {
196
+ const trimmed = title.trim();
197
+ if (!trimmed)
198
+ throw new ChatStoreError("A title is required", "invalid");
199
+ await this.updateConversation(id, "title = ?", [trimmed.slice(0, 200)]);
200
+ }
201
+ async setConversationScope(id, scopeTag) {
202
+ await this.updateConversation(id, "scope_tag = ?", [scopeTag]);
203
+ }
204
+ async moveConversation(id, folderId) {
205
+ if (folderId)
206
+ await this.requireFolder(folderId);
207
+ await this.updateConversation(id, "folder_id = ?", [folderId]);
208
+ }
209
+ async deleteConversation(id) {
210
+ this.writable();
211
+ const db = await this.db();
212
+ const changed = await db.run("DELETE FROM chat_conversations WHERE id = ?", [id]);
213
+ if (changed === 0)
214
+ throw new ChatStoreError("Conversation not found", "not_found");
215
+ }
216
+ /** Deletes every id or none: an unknown id fails the whole request. */
217
+ async bulkDeleteConversations(ids) {
218
+ this.writable();
219
+ const unique = [...new Set(ids)];
220
+ if (unique.length === 0 || unique.length > BULK_DELETE_MAX) {
221
+ throw new ChatStoreError(`Select 1 to ${BULK_DELETE_MAX} conversations`, "invalid");
222
+ }
223
+ const db = await this.db();
224
+ const marks = unique.map(() => "?").join(",");
225
+ return db.transaction("write", async (tx) => {
226
+ const found = await tx.execute({
227
+ sql: `SELECT COUNT(*) AS n FROM chat_conversations WHERE id IN (${marks})`,
228
+ args: unique,
229
+ });
230
+ if (Number(found.rows[0]?.n) !== unique.length) {
231
+ throw new ChatStoreError("Conversation not found", "not_found");
232
+ }
233
+ const result = await tx.execute({
234
+ sql: `DELETE FROM chat_conversations WHERE id IN (${marks})`,
235
+ args: unique,
236
+ });
237
+ return Number(result.rowsAffected);
238
+ });
239
+ }
240
+ async appendMessage(conversationId, input) {
241
+ this.writable();
242
+ const db = await this.db();
243
+ const stamp = this.now();
244
+ const sources = (input.sources ?? []).map(snapshot);
245
+ const message = {
246
+ id: randomUUID(),
247
+ conversationId,
248
+ role: input.role,
249
+ content: input.content,
250
+ sources,
251
+ plan: input.plan ?? null,
252
+ model: input.model ?? null,
253
+ errorCode: input.errorCode ?? null,
254
+ notice: input.notice ?? null,
255
+ createdAt: stamp,
256
+ };
257
+ await db.batch([
258
+ {
259
+ sql: "INSERT INTO chat_messages (id, conversation_id, role, content, sources_json, plan_json, model, error_code, notice, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
260
+ args: [
261
+ message.id,
262
+ conversationId,
263
+ message.role,
264
+ message.content,
265
+ sources.length ? JSON.stringify(sources) : null,
266
+ message.plan ? JSON.stringify(message.plan) : null,
267
+ message.model,
268
+ message.errorCode,
269
+ message.notice,
270
+ stamp,
271
+ ],
272
+ },
273
+ {
274
+ sql: "UPDATE chat_conversations SET updated_at = ? WHERE id = ?",
275
+ args: [stamp, conversationId],
276
+ },
277
+ ]);
278
+ return message;
279
+ }
280
+ async deleteMessage(id) {
281
+ this.writable();
282
+ const db = await this.db();
283
+ await db.run("DELETE FROM chat_messages WHERE id = ?", [id]);
284
+ }
285
+ async countMessages() {
286
+ const db = await this.db();
287
+ const row = await db.get("SELECT COUNT(*) AS n FROM chat_messages");
288
+ return Number(row?.n ?? 0);
289
+ }
290
+ async listFolders() {
291
+ const db = await this.db();
292
+ const rows = await db.all(`SELECT f.*, (SELECT COUNT(*) FROM chat_conversations c WHERE c.folder_id = f.id) AS n
293
+ FROM chat_folders f ORDER BY f.name_key`);
294
+ return rows.map((row) => ({
295
+ id: String(row.id),
296
+ name: String(row.name),
297
+ createdAt: Number(row.created_at),
298
+ updatedAt: Number(row.updated_at),
299
+ conversationCount: Number(row.n),
300
+ }));
301
+ }
302
+ async requireFolder(id) {
303
+ const db = await this.db();
304
+ if (!(await db.get("SELECT id FROM chat_folders WHERE id = ?", [id]))) {
305
+ throw new ChatStoreError("Folder not found", "not_found");
306
+ }
307
+ }
308
+ async assertNameFree(key, exceptId) {
309
+ const db = await this.db();
310
+ const clash = await db.get("SELECT id FROM chat_folders WHERE name_key = ? AND id != ?", [
311
+ key,
312
+ exceptId ?? "",
313
+ ]);
314
+ if (clash)
315
+ throw new ChatStoreError("A folder with this name exists", "conflict");
316
+ }
317
+ async createFolder(name) {
318
+ this.writable();
319
+ const clean = folderName(name);
320
+ await this.assertNameFree(clean.key);
321
+ const db = await this.db();
322
+ const stamp = this.now();
323
+ const id = randomUUID();
324
+ await db.run("INSERT INTO chat_folders (id, name, name_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [id, clean.name, clean.key, stamp, stamp]);
325
+ return { id, name: clean.name, createdAt: stamp, updatedAt: stamp, conversationCount: 0 };
326
+ }
327
+ async renameFolder(id, name) {
328
+ this.writable();
329
+ const clean = folderName(name);
330
+ await this.assertNameFree(clean.key, id);
331
+ const db = await this.db();
332
+ const changed = await db.run("UPDATE chat_folders SET name = ?, name_key = ?, updated_at = ? WHERE id = ?", [clean.name, clean.key, this.now(), id]);
333
+ if (changed === 0)
334
+ throw new ChatStoreError("Folder not found", "not_found");
335
+ }
336
+ /** Deletes the folder and every conversation in it. Returns how many conversations went. */
337
+ async deleteFolder(id) {
338
+ this.writable();
339
+ const db = await this.db();
340
+ return db.transaction("write", async (tx) => {
341
+ const count = await tx.execute({
342
+ sql: "SELECT COUNT(*) AS n FROM chat_conversations WHERE folder_id = ?",
343
+ args: [id],
344
+ });
345
+ const result = await tx.execute({ sql: "DELETE FROM chat_folders WHERE id = ?", args: [id] });
346
+ if (Number(result.rowsAffected) === 0) {
347
+ throw new ChatStoreError("Folder not found", "not_found");
348
+ }
349
+ return Number(count.rows[0]?.n ?? 0);
350
+ });
351
+ }
352
+ }
@@ -0,0 +1,59 @@
1
+ /** Shared shapes for the web chat: stored conversations, sources, and the search plan. */
2
+ export type ChatRole = "user" | "assistant";
3
+ /** A source as it was sent to the model, saved with the answer so it still opens later. */
4
+ export interface ChatSource {
5
+ /** `S1`, `S2`, … for memories; `P1`, `P2`, … for profile items. */
6
+ label: string;
7
+ kind: "memory" | "profile";
8
+ id: string;
9
+ text: string;
10
+ projectName?: string;
11
+ projectTag?: string;
12
+ createdAt?: number;
13
+ tags?: string[];
14
+ score?: number;
15
+ /** Profile sources only. */
16
+ section?: "preferences" | "patterns" | "workflows";
17
+ category?: string;
18
+ evidence?: string[];
19
+ }
20
+ export interface ChatPlanRecord {
21
+ queries: string[];
22
+ tags: string[];
23
+ profileCategories: string[];
24
+ /** True when the plan call failed or returned nothing usable. */
25
+ skipped: boolean;
26
+ }
27
+ /** Shown with an assistant message that is not a normal cited answer. */
28
+ export type ChatNotice = "no_sources" | "insufficient" | "uncited";
29
+ export interface ChatMessage {
30
+ id: string;
31
+ conversationId: string;
32
+ role: ChatRole;
33
+ content: string;
34
+ sources: ChatSource[];
35
+ plan: ChatPlanRecord | null;
36
+ model: string | null;
37
+ errorCode: string | null;
38
+ notice: ChatNotice | null;
39
+ createdAt: number;
40
+ }
41
+ export interface ChatConversation {
42
+ id: string;
43
+ folderId: string | null;
44
+ title: string;
45
+ scopeTag: string | null;
46
+ createdAt: number;
47
+ updatedAt: number;
48
+ }
49
+ export interface ChatFolder {
50
+ id: string;
51
+ name: string;
52
+ createdAt: number;
53
+ updatedAt: number;
54
+ conversationCount: number;
55
+ }
56
+ export declare class ChatStoreError extends Error {
57
+ readonly code: "not_found" | "conflict" | "invalid" | "read_only";
58
+ constructor(message: string, code: "not_found" | "conflict" | "invalid" | "read_only");
59
+ }
@@ -0,0 +1,8 @@
1
+ /** Shared shapes for the web chat: stored conversations, sources, and the search plan. */
2
+ export class ChatStoreError extends Error {
3
+ code;
4
+ constructor(message, code) {
5
+ super(message);
6
+ this.code = code;
7
+ }
8
+ }
@@ -22,6 +22,7 @@ export declare class LocalMemoryClient {
22
22
  results: import("./turso/types.js").SearchResult[];
23
23
  total: number;
24
24
  timing: number;
25
+ queryVector: Float32Array<ArrayBufferLike>;
25
26
  error?: undefined;
26
27
  } | {
27
28
  success: false;
@@ -92,7 +92,10 @@ export class LocalMemoryClient {
92
92
  // embedding, and shard reads all await, and a config edit during them must
93
93
  // not change this search's limit or reranker target.
94
94
  const maxMemories = CONFIG.maxMemories;
95
+ const keywordBoost = CONFIG.keywordBoost;
95
96
  const reranker = rerankSettingsFrom(CONFIG);
97
+ // The reranker path below always uses the boost: it needs a full pool on the similarity scale.
98
+ const keywordMode = CONFIG.keywordMode;
96
99
  if (CONFIG.rerankerEnabled && !reranker) {
97
100
  // On but incomplete: search must keep working, so treat it as off.
98
101
  log("Reranker enabled without a URL or model; searching without it", {
@@ -105,7 +108,7 @@ export class LocalMemoryClient {
105
108
  const resolved = resolveScopeValue(scope, containerTag);
106
109
  const shards = (await Promise.all(resolved.map((ref) => tursoShardManager.getAllShards(ref.scope, ref.hash)))).flat();
107
110
  if (shards.length === 0) {
108
- return { success: true, results: [], total: 0, timing: 0 };
111
+ return { success: true, results: [], total: 0, timing: 0, queryVector };
109
112
  }
110
113
  let results;
111
114
  let warnings;
@@ -120,18 +123,20 @@ export class LocalMemoryClient {
120
123
  maxMemories,
121
124
  similarityThreshold: CONFIG.similarityThreshold,
122
125
  }, reranker, {
123
- search: (shardList, vector, tag, limit, threshold, queryText) => tursoVectorSearch.searchAcrossShards(shardList, vector, tag, limit, threshold, queryText),
126
+ search: (shardList, vector, tag, limit, threshold, queryText) => tursoVectorSearch.searchAcrossShards(shardList, vector, tag, limit, threshold, queryText, keywordBoost),
124
127
  log: (message, meta) => log(message, meta),
125
128
  }));
126
129
  }
127
130
  else {
128
- ({ results, warnings } = await tursoVectorSearch.searchAcrossShards(shards, queryVector, scope === "all-projects" ? "" : containerTag, maxMemories, CONFIG.similarityThreshold, query));
131
+ ({ results, warnings } = await tursoVectorSearch.searchAcrossShards(shards, queryVector, scope === "all-projects" ? "" : containerTag, maxMemories, CONFIG.similarityThreshold, query, keywordBoost, keywordMode));
129
132
  }
130
133
  return {
131
134
  success: true,
132
135
  results,
133
136
  total: results.length,
134
137
  timing: 0,
138
+ // The embedded prompt, so callers (the profile step) need not embed it again.
139
+ queryVector,
135
140
  ...(warnings.length > 0 ? { warnings } : {}),
136
141
  };
137
142
  }
@@ -15,5 +15,7 @@ interface MemoriesResponseMinimal {
15
15
  * order, and the first oversized memory is shortened with a visible marker.
16
16
  * Hosts emit several sections in one request by passing `maxBytes` residuals.
17
17
  */
18
- export declare function formatContextForPrompt(userId: string | null, projectMemories: MemoriesResponseMinimal, budget?: ContextBudgetOptions): Promise<string>;
18
+ export declare function formatContextForPrompt(userId: string | null, projectMemories: MemoriesResponseMinimal, budget?: ContextBudgetOptions,
19
+ /** The embedded prompt, when there is one. Lets the profile block follow the prompt. */
20
+ queryVector?: ArrayLike<number>): Promise<string>;
19
21
  export {};
@@ -13,10 +13,12 @@ const HEADER_TEXT = "The following block is reference context injected from the
13
13
  * order, and the first oversized memory is shortened with a visible marker.
14
14
  * Hosts emit several sections in one request by passing `maxBytes` residuals.
15
15
  */
16
- export async function formatContextForPrompt(userId, projectMemories, budget) {
16
+ export async function formatContextForPrompt(userId, projectMemories, budget,
17
+ /** The embedded prompt, when there is one. Lets the profile block follow the prompt. */
18
+ queryVector) {
17
19
  const blocks = [];
18
20
  if (CONFIG.injectProfile && userId) {
19
- const profileContext = await getUserProfileContext(userId);
21
+ const profileContext = await getUserProfileContext(userId, queryVector);
20
22
  if (profileContext) {
21
23
  blocks.push({
22
24
  prefix: "<user_profile>\n",
@@ -5,6 +5,7 @@ import { applyEdits, modify, parse } from "jsonc-parser";
5
5
  import { getGlobalConfigSourcePath, getGlobalConfigWritePath, validateGlobalConfig, } from "../config.js";
6
6
  import { CONFIG_TEMPLATE } from "../config-template.js";
7
7
  import { isValidClaudeConfigDir } from "./claude-folder.js";
8
+ import { KEYWORD_MODES } from "../utils/keyword-boost.js";
8
9
  import { getMemoryLimitRule } from "../utils/memory-limits.js";
9
10
  import { RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS } from "./request-timeouts.js";
10
11
  const keys = new Set([
@@ -36,6 +37,7 @@ const keys = new Set([
36
37
  "userProfileMaxContextBytes",
37
38
  "retrievalMaxTokens",
38
39
  "similarityThreshold",
40
+ "keywordMode",
39
41
  "rerankerEnabled",
40
42
  "rerankerApiUrl",
41
43
  "rerankerModel",
@@ -76,6 +78,9 @@ function isValidEdit(key, value) {
76
78
  return true;
77
79
  if (OPTIONAL_RERANKER_KEYS.includes(key) && value === null)
78
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);
79
84
  // The five opt-in reranker keys (openspec retrieval-threshold-and-reranker).
80
85
  if (key === "rerankerEnabled")
81
86
  return typeof value === "boolean";
@@ -186,6 +191,7 @@ options = {}) {
186
191
  rerankerApiUrl: "an http:// or https:// URL",
187
192
  rerankerModel: "a non-empty string",
188
193
  rerankerTimeoutMs: `integers from ${RERANKER_TIMEOUT_MIN_MS} to ${RERANKER_TIMEOUT_MAX_MS}`,
194
+ keywordMode: KEYWORD_MODES.join(" or "),
189
195
  };
190
196
  throw new Error(["memoryApiKey", "embeddingApiKey", "webServerAuthPassword", "rerankerApiKey"].includes(key)
191
197
  ? `${key} must be an env:// or file:// reference`
@@ -217,7 +223,8 @@ options = {}) {
217
223
  const jsoncPath = (getMemoryLimitRule(key)?.path ?? [key]);
218
224
  // null is the wire form of "reset to default": remove the key so the
219
225
  // value follows the default again (undefined removes in jsonc modify).
220
- const jsoncValue = value === null && (getMemoryLimitRule(key) || OPTIONAL_RERANKER_KEYS.includes(key))
226
+ const jsoncValue = value === null &&
227
+ (getMemoryLimitRule(key) || OPTIONAL_RERANKER_KEYS.includes(key) || key === "keywordMode")
221
228
  ? undefined
222
229
  : value;
223
230
  content = applyEdits(content, modify(content, jsoncPath, jsoncValue, {
@@ -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
- const stripped = stripPrivateContent(content).trim();
68
- return stripped === REDACTED || stripped === "";
67
+ // Several private regions leave several markers, so remove them all.
68
+ return stripPrivateContent(content).split(REDACTED).join("").trim() === "";
69
69
  }
@@ -33,6 +33,7 @@ const editable = [
33
33
  "rerankerApiUrl",
34
34
  "rerankerModel",
35
35
  "rerankerTimeoutMs",
36
+ "keywordMode",
36
37
  ...MEMORY_LIMIT_SETTINGS,
37
38
  ];
38
39
  /** Keys a project config cannot override, so the page always shows the global value. */
@@ -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;