@geravant/sinain 1.18.3 → 1.20.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/cli.js +10 -0
- package/onboard.js +32 -6
- package/package.json +1 -1
- package/sinain-core/package-lock.json +439 -0
- package/sinain-core/package.json +2 -0
- package/sinain-core/src/index.ts +283 -0
- package/sinain-core/src/server.ts +1001 -4
- package/sinain-core/src/web-db/schema.ts +100 -0
- package/sinain-core/src/web-db/store.ts +279 -0
- package/sinain-memory/concept_export.py +310 -0
- package/sinain-memory/concept_import.py +254 -0
- package/sinain-memory/graph_query.py +455 -0
- package/sinain-memory/page_renderer.py +447 -0
- package/sinain-memory/retract.py +236 -0
package/sinain-core/package.json
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@huggingface/transformers": "^4.0.1",
|
|
16
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
16
17
|
"@types/node": "^22.19.7",
|
|
17
18
|
"@types/ws": "^8.18.1",
|
|
19
|
+
"better-sqlite3": "^11.7.0",
|
|
18
20
|
"tsx": "^4.21.0",
|
|
19
21
|
"typescript": "^5.9.3",
|
|
20
22
|
"ws": "^8.18.0"
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { SignalCollector } from "./learning/signal-collector.js";
|
|
|
19
19
|
import { LocalCurationService } from "./learning/local-curation.js";
|
|
20
20
|
import { EmbeddingService } from "./embedding/service.js";
|
|
21
21
|
import { createAppServer } from "./server.js";
|
|
22
|
+
import { WebDb } from "./web-db/store.js";
|
|
22
23
|
import { Profiler } from "./profiler.js";
|
|
23
24
|
import { CostTracker } from "./cost/tracker.js";
|
|
24
25
|
import type { SenseEvent, EscalationMode, FeedItem } from "./types.js";
|
|
@@ -173,6 +174,270 @@ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
|
|
|
173
174
|
return JSON.stringify(unique.slice(0, max));
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/** Resolve graph_query.py script path. Used by all knowledge-graph subprocess calls. */
|
|
178
|
+
function resolveGraphQueryScript(): string {
|
|
179
|
+
const __dir = new URL(import.meta.url).pathname.replace(/\/[^/]+$/, "");
|
|
180
|
+
const candidates = [
|
|
181
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory/graph_query.py`,
|
|
182
|
+
`${__dir}/../sinain-memory/graph_query.py`,
|
|
183
|
+
`${resolveWorkspace()}/sinain-memory/graph_query.py`,
|
|
184
|
+
];
|
|
185
|
+
return candidates.find(p => existsSync(p)) || candidates[0];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** List of candidate knowledge DB paths (local + workspace). */
|
|
189
|
+
function resolveKnowledgeDbPaths(): string[] {
|
|
190
|
+
return [
|
|
191
|
+
`${resolveLocalMemoryDir()}/knowledge-graph.db`,
|
|
192
|
+
`${resolveWorkspace()}/memory/knowledge-graph.db`,
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Search entities across all knowledge DBs. Returns ranked list with snippets. */
|
|
197
|
+
async function searchEntitiesMulti(query: string, limit: number): Promise<unknown> {
|
|
198
|
+
const { execFileSync } = await import("node:child_process");
|
|
199
|
+
const scriptPath = resolveGraphQueryScript();
|
|
200
|
+
const merged: Map<string, any> = new Map();
|
|
201
|
+
let topicFallback = true;
|
|
202
|
+
|
|
203
|
+
for (const dbPath of resolveKnowledgeDbPaths()) {
|
|
204
|
+
if (!existsSync(dbPath)) continue;
|
|
205
|
+
try {
|
|
206
|
+
const out = execFileSync("python3", [
|
|
207
|
+
scriptPath, "--db", dbPath,
|
|
208
|
+
"--search-entities", query,
|
|
209
|
+
"--search-limit", String(limit * 2), // 2x then de-dup
|
|
210
|
+
], { timeout: 5000, encoding: "utf-8" });
|
|
211
|
+
const parsed = JSON.parse(out);
|
|
212
|
+
if (!parsed.topic_fallback) topicFallback = false;
|
|
213
|
+
for (const r of parsed.results || []) {
|
|
214
|
+
const existing = merged.get(r.entity);
|
|
215
|
+
if (!existing || existing.score < r.score) {
|
|
216
|
+
merged.set(r.entity, r);
|
|
217
|
+
} else {
|
|
218
|
+
existing.fact_count += r.fact_count; // sum across DBs when same entity present
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch { /* skip failed DB */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const results = Array.from(merged.values())
|
|
225
|
+
.sort((a, b) => (b.score - a.score) || (b.fact_count - a.fact_count))
|
|
226
|
+
.slice(0, limit);
|
|
227
|
+
|
|
228
|
+
return { results, topic_fallback: topicFallback && results.every(r => r.score < 0.4) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Export a concept bundle (entity + neighborhood) as JSON. */
|
|
232
|
+
async function exportConceptBundle(
|
|
233
|
+
entity: string,
|
|
234
|
+
depth: number,
|
|
235
|
+
opts: { includeRetracted: boolean; includePage: boolean; redactRules: string[] },
|
|
236
|
+
): Promise<unknown> {
|
|
237
|
+
const { execFile } = await import("node:child_process");
|
|
238
|
+
const { promisify } = await import("node:util");
|
|
239
|
+
const pExecFile = promisify(execFile);
|
|
240
|
+
const __dir = new URL(import.meta.url).pathname.replace(/\/[^/]+$/, "");
|
|
241
|
+
const scriptCandidates = [
|
|
242
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory/concept_export.py`,
|
|
243
|
+
`${__dir}/../sinain-memory/concept_export.py`,
|
|
244
|
+
`${resolveWorkspace()}/sinain-memory/concept_export.py`,
|
|
245
|
+
];
|
|
246
|
+
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
247
|
+
const webDbPath = `${resolveLocalMemoryDir()}/web.db`;
|
|
248
|
+
|
|
249
|
+
for (const dbPath of resolveKnowledgeDbPaths()) {
|
|
250
|
+
if (!existsSync(dbPath)) continue;
|
|
251
|
+
const args = [
|
|
252
|
+
scriptPath,
|
|
253
|
+
"--db", dbPath,
|
|
254
|
+
"--root", entity,
|
|
255
|
+
"--depth", String(depth),
|
|
256
|
+
"--web-db", webDbPath,
|
|
257
|
+
"--redact", opts.redactRules.join(","),
|
|
258
|
+
];
|
|
259
|
+
if (opts.includeRetracted) args.push("--include-retracted");
|
|
260
|
+
if (opts.includePage) args.push("--include-page");
|
|
261
|
+
try {
|
|
262
|
+
// 30s budget — large 2-hop exports can take time on big graphs.
|
|
263
|
+
const { stdout } = await pExecFile("python3", args,
|
|
264
|
+
{ timeout: 30_000, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
|
|
265
|
+
const parsed = JSON.parse(stdout);
|
|
266
|
+
// If the export found at least one entity (the root), return it.
|
|
267
|
+
if (parsed.stats && parsed.stats.entities > 0) return parsed;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// try next DB
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { ok: false, error: "entity not found in any knowledge graph" };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Import a concept bundle into the local knowledge graph. */
|
|
276
|
+
async function importConceptBundle(
|
|
277
|
+
envelope: unknown,
|
|
278
|
+
conflict: "skip" | "merge" | "overwrite",
|
|
279
|
+
): Promise<unknown> {
|
|
280
|
+
const { execFile } = await import("node:child_process");
|
|
281
|
+
const { promisify } = await import("node:util");
|
|
282
|
+
const pExecFile = promisify(execFile);
|
|
283
|
+
const __dir = new URL(import.meta.url).pathname.replace(/\/[^/]+$/, "");
|
|
284
|
+
const scriptCandidates = [
|
|
285
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory/concept_import.py`,
|
|
286
|
+
`${__dir}/../sinain-memory/concept_import.py`,
|
|
287
|
+
`${resolveWorkspace()}/sinain-memory/concept_import.py`,
|
|
288
|
+
];
|
|
289
|
+
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
290
|
+
const localDir = resolveLocalMemoryDir();
|
|
291
|
+
const dbPath = `${localDir}/knowledge-graph.db`;
|
|
292
|
+
const webDbPath = `${localDir}/web.db`;
|
|
293
|
+
|
|
294
|
+
// Pipe envelope to stdin via spawn to avoid huge command-line args.
|
|
295
|
+
// (execFile doesn't accept stdin input — that's a spawn-only option.)
|
|
296
|
+
const args = [
|
|
297
|
+
scriptPath,
|
|
298
|
+
"--db", dbPath,
|
|
299
|
+
"--web-db", webDbPath,
|
|
300
|
+
"--bundle", "-",
|
|
301
|
+
"--conflict", conflict,
|
|
302
|
+
];
|
|
303
|
+
const { spawn } = await import("node:child_process");
|
|
304
|
+
return await new Promise((resolve) => {
|
|
305
|
+
const child = spawn("python3", args, { timeout: 30_000 });
|
|
306
|
+
let stdout = "";
|
|
307
|
+
let stderr = "";
|
|
308
|
+
child.stdout.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
|
|
309
|
+
child.stderr.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
|
|
310
|
+
child.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
311
|
+
child.on("close", (code) => {
|
|
312
|
+
if (code !== 0) {
|
|
313
|
+
resolve({ ok: false, error: `python exited ${code}: ${stderr.slice(0, 300)}` });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
try { resolve(JSON.parse(stdout)); }
|
|
317
|
+
catch (e: any) { resolve({ ok: false, error: `parse failed: ${e.message}` }); }
|
|
318
|
+
});
|
|
319
|
+
child.stdin.write(JSON.stringify(envelope));
|
|
320
|
+
child.stdin.end();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Retract or restore a fact entity via the Python retract.py subprocess. */
|
|
325
|
+
async function retractOrRestoreFact(
|
|
326
|
+
mode: "retract" | "restore",
|
|
327
|
+
factId: string,
|
|
328
|
+
opts: { reason?: string | null; actor?: string | null; sourceEntity?: string | null; undoToken?: string },
|
|
329
|
+
): Promise<unknown> {
|
|
330
|
+
const { execFile } = await import("node:child_process");
|
|
331
|
+
const { promisify } = await import("node:util");
|
|
332
|
+
const pExecFile = promisify(execFile);
|
|
333
|
+
const __dir = new URL(import.meta.url).pathname.replace(/\/[^/]+$/, "");
|
|
334
|
+
const scriptCandidates = [
|
|
335
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory/retract.py`,
|
|
336
|
+
`${__dir}/../sinain-memory/retract.py`,
|
|
337
|
+
`${resolveWorkspace()}/sinain-memory/retract.py`,
|
|
338
|
+
];
|
|
339
|
+
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
340
|
+
const webDbPath = `${resolveLocalMemoryDir()}/web.db`;
|
|
341
|
+
|
|
342
|
+
// Try DBs in order — the fact lives in one of them.
|
|
343
|
+
for (const dbPath of resolveKnowledgeDbPaths()) {
|
|
344
|
+
if (!existsSync(dbPath)) continue;
|
|
345
|
+
const args = [
|
|
346
|
+
scriptPath,
|
|
347
|
+
"--db", dbPath,
|
|
348
|
+
"--web-db", webDbPath,
|
|
349
|
+
"--fact-id", factId,
|
|
350
|
+
mode === "retract" ? "--retract" : "--restore",
|
|
351
|
+
];
|
|
352
|
+
if (mode === "retract") {
|
|
353
|
+
if (opts.reason) args.push("--reason", opts.reason);
|
|
354
|
+
if (opts.actor) args.push("--actor", opts.actor);
|
|
355
|
+
if (opts.sourceEntity) args.push("--source-entity", opts.sourceEntity);
|
|
356
|
+
} else {
|
|
357
|
+
if (opts.undoToken) args.push("--undo-token", opts.undoToken);
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const { stdout } = await pExecFile("python3", args, { timeout: 10_000, encoding: "utf-8" });
|
|
361
|
+
const parsed = JSON.parse(stdout);
|
|
362
|
+
if (parsed.ok) return parsed;
|
|
363
|
+
// If error is "fact not found" try the next DB; otherwise return the error
|
|
364
|
+
if (!String(parsed.error || "").includes("not found")) return parsed;
|
|
365
|
+
} catch (e) {
|
|
366
|
+
// continue to next DB
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { ok: false, error: "fact not found in any knowledge graph" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Render a Confluence-style page for an entity via Python LLM script. */
|
|
373
|
+
async function renderEntityPageMulti(
|
|
374
|
+
entity: string,
|
|
375
|
+
opts: { refresh: boolean; maxFacts: number },
|
|
376
|
+
): Promise<unknown> {
|
|
377
|
+
const { execFile } = await import("node:child_process");
|
|
378
|
+
const { promisify } = await import("node:util");
|
|
379
|
+
const pExecFile = promisify(execFile);
|
|
380
|
+
const __dir = new URL(import.meta.url).pathname.replace(/\/[^/]+$/, "");
|
|
381
|
+
const scriptCandidates = [
|
|
382
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory/page_renderer.py`,
|
|
383
|
+
`${__dir}/../sinain-memory/page_renderer.py`,
|
|
384
|
+
`${resolveWorkspace()}/sinain-memory/page_renderer.py`,
|
|
385
|
+
];
|
|
386
|
+
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
387
|
+
const webDbPath = `${resolveLocalMemoryDir()}/web.db`;
|
|
388
|
+
|
|
389
|
+
// Try DBs in order; first one with the entity wins.
|
|
390
|
+
for (const dbPath of resolveKnowledgeDbPaths()) {
|
|
391
|
+
if (!existsSync(dbPath)) continue;
|
|
392
|
+
const args = [
|
|
393
|
+
scriptPath,
|
|
394
|
+
"--db", dbPath,
|
|
395
|
+
"--entity", entity,
|
|
396
|
+
"--max-facts", String(opts.maxFacts),
|
|
397
|
+
"--web-db", webDbPath,
|
|
398
|
+
];
|
|
399
|
+
if (opts.refresh) args.push("--refresh");
|
|
400
|
+
try {
|
|
401
|
+
// 60s budget — LLM rendering for large entities can take 20-30s.
|
|
402
|
+
const { stdout } = await pExecFile("python3", args, { timeout: 60_000, encoding: "utf-8" });
|
|
403
|
+
const parsed = JSON.parse(stdout);
|
|
404
|
+
if (parsed.fact_count > 0) return parsed;
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// continue to next DB
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// No DB had the entity — return an empty page rather than 404 so the UI can show empty state.
|
|
410
|
+
return {
|
|
411
|
+
entity,
|
|
412
|
+
tx_watermark: 0,
|
|
413
|
+
fact_count: 0,
|
|
414
|
+
facts_used: 0,
|
|
415
|
+
summary: "No knowledge captured for this entity yet.",
|
|
416
|
+
sections: [],
|
|
417
|
+
stats: { from_cache: false, tokens_in: 0, tokens_out: 0, dropped_bullets: 0 },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Lazy-load graph children for an entity. Local DB first, workspace as fallback. */
|
|
422
|
+
async function graphChildrenMulti(entity: string): Promise<unknown> {
|
|
423
|
+
const { execFileSync } = await import("node:child_process");
|
|
424
|
+
const scriptPath = resolveGraphQueryScript();
|
|
425
|
+
|
|
426
|
+
for (const dbPath of resolveKnowledgeDbPaths()) {
|
|
427
|
+
if (!existsSync(dbPath)) continue;
|
|
428
|
+
try {
|
|
429
|
+
const out = execFileSync("python3", [
|
|
430
|
+
scriptPath, "--db", dbPath,
|
|
431
|
+
"--graph-children", entity,
|
|
432
|
+
"--graph-limit", "50",
|
|
433
|
+
], { timeout: 5000, encoding: "utf-8" });
|
|
434
|
+
const parsed = JSON.parse(out);
|
|
435
|
+
if (parsed.groups && parsed.groups.length > 0) return parsed;
|
|
436
|
+
} catch { /* skip */ }
|
|
437
|
+
}
|
|
438
|
+
return { entity, groups: [] };
|
|
439
|
+
}
|
|
440
|
+
|
|
176
441
|
/** Bi-temporal entity query: what did we know about entity X on a given date? */
|
|
177
442
|
async function queryKnowledgeAsOfMulti(entity: string, date: string): Promise<string> {
|
|
178
443
|
const { execFileSync } = await import("node:child_process");
|
|
@@ -417,6 +682,14 @@ async function main() {
|
|
|
417
682
|
embeddingService = new EmbeddingService();
|
|
418
683
|
embeddingService.loadAsync(); // ~9s background load, server starts immediately
|
|
419
684
|
|
|
685
|
+
// ── Initialize web.db (UI metadata: bookmarks, page cache, retraction undo) ──
|
|
686
|
+
const webDb = new WebDb(`${resolveLocalMemoryDir()}/web.db`);
|
|
687
|
+
// Periodic prune of expired retraction undo tokens (10-min TTL).
|
|
688
|
+
setInterval(() => {
|
|
689
|
+
const pruned = webDb.pruneExpiredUndos();
|
|
690
|
+
if (pruned > 0) log(TAG, `web.db: pruned ${pruned} expired undo tokens`);
|
|
691
|
+
}, 5 * 60 * 1000);
|
|
692
|
+
|
|
420
693
|
// ── Initialize local knowledge pipeline ──
|
|
421
694
|
// Pass wsHandler.broadcast so the periodic curator (insight_synthesizer)
|
|
422
695
|
// can push suggestions/insights directly to HUD without going through the
|
|
@@ -755,6 +1028,16 @@ async function main() {
|
|
|
755
1028
|
profiler,
|
|
756
1029
|
costTracker,
|
|
757
1030
|
feedbackStore: feedbackStore ?? undefined,
|
|
1031
|
+
webDb,
|
|
1032
|
+
searchEntities: (q, limit) => searchEntitiesMulti(q, limit),
|
|
1033
|
+
graphChildren: (entity) => graphChildrenMulti(entity),
|
|
1034
|
+
renderEntityPage: (entity, opts) => renderEntityPageMulti(entity, opts),
|
|
1035
|
+
retractFact: (factId, reason, actor, sourceEntity) =>
|
|
1036
|
+
retractOrRestoreFact("retract", factId, { reason, actor, sourceEntity }),
|
|
1037
|
+
restoreFact: (factId, undoToken) =>
|
|
1038
|
+
retractOrRestoreFact("restore", factId, { undoToken }),
|
|
1039
|
+
exportConcept: (entity, depth, opts) => exportConceptBundle(entity, depth, opts),
|
|
1040
|
+
importConcept: (envelope, conflict) => importConceptBundle(envelope, conflict),
|
|
758
1041
|
isScreenActive: () => screenActive,
|
|
759
1042
|
|
|
760
1043
|
onSenseEvent: (event: SenseEvent) => {
|