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.
- package/dist/adapters/opencode/user-prompt.js +6 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +5 -44
- 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 +8 -3
- package/dist/services/context.d.ts +3 -1
- package/dist/services/context.js +4 -2
- package/dist/services/global-config-writer.js +8 -1
- package/dist/services/privacy.js +2 -2
- package/dist/services/settings-snapshot.js +1 -0
- 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 +5 -4
- package/dist/utils/keyword-boost.d.ts +17 -0
- package/dist/utils/keyword-boost.js +32 -0
- package/dist/utils/reranker-settings.d.ts +13 -0
- package/dist/utils/reranker-settings.js +48 -0
- package/dist/web/assets/index-CjfE2byA.js +126 -0
- package/dist/web/assets/index-SK0gAreR.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B_2GCxJJ.js +0 -127
- 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
|
+
}
|
package/dist/services/client.js
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/services/context.js
CHANGED
|
@@ -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 &&
|
|
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, {
|
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
|
}
|
|
@@ -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;
|