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.
- package/README.md +205 -0
- package/app/cli/bin/recallx-mcp.js +2 -0
- package/app/cli/bin/recallx.js +8 -0
- package/app/cli/src/cli.js +808 -0
- package/app/cli/src/format.js +242 -0
- package/app/cli/src/http.js +35 -0
- package/app/mcp/api-client.js +101 -0
- package/app/mcp/index.js +128 -0
- package/app/mcp/server.js +786 -0
- package/app/server/app.js +2263 -0
- package/app/server/config.js +27 -0
- package/app/server/db.js +399 -0
- package/app/server/errors.js +17 -0
- package/app/server/governance.js +466 -0
- package/app/server/index.js +26 -0
- package/app/server/inferred-relations.js +247 -0
- package/app/server/observability.js +495 -0
- package/app/server/project-graph.js +199 -0
- package/app/server/relation-scoring.js +59 -0
- package/app/server/repositories.js +2992 -0
- package/app/server/retrieval.js +486 -0
- package/app/server/semantic/chunker.js +85 -0
- package/app/server/semantic/provider.js +124 -0
- package/app/server/semantic/types.js +1 -0
- package/app/server/semantic/vector-store.js +169 -0
- package/app/server/utils.js +43 -0
- package/app/server/workspace-session.js +128 -0
- package/app/server/workspace.js +79 -0
- package/app/shared/contracts.js +268 -0
- package/app/shared/request-runtime.js +30 -0
- package/app/shared/types.js +1 -0
- package/app/shared/version.js +1 -0
- package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
- package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
- package/dist/renderer/assets/index-CrDu22h7.js +76 -0
- package/dist/renderer/index.html +13 -0
- 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
|
+
}
|