infobroker 2026.8.23
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/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/infobroker.js +5 -0
- package/dist/chain.d.ts +14 -0
- package/dist/chain.js +63 -0
- package/dist/chain.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +271 -0
- package/dist/config.js.map +1 -0
- package/dist/corroborate.d.ts +25 -0
- package/dist/corroborate.js +421 -0
- package/dist/corroborate.js.map +1 -0
- package/dist/http.d.ts +5 -0
- package/dist/http.js +25 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +909 -0
- package/dist/index.js.map +1 -0
- package/dist/kb-crypto.d.ts +43 -0
- package/dist/kb-crypto.js +199 -0
- package/dist/kb-crypto.js.map +1 -0
- package/dist/kb.d.ts +100 -0
- package/dist/kb.js +1009 -0
- package/dist/kb.js.map +1 -0
- package/dist/lib/html.d.ts +2 -0
- package/dist/lib/html.js +13 -0
- package/dist/lib/html.js.map +1 -0
- package/dist/lib/url-guard.d.ts +14 -0
- package/dist/lib/url-guard.js +75 -0
- package/dist/lib/url-guard.js.map +1 -0
- package/dist/normalizer.d.ts +2 -0
- package/dist/normalizer.js +53 -0
- package/dist/normalizer.js.map +1 -0
- package/dist/providers/arxiv.d.ts +2 -0
- package/dist/providers/arxiv.js +78 -0
- package/dist/providers/arxiv.js.map +1 -0
- package/dist/providers/brave.d.ts +2 -0
- package/dist/providers/brave.js +79 -0
- package/dist/providers/brave.js.map +1 -0
- package/dist/providers/core.d.ts +2 -0
- package/dist/providers/core.js +62 -0
- package/dist/providers/core.js.map +1 -0
- package/dist/providers/duckduckgo.d.ts +2 -0
- package/dist/providers/duckduckgo.js +93 -0
- package/dist/providers/duckduckgo.js.map +1 -0
- package/dist/providers/exa.d.ts +2 -0
- package/dist/providers/exa.js +68 -0
- package/dist/providers/exa.js.map +1 -0
- package/dist/providers/generic-http.d.ts +2 -0
- package/dist/providers/generic-http.js +96 -0
- package/dist/providers/generic-http.js.map +1 -0
- package/dist/providers/github.d.ts +2 -0
- package/dist/providers/github.js +58 -0
- package/dist/providers/github.js.map +1 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.js +59 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/internet_archive.d.ts +2 -0
- package/dist/providers/internet_archive.js +59 -0
- package/dist/providers/internet_archive.js.map +1 -0
- package/dist/providers/jina.d.ts +2 -0
- package/dist/providers/jina.js +34 -0
- package/dist/providers/jina.js.map +1 -0
- package/dist/providers/marginalia.d.ts +2 -0
- package/dist/providers/marginalia.js +47 -0
- package/dist/providers/marginalia.js.map +1 -0
- package/dist/providers/mojeek.d.ts +2 -0
- package/dist/providers/mojeek.js +47 -0
- package/dist/providers/mojeek.js.map +1 -0
- package/dist/providers/openstreetmap.d.ts +2 -0
- package/dist/providers/openstreetmap.js +43 -0
- package/dist/providers/openstreetmap.js.map +1 -0
- package/dist/providers/searxng.d.ts +2 -0
- package/dist/providers/searxng.js +52 -0
- package/dist/providers/searxng.js.map +1 -0
- package/dist/providers/semantic_scholar.d.ts +2 -0
- package/dist/providers/semantic_scholar.js +54 -0
- package/dist/providers/semantic_scholar.js.map +1 -0
- package/dist/providers/stack_exchange.d.ts +2 -0
- package/dist/providers/stack_exchange.js +83 -0
- package/dist/providers/stack_exchange.js.map +1 -0
- package/dist/providers/tavily.d.ts +2 -0
- package/dist/providers/tavily.js +66 -0
- package/dist/providers/tavily.js.map +1 -0
- package/dist/providers/wiby.d.ts +2 -0
- package/dist/providers/wiby.js +46 -0
- package/dist/providers/wiby.js.map +1 -0
- package/dist/providers/wikidata.d.ts +2 -0
- package/dist/providers/wikidata.js +46 -0
- package/dist/providers/wikidata.js.map +1 -0
- package/dist/providers/wikipedia.d.ts +2 -0
- package/dist/providers/wikipedia.js +71 -0
- package/dist/providers/wikipedia.js.map +1 -0
- package/dist/providers/wiktionary.d.ts +2 -0
- package/dist/providers/wiktionary.js +48 -0
- package/dist/providers/wiktionary.js.map +1 -0
- package/dist/providers/yep.d.ts +2 -0
- package/dist/providers/yep.js +104 -0
- package/dist/providers/yep.js.map +1 -0
- package/dist/quota.d.ts +37 -0
- package/dist/quota.js +116 -0
- package/dist/quota.js.map +1 -0
- package/dist/rate-limiter.d.ts +7 -0
- package/dist/rate-limiter.js +37 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +51 -0
- package/dist/retry.js.map +1 -0
- package/dist/types.d.ts +231 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
package/dist/kb.js
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
// @implements REQ-060 REQ-060a REQ-060b REQ-060c REQ-060d REQ-060e REQ-060f REQ-064 REQ-065 REQ-066 REQ-067 REQ-072 REQ-074 REQ-075 REQ-076 REQ-082 REQ-083 REQ-084 REQ-085 REQ-086
|
|
2
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync, writeSync, existsSync, mkdirSync, renameSync, copyFileSync, openSync, closeSync, fsyncSync, unlinkSync, readdirSync, statSync, chmodSync, } from "node:fs";
|
|
4
|
+
import { join, dirname, basename } from "node:path";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { sealEnvelope, openEnvelope, isEncryptedEnvelope, resolveKeySource, rawKeyFromString, passphraseKey, readKeyFile, } from "./kb-crypto.js";
|
|
7
|
+
let store = null;
|
|
8
|
+
let kbConfig = null;
|
|
9
|
+
let storagePath = null;
|
|
10
|
+
let maintenanceTimer = null;
|
|
11
|
+
let writeTimer = null;
|
|
12
|
+
let resolvedKey = null;
|
|
13
|
+
let lockError = null;
|
|
14
|
+
let loadedStat = null;
|
|
15
|
+
const WRITE_INTERVAL_MS = 30_000;
|
|
16
|
+
const modelAvailable = true;
|
|
17
|
+
const CONFIG_ERROR_CODE = "config_error";
|
|
18
|
+
const KB_UNINITIALIZED = "knowledge base not configured";
|
|
19
|
+
const TRUNC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const STORE_FILENAME = "vector-store.json";
|
|
21
|
+
function resolvePath(p) {
|
|
22
|
+
if (p.startsWith("~/"))
|
|
23
|
+
return join(homedir(), p.slice(2));
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
function tokenize(text) {
|
|
27
|
+
return text
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter((w) => w.length > 1);
|
|
32
|
+
}
|
|
33
|
+
function computeTf(tokens) {
|
|
34
|
+
const tf = {};
|
|
35
|
+
for (const t of tokens) {
|
|
36
|
+
tf[t] = (tf[t] || 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
const total = tokens.length || 1;
|
|
39
|
+
for (const k of Object.keys(tf)) {
|
|
40
|
+
tf[k] /= total;
|
|
41
|
+
}
|
|
42
|
+
return tf;
|
|
43
|
+
}
|
|
44
|
+
function computeTfIdfVector(tokens, idf, docCount) {
|
|
45
|
+
return activeModel.vectorize(tokens, idf, docCount);
|
|
46
|
+
}
|
|
47
|
+
// The built-in, zero-dependency model. A richer model can be registered at
|
|
48
|
+
// startup without changing call sites: assign `activeModel` to a new
|
|
49
|
+
// implementation and report its `name` via kbStats (REQ-060c).
|
|
50
|
+
const HASH_DIMS = 4096;
|
|
51
|
+
function hashIndex(token) {
|
|
52
|
+
let h = 0x811c9dc5;
|
|
53
|
+
for (let i = 0; i < token.length; i++) {
|
|
54
|
+
h ^= token.charCodeAt(i);
|
|
55
|
+
h = Math.imul(h, 0x01000193);
|
|
56
|
+
}
|
|
57
|
+
return h >>> 0;
|
|
58
|
+
}
|
|
59
|
+
function hashSign(token) {
|
|
60
|
+
let h = 5381;
|
|
61
|
+
for (let i = 0; i < token.length; i++) {
|
|
62
|
+
h = ((h << 5) + h) ^ token.charCodeAt(i);
|
|
63
|
+
}
|
|
64
|
+
return h >>> 0;
|
|
65
|
+
}
|
|
66
|
+
const activeModel = {
|
|
67
|
+
name: "signed-hash-tfidf",
|
|
68
|
+
vectorize(tokens, idf, docCount) {
|
|
69
|
+
return tfIdfVectorize(tokens, idf, docCount);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
function tfIdfVectorize(tokens, idf, docCount) {
|
|
73
|
+
const tf = computeTf(tokens);
|
|
74
|
+
const nDocs = docCount || 1;
|
|
75
|
+
const vec = new Array(HASH_DIMS).fill(0);
|
|
76
|
+
for (const term of Object.keys(tf)) {
|
|
77
|
+
const idfVal = Math.log((nDocs + 1) / ((idf[term] || 0) + 1)) + 1;
|
|
78
|
+
const weight = tf[term] * idfVal;
|
|
79
|
+
const idx = hashIndex(term) % HASH_DIMS;
|
|
80
|
+
const sign = (hashSign(term) & 1) === 0 ? 1 : -1;
|
|
81
|
+
vec[idx] += sign * weight;
|
|
82
|
+
}
|
|
83
|
+
return vec;
|
|
84
|
+
}
|
|
85
|
+
function cosineSimilarity(a, b) {
|
|
86
|
+
if (a.length === 0 || b.length === 0)
|
|
87
|
+
return 0;
|
|
88
|
+
let dot = 0;
|
|
89
|
+
let normA = 0;
|
|
90
|
+
let normB = 0;
|
|
91
|
+
for (let i = 0; i < a.length; i++) {
|
|
92
|
+
dot += a[i] * b[i];
|
|
93
|
+
normA += a[i] * a[i];
|
|
94
|
+
normB += b[i] * b[i];
|
|
95
|
+
}
|
|
96
|
+
if (normA === 0 || normB === 0)
|
|
97
|
+
return 0;
|
|
98
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
99
|
+
}
|
|
100
|
+
function keywordScore(regexes, text) {
|
|
101
|
+
const lower = text.toLowerCase();
|
|
102
|
+
let score = 0;
|
|
103
|
+
for (const re of regexes) {
|
|
104
|
+
const matches = lower.match(re);
|
|
105
|
+
if (matches)
|
|
106
|
+
score += matches.length;
|
|
107
|
+
}
|
|
108
|
+
return score / (text.length || 1);
|
|
109
|
+
}
|
|
110
|
+
function storeFilePath() {
|
|
111
|
+
return storagePath ? join(storagePath, STORE_FILENAME) : null;
|
|
112
|
+
}
|
|
113
|
+
function atomicWriteFile(fpath, bytes) {
|
|
114
|
+
const dir = dirname(fpath);
|
|
115
|
+
if (!existsSync(dir))
|
|
116
|
+
mkdirSync(dir, { recursive: true });
|
|
117
|
+
const tmp = join(dir, `.${basename(fpath)}.tmp-${randomUUID()}`);
|
|
118
|
+
try {
|
|
119
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
120
|
+
try {
|
|
121
|
+
writeSync(fd, bytes);
|
|
122
|
+
fsyncSync(fd);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
chmodSync(tmp, 0o600);
|
|
128
|
+
renameSync(tmp, fpath);
|
|
129
|
+
// Best-effort directory fsync for rename durability (POSIX); harmless where
|
|
130
|
+
// unsupported (Windows, some FUSE filesystems).
|
|
131
|
+
try {
|
|
132
|
+
const dfd = openSync(dir, "r");
|
|
133
|
+
try {
|
|
134
|
+
fsyncSync(dfd);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
closeSync(dfd);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
try {
|
|
146
|
+
if (existsSync(tmp))
|
|
147
|
+
unlinkSync(tmp);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function readStoreBytes() {
|
|
156
|
+
const fpath = storeFilePath();
|
|
157
|
+
if (!fpath || !existsSync(fpath))
|
|
158
|
+
return null;
|
|
159
|
+
return readFileSync(fpath, null);
|
|
160
|
+
}
|
|
161
|
+
function loadStore() {
|
|
162
|
+
lockError = null;
|
|
163
|
+
loadedStat = null;
|
|
164
|
+
const fpath = storeFilePath();
|
|
165
|
+
if (!fpath || !existsSync(fpath)) {
|
|
166
|
+
store = { chunks: [], idf: {}, docCount: 0, events: [] };
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const st = statSync(fpath);
|
|
170
|
+
loadedStat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
171
|
+
const bytes = readStoreBytes();
|
|
172
|
+
const encrypted = isEncryptedEnvelope(bytes);
|
|
173
|
+
if (encrypted) {
|
|
174
|
+
// The on-disk state is authoritative: an encrypted store requires a key
|
|
175
|
+
// regardless of the current `enabled` flag. Never rename, never reset.
|
|
176
|
+
const key = resolveKeySource(kbConfig?.encryption?.key_file);
|
|
177
|
+
if (!key) {
|
|
178
|
+
store = null;
|
|
179
|
+
lockError = {
|
|
180
|
+
code: "config_error",
|
|
181
|
+
message: "Knowledge base is encrypted but no key is configured",
|
|
182
|
+
remediation: "Set INFOBROKER_KB_KEY or INFOBROKER_KB_PASSPHRASE, or point kb.encryption.key_file at a key file, then reload. Data is preserved and untouched. If the key is lost, restore it from a backup (see kb encryption 'backup' action) — without the key the store is unrecoverable by design.",
|
|
183
|
+
};
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const plain = openEnvelope(key, bytes).toString("utf-8");
|
|
188
|
+
const raw = JSON.parse(plain);
|
|
189
|
+
if (raw && Array.isArray(raw.chunks) && typeof raw.idf === "object") {
|
|
190
|
+
resolvedKey = key;
|
|
191
|
+
store = raw;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
throw new Error("decrypted store is not a valid knowledge base");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
store = null;
|
|
198
|
+
lockError = {
|
|
199
|
+
code: "config_error",
|
|
200
|
+
message: "Knowledge base could not be decrypted (wrong key or tampered store)",
|
|
201
|
+
remediation: "Verify the key matches the one used to encrypt the store (see kb encryption 'verify' action). The store file has not been modified; fix the key and restart. If the key was lost, restore it from a backup or re-key from a known secret (kb encryption 'rekey').",
|
|
202
|
+
};
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Plaintext (legacy) store.
|
|
207
|
+
try {
|
|
208
|
+
const raw = JSON.parse(bytes.toString("utf-8"));
|
|
209
|
+
if (raw && Array.isArray(raw.chunks) && typeof raw.idf === "object") {
|
|
210
|
+
store = raw;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// fall through to corruption handling below
|
|
216
|
+
}
|
|
217
|
+
// The file is plaintext but unreadable as a store.
|
|
218
|
+
if (kbConfig?.encryption?.enabled) {
|
|
219
|
+
// When encryption is enabled we must not guess or reset: leave the file
|
|
220
|
+
// untouched for recovery.
|
|
221
|
+
store = null;
|
|
222
|
+
lockError = {
|
|
223
|
+
code: "config_error",
|
|
224
|
+
message: "Knowledge base store is unreadable; enabled encryption prevents automatic recovery",
|
|
225
|
+
remediation: "Inspect vector-store.json; restore a backup or disable encryption to enable legacy recovery.",
|
|
226
|
+
};
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
backupCorruptStore();
|
|
230
|
+
store = { chunks: [], idf: {}, docCount: 0, events: [] };
|
|
231
|
+
}
|
|
232
|
+
function scheduleWrite() {
|
|
233
|
+
if (writeTimer)
|
|
234
|
+
return;
|
|
235
|
+
writeTimer = setTimeout(() => {
|
|
236
|
+
writeTimer = null;
|
|
237
|
+
saveStore();
|
|
238
|
+
}, WRITE_INTERVAL_MS);
|
|
239
|
+
}
|
|
240
|
+
function flushWrite() {
|
|
241
|
+
if (writeTimer) {
|
|
242
|
+
clearTimeout(writeTimer);
|
|
243
|
+
writeTimer = null;
|
|
244
|
+
saveStore();
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
saveStore();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
process.on("beforeExit", () => flushWrite());
|
|
251
|
+
process.on("exit", () => flushWrite());
|
|
252
|
+
function saveStore() {
|
|
253
|
+
const fpath = storeFilePath();
|
|
254
|
+
if (!fpath || !store)
|
|
255
|
+
return;
|
|
256
|
+
// Detect-and-warn: if another process wrote the store since we loaded it,
|
|
257
|
+
// surface the hazard rather than silently overwriting (single-writer model).
|
|
258
|
+
if (loadedStat) {
|
|
259
|
+
try {
|
|
260
|
+
const st = statSync(fpath);
|
|
261
|
+
if (st.mtimeMs !== loadedStat.mtimeMs || st.size !== loadedStat.size) {
|
|
262
|
+
store.events.push(`Concurrent-write detected at ${new Date().toISOString()}: the store changed on disk since it was loaded; this process's pending changes may overwrite those of another instance`);
|
|
263
|
+
loadedStat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// file vanished; loadedStat reset on next load
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
let bytes;
|
|
271
|
+
const clearJson = Buffer.from(JSON.stringify(store), "utf-8");
|
|
272
|
+
if (kbConfig?.encryption?.enabled) {
|
|
273
|
+
const key = resolvedKey ?? resolveKeySource(kbConfig?.encryption?.key_file);
|
|
274
|
+
if (!key) {
|
|
275
|
+
// Should not happen after a successful load, but never write plaintext
|
|
276
|
+
// when encryption is enabled.
|
|
277
|
+
lockError = {
|
|
278
|
+
code: "config_error",
|
|
279
|
+
message: "Knowledge base encryption key unavailable at write time",
|
|
280
|
+
remediation: "Reconfigure the key and restart; no data was written.",
|
|
281
|
+
};
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const sealed = sealEnvelope(key, clearJson);
|
|
286
|
+
// Self-verify before rename: the sealed blob must round-trip to the same
|
|
287
|
+
// plaintext, otherwise the atomic rename is skipped and the old file
|
|
288
|
+
// remains intact.
|
|
289
|
+
const rechecked = openEnvelope(key, sealed).toString("utf-8");
|
|
290
|
+
if (rechecked !== clearJson.toString("utf-8")) {
|
|
291
|
+
throw new Error("encryption self-verify failed");
|
|
292
|
+
}
|
|
293
|
+
bytes = sealed;
|
|
294
|
+
resolvedKey = key;
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
store.events.push(`Encryption write failed at ${new Date().toISOString()}: ${e instanceof Error ? e.message : String(e)}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
bytes = clearJson;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
atomicWriteFile(fpath, bytes);
|
|
306
|
+
const st = statSync(fpath);
|
|
307
|
+
loadedStat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// fail silently — will retry on next write
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function backupCorruptStore() {
|
|
314
|
+
const fpath = storeFilePath();
|
|
315
|
+
if (!fpath || !existsSync(fpath))
|
|
316
|
+
return;
|
|
317
|
+
try {
|
|
318
|
+
const backup = join(storagePath, `vector-store.corrupt.${Date.now()}.json`);
|
|
319
|
+
renameSync(fpath, backup);
|
|
320
|
+
chmodSync(backup, 0o600);
|
|
321
|
+
if (store)
|
|
322
|
+
store.events.push(`Storage corruption detected at ${new Date().toISOString()}. Backup: ${backup}`);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// best effort
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function getIdf() {
|
|
329
|
+
return store?.idf ?? {};
|
|
330
|
+
}
|
|
331
|
+
function getDocCount() {
|
|
332
|
+
return store?.docCount ?? 0;
|
|
333
|
+
}
|
|
334
|
+
function updateIdf(tokens) {
|
|
335
|
+
if (!store)
|
|
336
|
+
return;
|
|
337
|
+
const seen = new Set();
|
|
338
|
+
for (const t of tokens) {
|
|
339
|
+
if (seen.has(t))
|
|
340
|
+
continue;
|
|
341
|
+
seen.add(t);
|
|
342
|
+
store.idf[t] = (store.idf[t] || 0) + 1;
|
|
343
|
+
}
|
|
344
|
+
store.docCount++;
|
|
345
|
+
}
|
|
346
|
+
function chunkText(text, title) {
|
|
347
|
+
if (!kbConfig)
|
|
348
|
+
return [text];
|
|
349
|
+
const chunkSize = kbConfig.chunk_size;
|
|
350
|
+
const overlap = kbConfig.chunk_overlap;
|
|
351
|
+
const chunks = [];
|
|
352
|
+
let start = 0;
|
|
353
|
+
while (start < text.length) {
|
|
354
|
+
let end = start + chunkSize;
|
|
355
|
+
if (end >= text.length) {
|
|
356
|
+
const c = text.slice(start).trim();
|
|
357
|
+
if (c)
|
|
358
|
+
chunks.push(title ? `${title}: ${c}` : c);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
const slice = text.slice(start, end);
|
|
362
|
+
const lastPeriod = slice.lastIndexOf(".");
|
|
363
|
+
const lastNewline = slice.lastIndexOf("\n");
|
|
364
|
+
const splitPoint = Math.max(lastPeriod, lastNewline);
|
|
365
|
+
if (splitPoint > chunkSize * 0.5) {
|
|
366
|
+
end = start + splitPoint + 1;
|
|
367
|
+
}
|
|
368
|
+
const c = text.slice(start, end).trim();
|
|
369
|
+
if (c)
|
|
370
|
+
chunks.push(title ? `${title}: ${c}` : c);
|
|
371
|
+
start = end - overlap;
|
|
372
|
+
}
|
|
373
|
+
return chunks;
|
|
374
|
+
}
|
|
375
|
+
function classifyFreshness(query, timeRange, provider, sourceType) {
|
|
376
|
+
if (!kbConfig?.freshness?.auto_classify) {
|
|
377
|
+
return kbConfig?.freshness?.default_tier || "stable";
|
|
378
|
+
}
|
|
379
|
+
if (timeRange === "day")
|
|
380
|
+
return "ephemeral";
|
|
381
|
+
if (timeRange === "week")
|
|
382
|
+
return "recent";
|
|
383
|
+
if (provider === "wikipedia")
|
|
384
|
+
return "evergreen";
|
|
385
|
+
if (sourceType === "corroborate")
|
|
386
|
+
return "stable";
|
|
387
|
+
if (sourceType === "fetch_page")
|
|
388
|
+
return "stable";
|
|
389
|
+
if (query) {
|
|
390
|
+
const lower = query.toLowerCase();
|
|
391
|
+
if (/\b(latest|current|today|breaking|live|now|just\s+in)\b/.test(lower))
|
|
392
|
+
return "ephemeral";
|
|
393
|
+
if (/\b(recent|update|this\s+week|this\s+month)\b/.test(lower))
|
|
394
|
+
return "recent";
|
|
395
|
+
if (/\b(202\d|this\s+year)\b/.test(lower))
|
|
396
|
+
return "recent";
|
|
397
|
+
}
|
|
398
|
+
return kbConfig?.freshness?.default_tier || "stable";
|
|
399
|
+
}
|
|
400
|
+
function computeFreshnessScore(tier, ingestedAt, now) {
|
|
401
|
+
const tiers = kbConfig?.freshness?.tiers;
|
|
402
|
+
if (!tiers || !tiers[tier])
|
|
403
|
+
return 1;
|
|
404
|
+
const def = tiers[tier];
|
|
405
|
+
if (!def.decay_hours || def.decay_hours === 0)
|
|
406
|
+
return 1;
|
|
407
|
+
const ageHours = (now - ingestedAt) / (1000 * 60 * 60);
|
|
408
|
+
const factor = Math.max(0, 1 - ageHours / def.decay_hours);
|
|
409
|
+
return factor;
|
|
410
|
+
}
|
|
411
|
+
export function initKb(config) {
|
|
412
|
+
const raw = resolvePath(config.storage_path);
|
|
413
|
+
const wasEncrypted = storagePath !== null && (kbConfig?.encryption?.enabled ?? false);
|
|
414
|
+
// If the storage path changes (e.g. an update alters the shipped default, or
|
|
415
|
+
// a user overlay stops overriding it), flush any pending in-memory writes to
|
|
416
|
+
// the *current* path before re-initializing — otherwise unflushed chunks
|
|
417
|
+
// would be silently dropped. Data at the previous path is never migrated.
|
|
418
|
+
if (storagePath !== null && storagePath !== raw) {
|
|
419
|
+
flushWrite();
|
|
420
|
+
const oldPath = storagePath;
|
|
421
|
+
kbConfig = config;
|
|
422
|
+
storagePath = raw;
|
|
423
|
+
resolvedKey = null;
|
|
424
|
+
lockError = null;
|
|
425
|
+
if (!existsSync(raw))
|
|
426
|
+
mkdirSync(raw, { recursive: true });
|
|
427
|
+
loadStore();
|
|
428
|
+
const event = `Storage path changed at ${new Date().toISOString()}: ${oldPath} → ${raw}. ` +
|
|
429
|
+
`Data at the previous path is not migrated; set kb.storage_path in config.local.json to restore it.`;
|
|
430
|
+
console.warn(`[infobroker] ${event}`);
|
|
431
|
+
if (store)
|
|
432
|
+
store.events.push(event);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
kbConfig = config;
|
|
436
|
+
storagePath = raw;
|
|
437
|
+
if (!existsSync(raw))
|
|
438
|
+
mkdirSync(raw, { recursive: true });
|
|
439
|
+
resolvedKey = null;
|
|
440
|
+
lockError = null;
|
|
441
|
+
loadStore();
|
|
442
|
+
}
|
|
443
|
+
// Resolve the encryption key after load so the in-hand key is registered for
|
|
444
|
+
// subsequent writes. Warn loudly when encryption was just enabled.
|
|
445
|
+
const newlyEnabled = config.encryption?.enabled && !wasEncrypted;
|
|
446
|
+
const newlyDisabled = wasEncrypted && !(config.encryption?.enabled ?? false);
|
|
447
|
+
if (config.encryption?.enabled && !resolvedKey && store !== null) {
|
|
448
|
+
resolvedKey = resolveKeySource(config.encryption?.key_file) ?? null;
|
|
449
|
+
}
|
|
450
|
+
// Re-key runs as a one-shot at init (operator shell, not a client) when the
|
|
451
|
+
// rekey source/target environment variables are present. rekeyStore re-seals
|
|
452
|
+
// the file and updates the in-memory store directly, so no reload is needed.
|
|
453
|
+
try {
|
|
454
|
+
const result = rekeyStore();
|
|
455
|
+
if (result) {
|
|
456
|
+
console.warn(`[infobroker] ${result}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
console.error(`[infobroker] rekey failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
461
|
+
}
|
|
462
|
+
if (newlyEnabled && config.encryption?.enabled) {
|
|
463
|
+
if (!resolvedKey && !lockError) {
|
|
464
|
+
lockError = {
|
|
465
|
+
code: "config_error",
|
|
466
|
+
message: "Knowledge base encryption is enabled but no key is available",
|
|
467
|
+
remediation: "Set INFOBROKER_KB_KEY / INFOBROKER_KB_PASSPHRASE or kb.encryption.key_file, then reload.",
|
|
468
|
+
};
|
|
469
|
+
store = null;
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
console.warn("[infobroker] Knowledge base encryption has been enabled. Enabling encryption makes the store unrecoverable without the key — back up your key now.");
|
|
473
|
+
// Eager migration: encrypt the legacy plaintext store immediately so no
|
|
474
|
+
// plaintext copy lingers on disk.
|
|
475
|
+
if (store !== null && resolvedKey)
|
|
476
|
+
saveStore();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Disabling encryption is an explicit, immediate transition: the store is
|
|
480
|
+
// already decrypted in memory (loadStore requires the key regardless of the
|
|
481
|
+
// enabled flag), so flush it to plaintext now rather than deferring to the
|
|
482
|
+
// next write. This is the reverse of the eager migrate-on-enable above.
|
|
483
|
+
if (newlyDisabled && store !== null) {
|
|
484
|
+
try {
|
|
485
|
+
saveStore();
|
|
486
|
+
console.warn("[infobroker] Knowledge base encryption has been disabled. The store has been decrypted to plaintext on disk.");
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
console.error(`[infobroker] decryption-on-disable failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
runTruncSweep();
|
|
493
|
+
if (store)
|
|
494
|
+
ensureStableEmbeddings();
|
|
495
|
+
runMaintenance();
|
|
496
|
+
if (maintenanceTimer)
|
|
497
|
+
clearInterval(maintenanceTimer);
|
|
498
|
+
maintenanceTimer = setInterval(runMaintenance, config.maintenance_interval_minutes * 60 * 1000);
|
|
499
|
+
}
|
|
500
|
+
function ensureStableEmbeddings() {
|
|
501
|
+
if (!store)
|
|
502
|
+
return;
|
|
503
|
+
if (store.model === activeModel.name)
|
|
504
|
+
return;
|
|
505
|
+
let reembedded = 0;
|
|
506
|
+
for (const chunk of store.chunks) {
|
|
507
|
+
chunk.embedding = activeModel.vectorize(tokenize(chunk.text), store.idf, store.docCount);
|
|
508
|
+
reembedded++;
|
|
509
|
+
}
|
|
510
|
+
store.model = activeModel.name;
|
|
511
|
+
store.events.push(`Embedding model reconciled at ${new Date().toISOString()}: "${activeModel.name}", ${reembedded} chunk(s) re-embedded`);
|
|
512
|
+
saveStore();
|
|
513
|
+
}
|
|
514
|
+
export function flushKbWrites() {
|
|
515
|
+
flushWrite();
|
|
516
|
+
}
|
|
517
|
+
export function isKbConfigured() {
|
|
518
|
+
return kbConfig !== null;
|
|
519
|
+
}
|
|
520
|
+
/** The lock state when the store could not be loaded (REQ-085), else null. */
|
|
521
|
+
export function getKbLockError() {
|
|
522
|
+
return lockError;
|
|
523
|
+
}
|
|
524
|
+
export function getKbEncryptionState() {
|
|
525
|
+
if (lockError)
|
|
526
|
+
return "locked";
|
|
527
|
+
return kbConfig?.encryption?.enabled ? "enabled" : "disabled";
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Sweep stale truncated-response spill files from $TMPDIR/infobroker. Content
|
|
531
|
+
* that exceeds the output length limit is written there in plaintext; these
|
|
532
|
+
* files are transient and never cleaned elsewhere, so age them out here without
|
|
533
|
+
* ever touching quota.json or any other persistent state.
|
|
534
|
+
*/
|
|
535
|
+
export function runTruncSweep(ttlMs = TRUNC_TTL_MS) {
|
|
536
|
+
const dir = join(tmpdir(), "infobroker");
|
|
537
|
+
let removed = 0;
|
|
538
|
+
try {
|
|
539
|
+
if (!existsSync(dir))
|
|
540
|
+
return 0;
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
for (const name of readdirSync(dir)) {
|
|
543
|
+
if (!name.startsWith("trunc-") || !name.endsWith(".txt"))
|
|
544
|
+
continue;
|
|
545
|
+
const fpath = join(dir, name);
|
|
546
|
+
try {
|
|
547
|
+
if (now - statSync(fpath).mtimeMs > ttlMs) {
|
|
548
|
+
unlinkSync(fpath);
|
|
549
|
+
removed++;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// best effort
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// best effort
|
|
559
|
+
}
|
|
560
|
+
return removed;
|
|
561
|
+
}
|
|
562
|
+
export function getKbConfig() {
|
|
563
|
+
return kbConfig;
|
|
564
|
+
}
|
|
565
|
+
export function kbSearch(query, maxResults = 10, collection, sourceType) {
|
|
566
|
+
if (!kbConfig)
|
|
567
|
+
throw new Error(CONFIG_ERROR_CODE);
|
|
568
|
+
if (!store)
|
|
569
|
+
return [];
|
|
570
|
+
const queryTokens = tokenize(query);
|
|
571
|
+
const queryVec = computeTfIdfVector(queryTokens, getIdf(), getDocCount());
|
|
572
|
+
const kwRegexes = queryTokens.map((t) => new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"));
|
|
573
|
+
const actualMax = Math.min(maxResults, kbConfig.max_results);
|
|
574
|
+
const results = [];
|
|
575
|
+
const now = Date.now();
|
|
576
|
+
for (const chunk of store.chunks) {
|
|
577
|
+
if (collection && chunk.collection !== collection)
|
|
578
|
+
continue;
|
|
579
|
+
if (sourceType && chunk.source_type !== sourceType)
|
|
580
|
+
continue;
|
|
581
|
+
const vecSimilarity = cosineSimilarity(queryVec, chunk.embedding);
|
|
582
|
+
const kwScore = keywordScore(kwRegexes, chunk.text);
|
|
583
|
+
const combinedScore = vecSimilarity * 0.7 + kwScore * 0.3;
|
|
584
|
+
if (combinedScore > 0) {
|
|
585
|
+
const freshnessScore = computeFreshnessScore(chunk.freshness_tier, chunk.ingested_at, now);
|
|
586
|
+
results.push({
|
|
587
|
+
score: combinedScore,
|
|
588
|
+
freshness_score: freshnessScore,
|
|
589
|
+
freshness_tier: chunk.freshness_tier,
|
|
590
|
+
source_url: chunk.source_url,
|
|
591
|
+
title: chunk.title,
|
|
592
|
+
snippet: chunk.text.slice(0, 200),
|
|
593
|
+
collection: chunk.collection,
|
|
594
|
+
provider: chunk.provider,
|
|
595
|
+
source_type: chunk.source_type,
|
|
596
|
+
ingested_at: chunk.ingested_at,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
results.sort((a, b) => (b.score * b.freshness_score) - (a.score * a.freshness_score));
|
|
601
|
+
return results.slice(0, actualMax);
|
|
602
|
+
}
|
|
603
|
+
export function kbIngest(text, title, sourceUrl, provider, collection, sourceType, freshnessTier) {
|
|
604
|
+
if (!kbConfig)
|
|
605
|
+
throw new Error(CONFIG_ERROR_CODE);
|
|
606
|
+
if (!store)
|
|
607
|
+
return 0;
|
|
608
|
+
const s = store;
|
|
609
|
+
const resolvedCollection = collection || kbConfig.default_collection || "default";
|
|
610
|
+
const resolvedSourceType = sourceType || "web_search";
|
|
611
|
+
const resolvedTier = freshnessTier || kbConfig?.freshness?.default_tier || "stable";
|
|
612
|
+
const chunks = chunkText(text, title);
|
|
613
|
+
if (chunks.length === 0) {
|
|
614
|
+
s.events.push(`Ingest skipped at ${new Date().toISOString()}: empty or unsplittable text (${text.slice(0, 80)})`);
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
const now = Date.now();
|
|
618
|
+
if (sourceUrl) {
|
|
619
|
+
s.chunks = s.chunks.filter((c) => c.source_url !== sourceUrl);
|
|
620
|
+
}
|
|
621
|
+
const preIngestIdf = { ...s.idf };
|
|
622
|
+
const preIngestDocCount = s.docCount;
|
|
623
|
+
chunks.forEach((chunkText, index) => {
|
|
624
|
+
const tokens = tokenize(chunkText);
|
|
625
|
+
const embedding = computeTfIdfVector(tokens, preIngestIdf, preIngestDocCount);
|
|
626
|
+
const id = randomUUID();
|
|
627
|
+
s.chunks.push({
|
|
628
|
+
id,
|
|
629
|
+
text: chunkText,
|
|
630
|
+
embedding,
|
|
631
|
+
source_url: sourceUrl,
|
|
632
|
+
chunk_index: index,
|
|
633
|
+
title,
|
|
634
|
+
provider,
|
|
635
|
+
collection: resolvedCollection,
|
|
636
|
+
source_type: resolvedSourceType,
|
|
637
|
+
freshness_tier: resolvedTier,
|
|
638
|
+
ingested_at: now,
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
for (const chunkText of chunks) {
|
|
642
|
+
updateIdf(tokenize(chunkText));
|
|
643
|
+
}
|
|
644
|
+
scheduleWrite();
|
|
645
|
+
return chunks.length;
|
|
646
|
+
}
|
|
647
|
+
export function kbStats() {
|
|
648
|
+
if (!kbConfig) {
|
|
649
|
+
return {
|
|
650
|
+
chunk_count: 0,
|
|
651
|
+
collections: {},
|
|
652
|
+
storage_size_bytes: 0,
|
|
653
|
+
last_ingestion: null,
|
|
654
|
+
model_available: false,
|
|
655
|
+
model_name: "none",
|
|
656
|
+
events: ["knowledge base not configured"],
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const collections = {};
|
|
660
|
+
const tiers = {};
|
|
661
|
+
let lastIngestion = 0;
|
|
662
|
+
for (const c of store?.chunks ?? []) {
|
|
663
|
+
collections[c.collection] = (collections[c.collection] || 0) + 1;
|
|
664
|
+
tiers[c.freshness_tier] = (tiers[c.freshness_tier] || 0) + 1;
|
|
665
|
+
if (c.ingested_at > lastIngestion)
|
|
666
|
+
lastIngestion = c.ingested_at;
|
|
667
|
+
}
|
|
668
|
+
let sizeBytes = 0;
|
|
669
|
+
if (storagePath && existsSync(join(storagePath, STORE_FILENAME))) {
|
|
670
|
+
try {
|
|
671
|
+
sizeBytes = readFileSync(join(storagePath, STORE_FILENAME)).length;
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
// ignore
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
chunk_count: store?.chunks.length ?? 0,
|
|
679
|
+
collections,
|
|
680
|
+
freshness_tiers: Object.keys(tiers).length > 0 ? tiers : undefined,
|
|
681
|
+
storage_size_bytes: sizeBytes,
|
|
682
|
+
last_ingestion: lastIngestion ? new Date(lastIngestion).toISOString() : null,
|
|
683
|
+
model_available: modelAvailable,
|
|
684
|
+
model_name: activeModel.name,
|
|
685
|
+
events: store?.events ?? [],
|
|
686
|
+
encryption: getKbEncryptionState(),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
export function kbList(collection, sourceType) {
|
|
690
|
+
if (!kbConfig)
|
|
691
|
+
throw new Error(CONFIG_ERROR_CODE);
|
|
692
|
+
if (!store)
|
|
693
|
+
return [];
|
|
694
|
+
const bySource = new Map();
|
|
695
|
+
for (const chunk of store.chunks) {
|
|
696
|
+
if (collection && chunk.collection !== collection)
|
|
697
|
+
continue;
|
|
698
|
+
if (sourceType && chunk.source_type !== sourceType)
|
|
699
|
+
continue;
|
|
700
|
+
const key = chunk.source_url || `chunk:${chunk.id}`;
|
|
701
|
+
const existing = bySource.get(key);
|
|
702
|
+
if (existing) {
|
|
703
|
+
existing.chunk_count++;
|
|
704
|
+
if (chunk.ingested_at > existing.ingested_at)
|
|
705
|
+
existing.ingested_at = chunk.ingested_at;
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
bySource.set(key, {
|
|
709
|
+
source_url: chunk.source_url,
|
|
710
|
+
title: chunk.title,
|
|
711
|
+
collection: chunk.collection,
|
|
712
|
+
source_type: chunk.source_type,
|
|
713
|
+
freshness_tier: chunk.freshness_tier,
|
|
714
|
+
chunk_count: 1,
|
|
715
|
+
ingested_at: chunk.ingested_at,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return [...bySource.values()].sort((a, b) => b.ingested_at - a.ingested_at);
|
|
720
|
+
}
|
|
721
|
+
export function kbGet(sourceUrl) {
|
|
722
|
+
if (!kbConfig)
|
|
723
|
+
throw new Error(CONFIG_ERROR_CODE);
|
|
724
|
+
if (!store)
|
|
725
|
+
return null;
|
|
726
|
+
const matches = store.chunks
|
|
727
|
+
.filter((c) => c.source_url === sourceUrl)
|
|
728
|
+
.sort((a, b) => (a.chunk_index ?? 0) - (b.chunk_index ?? 0));
|
|
729
|
+
if (matches.length === 0)
|
|
730
|
+
return null;
|
|
731
|
+
const first = matches[0];
|
|
732
|
+
return {
|
|
733
|
+
title: first.title,
|
|
734
|
+
source_url: first.source_url,
|
|
735
|
+
collection: first.collection,
|
|
736
|
+
source_type: first.source_type,
|
|
737
|
+
freshness_tier: first.freshness_tier,
|
|
738
|
+
ingested_at: matches.reduce((m, c) => Math.max(m, c.ingested_at), 0),
|
|
739
|
+
text: matches.map((c) => c.text).join("\n"),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function slugify(title) {
|
|
743
|
+
return title
|
|
744
|
+
.toLowerCase()
|
|
745
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
746
|
+
.replace(/^-+|-+$/g, "")
|
|
747
|
+
.slice(0, 80) || "report";
|
|
748
|
+
}
|
|
749
|
+
export function resolveReportIdentity(title, providedUrl) {
|
|
750
|
+
if (providedUrl)
|
|
751
|
+
return providedUrl;
|
|
752
|
+
return `report://${slugify(title)}`;
|
|
753
|
+
}
|
|
754
|
+
export function kbDelete(collection, sourceUrl) {
|
|
755
|
+
if (!kbConfig)
|
|
756
|
+
throw new Error(CONFIG_ERROR_CODE);
|
|
757
|
+
if (!store)
|
|
758
|
+
return 0;
|
|
759
|
+
if (!collection && !sourceUrl)
|
|
760
|
+
throw new Error("invalid_input");
|
|
761
|
+
const before = store.chunks.length;
|
|
762
|
+
store.chunks = store.chunks.filter((c) => {
|
|
763
|
+
if (collection && c.collection === collection)
|
|
764
|
+
return false;
|
|
765
|
+
if (sourceUrl && c.source_url === sourceUrl)
|
|
766
|
+
return false;
|
|
767
|
+
return true;
|
|
768
|
+
});
|
|
769
|
+
const removed = before - store.chunks.length;
|
|
770
|
+
if (removed > 0) {
|
|
771
|
+
rebuildIdf();
|
|
772
|
+
scheduleWrite();
|
|
773
|
+
}
|
|
774
|
+
return removed;
|
|
775
|
+
}
|
|
776
|
+
function rebuildIdf() {
|
|
777
|
+
if (!store)
|
|
778
|
+
return;
|
|
779
|
+
store.idf = {};
|
|
780
|
+
store.docCount = 0;
|
|
781
|
+
for (const chunk of store.chunks) {
|
|
782
|
+
const tokens = tokenize(chunk.text);
|
|
783
|
+
const seen = new Set();
|
|
784
|
+
for (const t of tokens) {
|
|
785
|
+
if (seen.has(t))
|
|
786
|
+
continue;
|
|
787
|
+
seen.add(t);
|
|
788
|
+
store.idf[t] = (store.idf[t] || 0) + 1;
|
|
789
|
+
}
|
|
790
|
+
store.docCount++;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
export function autoIndex(results, provider, collection, sourceType, query, timeRange) {
|
|
794
|
+
if (!kbConfig || !kbConfig.auto_index)
|
|
795
|
+
return;
|
|
796
|
+
if (!store)
|
|
797
|
+
return;
|
|
798
|
+
setImmediate(() => {
|
|
799
|
+
try {
|
|
800
|
+
let totalChunks = 0;
|
|
801
|
+
const tier = classifyFreshness(query, timeRange, provider, sourceType || provider);
|
|
802
|
+
for (const r of results) {
|
|
803
|
+
if (!r.snippet && !r.title)
|
|
804
|
+
continue;
|
|
805
|
+
const text = r.snippet || r.title;
|
|
806
|
+
const sourceUrl = r.url || "";
|
|
807
|
+
totalChunks += kbIngest(text, r.title, sourceUrl, provider, collection, sourceType || provider, tier);
|
|
808
|
+
}
|
|
809
|
+
if (totalChunks > 0)
|
|
810
|
+
flushWrite();
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
if (store)
|
|
814
|
+
store.events.push(`Auto-index error at ${new Date().toISOString()}`);
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
export function runMaintenance() {
|
|
819
|
+
if (!kbConfig || !store)
|
|
820
|
+
return;
|
|
821
|
+
const tiers = kbConfig.freshness?.tiers;
|
|
822
|
+
if (!tiers)
|
|
823
|
+
return;
|
|
824
|
+
const now = Date.now();
|
|
825
|
+
const before = store.chunks.length;
|
|
826
|
+
store.chunks = store.chunks.filter((c) => {
|
|
827
|
+
const def = tiers[c.freshness_tier];
|
|
828
|
+
if (!def || !def.expiry_hours || def.expiry_hours <= 0)
|
|
829
|
+
return true;
|
|
830
|
+
const cutoff = now - def.expiry_hours * 60 * 60 * 1000;
|
|
831
|
+
return c.ingested_at > cutoff;
|
|
832
|
+
});
|
|
833
|
+
const removed = before - store.chunks.length;
|
|
834
|
+
if (removed > 0) {
|
|
835
|
+
rebuildIdf();
|
|
836
|
+
scheduleWrite();
|
|
837
|
+
store.events.push(`Maintenance at ${new Date().toISOString()}: removed ${removed} expired chunks`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Encrypt report bytes for disk save when encryption is enabled. Returns the
|
|
842
|
+
* sealed envelope bytes, or the plaintext bytes when encryption is disabled
|
|
843
|
+
* (or no key is resolved).
|
|
844
|
+
*/
|
|
845
|
+
export function sealReportBytes(clear) {
|
|
846
|
+
if (kbConfig?.encryption?.enabled) {
|
|
847
|
+
const key = resolvedKey ?? resolveKeySource(kbConfig.encryption.key_file);
|
|
848
|
+
if (key) {
|
|
849
|
+
const sealed = sealEnvelope(key, clear);
|
|
850
|
+
const rechecked = openEnvelope(key, sealed);
|
|
851
|
+
if (rechecked.equals(clear))
|
|
852
|
+
return sealed;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return clear;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Re-seal an on-disk encrypted store from one key to another without loss of
|
|
859
|
+
* stored content (REQ-084 "change the secret"). Verifies the result before the
|
|
860
|
+
* atomic commit. Returns a human-readable result, or null when the store is not
|
|
861
|
+
* encrypted. Supports null `from` (use the currently-resolved/in-hand key) for
|
|
862
|
+
* in-tool re-key where the source secret is already loaded.
|
|
863
|
+
*/
|
|
864
|
+
export function rekeyStoreTo(from, to) {
|
|
865
|
+
const fpath = storeFilePath();
|
|
866
|
+
if (!fpath || !existsSync(fpath))
|
|
867
|
+
return null;
|
|
868
|
+
const bytes = readStoreBytes();
|
|
869
|
+
if (!isEncryptedEnvelope(bytes))
|
|
870
|
+
return null;
|
|
871
|
+
const src = from ?? resolvedKey;
|
|
872
|
+
if (!src)
|
|
873
|
+
return null;
|
|
874
|
+
const plain = openEnvelope(src, bytes);
|
|
875
|
+
const sealed = sealEnvelope(to, plain);
|
|
876
|
+
const rechecked = openEnvelope(to, sealed);
|
|
877
|
+
if (!rechecked.equals(plain))
|
|
878
|
+
throw new Error("rekey self-verify failed");
|
|
879
|
+
atomicWriteFile(fpath, sealed);
|
|
880
|
+
const st = statSync(fpath);
|
|
881
|
+
loadedStat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
882
|
+
// Load the re-keyed content directly (no file re-read, so the new key need
|
|
883
|
+
// not be resolvable from config yet) and register the new key for writes.
|
|
884
|
+
try {
|
|
885
|
+
const parsed = JSON.parse(plain.toString("utf-8"));
|
|
886
|
+
if (parsed && Array.isArray(parsed.chunks) && typeof parsed.idf === "object") {
|
|
887
|
+
store = parsed;
|
|
888
|
+
resolvedKey = to;
|
|
889
|
+
lockError = null;
|
|
890
|
+
store.events.push(`Encryption key changed at ${new Date().toISOString()}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
// Leave re-keyed file in place; memory state refreshes on next load.
|
|
895
|
+
}
|
|
896
|
+
return "knowledge base re-keyed";
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Re-key the store from one secret to another (REQ-084 "change the secret
|
|
900
|
+
* without loss of stored content"). Reads the rekey source/target from the
|
|
901
|
+
* environment; wraps rekeyStoreTo, which re-seals the store atomically and
|
|
902
|
+
* records the operation in the store events. Returns a human-readable result,
|
|
903
|
+
* or null when no rekey is requested or the store is not encrypted.
|
|
904
|
+
*/
|
|
905
|
+
export function rekeyStore() {
|
|
906
|
+
const fromEnv = rawKeyFromString(process.env["INFOBROKER_KB_REKEY_FROM"])
|
|
907
|
+
?? passphraseKey(process.env["INFOBROKER_KB_REKEY_FROM_PASSPHRASE"]);
|
|
908
|
+
const toFromKeyFile = process.env["INFOBROKER_KB_REKEY_TO_KEY_FILE"]
|
|
909
|
+
? { kind: "raw", dek: readKeyFile(process.env["INFOBROKER_KB_REKEY_TO_KEY_FILE"]) }
|
|
910
|
+
: null;
|
|
911
|
+
const to = rawKeyFromString(process.env["INFOBROKER_KB_REKEY_TO"])
|
|
912
|
+
?? passphraseKey(process.env["INFOBROKER_KB_REKEY_TO_PASSPHRASE"])
|
|
913
|
+
?? toFromKeyFile;
|
|
914
|
+
if (!fromEnv || !to)
|
|
915
|
+
return null;
|
|
916
|
+
return rekeyStoreTo(fromEnv, to);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Write a fresh 32-byte raw key (base64) to `path` with 0600 permissions and
|
|
920
|
+
* return the path. Never returns the secret material — the caller learns only
|
|
921
|
+
* where the key lives and that it must be backed up.
|
|
922
|
+
*/
|
|
923
|
+
export function generateKeyFile(path) {
|
|
924
|
+
const key = randomBytes(32).toString("base64");
|
|
925
|
+
const dir = dirname(path);
|
|
926
|
+
if (!existsSync(dir))
|
|
927
|
+
mkdirSync(dir, { recursive: true });
|
|
928
|
+
atomicWriteFile(path, Buffer.from(`${key}\n`, "utf-8"));
|
|
929
|
+
return path;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Verify the currently-resolved (or explicitly provided) secret against the
|
|
933
|
+
* on-disk store without writing anything. Returns true when the secret opens
|
|
934
|
+
* the envelope to a valid store, false otherwise (missing store, plaintext
|
|
935
|
+
* store, wrong key, or tampered store).
|
|
936
|
+
*/
|
|
937
|
+
export function verifyStoreKey(key) {
|
|
938
|
+
const fpath = storeFilePath();
|
|
939
|
+
if (!fpath || !existsSync(fpath))
|
|
940
|
+
return false;
|
|
941
|
+
const bytes = readStoreBytes();
|
|
942
|
+
if (!isEncryptedEnvelope(bytes))
|
|
943
|
+
return false;
|
|
944
|
+
const candidate = key ?? resolveKeySource(kbConfig?.encryption?.key_file) ?? resolvedKey;
|
|
945
|
+
if (!candidate)
|
|
946
|
+
return false;
|
|
947
|
+
try {
|
|
948
|
+
const plain = openEnvelope(candidate, bytes).toString("utf-8");
|
|
949
|
+
const raw = JSON.parse(plain);
|
|
950
|
+
return !!raw && Array.isArray(raw.chunks) && typeof raw.idf === "object";
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Copy the currently-resolved key file to a backup path (0600) so the user has
|
|
958
|
+
* a recovery artifact. Returns the backup path. The secret itself is never
|
|
959
|
+
* returned. If the active key source is not a file, returns null.
|
|
960
|
+
*/
|
|
961
|
+
export function backupKeyFile(backupPath) {
|
|
962
|
+
const keyFile = kbConfig?.encryption?.key_file;
|
|
963
|
+
const resolved = resolveKeySource(keyFile);
|
|
964
|
+
if (!keyFile || !resolved || resolved.kind !== "raw")
|
|
965
|
+
return null;
|
|
966
|
+
const src = resolvePath(keyFile);
|
|
967
|
+
if (!existsSync(src))
|
|
968
|
+
return null;
|
|
969
|
+
const dir = dirname(backupPath);
|
|
970
|
+
if (!existsSync(dir))
|
|
971
|
+
mkdirSync(dir, { recursive: true });
|
|
972
|
+
copyFileSync(src, backupPath);
|
|
973
|
+
chmodSync(backupPath, 0o600);
|
|
974
|
+
if (store)
|
|
975
|
+
store.events.push(`Encryption key backed up at ${new Date().toISOString()}: ${backupPath}`);
|
|
976
|
+
return backupPath;
|
|
977
|
+
}
|
|
978
|
+
/** On-disk format of the store at rest: "encrypted", "plaintext", or "none". */
|
|
979
|
+
export function kbStoreFormat() {
|
|
980
|
+
const fpath = storeFilePath();
|
|
981
|
+
if (!fpath || !existsSync(fpath))
|
|
982
|
+
return "none";
|
|
983
|
+
const bytes = readStoreBytes();
|
|
984
|
+
return isEncryptedEnvelope(bytes) ? "encrypted" : "plaintext";
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Structured encryption status for the client-facing `encryption` action:
|
|
988
|
+
* current state, on-disk format, active key-source kind, whether the key
|
|
989
|
+
* currently resolves, and (when locked) the lock error with its remediation.
|
|
990
|
+
*/
|
|
991
|
+
export function kbEncryptionStatus() {
|
|
992
|
+
const keyFile = kbConfig?.encryption?.key_file;
|
|
993
|
+
const resolved = resolveKeySource(keyFile);
|
|
994
|
+
let keySource = "none";
|
|
995
|
+
if (keyFile)
|
|
996
|
+
keySource = "key_file";
|
|
997
|
+
else if (process.env["INFOBROKER_KB_KEY"])
|
|
998
|
+
keySource = "raw_env";
|
|
999
|
+
else if (process.env["INFOBROKER_KB_PASSPHRASE"])
|
|
1000
|
+
keySource = "passphrase";
|
|
1001
|
+
return {
|
|
1002
|
+
state: getKbEncryptionState(),
|
|
1003
|
+
on_disk_format: kbStoreFormat(),
|
|
1004
|
+
key_source: keySource,
|
|
1005
|
+
key_resolvable: resolved !== null,
|
|
1006
|
+
lock: lockError,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
//# sourceMappingURL=kb.js.map
|