wendkeep 0.85.1 → 0.87.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/.githooks/commit-msg +16 -0
- package/.githooks/prepare-commit-msg +16 -0
- package/CHANGELOG.md +40 -0
- package/README.en.md +4 -1
- package/README.md +4 -1
- package/docs/en/commands/commit.md +159 -0
- package/docs/en/commands/evidence-embeddings.md +243 -0
- package/docs/en/commands/mcp.md +67 -7
- package/docs/pt-BR/commands/commit.md +159 -0
- package/docs/pt-BR/commands/evidence-embeddings.md +244 -0
- package/docs/pt-BR/commands/mcp.md +66 -7
- package/hooks/evidence-context.mjs +41 -7
- package/hooks/evidence-recall.mjs +10 -0
- package/package.json +5 -2
- package/packages/cli/src/index.mjs +11 -1
- package/packages/commit/package.json +6 -0
- package/packages/commit/src/cli.mjs +89 -0
- package/packages/commit/src/commit-input.mjs +181 -0
- package/packages/commit/src/commit-message.mjs +51 -0
- package/packages/commit/src/commit-policy.mjs +144 -0
- package/packages/commit/src/git-runtime.mjs +428 -0
- package/packages/commit/src/index.mjs +28 -0
- package/packages/commit/src/proof-validation.mjs +443 -0
- package/packages/mcp/src/effects.mjs +3 -2
- package/packages/mcp/src/evidence-recall.mjs +130 -0
- package/packages/mcp/src/executor.mjs +4 -0
- package/packages/mcp/src/server.mjs +31 -1
- package/packages/vault/src/evidence-embedding-plugin.mjs +531 -0
- package/packages/vault/src/evidence-index-store.mjs +360 -0
- package/packages/vault/src/evidence-recall-page.mjs +381 -0
- package/packages/vault/src/evidence-search-index.mjs +917 -0
- package/packages/vault/src/index.mjs +12 -1
- package/packages/vault/src/memory-ledger-view-base.mjs +545 -0
- package/packages/vault/src/memory-ledger-view.mjs +41 -0
- package/packages/vault/src/memory-rotation-store.mjs +967 -0
- package/packages/vault/src/memory-segment-store.mjs +820 -0
- package/packages/vault/src/memory-snapshot-store.mjs +1105 -0
- package/packages/vault/src/memory-store-base.mjs +1161 -0
- package/packages/vault/src/memory-store-core.mjs +2 -0
- package/packages/vault/src/memory-store.mjs +46 -1161
- package/schema/commit-message-v1.schema.json +75 -0
- package/scripts/validate-commit-range.mjs +244 -0
- package/src/doctor.mjs +48 -5
- package/src/evidence-search-health.mjs +221 -0
- package/src/git-commit-hooks.mjs +112 -0
- package/src/init.mjs +13 -0
- package/src/memory-scale-health.mjs +210 -0
- package/src/observer-snapshot.mjs +87 -1
- package/src/skills-seed.mjs +79 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EVIDENCE_INDEX_FILE,
|
|
8
|
+
loadEvidenceIndex,
|
|
9
|
+
recallEvidence,
|
|
10
|
+
recallTerms,
|
|
11
|
+
} from './evidence-recall.mjs';
|
|
12
|
+
import {
|
|
13
|
+
filterEvidenceRecallRows,
|
|
14
|
+
normalizeEvidenceRecallFilters,
|
|
15
|
+
} from './evidence-recall-page.mjs';
|
|
16
|
+
import { EVIDENCE_INDEX_STATE_FILE } from './evidence-index-store.mjs';
|
|
17
|
+
import {
|
|
18
|
+
assertVaultPathSafe,
|
|
19
|
+
mkdirVaultPath,
|
|
20
|
+
renameVaultPath,
|
|
21
|
+
unlinkVaultFile,
|
|
22
|
+
writeVaultFileAtomic,
|
|
23
|
+
} from './vault-path-safety.mjs';
|
|
24
|
+
|
|
25
|
+
export const EVIDENCE_SEARCH_STATE_FILE = 'EVIDENCE_SEARCH_STATE.json';
|
|
26
|
+
export const EVIDENCE_SEARCH_STATE_VERSION = 1;
|
|
27
|
+
export const EVIDENCE_SEARCH_DEFAULT_CANDIDATES = 512;
|
|
28
|
+
export const EVIDENCE_SEARCH_MAX_CANDIDATES = 4096;
|
|
29
|
+
export const EVIDENCE_SEARCH_DEFAULT_POSTING_BUDGET = 65_536;
|
|
30
|
+
export const EVIDENCE_SEARCH_MAX_POSTING_BUDGET = 1_048_576;
|
|
31
|
+
|
|
32
|
+
const SEARCH_DIRECTORY = 'evidence-search';
|
|
33
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
34
|
+
const ARTIFACT_PATH = /^evidence-search\/[A-Za-z0-9._-]+$/;
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const lexicalCache = new Map();
|
|
37
|
+
let sqliteCapability = null;
|
|
38
|
+
|
|
39
|
+
function brainDir(vaultBase) {
|
|
40
|
+
return join(vaultBase, '.brain');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function searchDir(vaultBase) {
|
|
44
|
+
return join(brainDir(vaultBase), SEARCH_DIRECTORY);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function searchStatePath(vaultBase) {
|
|
48
|
+
return join(brainDir(vaultBase), EVIDENCE_SEARCH_STATE_FILE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function evidenceIndexPath(vaultBase) {
|
|
52
|
+
return join(brainDir(vaultBase), EVIDENCE_INDEX_FILE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function evidenceIndexStatePath(vaultBase) {
|
|
56
|
+
return join(brainDir(vaultBase), EVIDENCE_INDEX_STATE_FILE);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sha256(value) {
|
|
60
|
+
return createHash('sha256').update(value).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function compareText(left, right) {
|
|
64
|
+
const a = String(left ?? '');
|
|
65
|
+
const b = String(right ?? '');
|
|
66
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function canonicalize(value) {
|
|
70
|
+
if (Array.isArray(value)) return value.map((item) => canonicalize(item));
|
|
71
|
+
if (!value || typeof value !== 'object') return value;
|
|
72
|
+
return Object.fromEntries(Object.keys(value).sort(compareText)
|
|
73
|
+
.filter((key) => value[key] !== undefined)
|
|
74
|
+
.map((key) => [key, canonicalize(value[key])]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stableJson(value) {
|
|
78
|
+
return JSON.stringify(canonicalize(value));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bigintText(value, fallbackMs = 0) {
|
|
82
|
+
if (typeof value === 'bigint') return value.toString();
|
|
83
|
+
return BigInt(Math.max(0, Math.trunc(Number(fallbackMs || 0) * 1_000_000))).toString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeFile(vaultBase, path, label, { allowMissing = true } = {}) {
|
|
87
|
+
return assertVaultPathSafe(vaultBase, path, {
|
|
88
|
+
allowMissing,
|
|
89
|
+
expectedType: 'file',
|
|
90
|
+
label,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fileFingerprint(vaultBase, path, label) {
|
|
95
|
+
let checked = safeFile(vaultBase, path, label);
|
|
96
|
+
if (!checked.exists) return null;
|
|
97
|
+
checked = safeFile(vaultBase, checked.target, label, { allowMissing: false });
|
|
98
|
+
const stat = statSync(checked.target, { bigint: true });
|
|
99
|
+
if (!stat.isFile()) return null;
|
|
100
|
+
return {
|
|
101
|
+
size: stat.size.toString(),
|
|
102
|
+
mtime_ns: bigintText(stat.mtimeNs, stat.mtimeMs),
|
|
103
|
+
ctime_ns: bigintText(stat.ctimeNs, stat.ctimeMs),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sameFingerprint(left, right) {
|
|
108
|
+
if (left === null || right === null) return left === right;
|
|
109
|
+
return Boolean(left && right)
|
|
110
|
+
&& left.size === right.size
|
|
111
|
+
&& left.mtime_ns === right.mtime_ns
|
|
112
|
+
&& left.ctime_ns === right.ctime_ns;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readSafeFile(vaultBase, path, label, encoding = 'utf8') {
|
|
116
|
+
let checked = safeFile(vaultBase, path, label);
|
|
117
|
+
if (!checked.exists) return null;
|
|
118
|
+
checked = safeFile(vaultBase, checked.target, label, { allowMissing: false });
|
|
119
|
+
return encoding === null ? readFileSync(checked.target) : readFileSync(checked.target, encoding);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validFingerprint(value) {
|
|
123
|
+
return value === null || (Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
124
|
+
&& /^\d+$/.test(String(value.size || ''))
|
|
125
|
+
&& /^\d+$/.test(String(value.mtime_ns || ''))
|
|
126
|
+
&& /^\d+$/.test(String(value.ctime_ns || '')));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validArtifact(value, kind) {
|
|
130
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
131
|
+
&& value.kind === kind
|
|
132
|
+
&& ARTIFACT_PATH.test(String(value.path || ''))
|
|
133
|
+
&& SHA256.test(String(value.hash || ''))
|
|
134
|
+
&& validFingerprint(value.fingerprint)
|
|
135
|
+
&& value.fingerprint !== null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseState(raw) {
|
|
139
|
+
if (raw === null) return null;
|
|
140
|
+
try {
|
|
141
|
+
const state = JSON.parse(raw);
|
|
142
|
+
if (state?.schema_version !== EVIDENCE_SEARCH_STATE_VERSION
|
|
143
|
+
|| !SHA256.test(String(state?.index_hash || ''))
|
|
144
|
+
|| !Number.isSafeInteger(state?.row_count)
|
|
145
|
+
|| state.row_count < 0
|
|
146
|
+
|| !state?.source
|
|
147
|
+
|| !validFingerprint(state.source.index)
|
|
148
|
+
|| state.source.index === null
|
|
149
|
+
|| !validFingerprint(state.source.state)
|
|
150
|
+
|| !validArtifact(state.lexical, 'lexical')
|
|
151
|
+
|| (state.sqlite !== null && !validArtifact(state.sqlite, 'sqlite'))) return null;
|
|
152
|
+
return state;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function loadEvidenceSearchState(vaultBase) {
|
|
159
|
+
const raw = readSafeFile(
|
|
160
|
+
vaultBase,
|
|
161
|
+
searchStatePath(vaultBase),
|
|
162
|
+
'estado do índice de busca de evidências',
|
|
163
|
+
);
|
|
164
|
+
return parseState(raw);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function currentSource(vaultBase) {
|
|
168
|
+
return {
|
|
169
|
+
index: fileFingerprint(vaultBase, evidenceIndexPath(vaultBase), 'autoridade JSONL da busca de evidências'),
|
|
170
|
+
state: fileFingerprint(vaultBase, evidenceIndexStatePath(vaultBase), 'estado incremental da busca de evidências'),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function artifactPath(vaultBase, artifact) {
|
|
175
|
+
return join(brainDir(vaultBase), artifact.path);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function artifactCurrent(vaultBase, artifact) {
|
|
179
|
+
try {
|
|
180
|
+
return sameFingerprint(
|
|
181
|
+
artifact.fingerprint,
|
|
182
|
+
fileFingerprint(vaultBase, artifactPath(vaultBase, artifact), `artefato ${artifact.kind} da busca`),
|
|
183
|
+
);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stateCurrent(vaultBase, state) {
|
|
190
|
+
if (!state) return false;
|
|
191
|
+
const source = currentSource(vaultBase);
|
|
192
|
+
return sameFingerprint(state.source.index, source.index)
|
|
193
|
+
&& sameFingerprint(state.source.state, source.state)
|
|
194
|
+
&& artifactCurrent(vaultBase, state.lexical)
|
|
195
|
+
&& (state.sqlite === null || artifactCurrent(vaultBase, state.sqlite));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validEvidenceRow(row) {
|
|
199
|
+
return Boolean(row && typeof row === 'object' && !Array.isArray(row))
|
|
200
|
+
&& typeof row.chunk_id === 'string'
|
|
201
|
+
&& row.chunk_id.length > 0
|
|
202
|
+
&& typeof row.logical_path === 'string'
|
|
203
|
+
&& Number.isSafeInteger(row.ordinal)
|
|
204
|
+
&& row.ordinal >= 0
|
|
205
|
+
&& typeof row.content === 'string'
|
|
206
|
+
&& SHA256.test(String(row.content_hash || ''))
|
|
207
|
+
&& sha256(row.content) === row.content_hash;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function canonicalRows(rows) {
|
|
211
|
+
if (!Array.isArray(rows)) throw new TypeError('evidence search rows must be an array');
|
|
212
|
+
if (!rows.every(validEvidenceRow)) throw new TypeError('evidence search rows contain an invalid chunk');
|
|
213
|
+
const sorted = [...rows].sort((left, right) => compareText(left.logical_path, right.logical_path)
|
|
214
|
+
|| left.ordinal - right.ordinal
|
|
215
|
+
|| compareText(left.chunk_id, right.chunk_id)
|
|
216
|
+
|| compareText(left.content_hash, right.content_hash));
|
|
217
|
+
const ids = new Set();
|
|
218
|
+
for (const row of sorted) {
|
|
219
|
+
if (ids.has(row.chunk_id)) throw new TypeError(`duplicate evidence chunk id: ${row.chunk_id}`);
|
|
220
|
+
ids.add(row.chunk_id);
|
|
221
|
+
}
|
|
222
|
+
return sorted;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function evidenceSearchIndexHash(rows) {
|
|
226
|
+
return sha256(canonicalRows(rows).map((row) => stableJson(row)).join('\n'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function weightedTerms(row) {
|
|
230
|
+
const weights = new Map();
|
|
231
|
+
const add = (value, multiplier) => {
|
|
232
|
+
const counts = new Map();
|
|
233
|
+
for (const term of recallTerms(value)) {
|
|
234
|
+
counts.set(term, Math.min(32, (counts.get(term) || 0) + 1));
|
|
235
|
+
}
|
|
236
|
+
for (const [term, count] of counts) {
|
|
237
|
+
weights.set(term, (weights.get(term) || 0) + count * multiplier);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
add(row.content, 1);
|
|
241
|
+
add(row.logical_path, 2);
|
|
242
|
+
add(row.heading, 3);
|
|
243
|
+
add(row.title, 4);
|
|
244
|
+
return weights;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildLexicalArtifact(rows, indexHash) {
|
|
248
|
+
const postings = new Map();
|
|
249
|
+
rows.forEach((row, rowIndex) => {
|
|
250
|
+
for (const [term, weight] of weightedTerms(row)) {
|
|
251
|
+
if (!postings.has(term)) postings.set(term, []);
|
|
252
|
+
postings.get(term).push([rowIndex, weight]);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
const renderedPostings = {};
|
|
256
|
+
for (const term of [...postings.keys()].sort(compareText)) {
|
|
257
|
+
renderedPostings[term] = postings.get(term).sort((left, right) => right[1] - left[1]
|
|
258
|
+
|| left[0] - right[0]);
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
schema_version: EVIDENCE_SEARCH_STATE_VERSION,
|
|
262
|
+
index_hash: indexHash,
|
|
263
|
+
row_count: rows.length,
|
|
264
|
+
rows,
|
|
265
|
+
postings: renderedPostings,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateLexicalArtifact(value, expectedState = null) {
|
|
270
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
271
|
+
|| value.schema_version !== EVIDENCE_SEARCH_STATE_VERSION
|
|
272
|
+
|| !SHA256.test(String(value.index_hash || ''))
|
|
273
|
+
|| !Number.isSafeInteger(value.row_count)
|
|
274
|
+
|| value.row_count < 0
|
|
275
|
+
|| !Array.isArray(value.rows)
|
|
276
|
+
|| value.rows.length !== value.row_count
|
|
277
|
+
|| !value.rows.every(validEvidenceRow)
|
|
278
|
+
|| !value.postings
|
|
279
|
+
|| typeof value.postings !== 'object'
|
|
280
|
+
|| Array.isArray(value.postings)) return false;
|
|
281
|
+
if (expectedState && (value.index_hash !== expectedState.index_hash
|
|
282
|
+
|| value.row_count !== expectedState.row_count)) return false;
|
|
283
|
+
for (const [term, entries] of Object.entries(value.postings)) {
|
|
284
|
+
if (!term || !Array.isArray(entries)) return false;
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (!Array.isArray(entry) || entry.length !== 2
|
|
287
|
+
|| !Number.isSafeInteger(entry[0]) || entry[0] < 0 || entry[0] >= value.row_count
|
|
288
|
+
|| !Number.isFinite(entry[1]) || entry[1] <= 0) return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return evidenceSearchIndexHash(value.rows) === value.index_hash;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function lexicalFileContent(artifact) {
|
|
295
|
+
return `${JSON.stringify(artifact)}\n`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function writeContentAddressedText(vaultBase, prefix, indexHash, content) {
|
|
299
|
+
const contentHash = sha256(content);
|
|
300
|
+
const relativePath = `${SEARCH_DIRECTORY}/${prefix}-${indexHash.slice(0, 20)}-${contentHash.slice(0, 20)}.json`;
|
|
301
|
+
const path = join(brainDir(vaultBase), relativePath);
|
|
302
|
+
const current = readSafeFile(vaultBase, path, `artefato ${prefix} da busca`);
|
|
303
|
+
let written = false;
|
|
304
|
+
if (current === null) {
|
|
305
|
+
writeVaultFileAtomic(vaultBase, path, content, 'utf8', {
|
|
306
|
+
label: `artefato imutável ${prefix} da busca`,
|
|
307
|
+
scopeRoot: searchDir(vaultBase),
|
|
308
|
+
});
|
|
309
|
+
written = true;
|
|
310
|
+
} else if (sha256(current) !== contentHash || current !== content) {
|
|
311
|
+
const error = new Error(`content-addressed evidence search artifact diverges: ${relativePath}`);
|
|
312
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_DIVERGED';
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
artifact: {
|
|
317
|
+
kind: 'lexical',
|
|
318
|
+
path: relativePath,
|
|
319
|
+
hash: contentHash,
|
|
320
|
+
fingerprint: fileFingerprint(vaultBase, path, `artefato ${prefix} da busca`),
|
|
321
|
+
},
|
|
322
|
+
written,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sqliteDatabaseSync() {
|
|
327
|
+
try {
|
|
328
|
+
const module = require('node:sqlite');
|
|
329
|
+
return typeof module?.DatabaseSync === 'function' ? module.DatabaseSync : null;
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function detectSqliteCapability() {
|
|
336
|
+
if (sqliteCapability) return sqliteCapability;
|
|
337
|
+
const DatabaseSync = sqliteDatabaseSync();
|
|
338
|
+
if (!DatabaseSync) {
|
|
339
|
+
sqliteCapability = {
|
|
340
|
+
available: false,
|
|
341
|
+
DatabaseSync: null,
|
|
342
|
+
reason: 'node-sqlite-unavailable',
|
|
343
|
+
error_code: 'EVIDENCE_SEARCH_SQLITE_UNAVAILABLE',
|
|
344
|
+
};
|
|
345
|
+
return sqliteCapability;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let db = null;
|
|
349
|
+
try {
|
|
350
|
+
db = new DatabaseSync(':memory:', { open: true });
|
|
351
|
+
db.exec('CREATE VIRTUAL TABLE evidence_fts_probe USING fts5(content)');
|
|
352
|
+
sqliteCapability = { available: true, DatabaseSync, reason: '', error_code: '' };
|
|
353
|
+
} catch {
|
|
354
|
+
sqliteCapability = {
|
|
355
|
+
available: false,
|
|
356
|
+
DatabaseSync,
|
|
357
|
+
reason: 'fts5-unavailable',
|
|
358
|
+
error_code: 'EVIDENCE_SEARCH_FTS5_UNAVAILABLE',
|
|
359
|
+
};
|
|
360
|
+
} finally {
|
|
361
|
+
try { db?.close(); } catch { /* capability probe is best effort */ }
|
|
362
|
+
}
|
|
363
|
+
return sqliteCapability;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function evidenceSearchSqliteAvailable() {
|
|
367
|
+
return detectSqliteCapability().available;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function cleanupSqliteCandidate(vaultBase, path) {
|
|
371
|
+
try { unlinkVaultFile(vaultBase, `${path}-journal`, { label: 'journal temporário FTS' }); } catch { /* best effort */ }
|
|
372
|
+
try { unlinkVaultFile(vaultBase, `${path}-wal`, { label: 'WAL temporário FTS' }); } catch { /* best effort */ }
|
|
373
|
+
try { unlinkVaultFile(vaultBase, `${path}-shm`, { label: 'SHM temporário FTS' }); } catch { /* best effort */ }
|
|
374
|
+
try { unlinkVaultFile(vaultBase, path, { label: 'SQLite temporário FTS' }); } catch { /* best effort */ }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function buildSqliteArtifact(vaultBase, rows, indexHash, { required = false } = {}) {
|
|
378
|
+
const capability = detectSqliteCapability();
|
|
379
|
+
if (!capability.available) {
|
|
380
|
+
if (required) {
|
|
381
|
+
const error = new Error(capability.reason === 'node-sqlite-unavailable'
|
|
382
|
+
? 'node:sqlite is unavailable; evidence FTS requires a compatible Node.js runtime'
|
|
383
|
+
: 'the current node:sqlite build does not include FTS5; evidence search will use the lexical backend');
|
|
384
|
+
error.code = capability.error_code;
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
return { artifact: null, written: false, reason: capability.reason };
|
|
388
|
+
}
|
|
389
|
+
const { DatabaseSync } = capability;
|
|
390
|
+
|
|
391
|
+
const candidate = join(searchDir(vaultBase), `.fts-${indexHash.slice(0, 20)}-${randomUUID()}.tmp.sqlite`);
|
|
392
|
+
assertVaultPathSafe(vaultBase, candidate, {
|
|
393
|
+
expectedType: 'file',
|
|
394
|
+
mustNotExist: true,
|
|
395
|
+
label: 'candidate SQLite FTS',
|
|
396
|
+
});
|
|
397
|
+
let db = null;
|
|
398
|
+
try {
|
|
399
|
+
db = new DatabaseSync(candidate, { open: true });
|
|
400
|
+
db.exec(`
|
|
401
|
+
PRAGMA journal_mode = OFF;
|
|
402
|
+
PRAGMA synchronous = OFF;
|
|
403
|
+
PRAGMA temp_store = MEMORY;
|
|
404
|
+
CREATE TABLE evidence_meta (
|
|
405
|
+
key TEXT PRIMARY KEY,
|
|
406
|
+
value TEXT NOT NULL
|
|
407
|
+
) WITHOUT ROWID;
|
|
408
|
+
CREATE TABLE evidence_rows (
|
|
409
|
+
chunk_id TEXT PRIMARY KEY,
|
|
410
|
+
logical_path TEXT NOT NULL,
|
|
411
|
+
ordinal INTEGER NOT NULL,
|
|
412
|
+
authority TEXT NOT NULL,
|
|
413
|
+
validity TEXT NOT NULL,
|
|
414
|
+
entity_type TEXT NOT NULL,
|
|
415
|
+
project_id TEXT NOT NULL,
|
|
416
|
+
change_slug TEXT NOT NULL,
|
|
417
|
+
session_id TEXT NOT NULL,
|
|
418
|
+
work_session_id TEXT NOT NULL,
|
|
419
|
+
row_json TEXT NOT NULL
|
|
420
|
+
) WITHOUT ROWID;
|
|
421
|
+
CREATE VIRTUAL TABLE evidence_fts USING fts5(
|
|
422
|
+
chunk_id UNINDEXED,
|
|
423
|
+
title,
|
|
424
|
+
heading,
|
|
425
|
+
logical_path,
|
|
426
|
+
content,
|
|
427
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
428
|
+
);
|
|
429
|
+
`);
|
|
430
|
+
const insertMeta = db.prepare('INSERT INTO evidence_meta(key, value) VALUES (?, ?)');
|
|
431
|
+
const insertRow = db.prepare(`
|
|
432
|
+
INSERT INTO evidence_rows(
|
|
433
|
+
chunk_id, logical_path, ordinal, authority, validity, entity_type, project_id,
|
|
434
|
+
change_slug, session_id, work_session_id, row_json
|
|
435
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
436
|
+
`);
|
|
437
|
+
const insertFts = db.prepare(`
|
|
438
|
+
INSERT INTO evidence_fts(chunk_id, title, heading, logical_path, content)
|
|
439
|
+
VALUES (?, ?, ?, ?, ?)
|
|
440
|
+
`);
|
|
441
|
+
db.exec('BEGIN IMMEDIATE');
|
|
442
|
+
try {
|
|
443
|
+
insertMeta.run('schema_version', String(EVIDENCE_SEARCH_STATE_VERSION));
|
|
444
|
+
insertMeta.run('index_hash', indexHash);
|
|
445
|
+
insertMeta.run('row_count', String(rows.length));
|
|
446
|
+
for (const row of rows) {
|
|
447
|
+
insertRow.run(
|
|
448
|
+
row.chunk_id,
|
|
449
|
+
row.logical_path,
|
|
450
|
+
row.ordinal,
|
|
451
|
+
String(row.authority || ''),
|
|
452
|
+
String(row.validity || ''),
|
|
453
|
+
String(row.entity_type || ''),
|
|
454
|
+
String(row.project_id || ''),
|
|
455
|
+
String(row.change_slug || ''),
|
|
456
|
+
String(row.session_id || ''),
|
|
457
|
+
String(row.work_session_id || ''),
|
|
458
|
+
JSON.stringify(row),
|
|
459
|
+
);
|
|
460
|
+
insertFts.run(
|
|
461
|
+
row.chunk_id,
|
|
462
|
+
String(row.title || ''),
|
|
463
|
+
String(row.heading || ''),
|
|
464
|
+
row.logical_path,
|
|
465
|
+
row.content,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
db.exec('COMMIT');
|
|
469
|
+
} catch (error) {
|
|
470
|
+
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
db.close();
|
|
474
|
+
db = null;
|
|
475
|
+
|
|
476
|
+
const checkedCandidate = safeFile(vaultBase, candidate, 'candidate SQLite FTS', { allowMissing: false });
|
|
477
|
+
const bytes = readFileSync(checkedCandidate.target);
|
|
478
|
+
const contentHash = sha256(bytes);
|
|
479
|
+
const relativePath = `${SEARCH_DIRECTORY}/fts-${indexHash.slice(0, 20)}-${contentHash.slice(0, 20)}.sqlite`;
|
|
480
|
+
const finalPath = join(brainDir(vaultBase), relativePath);
|
|
481
|
+
const existing = readSafeFile(vaultBase, finalPath, 'artefato SQLite FTS', null);
|
|
482
|
+
let written = false;
|
|
483
|
+
if (existing === null) {
|
|
484
|
+
try {
|
|
485
|
+
renameVaultPath(vaultBase, candidate, finalPath, {
|
|
486
|
+
sourceType: 'file',
|
|
487
|
+
label: 'publicação do SQLite FTS',
|
|
488
|
+
});
|
|
489
|
+
written = true;
|
|
490
|
+
} catch (error) {
|
|
491
|
+
const raced = readSafeFile(vaultBase, finalPath, 'artefato SQLite FTS concorrente', null);
|
|
492
|
+
if (raced === null || sha256(raced) !== contentHash) throw error;
|
|
493
|
+
cleanupSqliteCandidate(vaultBase, candidate);
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
if (sha256(existing) !== contentHash) {
|
|
497
|
+
const error = new Error(`content-addressed SQLite artifact diverges: ${relativePath}`);
|
|
498
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_DIVERGED';
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
cleanupSqliteCandidate(vaultBase, candidate);
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
artifact: {
|
|
505
|
+
kind: 'sqlite',
|
|
506
|
+
path: relativePath,
|
|
507
|
+
hash: contentHash,
|
|
508
|
+
fingerprint: fileFingerprint(vaultBase, finalPath, 'artefato SQLite FTS'),
|
|
509
|
+
},
|
|
510
|
+
written,
|
|
511
|
+
reason: '',
|
|
512
|
+
};
|
|
513
|
+
} catch (error) {
|
|
514
|
+
try { db?.close(); } catch { /* ignore */ }
|
|
515
|
+
cleanupSqliteCandidate(vaultBase, candidate);
|
|
516
|
+
if (required) throw error;
|
|
517
|
+
return { artifact: null, written: false, reason: error?.code || 'sqlite-build-failed' };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function normalizeSqliteMode(value) {
|
|
522
|
+
const mode = String(value ?? 'auto').trim().toLowerCase();
|
|
523
|
+
if (!['auto', 'off', 'required'].includes(mode)) {
|
|
524
|
+
throw new TypeError('evidence search sqlite mode must be auto, off, or required');
|
|
525
|
+
}
|
|
526
|
+
return mode;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function renderState({ indexHash, rowCount, source, lexical, sqlite }) {
|
|
530
|
+
return `${JSON.stringify({
|
|
531
|
+
schema_version: EVIDENCE_SEARCH_STATE_VERSION,
|
|
532
|
+
index_hash: indexHash,
|
|
533
|
+
row_count: rowCount,
|
|
534
|
+
source,
|
|
535
|
+
lexical,
|
|
536
|
+
sqlite,
|
|
537
|
+
}, null, 2)}\n`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function refreshEvidenceSearchIndex(vaultBase, rows, {
|
|
541
|
+
force = false,
|
|
542
|
+
sqlite = 'auto',
|
|
543
|
+
} = {}) {
|
|
544
|
+
const sqliteMode = normalizeSqliteMode(sqlite);
|
|
545
|
+
mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da busca de evidências' });
|
|
546
|
+
mkdirVaultPath(vaultBase, searchDir(vaultBase), { label: 'diretório de artefatos da busca de evidências' });
|
|
547
|
+
const source = currentSource(vaultBase);
|
|
548
|
+
if (source.index === null) {
|
|
549
|
+
const error = new Error('EVIDENCE_INDEX.jsonl is required before building the search index');
|
|
550
|
+
error.code = 'EVIDENCE_SEARCH_SOURCE_MISSING';
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const previous = loadEvidenceSearchState(vaultBase);
|
|
555
|
+
if (!force && stateCurrent(vaultBase, previous)
|
|
556
|
+
&& (sqliteMode !== 'required' || previous.sqlite !== null)) {
|
|
557
|
+
return {
|
|
558
|
+
state: previous,
|
|
559
|
+
reused: true,
|
|
560
|
+
lexical_written: false,
|
|
561
|
+
sqlite_written: false,
|
|
562
|
+
sqlite_available: previous.sqlite !== null,
|
|
563
|
+
sqlite_reason: previous.sqlite ? '' : 'not-built',
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const canonical = canonicalRows(rows);
|
|
568
|
+
const indexHash = sha256(canonical.map((row) => stableJson(row)).join('\n'));
|
|
569
|
+
const lexicalValue = buildLexicalArtifact(canonical, indexHash);
|
|
570
|
+
const lexicalContent = lexicalFileContent(lexicalValue);
|
|
571
|
+
const lexicalResult = writeContentAddressedText(
|
|
572
|
+
vaultBase,
|
|
573
|
+
'lexical',
|
|
574
|
+
indexHash,
|
|
575
|
+
lexicalContent,
|
|
576
|
+
);
|
|
577
|
+
const sqliteResult = sqliteMode === 'off'
|
|
578
|
+
? { artifact: null, written: false, reason: 'disabled' }
|
|
579
|
+
: buildSqliteArtifact(vaultBase, canonical, indexHash, { required: sqliteMode === 'required' });
|
|
580
|
+
const stateContent = renderState({
|
|
581
|
+
indexHash,
|
|
582
|
+
rowCount: canonical.length,
|
|
583
|
+
source,
|
|
584
|
+
lexical: lexicalResult.artifact,
|
|
585
|
+
sqlite: sqliteResult.artifact,
|
|
586
|
+
});
|
|
587
|
+
const currentState = readSafeFile(vaultBase, searchStatePath(vaultBase), 'estado do índice de busca');
|
|
588
|
+
const stateWritten = currentState !== stateContent;
|
|
589
|
+
if (stateWritten) {
|
|
590
|
+
writeVaultFileAtomic(
|
|
591
|
+
vaultBase,
|
|
592
|
+
searchStatePath(vaultBase),
|
|
593
|
+
stateContent,
|
|
594
|
+
'utf8',
|
|
595
|
+
{ label: 'estado do índice de busca de evidências' },
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const state = loadEvidenceSearchState(vaultBase);
|
|
599
|
+
if (!state || !stateCurrent(vaultBase, state)) {
|
|
600
|
+
const error = new Error('published evidence search state did not validate');
|
|
601
|
+
error.code = 'EVIDENCE_SEARCH_STATE_INVALID';
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
lexicalCache.set(state.lexical.path, lexicalValue);
|
|
605
|
+
return {
|
|
606
|
+
state,
|
|
607
|
+
reused: false,
|
|
608
|
+
state_written: stateWritten,
|
|
609
|
+
lexical_written: lexicalResult.written,
|
|
610
|
+
sqlite_written: sqliteResult.written,
|
|
611
|
+
sqlite_available: Boolean(sqliteResult.artifact),
|
|
612
|
+
sqlite_reason: sqliteResult.reason,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function loadLexical(vaultBase, state) {
|
|
617
|
+
const cacheKey = `${state.lexical.path}:${state.lexical.fingerprint.mtime_ns}:${state.lexical.fingerprint.size}`;
|
|
618
|
+
const cached = lexicalCache.get(cacheKey) || lexicalCache.get(state.lexical.path);
|
|
619
|
+
if (cached && validateLexicalArtifact(cached, state)) return cached;
|
|
620
|
+
const raw = readSafeFile(vaultBase, artifactPath(vaultBase, state.lexical), 'artefato lexical da busca');
|
|
621
|
+
if (raw === null || sha256(raw) !== state.lexical.hash) {
|
|
622
|
+
const error = new Error('evidence lexical search artifact hash mismatch');
|
|
623
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_DIVERGED';
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
let parsed;
|
|
627
|
+
try { parsed = JSON.parse(raw); } catch {
|
|
628
|
+
const error = new Error('evidence lexical search artifact is invalid JSON');
|
|
629
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_INVALID';
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
if (!validateLexicalArtifact(parsed, state)) {
|
|
633
|
+
const error = new Error('evidence lexical search artifact failed validation');
|
|
634
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_INVALID';
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
lexicalCache.set(cacheKey, parsed);
|
|
638
|
+
return parsed;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function ensureSearchState(vaultBase, { sqlite = 'auto' } = {}) {
|
|
642
|
+
const state = loadEvidenceSearchState(vaultBase);
|
|
643
|
+
if (stateCurrent(vaultBase, state)) return { state, rebuilt: false, ephemeral: null };
|
|
644
|
+
const rows = loadEvidenceIndex(vaultBase);
|
|
645
|
+
if (!rows.length) return { state: null, rebuilt: false, ephemeral: buildLexicalArtifact([], sha256('')) };
|
|
646
|
+
try {
|
|
647
|
+
const refreshed = refreshEvidenceSearchIndex(vaultBase, rows, { force: true, sqlite });
|
|
648
|
+
return { state: refreshed.state, rebuilt: true, ephemeral: null };
|
|
649
|
+
} catch (error) {
|
|
650
|
+
const canonical = canonicalRows(rows);
|
|
651
|
+
const indexHash = sha256(canonical.map((row) => stableJson(row)).join('\n'));
|
|
652
|
+
return {
|
|
653
|
+
state: null,
|
|
654
|
+
rebuilt: false,
|
|
655
|
+
ephemeral: buildLexicalArtifact(canonical, indexHash),
|
|
656
|
+
fallback_reason: error?.code || 'search-index-rebuild-failed',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function normalizeCandidateLimit(value) {
|
|
662
|
+
const limit = Number(value ?? EVIDENCE_SEARCH_DEFAULT_CANDIDATES);
|
|
663
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > EVIDENCE_SEARCH_MAX_CANDIDATES) {
|
|
664
|
+
throw new RangeError(`evidence search candidateLimit must be between 1 and ${EVIDENCE_SEARCH_MAX_CANDIDATES}`);
|
|
665
|
+
}
|
|
666
|
+
return limit;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function normalizePostingBudget(value) {
|
|
670
|
+
const budget = Number(value ?? EVIDENCE_SEARCH_DEFAULT_POSTING_BUDGET);
|
|
671
|
+
if (!Number.isSafeInteger(budget) || budget < 1 || budget > EVIDENCE_SEARCH_MAX_POSTING_BUDGET) {
|
|
672
|
+
throw new RangeError(`evidence search postingBudget must be between 1 and ${EVIDENCE_SEARCH_MAX_POSTING_BUDGET}`);
|
|
673
|
+
}
|
|
674
|
+
return budget;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function normalizeBackend(value) {
|
|
678
|
+
const backend = String(value ?? 'auto').trim().toLowerCase();
|
|
679
|
+
if (!['auto', 'sqlite', 'lexical'].includes(backend)) {
|
|
680
|
+
throw new TypeError('evidence search backend must be auto, sqlite, or lexical');
|
|
681
|
+
}
|
|
682
|
+
return backend;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function ftsQuery(query) {
|
|
686
|
+
return [...new Set(recallTerms(query))]
|
|
687
|
+
.map((term) => `"${term.replaceAll('"', '""')}"`)
|
|
688
|
+
.join(' OR ');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const SQL_FIELDS = new Map([
|
|
692
|
+
['authority', 'authority'],
|
|
693
|
+
['validity', 'validity'],
|
|
694
|
+
['entity_type', 'entity_type'],
|
|
695
|
+
['project_id', 'project_id'],
|
|
696
|
+
['change_slug', 'change_slug'],
|
|
697
|
+
['session_id', 'session_id'],
|
|
698
|
+
['work_session_id', 'work_session_id'],
|
|
699
|
+
['logical_path', 'logical_path'],
|
|
700
|
+
]);
|
|
701
|
+
|
|
702
|
+
function escapeLike(value) {
|
|
703
|
+
return String(value).replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function sqlFilterClause(filters) {
|
|
707
|
+
const clauses = [];
|
|
708
|
+
const parameters = [];
|
|
709
|
+
for (const [field, column] of SQL_FIELDS) {
|
|
710
|
+
const values = filters[field];
|
|
711
|
+
if (!values?.length) continue;
|
|
712
|
+
clauses.push(`r.${column} IN (${values.map(() => '?').join(', ')})`);
|
|
713
|
+
parameters.push(...values);
|
|
714
|
+
}
|
|
715
|
+
if (filters.logical_path_prefix?.length) {
|
|
716
|
+
clauses.push(`(${filters.logical_path_prefix.map(() => "r.logical_path LIKE ? ESCAPE '\\'").join(' OR ')})`);
|
|
717
|
+
parameters.push(...filters.logical_path_prefix.map((prefix) => `${escapeLike(prefix)}%`));
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
sql: clauses.length ? ` AND ${clauses.join(' AND ')}` : '',
|
|
721
|
+
parameters,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function searchSqlite(vaultBase, state, query, filters, candidateLimit, postingBudget) {
|
|
726
|
+
const DatabaseSync = sqliteDatabaseSync();
|
|
727
|
+
if (!DatabaseSync || !state.sqlite) {
|
|
728
|
+
const error = new Error('SQLite FTS backend is unavailable');
|
|
729
|
+
error.code = 'EVIDENCE_SEARCH_SQLITE_UNAVAILABLE';
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
const expression = ftsQuery(query);
|
|
733
|
+
if (!expression) return { rows: [], posting_entries: 0, has_more: false };
|
|
734
|
+
const path = artifactPath(vaultBase, state.sqlite);
|
|
735
|
+
safeFile(vaultBase, path, 'artefato SQLite FTS', { allowMissing: false });
|
|
736
|
+
let db = null;
|
|
737
|
+
try {
|
|
738
|
+
db = new DatabaseSync(path, { open: true });
|
|
739
|
+
db.exec('PRAGMA query_only = ON');
|
|
740
|
+
const indexHash = db.prepare("SELECT value FROM evidence_meta WHERE key = 'index_hash'").get()?.value;
|
|
741
|
+
const rowCount = Number(db.prepare("SELECT value FROM evidence_meta WHERE key = 'row_count'").get()?.value);
|
|
742
|
+
if (indexHash !== state.index_hash || rowCount !== state.row_count) {
|
|
743
|
+
const error = new Error('SQLite FTS metadata does not match the search state');
|
|
744
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_DIVERGED';
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
const filter = sqlFilterClause(filters);
|
|
748
|
+
const matches = db.prepare(`
|
|
749
|
+
SELECT r.row_json,
|
|
750
|
+
bm25(evidence_fts, 0.0, 4.0, 3.0, 2.0, 1.0) AS fts_rank
|
|
751
|
+
FROM evidence_fts
|
|
752
|
+
JOIN evidence_rows r ON r.chunk_id = evidence_fts.chunk_id
|
|
753
|
+
WHERE evidence_fts MATCH ?${filter.sql}
|
|
754
|
+
ORDER BY fts_rank ASC, r.logical_path ASC, r.ordinal ASC, r.chunk_id ASC
|
|
755
|
+
LIMIT ?
|
|
756
|
+
`).all(expression, ...filter.parameters, postingBudget);
|
|
757
|
+
const rows = matches.slice(0, candidateLimit).map((match) => JSON.parse(match.row_json));
|
|
758
|
+
if (!rows.every(validEvidenceRow)) {
|
|
759
|
+
const error = new Error('SQLite FTS returned an invalid evidence row');
|
|
760
|
+
error.code = 'EVIDENCE_SEARCH_ARTIFACT_INVALID';
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
rows,
|
|
765
|
+
posting_entries: matches.length,
|
|
766
|
+
has_more: matches.length > candidateLimit || matches.length === postingBudget,
|
|
767
|
+
};
|
|
768
|
+
} finally {
|
|
769
|
+
try { db?.close(); } catch { /* ignore */ }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function searchLexical(artifact, query, filters, candidateLimit, postingBudget) {
|
|
774
|
+
const terms = [...new Set(recallTerms(query))];
|
|
775
|
+
if (!terms.length || !artifact.rows.length) {
|
|
776
|
+
return { rows: [], posting_entries: 0, has_more: false };
|
|
777
|
+
}
|
|
778
|
+
const allowedRows = filterEvidenceRecallRows(artifact.rows, filters);
|
|
779
|
+
const allowed = new Set(allowedRows.map((row) => row.chunk_id));
|
|
780
|
+
const scores = new Map();
|
|
781
|
+
let postingEntries = 0;
|
|
782
|
+
let truncated = false;
|
|
783
|
+
for (const term of terms) {
|
|
784
|
+
const entries = artifact.postings[term] || [];
|
|
785
|
+
const idf = Math.log(1 + artifact.row_count / Math.max(1, entries.length));
|
|
786
|
+
for (const [rowIndex, weight] of entries) {
|
|
787
|
+
if (postingEntries >= postingBudget) { truncated = true; break; }
|
|
788
|
+
postingEntries += 1;
|
|
789
|
+
const row = artifact.rows[rowIndex];
|
|
790
|
+
if (!allowed.has(row.chunk_id)) continue;
|
|
791
|
+
scores.set(rowIndex, (scores.get(rowIndex) || 0) + weight * idf);
|
|
792
|
+
}
|
|
793
|
+
if (postingEntries >= postingBudget) break;
|
|
794
|
+
}
|
|
795
|
+
const ranked = [...scores.entries()].sort((left, right) => right[1] - left[1]
|
|
796
|
+
|| compareText(artifact.rows[left[0]].logical_path, artifact.rows[right[0]].logical_path)
|
|
797
|
+
|| artifact.rows[left[0]].ordinal - artifact.rows[right[0]].ordinal
|
|
798
|
+
|| compareText(artifact.rows[left[0]].chunk_id, artifact.rows[right[0]].chunk_id));
|
|
799
|
+
return {
|
|
800
|
+
rows: ranked.slice(0, candidateLimit).map(([rowIndex]) => artifact.rows[rowIndex]),
|
|
801
|
+
posting_entries: postingEntries,
|
|
802
|
+
has_more: truncated || ranked.length > candidateLimit,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function searchEvidenceCandidates(vaultBase, query, {
|
|
807
|
+
candidateLimit: requestedCandidateLimit,
|
|
808
|
+
postingBudget: requestedPostingBudget,
|
|
809
|
+
filters = {},
|
|
810
|
+
backend: requestedBackend = 'auto',
|
|
811
|
+
sqlite = 'auto',
|
|
812
|
+
} = {}) {
|
|
813
|
+
const candidateLimit = normalizeCandidateLimit(requestedCandidateLimit);
|
|
814
|
+
const postingBudget = normalizePostingBudget(requestedPostingBudget);
|
|
815
|
+
const backend = normalizeBackend(requestedBackend);
|
|
816
|
+
const normalizedFilters = normalizeEvidenceRecallFilters(filters);
|
|
817
|
+
const ensured = ensureSearchState(vaultBase, { sqlite });
|
|
818
|
+
if (!ensured.state && !ensured.ephemeral) {
|
|
819
|
+
return {
|
|
820
|
+
rows: [],
|
|
821
|
+
backend: 'empty',
|
|
822
|
+
candidate_count: 0,
|
|
823
|
+
posting_entries: 0,
|
|
824
|
+
has_more: false,
|
|
825
|
+
rebuilt: ensured.rebuilt,
|
|
826
|
+
fallback_reason: ensured.fallback_reason || '',
|
|
827
|
+
index_hash: '',
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let fallbackReason = ensured.fallback_reason || '';
|
|
832
|
+
if (backend !== 'lexical' && ensured.state?.sqlite) {
|
|
833
|
+
try {
|
|
834
|
+
const result = searchSqlite(
|
|
835
|
+
vaultBase,
|
|
836
|
+
ensured.state,
|
|
837
|
+
query,
|
|
838
|
+
normalizedFilters,
|
|
839
|
+
candidateLimit,
|
|
840
|
+
postingBudget,
|
|
841
|
+
);
|
|
842
|
+
return {
|
|
843
|
+
...result,
|
|
844
|
+
backend: 'sqlite-fts5',
|
|
845
|
+
candidate_count: result.rows.length,
|
|
846
|
+
rebuilt: ensured.rebuilt,
|
|
847
|
+
fallback_reason: fallbackReason,
|
|
848
|
+
index_hash: ensured.state.index_hash,
|
|
849
|
+
};
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (backend === 'sqlite') throw error;
|
|
852
|
+
fallbackReason = error?.code || 'sqlite-query-failed';
|
|
853
|
+
}
|
|
854
|
+
} else if (backend === 'sqlite') {
|
|
855
|
+
const error = new Error('SQLite FTS backend is unavailable');
|
|
856
|
+
error.code = 'EVIDENCE_SEARCH_SQLITE_UNAVAILABLE';
|
|
857
|
+
throw error;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
let lexical = ensured.ephemeral;
|
|
861
|
+
if (!lexical && ensured.state) {
|
|
862
|
+
try {
|
|
863
|
+
lexical = loadLexical(vaultBase, ensured.state);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
const rows = loadEvidenceIndex(vaultBase);
|
|
866
|
+
const canonical = canonicalRows(rows);
|
|
867
|
+
const indexHash = sha256(canonical.map((row) => stableJson(row)).join('\n'));
|
|
868
|
+
lexical = buildLexicalArtifact(canonical, indexHash);
|
|
869
|
+
fallbackReason = error?.code || 'lexical-artifact-invalid';
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const result = searchLexical(
|
|
873
|
+
lexical,
|
|
874
|
+
query,
|
|
875
|
+
normalizedFilters,
|
|
876
|
+
candidateLimit,
|
|
877
|
+
postingBudget,
|
|
878
|
+
);
|
|
879
|
+
return {
|
|
880
|
+
...result,
|
|
881
|
+
backend: ensured.state ? 'lexical-sidecar' : 'lexical-ephemeral',
|
|
882
|
+
candidate_count: result.rows.length,
|
|
883
|
+
rebuilt: ensured.rebuilt,
|
|
884
|
+
fallback_reason: fallbackReason,
|
|
885
|
+
index_hash: lexical.index_hash,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export function recallEvidenceIndexed(vaultBase, query, {
|
|
890
|
+
topK = 5,
|
|
891
|
+
now = Date.now(),
|
|
892
|
+
candidateLimit = EVIDENCE_SEARCH_DEFAULT_CANDIDATES,
|
|
893
|
+
postingBudget = EVIDENCE_SEARCH_DEFAULT_POSTING_BUDGET,
|
|
894
|
+
filters = {},
|
|
895
|
+
backend = 'auto',
|
|
896
|
+
sqlite = 'auto',
|
|
897
|
+
} = {}) {
|
|
898
|
+
const candidates = searchEvidenceCandidates(vaultBase, query, {
|
|
899
|
+
candidateLimit,
|
|
900
|
+
postingBudget,
|
|
901
|
+
filters,
|
|
902
|
+
backend,
|
|
903
|
+
sqlite,
|
|
904
|
+
});
|
|
905
|
+
return {
|
|
906
|
+
results: recallEvidence(candidates.rows, query, { topK, now }),
|
|
907
|
+
metrics: {
|
|
908
|
+
backend: candidates.backend,
|
|
909
|
+
index_hash: candidates.index_hash,
|
|
910
|
+
candidate_count: candidates.candidate_count,
|
|
911
|
+
posting_entries: candidates.posting_entries,
|
|
912
|
+
has_more_candidates: candidates.has_more,
|
|
913
|
+
rebuilt: candidates.rebuilt,
|
|
914
|
+
fallback_reason: candidates.fallback_reason,
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
}
|