recallx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +205 -0
  2. package/app/cli/bin/recallx-mcp.js +2 -0
  3. package/app/cli/bin/recallx.js +8 -0
  4. package/app/cli/src/cli.js +808 -0
  5. package/app/cli/src/format.js +242 -0
  6. package/app/cli/src/http.js +35 -0
  7. package/app/mcp/api-client.js +101 -0
  8. package/app/mcp/index.js +128 -0
  9. package/app/mcp/server.js +786 -0
  10. package/app/server/app.js +2263 -0
  11. package/app/server/config.js +27 -0
  12. package/app/server/db.js +399 -0
  13. package/app/server/errors.js +17 -0
  14. package/app/server/governance.js +466 -0
  15. package/app/server/index.js +26 -0
  16. package/app/server/inferred-relations.js +247 -0
  17. package/app/server/observability.js +495 -0
  18. package/app/server/project-graph.js +199 -0
  19. package/app/server/relation-scoring.js +59 -0
  20. package/app/server/repositories.js +2992 -0
  21. package/app/server/retrieval.js +486 -0
  22. package/app/server/semantic/chunker.js +85 -0
  23. package/app/server/semantic/provider.js +124 -0
  24. package/app/server/semantic/types.js +1 -0
  25. package/app/server/semantic/vector-store.js +169 -0
  26. package/app/server/utils.js +43 -0
  27. package/app/server/workspace-session.js +128 -0
  28. package/app/server/workspace.js +79 -0
  29. package/app/shared/contracts.js +268 -0
  30. package/app/shared/request-runtime.js +30 -0
  31. package/app/shared/types.js +1 -0
  32. package/app/shared/version.js +1 -0
  33. package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
  34. package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
  35. package/dist/renderer/assets/index-CrDu22h7.js +76 -0
  36. package/dist/renderer/index.html +13 -0
  37. package/package.json +49 -0
@@ -0,0 +1,2992 @@
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { getSqliteVecExtensionRuntime } from "./db.js";
4
+ import { AppError, assertPresent } from "./errors.js";
5
+ import { appendCurrentTelemetryDetails } from "./observability.js";
6
+ import { computeMaintainedScores } from "./relation-scoring.js";
7
+ import { buildSemanticChunks, buildSemanticDocumentText, normalizeTagList } from "./semantic/chunker.js";
8
+ import { embedSemanticQueryText, normalizeSemanticProviderConfig, resolveSemanticEmbeddingProvider } from "./semantic/provider.js";
9
+ import { createVectorIndexStore, VectorIndexStoreError } from "./semantic/vector-store.js";
10
+ import { checksumText, createId, isPathWithinRoot, nowIso, parseJson, stableSummary } from "./utils.js";
11
+ function normalizeArtifactPath(value) {
12
+ const withForwardSlashes = value.replace(/[\\/]+/g, "/");
13
+ const normalized = path.posix.normalize(withForwardSlashes);
14
+ return normalized === "." ? "" : normalized;
15
+ }
16
+ const SUMMARY_UPDATED_AT_KEY = "summaryUpdatedAt";
17
+ const SUMMARY_SOURCE_KEY = "summarySource";
18
+ const SEARCH_TAG_INDEX_VERSION = 1;
19
+ const SEARCH_ACTIVITY_FTS_VERSION = 1;
20
+ const SEMANTIC_INDEX_STATUS_VALUES = ["pending", "processing", "stale", "ready", "failed"];
21
+ const SEMANTIC_ISSUE_STATUS_VALUES = ["pending", "stale", "failed"];
22
+ const DEFAULT_SEMANTIC_CHUNK_AGGREGATION = "max";
23
+ const SEMANTIC_TOP_K_CHUNK_COUNT = 2;
24
+ const SEMANTIC_CONFIGURATION_CHANGED_REASON = "embedding.configuration_changed";
25
+ const SEMANTIC_CONFIGURATION_SWEEP_LIMIT = 100;
26
+ const SEMANTIC_PENDING_TRANSITION_KEYS_SETTING = "search.semantic.configuration.pendingKeys";
27
+ const SEARCH_FEEDBACK_WINDOW_PADDING = 20;
28
+ const SEARCH_FEEDBACK_MAX_WINDOW = 100;
29
+ const ACTIVITY_RESULT_CAP_PER_TARGET = 2;
30
+ const WORKSPACE_CAPTURE_INBOX_KEY = "workspace.capture.inboxNodeId";
31
+ const SEARCH_FALLBACK_TOKEN_LIMIT = 5;
32
+ const workspaceInboxSource = {
33
+ actorType: "system",
34
+ actorLabel: "RecallX",
35
+ toolName: "recallx-system"
36
+ };
37
+ function normalizeSearchText(value) {
38
+ return (value ?? "").normalize("NFKC").toLowerCase();
39
+ }
40
+ function tokenizeSearchQuery(query, maxTokens = 12) {
41
+ const matches = normalizeSearchText(query).match(/[\p{L}\p{N}]{2,}/gu) ?? [];
42
+ return Array.from(new Set(matches)).slice(0, maxTokens);
43
+ }
44
+ function createSearchFieldMatcher(query) {
45
+ const trimmedQuery = normalizeSearchText(query).trim();
46
+ if (!trimmedQuery) {
47
+ return null;
48
+ }
49
+ const tokens = tokenizeSearchQuery(trimmedQuery);
50
+ return {
51
+ trimmedQuery,
52
+ matchTerms: tokens.length ? tokens : [trimmedQuery]
53
+ };
54
+ }
55
+ function collectMatchedFields(matcher, candidates) {
56
+ if (!matcher) {
57
+ return [];
58
+ }
59
+ const matches = new Set();
60
+ for (const candidate of candidates) {
61
+ const haystack = normalizeSearchText(candidate.value);
62
+ if (!haystack) {
63
+ continue;
64
+ }
65
+ if (haystack.includes(matcher.trimmedQuery) || matcher.matchTerms.some((term) => haystack.includes(term))) {
66
+ matches.add(candidate.field);
67
+ }
68
+ }
69
+ return [...matches];
70
+ }
71
+ function buildSearchMatchReason(strategy, matchedFields) {
72
+ return {
73
+ strategy,
74
+ matchedFields
75
+ };
76
+ }
77
+ function mergeMatchReasons(left, right, strategy) {
78
+ return {
79
+ strategy,
80
+ matchedFields: Array.from(new Set([...(left?.matchedFields ?? []), ...(right?.matchedFields ?? [])]))
81
+ };
82
+ }
83
+ function computeWorkspaceRankBonus(index, total) {
84
+ if (total <= 0) {
85
+ return 0;
86
+ }
87
+ return Math.max(0, Math.round(((total - index) / total) * 24));
88
+ }
89
+ function computeWorkspaceSmartScore(input) {
90
+ return (computeWorkspaceRankBonus(input.index, input.total) +
91
+ computeWorkspaceRecencyBonusFromAge(input.nowMs - new Date(input.timestamp).getTime(), input.resultType) +
92
+ (input.resultType === "activity" ? 4 : 0) -
93
+ (input.contested ? 20 : 0));
94
+ }
95
+ function computeWorkspaceRecencyBonusFromAge(ageMs, resultType) {
96
+ if (ageMs <= 60 * 60 * 1000)
97
+ return resultType === "activity" ? 16 : 12;
98
+ if (ageMs <= 24 * 60 * 60 * 1000)
99
+ return resultType === "activity" ? 12 : 8;
100
+ if (ageMs <= 7 * 24 * 60 * 60 * 1000)
101
+ return resultType === "activity" ? 7 : 5;
102
+ if (ageMs <= 30 * 24 * 60 * 60 * 1000)
103
+ return resultType === "activity" ? 3 : 2;
104
+ return 0;
105
+ }
106
+ function clampSearchFeedbackDelta(value) {
107
+ return Math.min(Math.max(value, -2), 2);
108
+ }
109
+ function computeSearchFeedbackDelta(verdict, confidence) {
110
+ switch (verdict) {
111
+ case "useful":
112
+ return confidence;
113
+ case "not_useful":
114
+ return -confidence;
115
+ default:
116
+ return 0;
117
+ }
118
+ }
119
+ function clampConfidence(value) {
120
+ return Math.min(Math.max(value, 0), 1);
121
+ }
122
+ function normalizeTagValue(tag) {
123
+ return tag.trim().toLowerCase().replace(/\s+/g, " ");
124
+ }
125
+ function readBooleanSetting(settings, key, fallback) {
126
+ return typeof settings[key] === "boolean" ? Boolean(settings[key]) : fallback;
127
+ }
128
+ function readStringSetting(settings, key) {
129
+ return typeof settings[key] === "string" ? String(settings[key]) : null;
130
+ }
131
+ function readNumberSetting(settings, key, fallback) {
132
+ const value = settings[key];
133
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
134
+ }
135
+ function normalizeSemanticIndexBackend(value) {
136
+ return value === "sqlite-vec" ? "sqlite-vec" : "sqlite";
137
+ }
138
+ function resolveActiveSemanticIndexBackend(configuredBackend, sqliteVecLoaded) {
139
+ if (configuredBackend === "sqlite-vec" && sqliteVecLoaded) {
140
+ return "sqlite-vec";
141
+ }
142
+ return "sqlite";
143
+ }
144
+ function resolveSemanticExtensionStatus(configuredBackend, sqliteVecLoaded) {
145
+ if (configuredBackend !== "sqlite-vec") {
146
+ return "disabled";
147
+ }
148
+ return sqliteVecLoaded ? "loaded" : "fallback";
149
+ }
150
+ function resolveSemanticEmbeddingSignature(input) {
151
+ const normalized = normalizeSemanticProviderConfig(input);
152
+ const provider = resolveSemanticEmbeddingProvider(normalized);
153
+ return {
154
+ provider: provider?.provider ?? normalized.provider,
155
+ model: provider?.model ?? normalized.model,
156
+ version: provider?.version ?? null
157
+ };
158
+ }
159
+ function readSemanticIndexSettingSnapshot(settings, runtime) {
160
+ const signature = resolveSemanticEmbeddingSignature({
161
+ provider: readStringSetting(settings, "search.semantic.provider"),
162
+ model: readStringSetting(settings, "search.semantic.model")
163
+ });
164
+ const configuredIndexBackend = normalizeSemanticIndexBackend(settings["search.semantic.indexBackend"]);
165
+ return {
166
+ enabled: readBooleanSetting(settings, "search.semantic.enabled", false),
167
+ provider: signature.provider,
168
+ model: signature.model,
169
+ version: signature.version,
170
+ configuredIndexBackend,
171
+ indexBackend: resolveActiveSemanticIndexBackend(configuredIndexBackend, runtime.sqliteVecLoaded),
172
+ extensionStatus: resolveSemanticExtensionStatus(configuredIndexBackend, runtime.sqliteVecLoaded),
173
+ extensionLoadError: configuredIndexBackend === "sqlite-vec" && !runtime.sqliteVecLoaded ? runtime.sqliteVecLoadError : null,
174
+ chunkEnabled: readBooleanSetting(settings, "search.semantic.chunk.enabled", false)
175
+ };
176
+ }
177
+ function shouldReindexForSemanticConfigChange(previous, next) {
178
+ return (previous.enabled !== next.enabled ||
179
+ previous.chunkEnabled !== next.chunkEnabled ||
180
+ previous.provider !== next.provider ||
181
+ previous.model !== next.model ||
182
+ previous.version !== next.version);
183
+ }
184
+ function buildSemanticContentHash(input) {
185
+ return checksumText(JSON.stringify({
186
+ title: input.title ?? "",
187
+ body: input.body ?? "",
188
+ summary: input.summary ?? "",
189
+ tags: normalizeTagList(input.tags)
190
+ }));
191
+ }
192
+ function semanticIssueStatusRank(status) {
193
+ switch (status) {
194
+ case "failed":
195
+ return 0;
196
+ case "stale":
197
+ return 1;
198
+ default:
199
+ return 2;
200
+ }
201
+ }
202
+ function encodeSemanticIssueCursor(cursor) {
203
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
204
+ }
205
+ function decodeSemanticIssueCursor(cursor) {
206
+ if (!cursor) {
207
+ return null;
208
+ }
209
+ try {
210
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
211
+ if (typeof parsed.statusRank !== "number" ||
212
+ !Number.isFinite(parsed.statusRank) ||
213
+ typeof parsed.updatedAt !== "string" ||
214
+ !parsed.updatedAt ||
215
+ typeof parsed.nodeId !== "string" ||
216
+ !parsed.nodeId) {
217
+ return null;
218
+ }
219
+ return {
220
+ statusRank: parsed.statusRank,
221
+ updatedAt: parsed.updatedAt,
222
+ nodeId: parsed.nodeId
223
+ };
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ function normalizeSemanticChunkAggregation(value) {
230
+ return value === "topk_mean" ? "topk_mean" : DEFAULT_SEMANTIC_CHUNK_AGGREGATION;
231
+ }
232
+ function aggregateChunkSimilarities(similarities, aggregation) {
233
+ if (!similarities.length) {
234
+ return 0;
235
+ }
236
+ if (aggregation === "topk_mean") {
237
+ const topK = [...similarities].sort((left, right) => right - left).slice(0, SEMANTIC_TOP_K_CHUNK_COUNT);
238
+ return topK.reduce((sum, value) => sum + value, 0) / topK.length;
239
+ }
240
+ return Math.max(...similarities);
241
+ }
242
+ function updateSemanticSimilarityAccumulator(accumulator, similarity, aggregation) {
243
+ accumulator.matchedChunks += 1;
244
+ accumulator.maxSimilarity = Math.max(accumulator.maxSimilarity, similarity);
245
+ if (aggregation !== "topk_mean") {
246
+ return;
247
+ }
248
+ accumulator.topSimilarities.push(similarity);
249
+ accumulator.topSimilarities.sort((left, right) => right - left);
250
+ if (accumulator.topSimilarities.length > SEMANTIC_TOP_K_CHUNK_COUNT) {
251
+ accumulator.topSimilarities.length = SEMANTIC_TOP_K_CHUNK_COUNT;
252
+ }
253
+ }
254
+ function normalizeSemanticBonusSimilarity(similarity, minSimilarity) {
255
+ if (!Number.isFinite(similarity) || similarity < minSimilarity || minSimilarity >= 1) {
256
+ return 0;
257
+ }
258
+ return Math.min(1, Math.max(0, similarity - minSimilarity) / (1 - minSimilarity));
259
+ }
260
+ function computeSemanticRetrievalRank(similarity, settings) {
261
+ const normalizedSimilarity = normalizeSemanticBonusSimilarity(similarity, settings.minSimilarity);
262
+ return Number((normalizedSimilarity * settings.maxBonus).toFixed(4));
263
+ }
264
+ function bucketSemanticQueryLength(length) {
265
+ if (length <= 12) {
266
+ return "short";
267
+ }
268
+ if (length <= 32) {
269
+ return "medium";
270
+ }
271
+ return "long";
272
+ }
273
+ function mapNode(row) {
274
+ return {
275
+ id: String(row.id),
276
+ type: row.type,
277
+ status: row.status,
278
+ canonicality: row.canonicality,
279
+ visibility: String(row.visibility),
280
+ title: row.title ? String(row.title) : null,
281
+ body: row.body ? String(row.body) : null,
282
+ summary: row.summary ? String(row.summary) : null,
283
+ createdBy: row.created_by ? String(row.created_by) : null,
284
+ sourceType: row.source_type ? String(row.source_type) : null,
285
+ sourceLabel: row.source_label ? String(row.source_label) : null,
286
+ createdAt: String(row.created_at),
287
+ updatedAt: String(row.updated_at),
288
+ tags: parseJson(row.tags_json, []),
289
+ metadata: parseJson(row.metadata_json, {})
290
+ };
291
+ }
292
+ function mapRelation(row) {
293
+ return {
294
+ id: String(row.id),
295
+ fromNodeId: String(row.from_node_id),
296
+ toNodeId: String(row.to_node_id),
297
+ relationType: row.relation_type,
298
+ status: row.status,
299
+ createdBy: row.created_by ? String(row.created_by) : null,
300
+ sourceType: row.source_type ? String(row.source_type) : null,
301
+ sourceLabel: row.source_label ? String(row.source_label) : null,
302
+ createdAt: String(row.created_at),
303
+ metadata: parseJson(row.metadata_json, {})
304
+ };
305
+ }
306
+ function mapActivity(row) {
307
+ return {
308
+ id: String(row.id),
309
+ targetNodeId: String(row.target_node_id),
310
+ activityType: row.activity_type,
311
+ body: row.body ? String(row.body) : null,
312
+ createdBy: row.created_by ? String(row.created_by) : null,
313
+ sourceType: row.source_type ? String(row.source_type) : null,
314
+ sourceLabel: row.source_label ? String(row.source_label) : null,
315
+ createdAt: String(row.created_at),
316
+ metadata: parseJson(row.metadata_json, {})
317
+ };
318
+ }
319
+ function mapInferredRelation(row) {
320
+ return {
321
+ id: String(row.id),
322
+ fromNodeId: String(row.from_node_id),
323
+ toNodeId: String(row.to_node_id),
324
+ relationType: row.relation_type,
325
+ baseScore: Number(row.base_score),
326
+ usageScore: Number(row.usage_score),
327
+ finalScore: Number(row.final_score),
328
+ status: row.status,
329
+ generator: String(row.generator),
330
+ evidence: parseJson(row.evidence_json, {}),
331
+ lastComputedAt: String(row.last_computed_at),
332
+ expiresAt: row.expires_at ? String(row.expires_at) : null,
333
+ metadata: parseJson(row.metadata_json, {})
334
+ };
335
+ }
336
+ function mapRelationUsageEvent(row) {
337
+ return {
338
+ id: String(row.id),
339
+ relationId: String(row.relation_id),
340
+ relationSource: row.relation_source,
341
+ eventType: row.event_type,
342
+ sessionId: row.session_id ? String(row.session_id) : null,
343
+ runId: row.run_id ? String(row.run_id) : null,
344
+ actorType: row.actor_type ? String(row.actor_type) : null,
345
+ actorLabel: row.actor_label ? String(row.actor_label) : null,
346
+ toolName: row.tool_name ? String(row.tool_name) : null,
347
+ delta: Number(row.delta),
348
+ createdAt: String(row.created_at),
349
+ metadata: parseJson(row.metadata_json, {})
350
+ };
351
+ }
352
+ function mapSearchFeedbackEvent(row) {
353
+ return {
354
+ id: String(row.id),
355
+ resultType: String(row.result_type),
356
+ resultId: String(row.result_id),
357
+ verdict: String(row.verdict),
358
+ query: row.query ? String(row.query) : null,
359
+ sessionId: row.session_id ? String(row.session_id) : null,
360
+ runId: row.run_id ? String(row.run_id) : null,
361
+ actorType: row.actor_type ? String(row.actor_type) : null,
362
+ actorLabel: row.actor_label ? String(row.actor_label) : null,
363
+ toolName: row.tool_name ? String(row.tool_name) : null,
364
+ confidence: Number(row.confidence),
365
+ delta: Number(row.delta),
366
+ createdAt: String(row.created_at),
367
+ metadata: parseJson(row.metadata_json, {})
368
+ };
369
+ }
370
+ function mapGovernanceEvent(row) {
371
+ return {
372
+ id: String(row.id),
373
+ entityType: row.entity_type,
374
+ entityId: String(row.entity_id),
375
+ eventType: row.event_type,
376
+ previousState: row.previous_state ? row.previous_state : null,
377
+ nextState: row.next_state,
378
+ confidence: Number(row.confidence),
379
+ reason: String(row.reason),
380
+ createdAt: String(row.created_at),
381
+ metadata: parseJson(row.metadata_json, {})
382
+ };
383
+ }
384
+ function mapGovernanceState(row) {
385
+ return {
386
+ entityType: row.entity_type,
387
+ entityId: String(row.entity_id),
388
+ state: row.state,
389
+ confidence: Number(row.confidence),
390
+ reasons: parseJson(row.reasons_json, []),
391
+ lastEvaluatedAt: String(row.last_evaluated_at),
392
+ lastTransitionAt: String(row.last_transition_at),
393
+ metadata: parseJson(row.metadata_json, {})
394
+ };
395
+ }
396
+ function mapArtifact(row) {
397
+ return {
398
+ id: String(row.id),
399
+ nodeId: String(row.node_id),
400
+ path: String(row.path),
401
+ mimeType: row.mime_type ? String(row.mime_type) : null,
402
+ sizeBytes: row.size_bytes ? Number(row.size_bytes) : null,
403
+ checksum: row.checksum ? String(row.checksum) : null,
404
+ createdBy: row.created_by ? String(row.created_by) : null,
405
+ sourceLabel: row.source_label ? String(row.source_label) : null,
406
+ createdAt: String(row.created_at),
407
+ metadata: parseJson(row.metadata_json, {})
408
+ };
409
+ }
410
+ function mapProvenance(row) {
411
+ return {
412
+ id: String(row.id),
413
+ entityType: String(row.entity_type),
414
+ entityId: String(row.entity_id),
415
+ operationType: String(row.operation_type),
416
+ actorType: String(row.actor_type),
417
+ actorLabel: row.actor_label ? String(row.actor_label) : null,
418
+ toolName: row.tool_name ? String(row.tool_name) : null,
419
+ toolVersion: row.tool_version ? String(row.tool_version) : null,
420
+ timestamp: String(row.timestamp),
421
+ inputRef: row.input_ref ? String(row.input_ref) : null,
422
+ metadata: parseJson(row.metadata_json, {})
423
+ };
424
+ }
425
+ function mapLegacyReviewQueue(row) {
426
+ return {
427
+ id: String(row.id),
428
+ entityType: String(row.entity_type),
429
+ entityId: String(row.entity_id),
430
+ reviewType: String(row.review_type),
431
+ proposedBy: row.proposed_by ? String(row.proposed_by) : null,
432
+ createdAt: String(row.created_at),
433
+ status: String(row.status),
434
+ notes: row.notes ? String(row.notes) : null,
435
+ metadata: parseJson(row.metadata_json, {})
436
+ };
437
+ }
438
+ function withSummaryMetadata(metadata, summaryUpdatedAt, summarySource) {
439
+ return {
440
+ ...metadata,
441
+ [SUMMARY_UPDATED_AT_KEY]: summaryUpdatedAt,
442
+ [SUMMARY_SOURCE_KEY]: summarySource
443
+ };
444
+ }
445
+ function mapIntegration(row) {
446
+ return {
447
+ id: String(row.id),
448
+ name: String(row.name),
449
+ kind: String(row.kind),
450
+ status: String(row.status),
451
+ capabilities: parseJson(row.capabilities_json, []),
452
+ config: parseJson(row.config_json, {}),
453
+ createdAt: String(row.created_at),
454
+ updatedAt: String(row.updated_at)
455
+ };
456
+ }
457
+ const RELATION_USAGE_ROLLUP_STATE_ID = "bootstrap";
458
+ export class RecallXRepository {
459
+ db;
460
+ workspaceRoot;
461
+ workspaceKey;
462
+ sqliteVectorIndexStore;
463
+ sqliteVecVectorIndexStore;
464
+ sqliteVecRuntime;
465
+ constructor(db, workspaceRoot) {
466
+ this.db = db;
467
+ this.workspaceRoot = workspaceRoot;
468
+ this.workspaceKey = checksumText(path.resolve(workspaceRoot));
469
+ this.sqliteVecRuntime = getSqliteVecExtensionRuntime(db);
470
+ this.sqliteVectorIndexStore = createVectorIndexStore(db, {
471
+ backend: "sqlite",
472
+ workspaceKey: this.workspaceKey
473
+ });
474
+ this.sqliteVecVectorIndexStore = createVectorIndexStore(db, {
475
+ backend: "sqlite-vec",
476
+ workspaceKey: this.workspaceKey
477
+ });
478
+ }
479
+ resolveVectorIndexStore(backend) {
480
+ return backend === "sqlite-vec" ? this.sqliteVecVectorIndexStore : this.sqliteVectorIndexStore;
481
+ }
482
+ runInTransaction(operation) {
483
+ this.db.exec("BEGIN IMMEDIATE");
484
+ try {
485
+ const result = operation();
486
+ this.db.exec("COMMIT");
487
+ return result;
488
+ }
489
+ catch (error) {
490
+ try {
491
+ this.db.exec("ROLLBACK");
492
+ }
493
+ catch {
494
+ // ignore rollback failures and rethrow the original error
495
+ }
496
+ throw error;
497
+ }
498
+ }
499
+ ensureRelationUsageRollupState(updatedAt = nowIso()) {
500
+ this.db
501
+ .prepare(`INSERT OR IGNORE INTO relation_usage_rollup_state (id, last_event_rowid, updated_at)
502
+ VALUES (?, 0, ?)`)
503
+ .run(RELATION_USAGE_ROLLUP_STATE_ID, updatedAt);
504
+ }
505
+ getRelationUsageRollupWatermark() {
506
+ this.ensureRelationUsageRollupState();
507
+ const row = this.db
508
+ .prepare(`SELECT last_event_rowid FROM relation_usage_rollup_state WHERE id = ?`)
509
+ .get(RELATION_USAGE_ROLLUP_STATE_ID);
510
+ return Number(row?.last_event_rowid ?? 0);
511
+ }
512
+ syncRelationUsageRollups() {
513
+ const lastEventRowid = this.getRelationUsageRollupWatermark();
514
+ const maxRowidRow = this.db
515
+ .prepare(`SELECT COALESCE(MAX(rowid), 0) AS max_rowid FROM relation_usage_events`)
516
+ .get();
517
+ const maxRowid = Number(maxRowidRow.max_rowid ?? 0);
518
+ if (maxRowid <= lastEventRowid) {
519
+ return;
520
+ }
521
+ const updatedAt = nowIso();
522
+ this.runInTransaction(() => {
523
+ this.db
524
+ .prepare(`INSERT INTO relation_usage_rollups (
525
+ relation_id, total_delta, event_count, last_event_at, last_event_rowid, updated_at
526
+ )
527
+ SELECT
528
+ relation_id,
529
+ COALESCE(SUM(delta), 0) AS total_delta,
530
+ COUNT(*) AS event_count,
531
+ MAX(created_at) AS last_event_at,
532
+ MAX(rowid) AS last_event_rowid,
533
+ ? AS updated_at
534
+ FROM relation_usage_events
535
+ WHERE rowid > ?
536
+ GROUP BY relation_id
537
+ ON CONFLICT(relation_id) DO UPDATE SET
538
+ total_delta = total_delta + excluded.total_delta,
539
+ event_count = event_count + excluded.event_count,
540
+ last_event_at = CASE
541
+ WHEN excluded.last_event_at > last_event_at THEN excluded.last_event_at
542
+ ELSE last_event_at
543
+ END,
544
+ last_event_rowid = CASE
545
+ WHEN excluded.last_event_rowid > last_event_rowid THEN excluded.last_event_rowid
546
+ ELSE last_event_rowid
547
+ END,
548
+ updated_at = excluded.updated_at`)
549
+ .run(updatedAt, lastEventRowid);
550
+ this.db
551
+ .prepare(`UPDATE relation_usage_rollup_state
552
+ SET last_event_rowid = ?, updated_at = ?
553
+ WHERE id = ?`)
554
+ .run(maxRowid, updatedAt, RELATION_USAGE_ROLLUP_STATE_ID);
555
+ });
556
+ }
557
+ touchNode(id) {
558
+ this.db.prepare(`UPDATE nodes SET updated_at = ? WHERE id = ?`).run(nowIso(), id);
559
+ }
560
+ upsertSemanticIndexState(params) {
561
+ const updatedAt = params.updatedAt ?? nowIso();
562
+ this.db
563
+ .prepare(`INSERT INTO node_index_state (
564
+ node_id, content_hash, embedding_status, embedding_provider, embedding_model, embedding_version, stale_reason, updated_at
565
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
566
+ ON CONFLICT(node_id) DO UPDATE SET
567
+ content_hash = excluded.content_hash,
568
+ embedding_status = excluded.embedding_status,
569
+ embedding_provider = excluded.embedding_provider,
570
+ embedding_model = excluded.embedding_model,
571
+ embedding_version = excluded.embedding_version,
572
+ stale_reason = excluded.stale_reason,
573
+ updated_at = excluded.updated_at`)
574
+ .run(params.nodeId, params.contentHash ?? null, params.status, params.embeddingProvider ?? null, params.embeddingModel ?? null, params.embeddingVersion ?? null, params.staleReason ?? null, updatedAt);
575
+ }
576
+ markNodeSemanticIndexState(nodeId, reason, input = {}) {
577
+ this.upsertSemanticIndexState({
578
+ nodeId,
579
+ status: input.status ?? "pending",
580
+ staleReason: reason,
581
+ contentHash: input.contentHash,
582
+ updatedAt: input.updatedAt
583
+ });
584
+ }
585
+ syncNodeTags(nodeId, tags) {
586
+ const normalizedTags = normalizeTagList(tags);
587
+ this.db.prepare(`DELETE FROM node_tags WHERE node_id = ?`).run(nodeId);
588
+ if (!normalizedTags.length) {
589
+ return;
590
+ }
591
+ const insertStatement = this.db.prepare(`INSERT INTO node_tags (node_id, tag) VALUES (?, ?)`);
592
+ for (const tag of normalizedTags) {
593
+ insertStatement.run(nodeId, tag);
594
+ }
595
+ }
596
+ readSemanticIndexSettings() {
597
+ const settings = this.getSettings([
598
+ "search.semantic.enabled",
599
+ "search.semantic.provider",
600
+ "search.semantic.model",
601
+ "search.semantic.indexBackend",
602
+ "search.semantic.chunk.enabled",
603
+ "search.semantic.chunk.aggregation",
604
+ "search.semantic.workspaceFallback.enabled"
605
+ ]);
606
+ return {
607
+ ...readSemanticIndexSettingSnapshot(settings, {
608
+ sqliteVecLoaded: this.sqliteVecRuntime.isLoaded,
609
+ sqliteVecLoadError: this.sqliteVecRuntime.loadError
610
+ }),
611
+ chunkAggregation: normalizeSemanticChunkAggregation(settings["search.semantic.chunk.aggregation"]),
612
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false)
613
+ };
614
+ }
615
+ getSemanticAugmentationSettings() {
616
+ const settings = this.getSettings([
617
+ "search.semantic.augmentation.minSimilarity",
618
+ "search.semantic.augmentation.maxBonus"
619
+ ]);
620
+ return {
621
+ minSimilarity: Math.min(Math.max(readNumberSetting(settings, "search.semantic.augmentation.minSimilarity", 0.2), 0), 1),
622
+ maxBonus: Math.max(readNumberSetting(settings, "search.semantic.augmentation.maxBonus", 18), 0)
623
+ };
624
+ }
625
+ markSemanticConfigurationMismatchesStale(limit = SEMANTIC_CONFIGURATION_SWEEP_LIMIT) {
626
+ const settings = this.readSemanticIndexSettings();
627
+ const rows = this.db
628
+ .prepare(`SELECT nis.node_id
629
+ FROM node_index_state nis
630
+ JOIN nodes n ON n.id = nis.node_id
631
+ WHERE n.status IN ('active', 'draft')
632
+ AND nis.embedding_status = 'ready'
633
+ AND (
634
+ nis.embedding_provider IS NOT ?
635
+ OR nis.embedding_model IS NOT ?
636
+ OR nis.embedding_version IS NOT ?
637
+ )
638
+ ORDER BY nis.updated_at ASC
639
+ LIMIT ?`)
640
+ .all(settings.provider, settings.model, settings.version, limit);
641
+ if (!rows.length) {
642
+ return 0;
643
+ }
644
+ const updatedAt = nowIso();
645
+ const updateStatement = this.db.prepare(`UPDATE node_index_state
646
+ SET embedding_status = 'stale', stale_reason = ?, updated_at = ?
647
+ WHERE node_id = ? AND embedding_status = 'ready'`);
648
+ for (const row of rows) {
649
+ updateStatement.run(SEMANTIC_CONFIGURATION_CHANGED_REASON, updatedAt, String(row.node_id));
650
+ }
651
+ return rows.length;
652
+ }
653
+ queueSemanticConfigurationReindex(reason = SEMANTIC_CONFIGURATION_CHANGED_REASON) {
654
+ const nodeIds = this.listSemanticIndexTargetNodeIds();
655
+ const updatedAt = nowIso();
656
+ this.queueSemanticReindexForNodeIds(nodeIds, reason, updatedAt);
657
+ this.writeSetting("search.semantic.last_backfill_at", updatedAt);
658
+ }
659
+ readPendingSemanticTransitionKeys() {
660
+ const value = this.getSettings([SEMANTIC_PENDING_TRANSITION_KEYS_SETTING])[SEMANTIC_PENDING_TRANSITION_KEYS_SETTING];
661
+ if (!Array.isArray(value)) {
662
+ return [];
663
+ }
664
+ return value
665
+ .filter((item) => typeof item === "string")
666
+ .filter((item) => item === "search.semantic.provider" || item === "search.semantic.model");
667
+ }
668
+ writePendingSemanticTransitionKeys(keys) {
669
+ this.writeSetting(SEMANTIC_PENDING_TRANSITION_KEYS_SETTING, keys);
670
+ }
671
+ updateSemanticSetting(key, value) {
672
+ const previousSettings = this.readSemanticIndexSettings();
673
+ this.writeSetting(key, value);
674
+ const nextSettings = this.readSemanticIndexSettings();
675
+ if (!shouldReindexForSemanticConfigChange(previousSettings, nextSettings)) {
676
+ if (key === "search.semantic.provider" || key === "search.semantic.model") {
677
+ const pendingKeys = new Set(this.readPendingSemanticTransitionKeys());
678
+ pendingKeys.delete(key);
679
+ this.writePendingSemanticTransitionKeys([...pendingKeys]);
680
+ }
681
+ return;
682
+ }
683
+ if (key === "search.semantic.provider" || key === "search.semantic.model") {
684
+ const pendingKeys = new Set(this.readPendingSemanticTransitionKeys());
685
+ pendingKeys.add(key);
686
+ if (!pendingKeys.has("search.semantic.provider") || !pendingKeys.has("search.semantic.model")) {
687
+ this.writePendingSemanticTransitionKeys([...pendingKeys]);
688
+ return;
689
+ }
690
+ this.writePendingSemanticTransitionKeys([]);
691
+ }
692
+ this.queueSemanticConfigurationReindex();
693
+ }
694
+ listPendingSemanticIndexRows(limit = 25) {
695
+ const rows = this.db
696
+ .prepare(`SELECT node_id, content_hash, embedding_status, stale_reason, updated_at
697
+ FROM node_index_state
698
+ WHERE embedding_status IN ('pending', 'stale')
699
+ ORDER BY updated_at ASC
700
+ LIMIT ?`)
701
+ .all(limit);
702
+ return rows.map((row) => ({
703
+ nodeId: String(row.node_id),
704
+ contentHash: row.content_hash ? String(row.content_hash) : null,
705
+ embeddingStatus: String(row.embedding_status),
706
+ staleReason: row.stale_reason ? String(row.stale_reason) : null,
707
+ updatedAt: String(row.updated_at)
708
+ }));
709
+ }
710
+ replaceSemanticChunks(nodeId, chunks, updatedAt) {
711
+ this.db.prepare(`DELETE FROM node_chunks WHERE node_id = ?`).run(nodeId);
712
+ if (!chunks.length) {
713
+ return;
714
+ }
715
+ const insertStatement = this.db.prepare(`INSERT INTO node_chunks (
716
+ node_id, ordinal, chunk_hash, chunk_text, token_count, start_offset, end_offset, updated_at
717
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
718
+ for (const chunk of chunks) {
719
+ insertStatement.run(nodeId, chunk.ordinal, chunk.chunkHash, chunk.chunkText, chunk.tokenCount, chunk.startOffset, chunk.endOffset, updatedAt);
720
+ }
721
+ }
722
+ replaceSemanticEmbeddings(nodeId, params) {
723
+ this.db.prepare(`DELETE FROM node_embeddings WHERE owner_type = 'node' AND owner_id = ?`).run(nodeId);
724
+ if (!params.rows.length) {
725
+ return;
726
+ }
727
+ const insertStatement = this.db.prepare(`INSERT INTO node_embeddings (
728
+ owner_type, owner_id, chunk_ordinal, vector_ref, vector_blob, embedding_provider, embedding_model, embedding_version,
729
+ content_hash, status, created_at, updated_at
730
+ ) VALUES ('node', ?, ?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?)`);
731
+ for (const row of params.rows) {
732
+ insertStatement.run(nodeId, row.chunkOrdinal, row.vectorRef, row.vectorBlob, params.provider, params.model, params.version, params.contentHash, params.updatedAt, params.updatedAt);
733
+ }
734
+ }
735
+ async syncSemanticDelete(vectorIndexStore, nodeId, finishedAt, params) {
736
+ await vectorIndexStore.deleteNode(nodeId);
737
+ this.runInTransaction(() => {
738
+ if (params.clearChunks) {
739
+ this.db.prepare(`DELETE FROM node_chunks WHERE node_id = ?`).run(nodeId);
740
+ }
741
+ this.db.prepare(`DELETE FROM node_embeddings WHERE owner_type = 'node' AND owner_id = ?`).run(nodeId);
742
+ this.upsertSemanticIndexState({
743
+ nodeId,
744
+ status: params.status,
745
+ staleReason: params.staleReason,
746
+ contentHash: params.contentHash,
747
+ embeddingProvider: params.embeddingProvider,
748
+ embeddingModel: params.embeddingModel,
749
+ embeddingVersion: params.embeddingVersion,
750
+ updatedAt: finishedAt
751
+ });
752
+ });
753
+ }
754
+ async processPendingSemanticIndex(limit = 25) {
755
+ const settings = this.readSemanticIndexSettings();
756
+ if (this.readPendingSemanticTransitionKeys().length) {
757
+ return {
758
+ processedNodeIds: [],
759
+ processedCount: 0,
760
+ readyCount: 0,
761
+ failedCount: 0,
762
+ remainingCount: this.listPendingSemanticIndexRows(limit).length,
763
+ mode: !settings.enabled || settings.provider === "disabled" || settings.model === "none" ? "chunk-only" : "provider-required"
764
+ };
765
+ }
766
+ this.markSemanticConfigurationMismatchesStale(limit);
767
+ const vectorIndexStore = this.resolveVectorIndexStore(settings.indexBackend);
768
+ const provider = resolveSemanticEmbeddingProvider({
769
+ provider: settings.provider,
770
+ model: settings.model
771
+ });
772
+ const pendingRows = this.listPendingSemanticIndexRows(limit);
773
+ const processedNodeIds = [];
774
+ const readyNodeIds = [];
775
+ const failedNodeIds = [];
776
+ for (const row of pendingRows) {
777
+ const startedAt = nowIso();
778
+ this.upsertSemanticIndexState({
779
+ nodeId: row.nodeId,
780
+ status: "processing",
781
+ staleReason: row.staleReason,
782
+ contentHash: row.contentHash,
783
+ embeddingProvider: settings.provider,
784
+ embeddingModel: settings.model,
785
+ updatedAt: startedAt
786
+ });
787
+ try {
788
+ const node = this.getNode(row.nodeId);
789
+ const contentHash = buildSemanticContentHash({
790
+ title: node.title,
791
+ body: node.body,
792
+ summary: node.summary,
793
+ tags: node.tags
794
+ });
795
+ const chunkText = buildSemanticDocumentText({
796
+ title: node.title,
797
+ summary: node.summary,
798
+ body: node.body,
799
+ tags: node.tags
800
+ });
801
+ const chunks = buildSemanticChunks(chunkText, settings.chunkEnabled);
802
+ const embeddingResults = provider && chunks.length
803
+ ? await provider.embedBatch(chunks.map((chunk) => ({
804
+ nodeId: node.id,
805
+ chunkOrdinal: chunk.ordinal,
806
+ contentHash,
807
+ text: chunk.chunkText
808
+ })))
809
+ : [];
810
+ const finishedAt = nowIso();
811
+ if (node.status === "archived") {
812
+ await this.syncSemanticDelete(vectorIndexStore, node.id, finishedAt, {
813
+ clearChunks: true,
814
+ contentHash,
815
+ embeddingProvider: settings.provider,
816
+ embeddingModel: settings.model,
817
+ status: "ready",
818
+ staleReason: null
819
+ });
820
+ readyNodeIds.push(node.id);
821
+ processedNodeIds.push(row.nodeId);
822
+ continue;
823
+ }
824
+ this.replaceSemanticChunks(node.id, chunks, finishedAt);
825
+ if (!settings.enabled || settings.provider === "disabled" || settings.model === "none" || !settings.provider || !settings.model) {
826
+ await this.syncSemanticDelete(vectorIndexStore, node.id, finishedAt, {
827
+ clearChunks: false,
828
+ contentHash,
829
+ embeddingProvider: settings.provider,
830
+ embeddingModel: settings.model,
831
+ status: "ready",
832
+ staleReason: null
833
+ });
834
+ readyNodeIds.push(node.id);
835
+ processedNodeIds.push(row.nodeId);
836
+ continue;
837
+ }
838
+ if (provider && embeddingResults.length === chunks.length) {
839
+ const ledgerRows = await vectorIndexStore.upsertNodeChunks({
840
+ nodeId: node.id,
841
+ chunks,
842
+ embeddings: embeddingResults,
843
+ contentHash,
844
+ embeddingProvider: provider.provider,
845
+ embeddingModel: provider.model ?? settings.model,
846
+ embeddingVersion: provider.version,
847
+ updatedAt: finishedAt
848
+ });
849
+ this.runInTransaction(() => {
850
+ this.replaceSemanticEmbeddings(node.id, {
851
+ provider: provider.provider,
852
+ model: provider.model ?? settings.model,
853
+ version: provider.version,
854
+ contentHash,
855
+ rows: ledgerRows,
856
+ updatedAt: finishedAt
857
+ });
858
+ this.upsertSemanticIndexState({
859
+ nodeId: node.id,
860
+ status: "ready",
861
+ staleReason: null,
862
+ contentHash,
863
+ embeddingProvider: provider.provider,
864
+ embeddingModel: provider.model ?? settings.model,
865
+ embeddingVersion: provider.version,
866
+ updatedAt: finishedAt
867
+ });
868
+ });
869
+ readyNodeIds.push(node.id);
870
+ processedNodeIds.push(row.nodeId);
871
+ continue;
872
+ }
873
+ await this.syncSemanticDelete(vectorIndexStore, node.id, finishedAt, {
874
+ clearChunks: false,
875
+ contentHash,
876
+ embeddingProvider: settings.provider,
877
+ embeddingModel: settings.model,
878
+ status: "failed",
879
+ staleReason: `embedding.provider_not_implemented:${settings.provider}`
880
+ });
881
+ failedNodeIds.push(node.id);
882
+ }
883
+ catch (error) {
884
+ const staleReason = error instanceof VectorIndexStoreError ? error.code : "embedding.node_not_found";
885
+ this.upsertSemanticIndexState({
886
+ nodeId: row.nodeId,
887
+ status: "failed",
888
+ staleReason,
889
+ contentHash: row.contentHash,
890
+ embeddingProvider: settings.provider,
891
+ embeddingModel: settings.model
892
+ });
893
+ failedNodeIds.push(row.nodeId);
894
+ }
895
+ processedNodeIds.push(row.nodeId);
896
+ }
897
+ return {
898
+ processedNodeIds,
899
+ processedCount: processedNodeIds.length,
900
+ readyCount: readyNodeIds.length,
901
+ failedCount: failedNodeIds.length,
902
+ remainingCount: this.listPendingSemanticIndexRows(limit).length,
903
+ mode: !settings.enabled || settings.provider === "disabled" || settings.model === "none" ? "chunk-only" : "provider-required"
904
+ };
905
+ }
906
+ ensureSearchTagIndex() {
907
+ const settings = this.getSettings(["search.tagIndex.version"]);
908
+ if (Number(settings["search.tagIndex.version"] ?? 0) >= SEARCH_TAG_INDEX_VERSION) {
909
+ return;
910
+ }
911
+ this.runInTransaction(() => {
912
+ this.db.prepare(`DELETE FROM node_tags`).run();
913
+ const rows = this.db
914
+ .prepare(`SELECT id, tags_json FROM nodes`)
915
+ .all();
916
+ const insertStatement = this.db.prepare(`INSERT INTO node_tags (node_id, tag) VALUES (?, ?)`);
917
+ for (const row of rows) {
918
+ const nodeId = String(row.id);
919
+ const tags = normalizeTagList(parseJson(row.tags_json, []));
920
+ for (const tag of tags) {
921
+ insertStatement.run(nodeId, tag);
922
+ }
923
+ }
924
+ this.setSetting("search.tagIndex.version", SEARCH_TAG_INDEX_VERSION);
925
+ });
926
+ }
927
+ ensureActivitySearchIndex() {
928
+ const settings = this.getSettings(["search.activityFts.version"]);
929
+ if (Number(settings["search.activityFts.version"] ?? 0) >= SEARCH_ACTIVITY_FTS_VERSION) {
930
+ return;
931
+ }
932
+ this.runInTransaction(() => {
933
+ this.db.prepare(`INSERT INTO activities_fts(activities_fts) VALUES ('delete-all')`).run();
934
+ const rows = this.db
935
+ .prepare(`SELECT rowid, id, body FROM activities`)
936
+ .all();
937
+ const insertStatement = this.db.prepare(`INSERT INTO activities_fts(rowid, id, body) VALUES (?, ?, ?)`);
938
+ for (const row of rows) {
939
+ insertStatement.run(Number(row.rowid), String(row.id), row.body ? String(row.body) : "");
940
+ }
941
+ this.setSetting("search.activityFts.version", SEARCH_ACTIVITY_FTS_VERSION);
942
+ });
943
+ }
944
+ listSemanticIndexTargetNodeIds(limit) {
945
+ const rows = (limit === undefined
946
+ ? this.db
947
+ .prepare(`SELECT id
948
+ FROM nodes
949
+ WHERE status IN ('active', 'draft')
950
+ ORDER BY updated_at DESC`)
951
+ .all()
952
+ : this.db
953
+ .prepare(`SELECT id
954
+ FROM nodes
955
+ WHERE status IN ('active', 'draft')
956
+ ORDER BY updated_at DESC
957
+ LIMIT ?`)
958
+ .all(limit));
959
+ return rows.map((row) => String(row.id));
960
+ }
961
+ queueSemanticReindexForNodeIds(nodeIds, reason, updatedAt = nowIso()) {
962
+ const nodesById = this.getNodesByIds(nodeIds);
963
+ for (const nodeId of nodeIds) {
964
+ const node = nodesById.get(nodeId);
965
+ if (!node) {
966
+ continue;
967
+ }
968
+ const contentHash = buildSemanticContentHash({
969
+ title: node.title,
970
+ body: node.body,
971
+ summary: node.summary,
972
+ tags: node.tags
973
+ });
974
+ this.markNodeSemanticIndexState(node.id, reason, {
975
+ status: "pending",
976
+ contentHash,
977
+ updatedAt
978
+ });
979
+ }
980
+ }
981
+ queueSemanticReindexForNode(nodeId, reason = "manual.reindex") {
982
+ const node = this.getNode(nodeId);
983
+ const contentHash = buildSemanticContentHash({
984
+ title: node.title,
985
+ body: node.body,
986
+ summary: node.summary,
987
+ tags: node.tags
988
+ });
989
+ this.markNodeSemanticIndexState(node.id, reason, {
990
+ status: "pending",
991
+ contentHash
992
+ });
993
+ return node;
994
+ }
995
+ queueSemanticReindex(limit = 250, reason = "manual.reindex") {
996
+ const nodeIds = this.listSemanticIndexTargetNodeIds(limit);
997
+ const updatedAt = nowIso();
998
+ this.queueSemanticReindexForNodeIds(nodeIds, reason, updatedAt);
999
+ this.setSetting("search.semantic.last_backfill_at", updatedAt);
1000
+ return {
1001
+ queuedNodeIds: nodeIds,
1002
+ queuedCount: nodeIds.length
1003
+ };
1004
+ }
1005
+ getSemanticStatus() {
1006
+ this.markSemanticConfigurationMismatchesStale();
1007
+ const settings = this.getSettings([
1008
+ "search.semantic.enabled",
1009
+ "search.semantic.provider",
1010
+ "search.semantic.model",
1011
+ "search.semantic.indexBackend",
1012
+ "search.semantic.chunk.enabled",
1013
+ "search.semantic.last_backfill_at"
1014
+ ]);
1015
+ const semanticSettings = readSemanticIndexSettingSnapshot(settings, {
1016
+ sqliteVecLoaded: this.sqliteVecRuntime.isLoaded,
1017
+ sqliteVecLoadError: this.sqliteVecRuntime.loadError
1018
+ });
1019
+ const { version: _version, ...semanticStatusSettings } = semanticSettings;
1020
+ const counts = Object.fromEntries(SEMANTIC_INDEX_STATUS_VALUES.map((status) => [status, 0]));
1021
+ const rows = this.db
1022
+ .prepare(`SELECT embedding_status, COUNT(*) AS total
1023
+ FROM node_index_state
1024
+ GROUP BY embedding_status`)
1025
+ .all();
1026
+ for (const row of rows) {
1027
+ const status = String(row.embedding_status);
1028
+ if (SEMANTIC_INDEX_STATUS_VALUES.includes(status)) {
1029
+ counts[status] = Number(row.total ?? 0);
1030
+ }
1031
+ }
1032
+ return {
1033
+ ...semanticStatusSettings,
1034
+ lastBackfillAt: readStringSetting(settings, "search.semantic.last_backfill_at"),
1035
+ counts
1036
+ };
1037
+ }
1038
+ listSemanticIssues(input = {}) {
1039
+ this.markSemanticConfigurationMismatchesStale();
1040
+ const limit = Math.min(Math.max(input.limit ?? 5, 1), 25);
1041
+ const normalizedStatuses = (input.statuses?.length ? input.statuses : [...SEMANTIC_ISSUE_STATUS_VALUES]).filter((status, index, values) => SEMANTIC_ISSUE_STATUS_VALUES.includes(status) && values.indexOf(status) === index);
1042
+ const statuses = normalizedStatuses.length ? normalizedStatuses : [...SEMANTIC_ISSUE_STATUS_VALUES];
1043
+ const cursor = decodeSemanticIssueCursor(input.cursor);
1044
+ const statusRankExpression = `CASE nis.embedding_status
1045
+ WHEN 'failed' THEN 0
1046
+ WHEN 'stale' THEN 1
1047
+ ELSE 2
1048
+ END`;
1049
+ const whereClauses = [`nis.embedding_status IN (${statuses.map(() => "?").join(", ")})`];
1050
+ const values = [...statuses];
1051
+ if (cursor) {
1052
+ whereClauses.push(`(
1053
+ ${statusRankExpression} > ?
1054
+ OR (${statusRankExpression} = ? AND nis.updated_at < ?)
1055
+ OR (${statusRankExpression} = ? AND nis.updated_at = ? AND nis.node_id < ?)
1056
+ )`);
1057
+ values.push(cursor.statusRank, cursor.statusRank, cursor.updatedAt, cursor.statusRank, cursor.updatedAt, cursor.nodeId);
1058
+ }
1059
+ const rows = this.db
1060
+ .prepare(`SELECT nis.node_id, n.title, nis.embedding_status, nis.stale_reason, nis.updated_at,
1061
+ ${statusRankExpression} AS status_rank
1062
+ FROM node_index_state nis
1063
+ JOIN nodes n ON n.id = nis.node_id
1064
+ WHERE ${whereClauses.join(" AND ")}
1065
+ ORDER BY
1066
+ status_rank ASC,
1067
+ nis.updated_at DESC,
1068
+ nis.node_id DESC
1069
+ LIMIT ?`)
1070
+ .all(...values, limit + 1);
1071
+ const items = rows.slice(0, limit).map((row) => ({
1072
+ nodeId: String(row.node_id),
1073
+ title: row.title ? String(row.title) : null,
1074
+ embeddingStatus: String(row.embedding_status),
1075
+ staleReason: row.stale_reason ? String(row.stale_reason) : null,
1076
+ updatedAt: String(row.updated_at)
1077
+ }));
1078
+ const hasMore = rows.length > limit;
1079
+ const lastItem = items.at(-1);
1080
+ return {
1081
+ items,
1082
+ nextCursor: hasMore && lastItem
1083
+ ? encodeSemanticIssueCursor({
1084
+ statusRank: semanticIssueStatusRank(lastItem.embeddingStatus),
1085
+ updatedAt: lastItem.updatedAt,
1086
+ nodeId: lastItem.nodeId
1087
+ })
1088
+ : null
1089
+ };
1090
+ }
1091
+ async rankSemanticCandidates(query, candidateNodeIds) {
1092
+ const normalizedQuery = query.trim();
1093
+ if (!normalizedQuery || !candidateNodeIds.length) {
1094
+ return new Map();
1095
+ }
1096
+ this.markSemanticConfigurationMismatchesStale();
1097
+ const settings = this.readSemanticIndexSettings();
1098
+ if (!settings.enabled || !settings.provider || !settings.model) {
1099
+ return new Map();
1100
+ }
1101
+ const queryEmbedding = await embedSemanticQueryText({
1102
+ provider: settings.provider,
1103
+ model: settings.model,
1104
+ text: normalizedQuery,
1105
+ });
1106
+ if (!queryEmbedding?.vector.length) {
1107
+ return new Map();
1108
+ }
1109
+ const similarityByNode = new Map();
1110
+ const matches = await this.resolveVectorIndexStore(settings.indexBackend).searchCandidates({
1111
+ queryVector: queryEmbedding.vector,
1112
+ candidateNodeIds,
1113
+ embeddingProvider: settings.provider,
1114
+ embeddingModel: settings.model,
1115
+ embeddingVersion: settings.version
1116
+ }).catch(() => []);
1117
+ for (const match of matches) {
1118
+ const accumulator = similarityByNode.get(match.nodeId) ?? {
1119
+ matchedChunks: 0,
1120
+ maxSimilarity: Number.NEGATIVE_INFINITY,
1121
+ topSimilarities: []
1122
+ };
1123
+ updateSemanticSimilarityAccumulator(accumulator, match.similarity, settings.chunkAggregation);
1124
+ similarityByNode.set(match.nodeId, accumulator);
1125
+ }
1126
+ const rankedMatches = new Map();
1127
+ for (const [nodeId, accumulator] of similarityByNode.entries()) {
1128
+ const similarities = settings.chunkAggregation === "topk_mean" ? accumulator.topSimilarities : [accumulator.maxSimilarity];
1129
+ rankedMatches.set(nodeId, {
1130
+ similarity: aggregateChunkSimilarities(similarities, settings.chunkAggregation),
1131
+ matchedChunks: accumulator.matchedChunks
1132
+ });
1133
+ }
1134
+ return rankedMatches;
1135
+ }
1136
+ listNodes(limit = 20) {
1137
+ const rows = this.db
1138
+ .prepare(`SELECT id, type, title, summary, status, canonicality, source_label, updated_at, tags_json
1139
+ FROM nodes
1140
+ ORDER BY updated_at DESC
1141
+ LIMIT ?`)
1142
+ .all(limit);
1143
+ return rows.map((row) => ({
1144
+ id: String(row.id),
1145
+ type: row.type,
1146
+ title: row.title ? String(row.title) : null,
1147
+ summary: row.summary ? String(row.summary) : null,
1148
+ status: row.status,
1149
+ canonicality: row.canonicality,
1150
+ sourceLabel: row.source_label ? String(row.source_label) : null,
1151
+ updatedAt: String(row.updated_at),
1152
+ tags: parseJson(row.tags_json, [])
1153
+ }));
1154
+ }
1155
+ listInferenceCandidateNodes(targetNodeId, limit = 200) {
1156
+ const rows = this.db
1157
+ .prepare(`SELECT * FROM nodes
1158
+ WHERE id != ?
1159
+ AND status = 'active'
1160
+ ORDER BY updated_at DESC
1161
+ LIMIT ?`)
1162
+ .all(targetNodeId, limit);
1163
+ return rows.map(mapNode);
1164
+ }
1165
+ listProjectMembershipIdsByNodeIds(nodeIds) {
1166
+ if (!nodeIds.length) {
1167
+ return new Map();
1168
+ }
1169
+ const uniqueIds = Array.from(new Set(nodeIds));
1170
+ const memberships = new Map(uniqueIds.map((nodeId) => [nodeId, new Set()]));
1171
+ const placeholders = uniqueIds.map(() => "?").join(", ");
1172
+ const projectRows = this.db
1173
+ .prepare(`SELECT id
1174
+ FROM nodes
1175
+ WHERE id IN (${placeholders})
1176
+ AND type = 'project'`)
1177
+ .all(...uniqueIds);
1178
+ for (const row of projectRows) {
1179
+ const nodeId = String(row.id);
1180
+ memberships.get(nodeId)?.add(nodeId);
1181
+ }
1182
+ const relationRows = this.db
1183
+ .prepare(`SELECT r.from_node_id AS node_id, r.to_node_id AS project_id
1184
+ FROM relations r
1185
+ JOIN nodes p ON p.id = r.to_node_id
1186
+ WHERE r.status = 'active'
1187
+ AND r.from_node_id IN (${placeholders})
1188
+ AND p.type = 'project'
1189
+ AND p.status = 'active'
1190
+ UNION
1191
+ SELECT r.to_node_id AS node_id, r.from_node_id AS project_id
1192
+ FROM relations r
1193
+ JOIN nodes p ON p.id = r.from_node_id
1194
+ WHERE r.status = 'active'
1195
+ AND r.to_node_id IN (${placeholders})
1196
+ AND p.type = 'project'
1197
+ AND p.status = 'active'`)
1198
+ .all(...uniqueIds, ...uniqueIds);
1199
+ for (const row of relationRows) {
1200
+ const nodeId = String(row.node_id);
1201
+ memberships.get(nodeId)?.add(String(row.project_id));
1202
+ }
1203
+ return new Map([...memberships.entries()].map(([nodeId, projectIds]) => [nodeId, Array.from(projectIds)]));
1204
+ }
1205
+ listArtifactKeysByNodeIds(nodeIds) {
1206
+ if (!nodeIds.length) {
1207
+ return new Map();
1208
+ }
1209
+ const uniqueIds = Array.from(new Set(nodeIds));
1210
+ const artifactsByNode = new Map(uniqueIds.map((nodeId) => [nodeId, { exactPaths: new Set(), baseNames: new Set() }]));
1211
+ const rows = this.db
1212
+ .prepare(`SELECT node_id, path
1213
+ FROM artifacts
1214
+ WHERE node_id IN (${uniqueIds.map(() => "?").join(", ")})
1215
+ ORDER BY created_at DESC`)
1216
+ .all(...uniqueIds);
1217
+ for (const row of rows) {
1218
+ const nodeId = String(row.node_id);
1219
+ const pathValue = normalizeSearchText(row.path ? String(row.path) : null);
1220
+ if (!pathValue) {
1221
+ continue;
1222
+ }
1223
+ const bucket = artifactsByNode.get(nodeId);
1224
+ if (!bucket) {
1225
+ continue;
1226
+ }
1227
+ bucket.exactPaths.add(pathValue);
1228
+ bucket.baseNames.add(normalizeSearchText(path.basename(pathValue)));
1229
+ }
1230
+ return new Map([...artifactsByNode.entries()].map(([nodeId, values]) => [
1231
+ nodeId,
1232
+ {
1233
+ exactPaths: Array.from(values.exactPaths),
1234
+ baseNames: Array.from(values.baseNames)
1235
+ }
1236
+ ]));
1237
+ }
1238
+ listSharedProjectMemberNodeIds(targetNodeId, limit = 200) {
1239
+ const projectIds = this.listProjectMembershipIdsByNodeIds([targetNodeId]).get(targetNodeId) ?? [];
1240
+ if (!projectIds.length) {
1241
+ return [];
1242
+ }
1243
+ const placeholders = projectIds.map(() => "?").join(", ");
1244
+ const rows = this.db
1245
+ .prepare(`SELECT node_id
1246
+ FROM (
1247
+ SELECT
1248
+ CASE
1249
+ WHEN r.from_node_id IN (${placeholders}) THEN r.to_node_id
1250
+ ELSE r.from_node_id
1251
+ END AS node_id,
1252
+ MAX(r.created_at) AS last_related_at
1253
+ FROM relations r
1254
+ JOIN nodes n
1255
+ ON n.id = CASE
1256
+ WHEN r.from_node_id IN (${placeholders}) THEN r.to_node_id
1257
+ ELSE r.from_node_id
1258
+ END
1259
+ WHERE r.status = 'active'
1260
+ AND (
1261
+ r.from_node_id IN (${placeholders})
1262
+ OR r.to_node_id IN (${placeholders})
1263
+ )
1264
+ AND n.status = 'active'
1265
+ AND n.id != ?
1266
+ GROUP BY node_id
1267
+ )
1268
+ ORDER BY last_related_at DESC
1269
+ LIMIT ?`)
1270
+ .all(...projectIds, ...projectIds, ...projectIds, ...projectIds, targetNodeId, limit);
1271
+ return rows.map((row) => String(row.node_id));
1272
+ }
1273
+ listNodesSharingArtifactPaths(targetNodeId, limit = 200) {
1274
+ const artifactPaths = Array.from(new Set(this.listArtifacts(targetNodeId)
1275
+ .map((artifact) => artifact.path)
1276
+ .filter(Boolean)));
1277
+ if (!artifactPaths.length) {
1278
+ return [];
1279
+ }
1280
+ const rows = this.db
1281
+ .prepare(`SELECT DISTINCT node_id
1282
+ FROM artifacts
1283
+ WHERE path IN (${artifactPaths.map(() => "?").join(", ")})
1284
+ AND node_id != ?
1285
+ ORDER BY created_at DESC
1286
+ LIMIT ?`)
1287
+ .all(...artifactPaths, targetNodeId, limit);
1288
+ return rows.map((row) => String(row.node_id));
1289
+ }
1290
+ listInferenceTargetNodeIds(limit = 250) {
1291
+ const rows = this.db
1292
+ .prepare(`SELECT id
1293
+ FROM nodes
1294
+ WHERE status = 'active'
1295
+ ORDER BY updated_at DESC
1296
+ LIMIT ?`)
1297
+ .all(limit);
1298
+ return rows.map((row) => String(row.id));
1299
+ }
1300
+ searchNodes(input) {
1301
+ if (input.query.trim()) {
1302
+ try {
1303
+ const result = this.searchNodesWithFts(input);
1304
+ appendCurrentTelemetryDetails({
1305
+ ftsFallback: false,
1306
+ resultCount: result.items.length,
1307
+ totalCount: result.total
1308
+ });
1309
+ return result;
1310
+ }
1311
+ catch {
1312
+ const fallbackResult = this.searchNodesWithLike(input);
1313
+ appendCurrentTelemetryDetails({
1314
+ ftsFallback: true,
1315
+ resultCount: fallbackResult.items.length,
1316
+ totalCount: fallbackResult.total
1317
+ });
1318
+ return fallbackResult;
1319
+ }
1320
+ }
1321
+ const result = this.searchNodesWithLike(input);
1322
+ appendCurrentTelemetryDetails({
1323
+ ftsFallback: false,
1324
+ resultCount: result.items.length,
1325
+ totalCount: result.total
1326
+ });
1327
+ return result;
1328
+ }
1329
+ searchActivities(input) {
1330
+ if (input.query.trim()) {
1331
+ try {
1332
+ const result = this.searchActivitiesWithFts(input);
1333
+ appendCurrentTelemetryDetails({
1334
+ ftsFallback: false,
1335
+ resultCount: result.items.length,
1336
+ totalCount: result.total
1337
+ });
1338
+ return result;
1339
+ }
1340
+ catch {
1341
+ const fallbackResult = this.searchActivitiesWithLike(input);
1342
+ appendCurrentTelemetryDetails({
1343
+ ftsFallback: true,
1344
+ resultCount: fallbackResult.items.length,
1345
+ totalCount: fallbackResult.total
1346
+ });
1347
+ return fallbackResult;
1348
+ }
1349
+ }
1350
+ const result = this.searchActivitiesWithLike(input);
1351
+ appendCurrentTelemetryDetails({
1352
+ ftsFallback: false,
1353
+ resultCount: result.items.length,
1354
+ totalCount: result.total
1355
+ });
1356
+ return result;
1357
+ }
1358
+ listWorkspaceSemanticFallbackCandidateNodeIds(filters, settings, limit) {
1359
+ if (!settings.provider || !settings.model) {
1360
+ return [];
1361
+ }
1362
+ this.markSemanticConfigurationMismatchesStale();
1363
+ const where = [
1364
+ `n.status IN (${(filters?.status?.length ? filters.status : ["active", "draft"]).map(() => "?").join(", ")})`,
1365
+ `nis.embedding_status = 'ready'`,
1366
+ `nis.embedding_provider = ?`,
1367
+ `nis.embedding_model = ?`,
1368
+ `nis.embedding_version ${settings.version === null ? "IS NULL" : "= ?"}`
1369
+ ];
1370
+ const whereValues = [
1371
+ ...(filters?.status?.length ? filters.status : ["active", "draft"]),
1372
+ settings.provider,
1373
+ settings.model,
1374
+ ...(settings.version === null ? [] : [settings.version])
1375
+ ];
1376
+ if (filters?.types?.length) {
1377
+ where.push(`n.type IN (${filters.types.map(() => "?").join(", ")})`);
1378
+ whereValues.push(...filters.types);
1379
+ }
1380
+ if (filters?.sourceLabels?.length) {
1381
+ where.push(`n.source_label IN (${filters.sourceLabels.map(() => "?").join(", ")})`);
1382
+ whereValues.push(...filters.sourceLabels);
1383
+ }
1384
+ if (filters?.tags?.length) {
1385
+ for (const tag of normalizeTagList(filters.tags)) {
1386
+ where.push("EXISTS (SELECT 1 FROM node_tags nt WHERE nt.node_id = n.id AND nt.tag = ?)");
1387
+ whereValues.push(tag);
1388
+ }
1389
+ }
1390
+ const rows = this.db
1391
+ .prepare(`SELECT n.id
1392
+ FROM nodes n
1393
+ JOIN node_index_state nis ON nis.node_id = n.id
1394
+ WHERE ${where.join(" AND ")}
1395
+ ORDER BY n.updated_at DESC
1396
+ LIMIT ?`)
1397
+ .all(...whereValues, limit);
1398
+ return rows.map((row) => String(row.id));
1399
+ }
1400
+ buildWorkspaceSemanticFallbackNodeItems(candidateNodeIds, semanticMatches, settings) {
1401
+ const rankedItems = [];
1402
+ const candidateNodes = this.getNodesByIds(candidateNodeIds);
1403
+ for (const nodeId of candidateNodeIds) {
1404
+ const semanticMatch = semanticMatches.get(nodeId);
1405
+ if (!semanticMatch) {
1406
+ continue;
1407
+ }
1408
+ const retrievalRank = computeSemanticRetrievalRank(semanticMatch.similarity, settings);
1409
+ if (retrievalRank <= 0) {
1410
+ continue;
1411
+ }
1412
+ const node = candidateNodes.get(nodeId);
1413
+ if (!node) {
1414
+ continue;
1415
+ }
1416
+ rankedItems.push({
1417
+ id: node.id,
1418
+ type: node.type,
1419
+ title: node.title,
1420
+ summary: node.summary,
1421
+ status: node.status,
1422
+ canonicality: node.canonicality,
1423
+ sourceLabel: node.sourceLabel,
1424
+ updatedAt: node.updatedAt,
1425
+ tags: node.tags,
1426
+ matchReason: buildSearchMatchReason("semantic", ["semantic"]),
1427
+ semanticSimilarity: Number(semanticMatch.similarity.toFixed(4)),
1428
+ semanticRetrievalRank: retrievalRank
1429
+ });
1430
+ }
1431
+ return rankedItems
1432
+ .sort((left, right) => right.semanticRetrievalRank - left.semanticRetrievalRank || right.updatedAt.localeCompare(left.updatedAt))
1433
+ .map(({ semanticSimilarity: _semanticSimilarity, semanticRetrievalRank: _semanticRetrievalRank, ...item }) => item);
1434
+ }
1435
+ async searchWorkspace(input, options = {}) {
1436
+ const includeNodes = input.scopes.includes("nodes");
1437
+ const includeActivities = input.scopes.includes("activities");
1438
+ const requestedWindow = Math.min(input.limit + input.offset + SEARCH_FEEDBACK_WINDOW_PADDING, SEARCH_FEEDBACK_MAX_WINDOW);
1439
+ const queryPresent = Boolean(input.query.trim());
1440
+ const searchSort = input.sort === "smart" ? (queryPresent ? "relevance" : "updated_at") : input.sort;
1441
+ const normalizedQuery = input.query.trim();
1442
+ const nodeResults = includeNodes
1443
+ ? this.searchNodes({
1444
+ query: input.query,
1445
+ filters: input.nodeFilters ?? {},
1446
+ limit: requestedWindow,
1447
+ offset: 0,
1448
+ sort: searchSort
1449
+ })
1450
+ : { items: [], total: 0 };
1451
+ const activityResults = includeActivities
1452
+ ? this.searchActivities({
1453
+ query: input.query,
1454
+ filters: input.activityFilters ?? {},
1455
+ limit: requestedWindow,
1456
+ offset: 0,
1457
+ sort: searchSort
1458
+ })
1459
+ : { items: [], total: 0 };
1460
+ const fallbackTriggered = queryPresent && nodeResults.total + activityResults.total === 0;
1461
+ const fallbackTokens = fallbackTriggered ? tokenizeSearchQuery(input.query, SEARCH_FALLBACK_TOKEN_LIMIT) : [];
1462
+ const resolvedNodeResults = fallbackTokens.length >= 2 && includeNodes
1463
+ ? this.searchWorkspaceNodeFallback(fallbackTokens, input.nodeFilters ?? {}, requestedWindow)
1464
+ : nodeResults;
1465
+ const resolvedActivityResults = fallbackTokens.length >= 2 && includeActivities
1466
+ ? this.searchWorkspaceActivityFallback(fallbackTokens, input.activityFilters ?? {}, requestedWindow)
1467
+ : activityResults;
1468
+ const merged = this.mergeWorkspaceSearchResults(resolvedNodeResults.items, resolvedActivityResults.items, input.sort);
1469
+ const deterministicResult = {
1470
+ total: fallbackTokens.length >= 2
1471
+ ? merged.length
1472
+ : resolvedNodeResults.total + resolvedActivityResults.total,
1473
+ items: merged.slice(input.offset, input.offset + input.limit)
1474
+ };
1475
+ const semanticSettings = this.readSemanticIndexSettings();
1476
+ const telemetry = {
1477
+ semanticFallbackEligible: false,
1478
+ semanticFallbackAttempted: false,
1479
+ semanticFallbackUsed: false,
1480
+ semanticFallbackCandidateCount: 0,
1481
+ semanticFallbackResultCount: 0,
1482
+ semanticFallbackBackend: null,
1483
+ semanticFallbackConfiguredBackend: semanticSettings.configuredIndexBackend,
1484
+ semanticFallbackSkippedReason: null,
1485
+ semanticFallbackQueryLengthBucket: queryPresent ? bucketSemanticQueryLength(normalizedQuery.length) : null
1486
+ };
1487
+ const appendWorkspaceSearchTelemetry = (result) => {
1488
+ appendCurrentTelemetryDetails({
1489
+ candidateCount: requestedWindow,
1490
+ nodeCandidateCount: resolvedNodeResults.items.length,
1491
+ activityCandidateCount: resolvedActivityResults.items.length,
1492
+ resultCount: result.items.length,
1493
+ totalCount: result.total,
1494
+ fallbackTokenCount: fallbackTokens.length,
1495
+ semanticFallbackEligible: telemetry.semanticFallbackEligible,
1496
+ semanticFallbackAttempted: telemetry.semanticFallbackAttempted,
1497
+ semanticFallbackUsed: telemetry.semanticFallbackUsed,
1498
+ semanticFallbackCandidateCount: telemetry.semanticFallbackCandidateCount,
1499
+ semanticFallbackResultCount: telemetry.semanticFallbackResultCount,
1500
+ semanticFallbackBackend: telemetry.semanticFallbackBackend,
1501
+ semanticFallbackConfiguredBackend: telemetry.semanticFallbackConfiguredBackend,
1502
+ semanticFallbackSkippedReason: telemetry.semanticFallbackSkippedReason
1503
+ });
1504
+ };
1505
+ const shouldAttemptSemanticFallback = includeNodes &&
1506
+ semanticSettings.workspaceFallbackEnabled &&
1507
+ queryPresent &&
1508
+ normalizedQuery.length >= 6 &&
1509
+ deterministicResult.total === 0 &&
1510
+ semanticSettings.enabled &&
1511
+ Boolean(semanticSettings.provider && semanticSettings.model);
1512
+ if (!includeNodes) {
1513
+ telemetry.semanticFallbackSkippedReason = "nodes_scope_disabled";
1514
+ }
1515
+ else if (!queryPresent) {
1516
+ telemetry.semanticFallbackSkippedReason = "query_empty";
1517
+ }
1518
+ else if (normalizedQuery.length < 6) {
1519
+ telemetry.semanticFallbackSkippedReason = "query_too_short";
1520
+ }
1521
+ else if (!semanticSettings.workspaceFallbackEnabled) {
1522
+ telemetry.semanticFallbackSkippedReason = "workspace_fallback_disabled";
1523
+ }
1524
+ else if (deterministicResult.total > 0) {
1525
+ telemetry.semanticFallbackSkippedReason = "deterministic_results_present";
1526
+ }
1527
+ else if (!semanticSettings.enabled) {
1528
+ telemetry.semanticFallbackSkippedReason = "semantic_disabled";
1529
+ }
1530
+ else if (!semanticSettings.provider || !semanticSettings.model) {
1531
+ telemetry.semanticFallbackSkippedReason = "semantic_provider_unconfigured";
1532
+ }
1533
+ if (shouldAttemptSemanticFallback) {
1534
+ const candidateNodeIds = this.listWorkspaceSemanticFallbackCandidateNodeIds(input.nodeFilters ?? {}, semanticSettings, 200);
1535
+ telemetry.semanticFallbackEligible = true;
1536
+ telemetry.semanticFallbackCandidateCount = candidateNodeIds.length;
1537
+ telemetry.semanticFallbackBackend = semanticSettings.indexBackend;
1538
+ if (!candidateNodeIds.length) {
1539
+ telemetry.semanticFallbackSkippedReason = "candidate_pool_empty";
1540
+ }
1541
+ else {
1542
+ telemetry.semanticFallbackAttempted = true;
1543
+ const runSemanticFallback = async () => {
1544
+ const semanticMatches = await this.rankSemanticCandidates(normalizedQuery, candidateNodeIds);
1545
+ const items = this.buildWorkspaceSemanticFallbackNodeItems(candidateNodeIds, semanticMatches, this.getSemanticAugmentationSettings()).map((node) => ({ resultType: "node", node }));
1546
+ return {
1547
+ items,
1548
+ resultCount: items.length
1549
+ };
1550
+ };
1551
+ try {
1552
+ const semanticResult = options.runSemanticFallbackSpan
1553
+ ? await options.runSemanticFallbackSpan({
1554
+ semanticFallbackCandidateCount: candidateNodeIds.length,
1555
+ semanticFallbackBackend: semanticSettings.indexBackend,
1556
+ semanticFallbackConfiguredBackend: semanticSettings.configuredIndexBackend,
1557
+ semanticFallbackQueryLengthBucket: telemetry.semanticFallbackQueryLengthBucket
1558
+ }, runSemanticFallback)
1559
+ : await runSemanticFallback();
1560
+ telemetry.semanticFallbackResultCount = semanticResult.resultCount;
1561
+ if (semanticResult.resultCount > 0) {
1562
+ telemetry.semanticFallbackUsed = true;
1563
+ const semanticWorkspaceResult = {
1564
+ total: semanticResult.resultCount,
1565
+ items: semanticResult.items
1566
+ };
1567
+ appendWorkspaceSearchTelemetry(semanticWorkspaceResult);
1568
+ return {
1569
+ ...semanticWorkspaceResult,
1570
+ telemetry
1571
+ };
1572
+ }
1573
+ telemetry.semanticFallbackSkippedReason = "semantic_no_matches";
1574
+ }
1575
+ catch (error) {
1576
+ telemetry.semanticFallbackSkippedReason =
1577
+ error instanceof VectorIndexStoreError ? error.code : "semantic_fallback_error";
1578
+ }
1579
+ }
1580
+ }
1581
+ appendWorkspaceSearchTelemetry(deterministicResult);
1582
+ return {
1583
+ ...deterministicResult,
1584
+ telemetry
1585
+ };
1586
+ }
1587
+ searchWorkspaceNodeFallback(tokens, filters, limit) {
1588
+ if (!tokens.length) {
1589
+ return { total: 0, items: [] };
1590
+ }
1591
+ const queryLikes = tokens.map((token) => `%${token}%`);
1592
+ const tokenWhere = tokens
1593
+ .map(() => `(lower(coalesce(n.title, '')) LIKE lower(?) OR lower(coalesce(n.body, '')) LIKE lower(?) OR lower(coalesce(n.summary, '')) LIKE lower(?))`)
1594
+ .join(" OR ");
1595
+ return this.runSearchQuery("nodes n", [`(${tokenWhere})`], queryLikes.flatMap((token) => [token, token, token]), "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, n.updated_at DESC", [], limit, 0, filters ?? {}, false, tokens.join(" "), "fallback_token");
1596
+ }
1597
+ searchWorkspaceActivityFallback(tokens, filters, limit) {
1598
+ if (!tokens.length) {
1599
+ return { total: 0, items: [] };
1600
+ }
1601
+ const queryLikes = tokens.map((token) => `%${token}%`);
1602
+ const initialWhere = [
1603
+ `(${tokens
1604
+ .map(() => `(lower(coalesce(a.body, '')) LIKE lower(?) OR lower(coalesce(n.title, '')) LIKE lower(?) OR lower(coalesce(a.activity_type, '')) LIKE lower(?) OR lower(coalesce(a.source_label, '')) LIKE lower(?))`)
1605
+ .join(" OR ")})`
1606
+ ];
1607
+ return this.runActivitySearchQuery({
1608
+ from: "activities a JOIN nodes n ON n.id = a.target_node_id",
1609
+ initialWhere,
1610
+ initialWhereValues: queryLikes.flatMap((token) => [token, token, token, token]),
1611
+ orderBy: "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, a.created_at DESC",
1612
+ orderValues: [],
1613
+ input: {
1614
+ query: tokens.join(" "),
1615
+ filters: filters ?? {},
1616
+ limit,
1617
+ offset: 0,
1618
+ sort: "updated_at"
1619
+ },
1620
+ strategy: "fallback_token"
1621
+ });
1622
+ }
1623
+ mergeWorkspaceSearchResults(nodeItems, activityItems, sort) {
1624
+ const includeSmartScore = sort === "smart";
1625
+ const nowMs = includeSmartScore ? Date.now() : 0;
1626
+ const merged = [
1627
+ ...nodeItems.map((node, index) => ({
1628
+ resultType: "node",
1629
+ node,
1630
+ index,
1631
+ total: nodeItems.length,
1632
+ timestamp: node.updatedAt,
1633
+ contested: node.status === "contested",
1634
+ smartScore: includeSmartScore
1635
+ ? computeWorkspaceSmartScore({
1636
+ index,
1637
+ total: nodeItems.length,
1638
+ timestamp: node.updatedAt,
1639
+ resultType: "node",
1640
+ contested: node.status === "contested",
1641
+ nowMs
1642
+ })
1643
+ : 0
1644
+ })),
1645
+ ...activityItems.map((activity, index) => ({
1646
+ resultType: "activity",
1647
+ activity,
1648
+ index,
1649
+ total: activityItems.length,
1650
+ timestamp: activity.createdAt,
1651
+ contested: activity.targetNodeStatus === "contested",
1652
+ smartScore: includeSmartScore
1653
+ ? computeWorkspaceSmartScore({
1654
+ index,
1655
+ total: activityItems.length,
1656
+ timestamp: activity.createdAt,
1657
+ resultType: "activity",
1658
+ contested: activity.targetNodeStatus === "contested",
1659
+ nowMs
1660
+ })
1661
+ : 0
1662
+ }))
1663
+ ];
1664
+ if (sort === "updated_at") {
1665
+ return merged
1666
+ .sort((left, right) => right.timestamp.localeCompare(left.timestamp))
1667
+ .map(({ index: _index, total: _total, timestamp: _timestamp, contested: _contested, smartScore: _smartScore, ...item }) => item);
1668
+ }
1669
+ if (sort === "smart") {
1670
+ return merged
1671
+ .sort((left, right) => right.smartScore - left.smartScore || right.timestamp.localeCompare(left.timestamp))
1672
+ .map(({ index: _index, total: _total, timestamp: _timestamp, contested: _contested, smartScore: _smartScore, ...item }) => item);
1673
+ }
1674
+ return merged.map(({ index: _index, total: _total, timestamp: _timestamp, contested: _contested, smartScore: _smartScore, ...item }) => item);
1675
+ }
1676
+ searchNodesWithFts(input) {
1677
+ const where = [];
1678
+ const values = [];
1679
+ const from = "nodes n JOIN nodes_fts fts ON fts.rowid = n.rowid";
1680
+ let orderBy = "n.updated_at DESC";
1681
+ where.push("nodes_fts MATCH ?");
1682
+ values.push(input.query.trim());
1683
+ if (input.sort === "relevance") {
1684
+ orderBy = "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, bm25(nodes_fts, 3.0, 1.5, 2.0), n.updated_at DESC";
1685
+ }
1686
+ return this.runSearchQuery(from, where, values, orderBy, [], input.limit, input.offset, input.filters, input.sort === "relevance", input.query, "fts");
1687
+ }
1688
+ searchNodesWithLike(input) {
1689
+ const where = [];
1690
+ const values = [];
1691
+ let orderBy = "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, n.updated_at DESC";
1692
+ if (input.query.trim()) {
1693
+ where.push(`(lower(coalesce(n.title, '')) LIKE lower(?) OR lower(coalesce(n.body, '')) LIKE lower(?) OR lower(coalesce(n.summary, '')) LIKE lower(?))`);
1694
+ const queryLike = `%${input.query.trim()}%`;
1695
+ values.push(queryLike, queryLike, queryLike);
1696
+ if (input.sort === "relevance") {
1697
+ orderBy = `
1698
+ CASE
1699
+ WHEN lower(coalesce(n.title, '')) LIKE lower(?) THEN 0
1700
+ WHEN lower(coalesce(n.summary, '')) LIKE lower(?) THEN 1
1701
+ ELSE 2
1702
+ END,
1703
+ CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END,
1704
+ n.updated_at DESC
1705
+ `;
1706
+ values.push(queryLike, queryLike);
1707
+ }
1708
+ }
1709
+ const orderValues = input.sort === "relevance" && input.query.trim() ? values.slice(-2) : [];
1710
+ const whereValues = orderValues.length ? values.slice(0, -2) : values;
1711
+ return this.runSearchQuery("nodes n", where, whereValues, orderBy, orderValues, input.limit, input.offset, input.filters, input.sort === "relevance", input.query, input.query.trim() ? "like" : "browse");
1712
+ }
1713
+ applySearchFeedbackBoost(items) {
1714
+ if (items.length <= 1) {
1715
+ return items;
1716
+ }
1717
+ const summaries = this.getSearchFeedbackSummaries("node", items.map((item) => item.id));
1718
+ return [...items]
1719
+ .map((item, index) => ({
1720
+ item,
1721
+ score: items.length - index +
1722
+ clampSearchFeedbackDelta(summaries.get(item.id)?.totalDelta ?? 0) * 2 -
1723
+ (item.status === "contested" ? 1 : 0)
1724
+ }))
1725
+ .sort((left, right) => right.score - left.score || right.item.updatedAt.localeCompare(left.item.updatedAt))
1726
+ .map(({ item }) => item);
1727
+ }
1728
+ applyActivitySearchFeedbackBoost(items) {
1729
+ if (items.length <= 1) {
1730
+ return items;
1731
+ }
1732
+ const summaries = this.getSearchFeedbackSummaries("activity", items.map((item) => item.id));
1733
+ return [...items]
1734
+ .map((item, index) => ({
1735
+ item,
1736
+ score: items.length - index +
1737
+ clampSearchFeedbackDelta(summaries.get(item.id)?.totalDelta ?? 0) * 2 -
1738
+ (item.targetNodeStatus === "contested" ? 1 : 0)
1739
+ }))
1740
+ .sort((left, right) => right.score - left.score || right.item.createdAt.localeCompare(left.item.createdAt))
1741
+ .map(({ item }) => item);
1742
+ }
1743
+ searchActivitiesWithFts(input) {
1744
+ return this.runActivitySearchQuery({
1745
+ from: "activities a JOIN activities_fts ON activities_fts.rowid = a.rowid JOIN nodes n ON n.id = a.target_node_id",
1746
+ initialWhere: ["activities_fts MATCH ?"],
1747
+ initialWhereValues: [input.query.trim()],
1748
+ orderBy: input.sort === "relevance"
1749
+ ? "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, bm25(activities_fts, 2.0, 1.0), a.created_at DESC"
1750
+ : "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, a.created_at DESC",
1751
+ orderValues: [],
1752
+ input,
1753
+ strategy: "fts"
1754
+ });
1755
+ }
1756
+ searchActivitiesWithLike(input) {
1757
+ const initialWhere = [];
1758
+ const initialWhereValues = [];
1759
+ let orderBy = "CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END, a.created_at DESC";
1760
+ const orderValues = [];
1761
+ if (input.query.trim()) {
1762
+ const queryLike = `%${input.query.trim()}%`;
1763
+ initialWhere.push(`(lower(coalesce(a.body, '')) LIKE lower(?) OR lower(coalesce(n.title, '')) LIKE lower(?) OR lower(coalesce(a.activity_type, '')) LIKE lower(?) OR lower(coalesce(a.source_label, '')) LIKE lower(?))`);
1764
+ initialWhereValues.push(queryLike, queryLike, queryLike, queryLike);
1765
+ if (input.sort === "relevance") {
1766
+ orderBy = `
1767
+ CASE
1768
+ WHEN lower(coalesce(a.body, '')) LIKE lower(?) THEN 0
1769
+ WHEN lower(coalesce(n.title, '')) LIKE lower(?) THEN 1
1770
+ WHEN lower(coalesce(a.activity_type, '')) LIKE lower(?) THEN 2
1771
+ ELSE 3
1772
+ END,
1773
+ CASE WHEN n.status = 'contested' THEN 1 ELSE 0 END,
1774
+ a.created_at DESC
1775
+ `;
1776
+ orderValues.push(queryLike, queryLike, queryLike);
1777
+ }
1778
+ }
1779
+ return this.runActivitySearchQuery({
1780
+ from: "activities a JOIN nodes n ON n.id = a.target_node_id",
1781
+ initialWhere,
1782
+ initialWhereValues,
1783
+ orderBy,
1784
+ orderValues,
1785
+ input,
1786
+ strategy: input.query.trim() ? "like" : "browse"
1787
+ });
1788
+ }
1789
+ capActivityResultsPerTarget(items) {
1790
+ const counts = new Map();
1791
+ const capped = [];
1792
+ for (const item of items) {
1793
+ const currentCount = counts.get(item.targetNodeId) ?? 0;
1794
+ if (currentCount >= ACTIVITY_RESULT_CAP_PER_TARGET) {
1795
+ continue;
1796
+ }
1797
+ counts.set(item.targetNodeId, currentCount + 1);
1798
+ capped.push(item);
1799
+ }
1800
+ return capped;
1801
+ }
1802
+ runActivitySearchQuery(params) {
1803
+ const where = [...params.initialWhere];
1804
+ const whereValues = [...params.initialWhereValues];
1805
+ const { input } = params;
1806
+ if (input.filters.targetNodeIds?.length) {
1807
+ where.push(`a.target_node_id IN (${input.filters.targetNodeIds.map(() => "?").join(", ")})`);
1808
+ whereValues.push(...input.filters.targetNodeIds);
1809
+ }
1810
+ if (input.filters.activityTypes?.length) {
1811
+ where.push(`a.activity_type IN (${input.filters.activityTypes.map(() => "?").join(", ")})`);
1812
+ whereValues.push(...input.filters.activityTypes);
1813
+ }
1814
+ if (input.filters.sourceLabels?.length) {
1815
+ where.push(`a.source_label IN (${input.filters.sourceLabels.map(() => "?").join(", ")})`);
1816
+ whereValues.push(...input.filters.sourceLabels);
1817
+ }
1818
+ if (input.filters.createdAfter) {
1819
+ where.push(`a.created_at >= ?`);
1820
+ whereValues.push(input.filters.createdAfter);
1821
+ }
1822
+ if (input.filters.createdBefore) {
1823
+ where.push(`a.created_at <= ?`);
1824
+ whereValues.push(input.filters.createdBefore);
1825
+ }
1826
+ const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
1827
+ const countRow = this.db
1828
+ .prepare(`SELECT COUNT(*) AS total
1829
+ FROM ${params.from}
1830
+ ${whereClause}`)
1831
+ .get(...whereValues);
1832
+ const useSearchFeedbackBoost = input.sort === "relevance";
1833
+ const effectiveLimit = useSearchFeedbackBoost
1834
+ ? Math.min(input.limit + input.offset + SEARCH_FEEDBACK_WINDOW_PADDING, SEARCH_FEEDBACK_MAX_WINDOW)
1835
+ : input.limit;
1836
+ const effectiveOffset = useSearchFeedbackBoost ? 0 : input.offset;
1837
+ const rows = this.db
1838
+ .prepare(`SELECT
1839
+ a.id,
1840
+ a.target_node_id,
1841
+ a.activity_type,
1842
+ a.body,
1843
+ a.source_label,
1844
+ a.created_at,
1845
+ n.title AS target_title,
1846
+ n.type AS target_type,
1847
+ n.status AS target_status
1848
+ FROM ${params.from}
1849
+ ${whereClause}
1850
+ ORDER BY ${params.orderBy}
1851
+ LIMIT ? OFFSET ?`)
1852
+ .all(...whereValues, ...params.orderValues, effectiveLimit, effectiveOffset);
1853
+ const matcher = params.strategy === "browse" ? null : createSearchFieldMatcher(params.input.query);
1854
+ const items = rows.map((row) => ({
1855
+ id: String(row.id),
1856
+ targetNodeId: String(row.target_node_id),
1857
+ targetNodeTitle: row.target_title ? String(row.target_title) : null,
1858
+ targetNodeType: row.target_type ? row.target_type : null,
1859
+ targetNodeStatus: row.target_status ? row.target_status : null,
1860
+ activityType: row.activity_type,
1861
+ body: row.body ? String(row.body) : null,
1862
+ sourceLabel: row.source_label ? String(row.source_label) : null,
1863
+ createdAt: String(row.created_at),
1864
+ matchReason: buildSearchMatchReason(params.strategy, collectMatchedFields(matcher, [
1865
+ { field: "body", value: row.body ? String(row.body) : null },
1866
+ { field: "targetNodeTitle", value: row.target_title ? String(row.target_title) : null },
1867
+ { field: "activityType", value: row.activity_type ? String(row.activity_type) : null },
1868
+ { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
1869
+ ]))
1870
+ }));
1871
+ const rankedItems = useSearchFeedbackBoost ? this.applyActivitySearchFeedbackBoost(items) : items;
1872
+ const cappedItems = this.capActivityResultsPerTarget(rankedItems);
1873
+ return {
1874
+ total: Number(countRow.total ?? 0),
1875
+ items: useSearchFeedbackBoost ? cappedItems.slice(input.offset, input.offset + input.limit) : cappedItems
1876
+ };
1877
+ }
1878
+ runSearchQuery(from, initialWhere, initialWhereValues, orderBy, orderValues, limit, offset, filters, useSearchFeedbackBoost, query, strategy) {
1879
+ const where = [...initialWhere];
1880
+ const whereValues = [...initialWhereValues];
1881
+ if (filters.types?.length) {
1882
+ where.push(`n.type IN (${filters.types.map(() => "?").join(", ")})`);
1883
+ whereValues.push(...filters.types);
1884
+ }
1885
+ if (filters.status?.length) {
1886
+ where.push(`n.status IN (${filters.status.map(() => "?").join(", ")})`);
1887
+ whereValues.push(...filters.status);
1888
+ }
1889
+ if (filters.sourceLabels?.length) {
1890
+ where.push(`n.source_label IN (${filters.sourceLabels.map(() => "?").join(", ")})`);
1891
+ whereValues.push(...filters.sourceLabels);
1892
+ }
1893
+ if (filters.tags?.length) {
1894
+ for (const tag of normalizeTagList(filters.tags)) {
1895
+ where.push("EXISTS (SELECT 1 FROM node_tags nt WHERE nt.node_id = n.id AND nt.tag = ?)");
1896
+ whereValues.push(tag);
1897
+ }
1898
+ }
1899
+ const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
1900
+ const countValues = whereValues;
1901
+ const rowValues = [...whereValues, ...orderValues];
1902
+ const countRow = this.db
1903
+ .prepare(`SELECT COUNT(*) as total FROM ${from} ${whereClause}`)
1904
+ .get(...countValues);
1905
+ const effectiveLimit = useSearchFeedbackBoost ? Math.min(limit + offset + SEARCH_FEEDBACK_WINDOW_PADDING, SEARCH_FEEDBACK_MAX_WINDOW) : limit;
1906
+ const effectiveOffset = useSearchFeedbackBoost ? 0 : offset;
1907
+ const rows = this.db
1908
+ .prepare(`SELECT n.id, n.type, n.title, n.body, n.summary, n.status, n.canonicality, n.source_label, n.updated_at, n.tags_json
1909
+ FROM ${from}
1910
+ ${whereClause}
1911
+ ORDER BY ${orderBy}
1912
+ LIMIT ? OFFSET ?`)
1913
+ .all(...rowValues, effectiveLimit, effectiveOffset);
1914
+ const matcher = strategy === "browse" ? null : createSearchFieldMatcher(query);
1915
+ const items = rows.map((row) => {
1916
+ const tags = parseJson(row.tags_json, []);
1917
+ return {
1918
+ id: String(row.id),
1919
+ type: row.type,
1920
+ title: row.title ? String(row.title) : null,
1921
+ summary: row.summary ? String(row.summary) : null,
1922
+ status: row.status,
1923
+ canonicality: row.canonicality,
1924
+ sourceLabel: row.source_label ? String(row.source_label) : null,
1925
+ updatedAt: String(row.updated_at),
1926
+ tags,
1927
+ matchReason: buildSearchMatchReason(strategy, collectMatchedFields(matcher, [
1928
+ { field: "title", value: row.title ? String(row.title) : null },
1929
+ { field: "summary", value: row.summary ? String(row.summary) : null },
1930
+ { field: "body", value: row.body ? String(row.body) : null },
1931
+ { field: "tags", value: tags.join(" ") },
1932
+ { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
1933
+ ]))
1934
+ };
1935
+ });
1936
+ const rankedItems = useSearchFeedbackBoost ? this.applySearchFeedbackBoost(items) : items;
1937
+ return {
1938
+ total: countRow.total,
1939
+ items: useSearchFeedbackBoost ? rankedItems.slice(offset, offset + limit) : rankedItems
1940
+ };
1941
+ }
1942
+ getNode(id) {
1943
+ const row = this.db.prepare(`SELECT * FROM nodes WHERE id = ?`).get(id);
1944
+ return mapNode(assertPresent(row, `Node ${id} not found`));
1945
+ }
1946
+ getNodesByIds(ids) {
1947
+ if (!ids.length) {
1948
+ return new Map();
1949
+ }
1950
+ const uniqueIds = Array.from(new Set(ids));
1951
+ const rows = this.db
1952
+ .prepare(`SELECT * FROM nodes WHERE id IN (${uniqueIds.map(() => "?").join(", ")})`)
1953
+ .all(...uniqueIds);
1954
+ return new Map(rows.map((row) => {
1955
+ const node = mapNode(row);
1956
+ return [node.id, node];
1957
+ }));
1958
+ }
1959
+ ensureWorkspaceInboxNode() {
1960
+ const settings = this.getSettings([WORKSPACE_CAPTURE_INBOX_KEY]);
1961
+ const inboxNodeId = typeof settings[WORKSPACE_CAPTURE_INBOX_KEY] === "string" ? String(settings[WORKSPACE_CAPTURE_INBOX_KEY]) : null;
1962
+ if (inboxNodeId) {
1963
+ try {
1964
+ const existing = this.getNode(inboxNodeId);
1965
+ if (existing.type === "conversation" && existing.status !== "archived") {
1966
+ return existing;
1967
+ }
1968
+ }
1969
+ catch {
1970
+ // fall through and recreate the system inbox node
1971
+ }
1972
+ }
1973
+ const inboxNode = this.createNode({
1974
+ type: "conversation",
1975
+ title: "Workspace Inbox",
1976
+ body: "Default timeline for captured agent updates when no target node is specified.",
1977
+ summary: "System-managed conversation node for untargeted capture activity.",
1978
+ tags: ["inbox"],
1979
+ source: workspaceInboxSource,
1980
+ metadata: {
1981
+ workspaceInbox: true,
1982
+ systemManaged: true
1983
+ },
1984
+ resolvedCanonicality: "canonical",
1985
+ resolvedStatus: "active"
1986
+ });
1987
+ this.setSetting(WORKSPACE_CAPTURE_INBOX_KEY, inboxNode.id);
1988
+ return inboxNode;
1989
+ }
1990
+ createNode(input) {
1991
+ const now = nowIso();
1992
+ const id = createId("node");
1993
+ const nextSummary = input.summary ?? stableSummary(input.title, input.body);
1994
+ const nextMetadata = withSummaryMetadata(input.metadata, now, input.summary !== undefined ? "explicit" : "derived");
1995
+ this.runInTransaction(() => {
1996
+ this.db
1997
+ .prepare(`INSERT INTO nodes (
1998
+ id, type, status, canonicality, visibility, title, body, summary,
1999
+ created_by, source_type, source_label, created_at, updated_at, tags_json, metadata_json
2000
+ ) VALUES (?, ?, ?, ?, 'normal', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2001
+ .run(id, input.type, input.resolvedStatus, input.resolvedCanonicality, input.title, input.body, nextSummary, input.source.actorLabel, input.source.actorType, input.source.actorLabel, now, now, JSON.stringify(input.tags), JSON.stringify(nextMetadata));
2002
+ this.syncNodeTags(id, input.tags);
2003
+ this.markNodeSemanticIndexState(id, "node.created", {
2004
+ status: "pending",
2005
+ contentHash: buildSemanticContentHash({
2006
+ title: input.title,
2007
+ body: input.body,
2008
+ summary: nextSummary,
2009
+ tags: input.tags
2010
+ }),
2011
+ updatedAt: now
2012
+ });
2013
+ });
2014
+ return this.getNode(id);
2015
+ }
2016
+ updateNode(id, input) {
2017
+ const existing = this.getNode(id);
2018
+ const nextTitle = input.title ?? existing.title;
2019
+ const nextBody = input.body ?? existing.body;
2020
+ const existingDerivedSummary = stableSummary(existing.title, existing.body);
2021
+ const shouldRefreshDerivedSummary = input.summary !== undefined
2022
+ ? false
2023
+ : input.title !== undefined || input.body !== undefined
2024
+ ? !existing.summary || existing.summary === existingDerivedSummary
2025
+ : false;
2026
+ const nextSummary = input.summary !== undefined
2027
+ ? input.summary
2028
+ : shouldRefreshDerivedSummary
2029
+ ? stableSummary(nextTitle, nextBody)
2030
+ : existing.summary;
2031
+ const nextTags = input.tags ?? existing.tags;
2032
+ const mergedMetadata = input.metadata ? { ...existing.metadata, ...input.metadata } : existing.metadata;
2033
+ const updatedAt = nowIso();
2034
+ const nextMetadata = input.summary !== undefined
2035
+ ? withSummaryMetadata(mergedMetadata, updatedAt, "explicit")
2036
+ : shouldRefreshDerivedSummary
2037
+ ? withSummaryMetadata(mergedMetadata, updatedAt, "derived")
2038
+ : mergedMetadata;
2039
+ const nextStatus = input.status ?? existing.status;
2040
+ this.runInTransaction(() => {
2041
+ this.db
2042
+ .prepare(`UPDATE nodes
2043
+ SET title = ?, body = ?, summary = ?, tags_json = ?, metadata_json = ?, status = ?, updated_at = ?
2044
+ WHERE id = ?`)
2045
+ .run(nextTitle, nextBody, nextSummary, JSON.stringify(nextTags), JSON.stringify(nextMetadata), nextStatus, updatedAt, id);
2046
+ this.syncNodeTags(id, nextTags);
2047
+ this.markNodeSemanticIndexState(id, "node.updated", {
2048
+ status: "pending",
2049
+ contentHash: buildSemanticContentHash({
2050
+ title: nextTitle,
2051
+ body: nextBody,
2052
+ summary: nextSummary,
2053
+ tags: nextTags
2054
+ }),
2055
+ updatedAt
2056
+ });
2057
+ });
2058
+ return this.getNode(id);
2059
+ }
2060
+ refreshNodeSummary(id) {
2061
+ const existing = this.getNode(id);
2062
+ const updatedAt = nowIso();
2063
+ const nextSummary = stableSummary(existing.title, existing.body);
2064
+ const nextMetadata = withSummaryMetadata(existing.metadata, updatedAt, "manual_refresh");
2065
+ this.db
2066
+ .prepare(`UPDATE nodes
2067
+ SET summary = ?, metadata_json = ?, updated_at = ?
2068
+ WHERE id = ?`)
2069
+ .run(nextSummary, JSON.stringify(nextMetadata), updatedAt, id);
2070
+ this.markNodeSemanticIndexState(id, "summary.refreshed", {
2071
+ status: "pending",
2072
+ contentHash: buildSemanticContentHash({
2073
+ title: existing.title,
2074
+ body: existing.body,
2075
+ summary: nextSummary,
2076
+ tags: existing.tags
2077
+ }),
2078
+ updatedAt
2079
+ });
2080
+ return this.getNode(id);
2081
+ }
2082
+ archiveNode(id) {
2083
+ const updatedAt = nowIso();
2084
+ this.db.prepare(`UPDATE nodes SET status = 'archived', updated_at = ? WHERE id = ?`).run(updatedAt, id);
2085
+ this.markNodeSemanticIndexState(id, "node.archived", {
2086
+ status: "stale",
2087
+ updatedAt
2088
+ });
2089
+ return this.getNode(id);
2090
+ }
2091
+ setNodeCanonicality(id, canonicality) {
2092
+ this.db.prepare(`UPDATE nodes SET canonicality = ?, updated_at = ? WHERE id = ?`).run(canonicality, nowIso(), id);
2093
+ return this.getNode(id);
2094
+ }
2095
+ listRelatedNodes(nodeId, depth = 1, relationFilter) {
2096
+ if (depth !== 1) {
2097
+ throw new AppError(400, "INVALID_INPUT", "Only depth=1 is supported in the hot path");
2098
+ }
2099
+ const relationWhere = relationFilter?.length
2100
+ ? `AND r.relation_type IN (${relationFilter.map(() => "?").join(", ")})`
2101
+ : "";
2102
+ const rows = this.db
2103
+ .prepare(`SELECT
2104
+ r.*,
2105
+ CASE WHEN r.from_node_id = ? THEN r.to_node_id ELSE r.from_node_id END AS related_id
2106
+ FROM relations r
2107
+ WHERE (r.from_node_id = ? OR r.to_node_id = ?)
2108
+ AND r.status != 'archived'
2109
+ ${relationWhere}
2110
+ ORDER BY r.created_at DESC`)
2111
+ .all(nodeId, nodeId, nodeId, ...(relationFilter ?? []));
2112
+ const relatedNodes = this.getNodesByIds(rows.map((row) => String(row.related_id)));
2113
+ return rows.flatMap((row) => {
2114
+ const node = relatedNodes.get(String(row.related_id));
2115
+ if (!node) {
2116
+ return [];
2117
+ }
2118
+ return [{
2119
+ relation: mapRelation(row),
2120
+ node
2121
+ }];
2122
+ });
2123
+ }
2124
+ listProjectMemberNodes(projectId, limit) {
2125
+ const rows = this.db
2126
+ .prepare(`SELECT
2127
+ r.*,
2128
+ CASE WHEN r.from_node_id = ? THEN r.to_node_id ELSE r.from_node_id END AS related_id
2129
+ FROM relations r
2130
+ JOIN nodes n
2131
+ ON n.id = CASE WHEN r.from_node_id = ? THEN r.to_node_id ELSE r.from_node_id END
2132
+ WHERE (r.from_node_id = ? OR r.to_node_id = ?)
2133
+ AND r.relation_type = 'relevant_to'
2134
+ AND r.status NOT IN ('archived', 'rejected')
2135
+ AND n.status != 'archived'
2136
+ ORDER BY r.created_at DESC
2137
+ LIMIT ?`)
2138
+ .all(projectId, projectId, projectId, projectId, limit);
2139
+ const relatedNodes = this.getNodesByIds(rows.map((row) => String(row.related_id)));
2140
+ return rows.flatMap((row) => {
2141
+ const node = relatedNodes.get(String(row.related_id));
2142
+ if (!node) {
2143
+ return [];
2144
+ }
2145
+ return [{
2146
+ relation: mapRelation(row),
2147
+ node
2148
+ }];
2149
+ });
2150
+ }
2151
+ listRelationsBetweenNodeIds(nodeIds) {
2152
+ if (!nodeIds.length) {
2153
+ return [];
2154
+ }
2155
+ const uniqueIds = Array.from(new Set(nodeIds));
2156
+ const placeholders = uniqueIds.map(() => "?").join(", ");
2157
+ const rows = this.db
2158
+ .prepare(`SELECT *
2159
+ FROM relations
2160
+ WHERE from_node_id IN (${placeholders})
2161
+ AND to_node_id IN (${placeholders})
2162
+ AND status NOT IN ('archived', 'rejected')
2163
+ ORDER BY created_at ASC, id ASC`)
2164
+ .all(...uniqueIds, ...uniqueIds);
2165
+ return rows.map(mapRelation);
2166
+ }
2167
+ createRelation(input) {
2168
+ const now = nowIso();
2169
+ const id = createId("rel");
2170
+ this.db
2171
+ .prepare(`INSERT INTO relations (
2172
+ id, from_node_id, to_node_id, relation_type, status, created_by, source_type,
2173
+ source_label, created_at, metadata_json
2174
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2175
+ .run(id, input.fromNodeId, input.toNodeId, input.relationType, input.resolvedStatus, input.source.actorLabel, input.source.actorType, input.source.actorLabel, now, JSON.stringify(input.metadata));
2176
+ return this.getRelation(id);
2177
+ }
2178
+ upsertInferredRelation(input) {
2179
+ const existing = this.db
2180
+ .prepare(`SELECT id FROM inferred_relations
2181
+ WHERE from_node_id = ? AND to_node_id = ? AND relation_type = ? AND generator = ?`)
2182
+ .get(input.fromNodeId, input.toNodeId, input.relationType, input.generator);
2183
+ const id = existing?.id ?? createId("irel");
2184
+ const now = nowIso();
2185
+ this.db
2186
+ .prepare(`INSERT INTO inferred_relations (
2187
+ id, from_node_id, to_node_id, relation_type, base_score, usage_score, final_score, status,
2188
+ generator, evidence_json, last_computed_at, expires_at, metadata_json
2189
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2190
+ ON CONFLICT(from_node_id, to_node_id, relation_type, generator) DO UPDATE SET
2191
+ base_score = excluded.base_score,
2192
+ usage_score = excluded.usage_score,
2193
+ final_score = excluded.final_score,
2194
+ status = excluded.status,
2195
+ evidence_json = excluded.evidence_json,
2196
+ last_computed_at = excluded.last_computed_at,
2197
+ expires_at = excluded.expires_at,
2198
+ metadata_json = excluded.metadata_json`)
2199
+ .run(id, input.fromNodeId, input.toNodeId, input.relationType, input.baseScore, input.usageScore, input.finalScore, input.status, input.generator, JSON.stringify(input.evidence), now, input.expiresAt ?? null, JSON.stringify(input.metadata));
2200
+ return this.getInferredRelationByIdentity(input.fromNodeId, input.toNodeId, input.relationType, input.generator);
2201
+ }
2202
+ getRelation(id) {
2203
+ const row = this.db.prepare(`SELECT * FROM relations WHERE id = ?`).get(id);
2204
+ return mapRelation(assertPresent(row, `Relation ${id} not found`));
2205
+ }
2206
+ getInferredRelation(id) {
2207
+ const row = this.db
2208
+ .prepare(`SELECT * FROM inferred_relations WHERE id = ?`)
2209
+ .get(id);
2210
+ return mapInferredRelation(assertPresent(row, `Inferred relation ${id} not found`));
2211
+ }
2212
+ getInferredRelationByIdentity(fromNodeId, toNodeId, relationType, generator) {
2213
+ const row = this.db
2214
+ .prepare(`SELECT * FROM inferred_relations
2215
+ WHERE from_node_id = ? AND to_node_id = ? AND relation_type = ? AND generator = ?`)
2216
+ .get(fromNodeId, toNodeId, relationType, generator);
2217
+ return mapInferredRelation(assertPresent(row, `Inferred relation ${fromNodeId}:${toNodeId}:${relationType}:${generator} not found`));
2218
+ }
2219
+ listInferredRelationsForNode(nodeId, limit = 20, status = "active") {
2220
+ const rows = this.db
2221
+ .prepare(`SELECT * FROM inferred_relations
2222
+ WHERE status = ?
2223
+ AND (from_node_id = ? OR to_node_id = ?)
2224
+ AND (expires_at IS NULL OR expires_at > ?)
2225
+ ORDER BY final_score DESC, last_computed_at DESC
2226
+ LIMIT ?`)
2227
+ .all(status, nodeId, nodeId, nowIso(), limit);
2228
+ return rows.map(mapInferredRelation);
2229
+ }
2230
+ listInferredRelationsBetweenNodeIds(nodeIds, limit = 100, status = "active") {
2231
+ if (!nodeIds.length) {
2232
+ return [];
2233
+ }
2234
+ const uniqueIds = Array.from(new Set(nodeIds));
2235
+ const placeholders = uniqueIds.map(() => "?").join(", ");
2236
+ const rows = this.db
2237
+ .prepare(`SELECT *
2238
+ FROM inferred_relations
2239
+ WHERE status = ?
2240
+ AND from_node_id IN (${placeholders})
2241
+ AND to_node_id IN (${placeholders})
2242
+ AND (expires_at IS NULL OR expires_at > ?)
2243
+ ORDER BY final_score DESC, last_computed_at DESC, id ASC
2244
+ LIMIT ?`)
2245
+ .all(status, ...uniqueIds, ...uniqueIds, nowIso(), limit);
2246
+ return rows.map(mapInferredRelation);
2247
+ }
2248
+ expireAutoInferredRelationsForNode(nodeId, generators, keepRelationIds = []) {
2249
+ if (!generators.length) {
2250
+ return 0;
2251
+ }
2252
+ const where = [
2253
+ `(from_node_id = ? OR to_node_id = ?)`,
2254
+ `generator IN (${generators.map(() => "?").join(", ")})`,
2255
+ `status != 'expired'`
2256
+ ];
2257
+ const values = [nodeId, nodeId, ...generators];
2258
+ if (keepRelationIds.length) {
2259
+ where.push(`id NOT IN (${keepRelationIds.map(() => "?").join(", ")})`);
2260
+ values.push(...keepRelationIds);
2261
+ }
2262
+ const result = this.db
2263
+ .prepare(`UPDATE inferred_relations
2264
+ SET status = 'expired', last_computed_at = ?
2265
+ WHERE ${where.join(" AND ")}`)
2266
+ .run(nowIso(), ...values);
2267
+ return Number(result.changes ?? 0);
2268
+ }
2269
+ appendRelationUsageEvent(input) {
2270
+ const id = createId("rue");
2271
+ const now = nowIso();
2272
+ this.runInTransaction(() => {
2273
+ const result = this.db
2274
+ .prepare(`INSERT INTO relation_usage_events (
2275
+ id, relation_id, relation_source, event_type, session_id, run_id, actor_type, actor_label,
2276
+ tool_name, delta, created_at, metadata_json
2277
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2278
+ .run(id, input.relationId, input.relationSource, input.eventType, input.sessionId ?? null, input.runId ?? null, input.source?.actorType ?? null, input.source?.actorLabel ?? null, input.source?.toolName ?? null, input.delta, now, JSON.stringify(input.metadata));
2279
+ const rowid = Number(result.lastInsertRowid ?? 0);
2280
+ this.db
2281
+ .prepare(`INSERT INTO relation_usage_rollups (
2282
+ relation_id, total_delta, event_count, last_event_at, last_event_rowid, updated_at
2283
+ ) VALUES (?, ?, ?, ?, ?, ?)
2284
+ ON CONFLICT(relation_id) DO UPDATE SET
2285
+ total_delta = total_delta + excluded.total_delta,
2286
+ event_count = event_count + excluded.event_count,
2287
+ last_event_at = CASE
2288
+ WHEN excluded.last_event_at > last_event_at THEN excluded.last_event_at
2289
+ ELSE last_event_at
2290
+ END,
2291
+ last_event_rowid = CASE
2292
+ WHEN excluded.last_event_rowid > last_event_rowid THEN excluded.last_event_rowid
2293
+ ELSE last_event_rowid
2294
+ END,
2295
+ updated_at = excluded.updated_at`)
2296
+ .run(input.relationId, input.delta, 1, now, rowid, now);
2297
+ this.ensureRelationUsageRollupState(now);
2298
+ this.db
2299
+ .prepare(`UPDATE relation_usage_rollup_state
2300
+ SET last_event_rowid = CASE
2301
+ WHEN ? > last_event_rowid THEN ?
2302
+ ELSE last_event_rowid
2303
+ END,
2304
+ updated_at = ?
2305
+ WHERE id = ?`)
2306
+ .run(rowid, rowid, now, RELATION_USAGE_ROLLUP_STATE_ID);
2307
+ });
2308
+ return this.getRelationUsageEvent(id);
2309
+ }
2310
+ getRelationUsageEvent(id) {
2311
+ const row = this.db
2312
+ .prepare(`SELECT * FROM relation_usage_events WHERE id = ?`)
2313
+ .get(id);
2314
+ return mapRelationUsageEvent(assertPresent(row, `Relation usage event ${id} not found`));
2315
+ }
2316
+ listRelationUsageEvents(relationId, limit = 50) {
2317
+ const rows = this.db
2318
+ .prepare(`SELECT * FROM relation_usage_events
2319
+ WHERE relation_id = ?
2320
+ ORDER BY created_at DESC
2321
+ LIMIT ?`)
2322
+ .all(relationId, limit);
2323
+ return rows.map(mapRelationUsageEvent);
2324
+ }
2325
+ getRelationUsageSummaries(relationIds) {
2326
+ if (!relationIds.length) {
2327
+ return new Map();
2328
+ }
2329
+ const uniqueIds = Array.from(new Set(relationIds));
2330
+ const readRows = () => this.db
2331
+ .prepare(`SELECT
2332
+ relation_id,
2333
+ total_delta,
2334
+ event_count,
2335
+ last_event_at
2336
+ FROM relation_usage_rollups
2337
+ WHERE relation_id IN (${uniqueIds.map(() => "?").join(", ")})
2338
+ ORDER BY relation_id`)
2339
+ .all(...uniqueIds);
2340
+ let rows = readRows();
2341
+ if (rows.length < uniqueIds.length) {
2342
+ this.syncRelationUsageRollups();
2343
+ rows = readRows();
2344
+ }
2345
+ return new Map(rows.map((row) => [
2346
+ String(row.relation_id),
2347
+ {
2348
+ relationId: String(row.relation_id),
2349
+ totalDelta: Number(row.total_delta),
2350
+ eventCount: Number(row.event_count),
2351
+ lastEventAt: row.last_event_at ? String(row.last_event_at) : null
2352
+ }
2353
+ ]));
2354
+ }
2355
+ appendSearchFeedbackEvent(input) {
2356
+ const id = createId("sfe");
2357
+ const now = nowIso();
2358
+ const delta = computeSearchFeedbackDelta(input.verdict, input.confidence);
2359
+ this.runInTransaction(() => {
2360
+ this.db
2361
+ .prepare(`INSERT INTO search_feedback_events (
2362
+ id, result_type, result_id, verdict, query, session_id, run_id, actor_type, actor_label,
2363
+ tool_name, confidence, delta, created_at, metadata_json
2364
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2365
+ .run(id, input.resultType, input.resultId, input.verdict, input.query ?? null, input.sessionId ?? null, input.runId ?? null, input.source?.actorType ?? null, input.source?.actorLabel ?? null, input.source?.toolName ?? null, input.confidence, delta, now, JSON.stringify(input.metadata));
2366
+ this.db
2367
+ .prepare(`INSERT INTO search_feedback_rollups (
2368
+ result_type, result_id, total_delta, event_count, useful_count, not_useful_count, uncertain_count, last_event_at, updated_at
2369
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2370
+ ON CONFLICT(result_type, result_id) DO UPDATE SET
2371
+ total_delta = total_delta + excluded.total_delta,
2372
+ event_count = event_count + excluded.event_count,
2373
+ useful_count = useful_count + excluded.useful_count,
2374
+ not_useful_count = not_useful_count + excluded.not_useful_count,
2375
+ uncertain_count = uncertain_count + excluded.uncertain_count,
2376
+ last_event_at = CASE
2377
+ WHEN excluded.last_event_at > last_event_at THEN excluded.last_event_at
2378
+ ELSE last_event_at
2379
+ END,
2380
+ updated_at = excluded.updated_at`)
2381
+ .run(input.resultType, input.resultId, delta, 1, input.verdict === "useful" ? 1 : 0, input.verdict === "not_useful" ? 1 : 0, input.verdict === "uncertain" ? 1 : 0, now, now);
2382
+ });
2383
+ return this.getSearchFeedbackEvent(id);
2384
+ }
2385
+ getSearchFeedbackEvent(id) {
2386
+ const row = this.db
2387
+ .prepare(`SELECT * FROM search_feedback_events WHERE id = ?`)
2388
+ .get(id);
2389
+ return mapSearchFeedbackEvent(assertPresent(row, `Search feedback event ${id} not found`));
2390
+ }
2391
+ listSearchFeedbackEvents(resultType, resultId, limit = 50) {
2392
+ const rows = this.db
2393
+ .prepare(`SELECT * FROM search_feedback_events
2394
+ WHERE result_type = ? AND result_id = ?
2395
+ ORDER BY created_at DESC
2396
+ LIMIT ?`)
2397
+ .all(resultType, resultId, limit);
2398
+ return rows.map(mapSearchFeedbackEvent);
2399
+ }
2400
+ getSearchFeedbackSummaries(resultType, resultIds) {
2401
+ if (!resultIds.length) {
2402
+ return new Map();
2403
+ }
2404
+ const rows = this.db
2405
+ .prepare(`SELECT
2406
+ result_id,
2407
+ total_delta,
2408
+ event_count,
2409
+ useful_count,
2410
+ not_useful_count,
2411
+ uncertain_count,
2412
+ last_event_at
2413
+ FROM search_feedback_rollups
2414
+ WHERE result_type = ?
2415
+ AND result_id IN (${resultIds.map(() => "?").join(", ")})
2416
+ ORDER BY result_id`)
2417
+ .all(resultType, ...resultIds);
2418
+ return new Map(rows.map((row) => [
2419
+ String(row.result_id),
2420
+ {
2421
+ resultType,
2422
+ resultId: String(row.result_id),
2423
+ totalDelta: Number(row.total_delta),
2424
+ eventCount: Number(row.event_count),
2425
+ usefulCount: Number(row.useful_count),
2426
+ notUsefulCount: Number(row.not_useful_count),
2427
+ uncertainCount: Number(row.uncertain_count),
2428
+ lastEventAt: row.last_event_at ? String(row.last_event_at) : null
2429
+ }
2430
+ ]));
2431
+ }
2432
+ appendGovernanceEvent(params) {
2433
+ const id = createId("gov");
2434
+ const now = nowIso();
2435
+ const confidence = clampConfidence(params.confidence);
2436
+ const metadata = params.metadata ?? {};
2437
+ this.db
2438
+ .prepare(`INSERT INTO governance_events (
2439
+ id, entity_type, entity_id, event_type, previous_state, next_state, confidence, reason, created_at, metadata_json
2440
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2441
+ .run(id, params.entityType, params.entityId, params.eventType, params.previousState ?? null, params.nextState, confidence, params.reason, now, JSON.stringify(metadata));
2442
+ return {
2443
+ id,
2444
+ entityType: params.entityType,
2445
+ entityId: params.entityId,
2446
+ eventType: params.eventType,
2447
+ previousState: params.previousState,
2448
+ nextState: params.nextState,
2449
+ confidence,
2450
+ reason: params.reason,
2451
+ createdAt: now,
2452
+ metadata
2453
+ };
2454
+ }
2455
+ getGovernanceEvent(id) {
2456
+ const row = this.db
2457
+ .prepare(`SELECT * FROM governance_events WHERE id = ?`)
2458
+ .get(id);
2459
+ return mapGovernanceEvent(assertPresent(row, `Governance event ${id} not found`));
2460
+ }
2461
+ listGovernanceEvents(entityType, entityId, limit = 20) {
2462
+ const rows = this.db
2463
+ .prepare(`SELECT * FROM governance_events
2464
+ WHERE entity_type = ? AND entity_id = ?
2465
+ ORDER BY created_at DESC
2466
+ LIMIT ?`)
2467
+ .all(entityType, entityId, limit);
2468
+ return rows.map(mapGovernanceEvent);
2469
+ }
2470
+ upsertGovernanceState(params) {
2471
+ const now = params.lastEvaluatedAt ?? nowIso();
2472
+ const existing = params.previousState === undefined
2473
+ ? this.getGovernanceStateNullable(params.entityType, params.entityId)
2474
+ : params.previousState;
2475
+ const lastTransitionAt = existing?.state === params.state ? existing.lastTransitionAt : now;
2476
+ this.db
2477
+ .prepare(`INSERT INTO governance_state (
2478
+ entity_type, entity_id, state, confidence, reasons_json, last_evaluated_at, last_transition_at, metadata_json
2479
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2480
+ ON CONFLICT(entity_type, entity_id) DO UPDATE SET
2481
+ state = excluded.state,
2482
+ confidence = excluded.confidence,
2483
+ reasons_json = excluded.reasons_json,
2484
+ last_evaluated_at = excluded.last_evaluated_at,
2485
+ last_transition_at = excluded.last_transition_at,
2486
+ metadata_json = excluded.metadata_json`)
2487
+ .run(params.entityType, params.entityId, params.state, clampConfidence(params.confidence), JSON.stringify(params.reasons), now, lastTransitionAt, JSON.stringify(params.metadata ?? {}));
2488
+ return {
2489
+ entityType: params.entityType,
2490
+ entityId: params.entityId,
2491
+ state: params.state,
2492
+ confidence: clampConfidence(params.confidence),
2493
+ reasons: [...params.reasons],
2494
+ lastEvaluatedAt: now,
2495
+ lastTransitionAt,
2496
+ metadata: params.metadata ?? {}
2497
+ };
2498
+ }
2499
+ getGovernanceState(entityType, entityId) {
2500
+ const row = this.db
2501
+ .prepare(`SELECT * FROM governance_state WHERE entity_type = ? AND entity_id = ?`)
2502
+ .get(entityType, entityId);
2503
+ return mapGovernanceState(assertPresent(row, `Governance state ${entityType}:${entityId} not found`));
2504
+ }
2505
+ getGovernanceStateNullable(entityType, entityId) {
2506
+ const row = this.db
2507
+ .prepare(`SELECT * FROM governance_state WHERE entity_type = ? AND entity_id = ?`)
2508
+ .get(entityType, entityId);
2509
+ return row ? mapGovernanceState(row) : null;
2510
+ }
2511
+ listGovernanceIssues(limit = 20, states) {
2512
+ const effectiveStates = states?.length ? states : ["low_confidence", "contested"];
2513
+ const nodeRows = this.db
2514
+ .prepare(`SELECT
2515
+ gs.*,
2516
+ n.title AS display_title,
2517
+ n.type AS display_subtitle
2518
+ FROM governance_state gs
2519
+ JOIN nodes n
2520
+ ON gs.entity_type = 'node'
2521
+ AND gs.entity_id = n.id
2522
+ WHERE gs.state IN (${effectiveStates.map(() => "?").join(", ")})
2523
+ AND n.status != 'archived'
2524
+ ORDER BY CASE WHEN gs.state = 'contested' THEN 0 ELSE 1 END, gs.confidence ASC, gs.last_transition_at DESC
2525
+ LIMIT ?`)
2526
+ .all(...effectiveStates, limit);
2527
+ const relationRows = this.db
2528
+ .prepare(`SELECT
2529
+ gs.*,
2530
+ COALESCE(fn.title, r.from_node_id) || ' ' || r.relation_type || ' ' || COALESCE(tn.title, r.to_node_id) AS display_title,
2531
+ r.status AS display_subtitle
2532
+ FROM governance_state gs
2533
+ JOIN relations r
2534
+ ON gs.entity_type = 'relation'
2535
+ AND gs.entity_id = r.id
2536
+ LEFT JOIN nodes fn ON fn.id = r.from_node_id
2537
+ LEFT JOIN nodes tn ON tn.id = r.to_node_id
2538
+ WHERE gs.state IN (${effectiveStates.map(() => "?").join(", ")})
2539
+ AND r.status != 'archived'
2540
+ ORDER BY CASE WHEN gs.state = 'contested' THEN 0 ELSE 1 END, gs.confidence ASC, gs.last_transition_at DESC
2541
+ LIMIT ?`)
2542
+ .all(...effectiveStates, limit);
2543
+ return [...nodeRows, ...relationRows]
2544
+ .map((row) => ({
2545
+ ...mapGovernanceState(row),
2546
+ title: row.display_title ? String(row.display_title) : null,
2547
+ subtitle: row.display_subtitle ? String(row.display_subtitle) : null
2548
+ }))
2549
+ .sort((left, right) => {
2550
+ const leftPriority = left.state === "contested" ? 0 : left.state === "low_confidence" ? 1 : 2;
2551
+ const rightPriority = right.state === "contested" ? 0 : right.state === "low_confidence" ? 1 : 2;
2552
+ return leftPriority - rightPriority || left.confidence - right.confidence || right.lastTransitionAt.localeCompare(left.lastTransitionAt);
2553
+ })
2554
+ .slice(0, limit);
2555
+ }
2556
+ listNodeIdsForGovernance(limit = 100, entityIds) {
2557
+ const rows = entityIds?.length
2558
+ ? this.db
2559
+ .prepare(`SELECT id
2560
+ FROM nodes
2561
+ WHERE id IN (${entityIds.map(() => "?").join(", ")})
2562
+ ORDER BY updated_at DESC
2563
+ LIMIT ?`)
2564
+ .all(...entityIds, limit)
2565
+ : this.db
2566
+ .prepare(`SELECT id
2567
+ FROM nodes
2568
+ WHERE status != 'archived'
2569
+ ORDER BY updated_at DESC
2570
+ LIMIT ?`)
2571
+ .all(limit);
2572
+ return rows.map((row) => String(row.id));
2573
+ }
2574
+ listRelationIdsForGovernance(limit = 100, entityIds) {
2575
+ const rows = entityIds?.length
2576
+ ? this.db
2577
+ .prepare(`SELECT id
2578
+ FROM relations
2579
+ WHERE id IN (${entityIds.map(() => "?").join(", ")})
2580
+ ORDER BY created_at DESC
2581
+ LIMIT ?`)
2582
+ .all(...entityIds, limit)
2583
+ : this.db
2584
+ .prepare(`SELECT id
2585
+ FROM relations
2586
+ WHERE status != 'archived'
2587
+ ORDER BY created_at DESC
2588
+ LIMIT ?`)
2589
+ .all(limit);
2590
+ return rows.map((row) => String(row.id));
2591
+ }
2592
+ countContradictionRelations(nodeId) {
2593
+ const row = this.db
2594
+ .prepare(`SELECT COUNT(*) AS total
2595
+ FROM relations
2596
+ WHERE relation_type = 'contradicts'
2597
+ AND status = 'active'
2598
+ AND (from_node_id = ? OR to_node_id = ?)`)
2599
+ .get(nodeId, nodeId);
2600
+ return Number(row.total ?? 0);
2601
+ }
2602
+ listLegacyReviewItems(limit = 500) {
2603
+ if (!this.hasLegacyReviewQueueTable()) {
2604
+ return [];
2605
+ }
2606
+ const rows = this.db
2607
+ .prepare(`SELECT * FROM review_queue ORDER BY created_at DESC LIMIT ?`)
2608
+ .all(limit);
2609
+ return rows.map(mapLegacyReviewQueue);
2610
+ }
2611
+ clearLegacyReviewQueue() {
2612
+ if (!this.hasLegacyReviewQueueTable()) {
2613
+ return;
2614
+ }
2615
+ this.db.prepare(`DELETE FROM review_queue`).run();
2616
+ }
2617
+ hasLegacyReviewQueueTable() {
2618
+ const row = this.db
2619
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'review_queue'`)
2620
+ .get();
2621
+ return Boolean(row?.name);
2622
+ }
2623
+ ensureLegacyReviewQueueTable() {
2624
+ this.db.exec(`
2625
+ CREATE TABLE IF NOT EXISTS review_queue (
2626
+ id TEXT PRIMARY KEY,
2627
+ entity_type TEXT NOT NULL,
2628
+ entity_id TEXT NOT NULL,
2629
+ review_type TEXT NOT NULL,
2630
+ proposed_by TEXT,
2631
+ created_at TEXT NOT NULL,
2632
+ status TEXT NOT NULL DEFAULT 'pending',
2633
+ notes TEXT,
2634
+ metadata_json TEXT
2635
+ );
2636
+ CREATE INDEX IF NOT EXISTS idx_review_queue_status ON review_queue(status);
2637
+ CREATE INDEX IF NOT EXISTS idx_review_queue_status_type_created_at
2638
+ ON review_queue(status, review_type, created_at DESC);
2639
+ `);
2640
+ }
2641
+ createLegacyReviewItem(params) {
2642
+ this.ensureLegacyReviewQueueTable();
2643
+ const id = createId("rev");
2644
+ this.db
2645
+ .prepare(`INSERT INTO review_queue (
2646
+ id, entity_type, entity_id, review_type, proposed_by, created_at, status, notes, metadata_json
2647
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2648
+ .run(id, params.entityType, params.entityId, params.reviewType, params.proposedBy, nowIso(), params.status ?? "pending", params.notes ?? null, JSON.stringify(params.metadata ?? {}));
2649
+ const row = this.db.prepare(`SELECT * FROM review_queue WHERE id = ?`).get(id);
2650
+ return mapLegacyReviewQueue(assertPresent(row, `Legacy review item ${id} not found`));
2651
+ }
2652
+ recomputeGovernanceTargets(input) {
2653
+ const limit = input.limit;
2654
+ const targetIds = input.entityIds?.length ? input.entityIds : undefined;
2655
+ const nodeIds = !input.entityType || input.entityType === "node" ? this.listNodeIdsForGovernance(limit, targetIds) : [];
2656
+ const relationIds = !input.entityType || input.entityType === "relation" ? this.listRelationIdsForGovernance(limit, targetIds) : [];
2657
+ return { nodeIds, relationIds };
2658
+ }
2659
+ getPendingRelationUsageStats(since) {
2660
+ const whereClause = since ? "WHERE created_at > ?" : "";
2661
+ const bindings = since ? [since] : [];
2662
+ const statsRow = this.db
2663
+ .prepare(`SELECT
2664
+ COUNT(*) AS event_count,
2665
+ MIN(created_at) AS earliest_event_at,
2666
+ MAX(created_at) AS latest_event_at
2667
+ FROM relation_usage_events
2668
+ ${whereClause}`)
2669
+ .get(...bindings);
2670
+ const relationRows = this.db
2671
+ .prepare(`SELECT
2672
+ relation_id,
2673
+ MAX(created_at) AS latest_event_at
2674
+ FROM relation_usage_events
2675
+ ${whereClause}
2676
+ GROUP BY relation_id
2677
+ ORDER BY latest_event_at DESC`)
2678
+ .all(...bindings);
2679
+ return {
2680
+ relationIds: relationRows.map((row) => String(row.relation_id)),
2681
+ eventCount: Number(statsRow.event_count ?? 0),
2682
+ earliestEventAt: statsRow.earliest_event_at ? String(statsRow.earliest_event_at) : null,
2683
+ latestEventAt: statsRow.latest_event_at ? String(statsRow.latest_event_at) : null
2684
+ };
2685
+ }
2686
+ recomputeInferredRelationScores(input) {
2687
+ const where = [];
2688
+ const values = [];
2689
+ if (!input.relationIds?.length) {
2690
+ where.push(`status != 'expired'`);
2691
+ }
2692
+ if (input.generator) {
2693
+ where.push("generator = ?");
2694
+ values.push(input.generator);
2695
+ }
2696
+ if (input.relationIds?.length) {
2697
+ where.push(`id IN (${input.relationIds.map(() => "?").join(", ")})`);
2698
+ values.push(...input.relationIds);
2699
+ }
2700
+ const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
2701
+ const rows = this.db
2702
+ .prepare(`SELECT * FROM inferred_relations
2703
+ ${whereClause}
2704
+ ORDER BY last_computed_at ASC
2705
+ LIMIT ?`)
2706
+ .all(...values, input.limit);
2707
+ if (!rows.length) {
2708
+ return {
2709
+ updatedCount: 0,
2710
+ expiredCount: 0,
2711
+ items: []
2712
+ };
2713
+ }
2714
+ const relationIds = rows.map((row) => String(row.id));
2715
+ const summaries = this.getRelationUsageSummaries(relationIds);
2716
+ const now = nowIso();
2717
+ const updateStatement = this.db.prepare(`UPDATE inferred_relations
2718
+ SET usage_score = ?, final_score = ?, status = ?, last_computed_at = ?
2719
+ WHERE id = ?`);
2720
+ let expiredCount = 0;
2721
+ const items = [];
2722
+ this.runInTransaction(() => {
2723
+ for (const row of rows) {
2724
+ const id = String(row.id);
2725
+ const currentStatus = String(row.status);
2726
+ const expiresAt = row.expires_at ? String(row.expires_at) : null;
2727
+ const recomputed = computeMaintainedScores(Number(row.base_score), summaries.get(id), String(row.last_computed_at));
2728
+ const nextStatus = expiresAt && expiresAt <= now ? "expired" : currentStatus;
2729
+ if (nextStatus === "expired") {
2730
+ expiredCount += 1;
2731
+ }
2732
+ updateStatement.run(recomputed.usageScore, recomputed.finalScore, nextStatus, now, id);
2733
+ items.push(mapInferredRelation({
2734
+ ...row,
2735
+ usage_score: recomputed.usageScore,
2736
+ final_score: recomputed.finalScore,
2737
+ status: nextStatus,
2738
+ last_computed_at: now
2739
+ }));
2740
+ }
2741
+ });
2742
+ return {
2743
+ updatedCount: rows.length,
2744
+ expiredCount,
2745
+ items
2746
+ };
2747
+ }
2748
+ countInferredRelations(status) {
2749
+ const row = status
2750
+ ? this.db.prepare(`SELECT COUNT(*) AS total FROM inferred_relations WHERE status = ?`).get(status)
2751
+ : this.db.prepare(`SELECT COUNT(*) AS total FROM inferred_relations`).get();
2752
+ return Number(row.total ?? 0);
2753
+ }
2754
+ updateRelationStatus(id, status) {
2755
+ this.db.prepare(`UPDATE relations SET status = ? WHERE id = ?`).run(status, id);
2756
+ return this.getRelation(id);
2757
+ }
2758
+ listNodeActivities(nodeId, limit = 20) {
2759
+ const rows = this.db
2760
+ .prepare(`SELECT * FROM activities WHERE target_node_id = ? ORDER BY created_at DESC LIMIT ?`)
2761
+ .all(nodeId, limit);
2762
+ return rows.map(mapActivity);
2763
+ }
2764
+ listActivitiesForNodeIds(nodeIds, limit = 200) {
2765
+ if (!nodeIds.length) {
2766
+ return [];
2767
+ }
2768
+ const uniqueIds = Array.from(new Set(nodeIds));
2769
+ const rows = this.db
2770
+ .prepare(`SELECT *
2771
+ FROM activities
2772
+ WHERE target_node_id IN (${uniqueIds.map(() => "?").join(", ")})
2773
+ ORDER BY created_at ASC, id ASC
2774
+ LIMIT ?`)
2775
+ .all(...uniqueIds, limit);
2776
+ return rows.map(mapActivity);
2777
+ }
2778
+ appendActivity(input) {
2779
+ const id = createId("act");
2780
+ const now = nowIso();
2781
+ this.db
2782
+ .prepare(`INSERT INTO activities (
2783
+ id, target_node_id, activity_type, body, created_by, source_type, source_label, created_at, metadata_json
2784
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2785
+ .run(id, input.targetNodeId, input.activityType, input.body, input.source.actorLabel, input.source.actorType, input.source.actorLabel, now, JSON.stringify(input.metadata));
2786
+ this.touchNode(input.targetNodeId);
2787
+ this.markNodeSemanticIndexState(input.targetNodeId, "activity.appended", {
2788
+ status: "pending",
2789
+ updatedAt: now
2790
+ });
2791
+ return this.getActivity(id);
2792
+ }
2793
+ getActivity(id) {
2794
+ const row = this.db.prepare(`SELECT * FROM activities WHERE id = ?`).get(id);
2795
+ return mapActivity(assertPresent(row, `Activity ${id} not found`));
2796
+ }
2797
+ attachArtifact(input) {
2798
+ const id = createId("art");
2799
+ const now = nowIso();
2800
+ const absolutePath = path.isAbsolute(input.path) ? input.path : path.resolve(this.workspaceRoot, input.path);
2801
+ const realWorkspaceRoot = realpathSync(this.workspaceRoot);
2802
+ if (!isPathWithinRoot(this.workspaceRoot, absolutePath)) {
2803
+ throw new AppError(403, "FORBIDDEN", "Artifact path escapes workspace root.");
2804
+ }
2805
+ const artifactRoot = path.join(this.workspaceRoot, "artifacts");
2806
+ const realArtifactRoot = realpathSync(artifactRoot);
2807
+ if (!isPathWithinRoot(artifactRoot, absolutePath)) {
2808
+ throw new AppError(403, "FORBIDDEN", "Artifact path must stay inside the workspace artifacts directory.");
2809
+ }
2810
+ let resolvedPath = "";
2811
+ let stats = null;
2812
+ try {
2813
+ const entryStats = lstatSync(absolutePath);
2814
+ if (entryStats.isSymbolicLink()) {
2815
+ throw new AppError(403, "FORBIDDEN", "Artifact path must not be a symbolic link.");
2816
+ }
2817
+ resolvedPath = realpathSync(absolutePath);
2818
+ if (!isPathWithinRoot(realWorkspaceRoot, resolvedPath)) {
2819
+ throw new AppError(403, "FORBIDDEN", "Artifact path escapes workspace root.");
2820
+ }
2821
+ if (!isPathWithinRoot(realArtifactRoot, resolvedPath)) {
2822
+ throw new AppError(403, "FORBIDDEN", "Artifact path must stay inside the workspace artifacts directory.");
2823
+ }
2824
+ stats = statSync(resolvedPath);
2825
+ if (!stats.isFile()) {
2826
+ throw new AppError(400, "INVALID_INPUT", "Artifact path must reference a regular file.");
2827
+ }
2828
+ }
2829
+ catch (error) {
2830
+ if (error instanceof AppError) {
2831
+ throw error;
2832
+ }
2833
+ if (error instanceof Error &&
2834
+ "code" in error &&
2835
+ (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "ELOOP")) {
2836
+ throw new AppError(404, "NOT_FOUND", "Artifact path does not exist.");
2837
+ }
2838
+ throw error;
2839
+ }
2840
+ if (!stats) {
2841
+ throw new AppError(500, "INTERNAL_ERROR", "Artifact metadata could not be read.");
2842
+ }
2843
+ this.db
2844
+ .prepare(`INSERT INTO artifacts (
2845
+ id, node_id, path, mime_type, size_bytes, checksum, created_by, source_label, created_at, metadata_json
2846
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2847
+ .run(id, input.nodeId, normalizeArtifactPath(path.relative(this.workspaceRoot, absolutePath)), input.mimeType ?? null, stats.size, checksumText(`${resolvedPath}:${stats.size}:${stats.mtimeMs}`), input.source.actorLabel, input.source.actorLabel, now, JSON.stringify(input.metadata));
2848
+ this.markNodeSemanticIndexState(input.nodeId, "artifact.attached", {
2849
+ status: "pending",
2850
+ updatedAt: now
2851
+ });
2852
+ return this.getArtifact(id);
2853
+ }
2854
+ listArtifacts(nodeId) {
2855
+ const rows = this.db
2856
+ .prepare(`SELECT * FROM artifacts WHERE node_id = ? ORDER BY created_at DESC`)
2857
+ .all(nodeId);
2858
+ return rows.map(mapArtifact);
2859
+ }
2860
+ getArtifact(id) {
2861
+ const row = this.db.prepare(`SELECT * FROM artifacts WHERE id = ?`).get(id);
2862
+ return mapArtifact(assertPresent(row, `Artifact ${id} not found`));
2863
+ }
2864
+ getWorkspaceKey() {
2865
+ return this.workspaceKey;
2866
+ }
2867
+ hasArtifactAtPath(relativePath) {
2868
+ const normalizedPath = normalizeArtifactPath(relativePath);
2869
+ const row = this.db
2870
+ .prepare(`SELECT 1 AS present FROM artifacts WHERE path = ? LIMIT 1`)
2871
+ .get(normalizedPath);
2872
+ return Boolean(row?.present);
2873
+ }
2874
+ recordProvenance(params) {
2875
+ const id = createId("prov");
2876
+ const timestamp = nowIso();
2877
+ this.db
2878
+ .prepare(`INSERT INTO provenance_events (
2879
+ id, entity_type, entity_id, operation_type, actor_type, actor_label, tool_name, tool_version,
2880
+ timestamp, input_ref, metadata_json
2881
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2882
+ .run(id, params.entityType, params.entityId, params.operationType, params.source.actorType, params.source.actorLabel, params.source.toolName, params.source.toolVersion ?? null, timestamp, params.inputRef ?? null, JSON.stringify(params.metadata ?? {}));
2883
+ return this.getProvenance(id);
2884
+ }
2885
+ listProvenance(entityType, entityId) {
2886
+ const rows = this.db
2887
+ .prepare(`SELECT * FROM provenance_events
2888
+ WHERE entity_type = ? AND entity_id = ?
2889
+ ORDER BY timestamp DESC`)
2890
+ .all(entityType, entityId);
2891
+ return rows.map(mapProvenance);
2892
+ }
2893
+ getProvenance(id) {
2894
+ const row = this.db
2895
+ .prepare(`SELECT * FROM provenance_events WHERE id = ?`)
2896
+ .get(id);
2897
+ return mapProvenance(assertPresent(row, `Provenance ${id} not found`));
2898
+ }
2899
+ listIntegrations() {
2900
+ const rows = this.db
2901
+ .prepare(`SELECT * FROM integrations ORDER BY updated_at DESC`)
2902
+ .all();
2903
+ return rows.map(mapIntegration);
2904
+ }
2905
+ registerIntegration(input) {
2906
+ const id = createId("int");
2907
+ const now = nowIso();
2908
+ this.db
2909
+ .prepare(`INSERT INTO integrations (
2910
+ id, name, kind, status, capabilities_json, config_json, created_at, updated_at
2911
+ ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?)`)
2912
+ .run(id, input.name, input.kind, JSON.stringify(input.capabilities), JSON.stringify(input.config), now, now);
2913
+ return this.getIntegration(id);
2914
+ }
2915
+ updateIntegration(id, input) {
2916
+ const existing = this.getIntegration(id);
2917
+ this.db
2918
+ .prepare(`UPDATE integrations
2919
+ SET name = ?, status = ?, capabilities_json = ?, config_json = ?, updated_at = ?
2920
+ WHERE id = ?`)
2921
+ .run(input.name ?? existing.name, input.status ?? existing.status, JSON.stringify(input.capabilities ?? existing.capabilities), JSON.stringify(input.config ?? existing.config), nowIso(), id);
2922
+ return this.getIntegration(id);
2923
+ }
2924
+ getIntegration(id) {
2925
+ const row = this.db.prepare(`SELECT * FROM integrations WHERE id = ?`).get(id);
2926
+ return mapIntegration(assertPresent(row, `Integration ${id} not found`));
2927
+ }
2928
+ getSettings(keys) {
2929
+ const rows = keys?.length
2930
+ ? this.db
2931
+ .prepare(`SELECT * FROM settings WHERE key IN (${keys.map(() => "?").join(", ")})`)
2932
+ .all(...keys)
2933
+ : this.db.prepare(`SELECT * FROM settings`).all();
2934
+ return Object.fromEntries(rows.map((row) => [String(row.key), parseJson(row.value_json, null)]));
2935
+ }
2936
+ writeSetting(key, value) {
2937
+ this.db
2938
+ .prepare(`INSERT INTO settings (key, value_json)
2939
+ VALUES (?, ?)
2940
+ ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json`)
2941
+ .run(key, JSON.stringify(value));
2942
+ }
2943
+ isSemanticReindexSettingKey(key) {
2944
+ return (key === "search.semantic.enabled" ||
2945
+ key === "search.semantic.provider" ||
2946
+ key === "search.semantic.model" ||
2947
+ key === "search.semantic.chunk.enabled");
2948
+ }
2949
+ setSetting(key, value) {
2950
+ if (this.isSemanticReindexSettingKey(key)) {
2951
+ this.updateSemanticSetting(key, value);
2952
+ return;
2953
+ }
2954
+ this.writeSetting(key, value);
2955
+ }
2956
+ setSettings(values) {
2957
+ const keys = Object.keys(values);
2958
+ if (!keys.length) {
2959
+ return;
2960
+ }
2961
+ const requiresSemanticCheck = keys.some((key) => this.isSemanticReindexSettingKey(key));
2962
+ const previousSettings = requiresSemanticCheck ? this.readSemanticIndexSettings() : null;
2963
+ for (const [key, value] of Object.entries(values)) {
2964
+ this.writeSetting(key, value);
2965
+ }
2966
+ if (requiresSemanticCheck) {
2967
+ this.writePendingSemanticTransitionKeys([]);
2968
+ }
2969
+ if (!requiresSemanticCheck || !previousSettings) {
2970
+ return;
2971
+ }
2972
+ const nextSettings = this.readSemanticIndexSettings();
2973
+ if (shouldReindexForSemanticConfigChange(previousSettings, nextSettings)) {
2974
+ this.queueSemanticConfigurationReindex();
2975
+ }
2976
+ }
2977
+ setSettingIfMissing(key, value) {
2978
+ this.db
2979
+ .prepare(`INSERT INTO settings (key, value_json)
2980
+ VALUES (?, ?)
2981
+ ON CONFLICT(key) DO NOTHING`)
2982
+ .run(key, JSON.stringify(value));
2983
+ }
2984
+ ensureBaseSettings(settings) {
2985
+ for (const [key, value] of Object.entries(settings)) {
2986
+ this.setSettingIfMissing(key, value);
2987
+ }
2988
+ }
2989
+ upsertBaseSettings(settings) {
2990
+ this.setSettings(settings);
2991
+ }
2992
+ }