@geravant/sinain 1.9.0 → 1.10.1

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.
@@ -16,6 +16,7 @@ import { Tracer } from "./trace/tracer.js";
16
16
  import { TraceStore } from "./trace/trace-store.js";
17
17
  import { FeedbackStore } from "./learning/feedback-store.js";
18
18
  import { SignalCollector } from "./learning/signal-collector.js";
19
+ import { LocalCurationService } from "./learning/local-curation.js";
19
20
  import { createAppServer } from "./server.js";
20
21
  import { Profiler } from "./profiler.js";
21
22
  import { CostTracker } from "./cost/tracker.js";
@@ -32,6 +33,266 @@ function resolveWorkspace(): string {
32
33
  return raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
33
34
  }
34
35
 
36
+ /** Resolve the local memory directory (independent of OpenClaw workspace). */
37
+ function resolveLocalMemoryDir(): string {
38
+ const raw = process.env.SINAIN_MEMORY_DIR || `${process.env.HOME}/.sinain/memory`;
39
+ return raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
40
+ }
41
+
42
+ /**
43
+ * Query knowledge facts from both local and workspace databases.
44
+ * Checks local (~/.sinain/memory) first, then workspace (~/.openclaw/workspace/memory).
45
+ * Merges results, deduplicates, returns up to maxFacts.
46
+ */
47
+ async function queryKnowledgeFactsMulti(entities: string[], maxFacts: number): Promise<string> {
48
+ const { execFileSync } = await import("node:child_process");
49
+ const { resolve } = await import("node:path");
50
+ const { dirname } = await import("node:path");
51
+ const { fileURLToPath } = await import("node:url");
52
+
53
+ // Candidate database paths (local first, then workspace)
54
+ const localDir = resolveLocalMemoryDir();
55
+ const workspaceDir = `${resolveWorkspace()}/memory`;
56
+ const dbPaths = [
57
+ `${localDir}/knowledge-graph.db`,
58
+ `${workspaceDir}/knowledge-graph.db`,
59
+ ];
60
+
61
+ // Candidate script paths
62
+ const __dir = dirname(fileURLToPath(import.meta.url));
63
+ const scriptCandidates = [
64
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
65
+ resolve(__dir, "..", "sinain-memory", "graph_query.py"),
66
+ `${resolveWorkspace()}/sinain-memory/graph_query.py`,
67
+ ];
68
+ const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
69
+
70
+ const results: string[] = [];
71
+ for (const dbPath of dbPaths) {
72
+ if (!existsSync(dbPath)) continue;
73
+ try {
74
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
75
+ if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
76
+ const out = execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" }).trim();
77
+ if (out) results.push(out);
78
+ } catch { /* skip failed db */ }
79
+ }
80
+
81
+ if (results.length === 0) return "";
82
+ if (results.length === 1) return results[0];
83
+
84
+ // Merge and deduplicate lines from both sources
85
+ const seen = new Set<string>();
86
+ const merged: string[] = [];
87
+ for (const block of results) {
88
+ for (const line of block.split("\n")) {
89
+ const key = line.replace(/\(confidence:.*$/, "").trim();
90
+ if (key && !seen.has(key)) {
91
+ seen.add(key);
92
+ merged.push(line);
93
+ }
94
+ }
95
+ }
96
+ return merged.slice(0, maxFacts).join("\n");
97
+ }
98
+
99
+ /** List all entities from both local and workspace knowledge graphs. */
100
+ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
101
+ const { execFileSync } = await import("node:child_process");
102
+ const { resolve, dirname } = await import("node:path");
103
+ const { fileURLToPath } = await import("node:url");
104
+
105
+ const localDir = resolveLocalMemoryDir();
106
+ const workspaceDir = `${resolveWorkspace()}/memory`;
107
+ const dbPaths = [
108
+ `${localDir}/knowledge-graph.db`,
109
+ `${workspaceDir}/knowledge-graph.db`,
110
+ ];
111
+
112
+ const __dir = dirname(fileURLToPath(import.meta.url));
113
+ const scriptCandidates = [
114
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
115
+ resolve(__dir, "..", "sinain-memory", "graph_query.py"),
116
+ `${resolveWorkspace()}/sinain-memory/graph_query.py`,
117
+ ];
118
+ const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
119
+
120
+ const allFacts: any[] = [];
121
+ for (const dbPath of dbPaths) {
122
+ if (!existsSync(dbPath)) continue;
123
+ try {
124
+ const out = execFileSync("python3", [
125
+ scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
126
+ ], { timeout: 5000, encoding: "utf-8" });
127
+ const parsed = JSON.parse(out);
128
+ if (parsed.facts) allFacts.push(...parsed.facts);
129
+ } catch { /* skip */ }
130
+ }
131
+
132
+ // Deduplicate by entityId, merge
133
+ const seen = new Set<string>();
134
+ const unique = allFacts.filter(f => {
135
+ const id = f.entityId || "";
136
+ if (seen.has(id)) return false;
137
+ seen.add(id);
138
+ return true;
139
+ });
140
+
141
+ return JSON.stringify(unique.slice(0, max));
142
+ }
143
+
144
+ /** Export knowledge facts as a portable JSON module. */
145
+ async function exportKnowledgeMulti(domain: string | null, max: number): Promise<string> {
146
+ const { execFileSync } = await import("node:child_process");
147
+ const { resolve, dirname } = await import("node:path");
148
+ const { fileURLToPath } = await import("node:url");
149
+
150
+ const localDir = resolveLocalMemoryDir();
151
+ const workspaceDir = `${resolveWorkspace()}/memory`;
152
+ const dbPaths = [
153
+ `${localDir}/knowledge-graph.db`,
154
+ `${workspaceDir}/knowledge-graph.db`,
155
+ ];
156
+
157
+ const __dir = dirname(fileURLToPath(import.meta.url));
158
+ const scriptCandidates = [
159
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
160
+ `${resolveWorkspace()}/sinain-memory/graph_query.py`,
161
+ ];
162
+ const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
163
+
164
+ const allFacts: any[] = [];
165
+ for (const dbPath of dbPaths) {
166
+ if (!existsSync(dbPath)) continue;
167
+ try {
168
+ const out = execFileSync("python3", [
169
+ scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
170
+ ], { timeout: 5000, encoding: "utf-8" });
171
+ const parsed = JSON.parse(out);
172
+ if (parsed.facts) allFacts.push(...parsed.facts);
173
+ } catch { /* skip */ }
174
+ }
175
+
176
+ // Deduplicate
177
+ const seen = new Set<string>();
178
+ let facts = allFacts.filter(f => {
179
+ const id = f.entityId || "";
180
+ if (seen.has(id)) return false;
181
+ seen.add(id);
182
+ return true;
183
+ });
184
+
185
+ // Filter by domain if specified
186
+ if (domain) {
187
+ facts = facts.filter(f => f.domain === domain);
188
+ }
189
+
190
+ return JSON.stringify({
191
+ format: "sinain-knowledge-export",
192
+ version: 1,
193
+ exportedAt: new Date().toISOString(),
194
+ domain: domain || "all",
195
+ count: facts.length,
196
+ facts: facts.slice(0, max),
197
+ }, null, 2);
198
+ }
199
+
200
+ /** Import knowledge facts from a portable JSON module into the local graph. */
201
+ async function importKnowledgeToLocal(data: string): Promise<string> {
202
+ const { execFileSync } = await import("node:child_process");
203
+ const { resolve, dirname } = await import("node:path");
204
+ const { fileURLToPath } = await import("node:url");
205
+ const { mkdirSync } = await import("node:fs");
206
+
207
+ let parsed: any;
208
+ try {
209
+ parsed = JSON.parse(data);
210
+ } catch {
211
+ return JSON.stringify({ ok: false, error: "Invalid JSON" });
212
+ }
213
+
214
+ const facts = parsed.facts || (Array.isArray(parsed) ? parsed : null);
215
+ if (!facts || !Array.isArray(facts) || facts.length === 0) {
216
+ const keys = Object.keys(parsed).join(", ");
217
+ return JSON.stringify({ ok: false, error: `No 'facts' array found. Expected sinain knowledge export format: {"facts":[...]}. Got keys: ${keys}` });
218
+ }
219
+
220
+ const localDir = resolveLocalMemoryDir();
221
+ mkdirSync(localDir, { recursive: true });
222
+ const dbPath = `${localDir}/knowledge-graph.db`;
223
+
224
+ const __dir = dirname(fileURLToPath(import.meta.url));
225
+ const scriptsDir = resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory");
226
+
227
+ // Convert facts to graph ops for knowledge_integrator
228
+ const graphOps = facts.map((f: any) => ({
229
+ op: "assert",
230
+ entity: f.entity || f.entityId?.replace(/^fact:/, "").replace(/-[a-f0-9]{12}$/, "") || "unknown",
231
+ attribute: f.attribute || "value",
232
+ value: f.value || "",
233
+ confidence: parseFloat(f.confidence || "0.7"),
234
+ domain: f.domain || "",
235
+ }));
236
+
237
+ try {
238
+ // Use triplestore directly via Python
239
+ const script = `
240
+ import json, sys
241
+ sys.path.insert(0, "${scriptsDir}")
242
+ from triplestore import TripleStore
243
+ import hashlib
244
+
245
+ db_path = "${dbPath}"
246
+ store = TripleStore(db_path)
247
+ ops = json.loads(sys.stdin.read())
248
+ stats = {"asserted": 0, "skipped": 0}
249
+
250
+ for op in ops:
251
+ entity = op.get("entity", "")
252
+ value = op.get("value", "")
253
+ if not entity or not value:
254
+ stats["skipped"] += 1
255
+ continue
256
+
257
+ h = hashlib.sha256(f"{entity}:{op.get('attribute','')}:{value}".encode()).hexdigest()[:12]
258
+ slug = entity.replace(" ", "-").lower()[:30]
259
+ entity_id = f"fact:{slug}-{h}"
260
+
261
+ # Check if already exists
262
+ existing = store.entity(entity_id)
263
+ if existing:
264
+ stats["skipped"] += 1
265
+ continue
266
+
267
+ tx = store.begin_tx("import", metadata=json.dumps({"source": "web-import"}))
268
+ store.assert_triple(tx, entity_id, "entity", entity)
269
+ store.assert_triple(tx, entity_id, "attribute", op.get("attribute", "value"))
270
+ store.assert_triple(tx, entity_id, "value", value)
271
+ store.assert_triple(tx, entity_id, "confidence", str(op.get("confidence", 0.7)))
272
+ store.assert_triple(tx, entity_id, "first_seen", "${new Date().toISOString()}")
273
+ store.assert_triple(tx, entity_id, "last_reinforced", "${new Date().toISOString()}")
274
+ store.assert_triple(tx, entity_id, "reinforce_count", "1")
275
+ if op.get("domain"):
276
+ store.assert_triple(tx, entity_id, "domain", op["domain"])
277
+ stats["asserted"] += 1
278
+
279
+ store.close()
280
+ print(json.dumps(stats))
281
+ `;
282
+
283
+ const result = execFileSync("python3", ["-c", script], {
284
+ input: JSON.stringify(graphOps),
285
+ timeout: 10_000,
286
+ encoding: "utf-8",
287
+ });
288
+
289
+ const stats = JSON.parse(result.trim());
290
+ return JSON.stringify({ ok: true, stats, imported: stats.asserted, skipped: stats.skipped });
291
+ } catch (err: any) {
292
+ return JSON.stringify({ ok: false, error: err.message?.slice(0, 200) });
293
+ }
294
+ }
295
+
35
296
  async function main() {
36
297
  log(TAG, "sinain-core starting...");
37
298
 
@@ -78,6 +339,11 @@ async function main() {
78
339
  ? new FeedbackStore(config.learningConfig.feedbackDir, config.learningConfig.retentionDays)
79
340
  : null;
80
341
 
342
+ // ── Initialize local knowledge pipeline ──
343
+ const localCuration = new LocalCurationService();
344
+ localCuration.distillPendingSession(); // Recover any session saved before a force-kill
345
+ localCuration.startPeriodicCuration();
346
+
81
347
  // ── Initialize trait engine ──
82
348
  const traitRoster = loadTraitRoster(config.traitConfig.configPath);
83
349
  const traitEngine = new TraitEngine(traitRoster, config.traitConfig);
@@ -90,17 +356,7 @@ async function main() {
90
356
  openclawConfig: config.openclawConfig,
91
357
  profiler,
92
358
  feedbackStore: feedbackStore ?? undefined,
93
- queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
94
- const workspace = resolveWorkspace();
95
- const dbPath = `${workspace}/memory/knowledge-graph.db`;
96
- const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
97
- try {
98
- const { execFileSync } = await import("node:child_process");
99
- const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
100
- if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
101
- return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
102
- } catch { return ""; }
103
- },
359
+ queryKnowledgeFacts: queryKnowledgeFactsMulti,
104
360
  });
105
361
 
106
362
  // ── Initialize agent loop (event-driven) ──
@@ -420,24 +676,19 @@ async function main() {
420
676
  getEscalationPending: () => escalator.getPendingHttp(),
421
677
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
422
678
 
423
- // Knowledge graph integration
679
+ // Knowledge graph integration (checks both local and workspace DBs)
424
680
  getKnowledgeDocPath: () => {
425
- const workspace = resolveWorkspace();
426
- const p = `${workspace}/memory/sinain-knowledge.md`;
427
- try { if (existsSync(p)) return p; } catch {}
681
+ // Check local first, then workspace
682
+ const localPath = `${resolveLocalMemoryDir()}/sinain-knowledge.md`;
683
+ const workspacePath = `${resolveWorkspace()}/memory/sinain-knowledge.md`;
684
+ try { if (existsSync(localPath)) return localPath; } catch {}
685
+ try { if (existsSync(workspacePath)) return workspacePath; } catch {}
428
686
  return null;
429
687
  },
430
- queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
431
- const workspace = resolveWorkspace();
432
- const dbPath = `${workspace}/memory/knowledge-graph.db`;
433
- const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
434
- try {
435
- const { execFileSync } = await import("node:child_process");
436
- const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
437
- if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
438
- return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
439
- } catch { return ""; }
440
- },
688
+ queryKnowledgeFacts: queryKnowledgeFactsMulti,
689
+ listKnowledgeEntities: listKnowledgeEntitiesMulti,
690
+ exportKnowledge: exportKnowledgeMulti,
691
+ importKnowledge: importKnowledgeToLocal,
441
692
 
442
693
  // Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
443
694
  onSpawnCommand: (text: string) => {
@@ -563,6 +814,26 @@ async function main() {
563
814
  signalCollector?.destroy();
564
815
  feedbackStore?.destroy();
565
816
  traceStore?.destroy();
817
+
818
+ // Save session knowledge — write feed items to disk FIRST (instant),
819
+ // then attempt LLM distillation. If tsx force-kills before distillation
820
+ // finishes, the saved file is recovered on next startup.
821
+ localCuration.stop();
822
+ const feedItems = feedBuffer.query(0);
823
+ try {
824
+ localCuration.savePendingSession(feedItems);
825
+ } catch (err: any) {
826
+ warn(TAG, `failed to save pending session: ${err.message?.slice(0, 100)}`);
827
+ }
828
+ try {
829
+ if (feedItems.length >= 3) {
830
+ log(TAG, `distilling session (${feedItems.length} feed items)...`);
831
+ await localCuration.distillSession(feedItems);
832
+ }
833
+ } catch (err: any) {
834
+ warn(TAG, `session distillation failed (will retry on next startup): ${err.message?.slice(0, 100)}`);
835
+ }
836
+
566
837
  await server.destroy();
567
838
  log(TAG, "goodbye");
568
839
  process.exit(0);