@geravant/sinain 1.10.0 → 1.11.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.
@@ -8,7 +8,6 @@ import { AudioPipeline } from "./audio/pipeline.js";
8
8
  import type { CaptureSpawner } from "./audio/capture-spawner.js";
9
9
  import { TranscriptionService } from "./audio/transcription.js";
10
10
  import { AgentLoop } from "./agent/loop.js";
11
- import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
12
11
  import { shortAppName } from "./agent/context-window.js";
13
12
  import { Escalator } from "./escalation/escalator.js";
14
13
  import { Recorder } from "./recorder.js";
@@ -16,6 +15,7 @@ import { Tracer } from "./trace/tracer.js";
16
15
  import { TraceStore } from "./trace/trace-store.js";
17
16
  import { FeedbackStore } from "./learning/feedback-store.js";
18
17
  import { SignalCollector } from "./learning/signal-collector.js";
18
+ import { LocalCurationService } from "./learning/local-curation.js";
19
19
  import { createAppServer } from "./server.js";
20
20
  import { Profiler } from "./profiler.js";
21
21
  import { CostTracker } from "./cost/tracker.js";
@@ -32,6 +32,266 @@ function resolveWorkspace(): string {
32
32
  return raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
33
33
  }
34
34
 
35
+ /** Resolve the local memory directory (independent of OpenClaw workspace). */
36
+ function resolveLocalMemoryDir(): string {
37
+ const raw = process.env.SINAIN_MEMORY_DIR || `${process.env.HOME}/.sinain/memory`;
38
+ return raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
39
+ }
40
+
41
+ /**
42
+ * Query knowledge facts from both local and workspace databases.
43
+ * Checks local (~/.sinain/memory) first, then workspace (~/.openclaw/workspace/memory).
44
+ * Merges results, deduplicates, returns up to maxFacts.
45
+ */
46
+ async function queryKnowledgeFactsMulti(entities: string[], maxFacts: number): Promise<string> {
47
+ const { execFileSync } = await import("node:child_process");
48
+ const { resolve } = await import("node:path");
49
+ const { dirname } = await import("node:path");
50
+ const { fileURLToPath } = await import("node:url");
51
+
52
+ // Candidate database paths (local first, then workspace)
53
+ const localDir = resolveLocalMemoryDir();
54
+ const workspaceDir = `${resolveWorkspace()}/memory`;
55
+ const dbPaths = [
56
+ `${localDir}/knowledge-graph.db`,
57
+ `${workspaceDir}/knowledge-graph.db`,
58
+ ];
59
+
60
+ // Candidate script paths
61
+ const __dir = dirname(fileURLToPath(import.meta.url));
62
+ const scriptCandidates = [
63
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
64
+ resolve(__dir, "..", "sinain-memory", "graph_query.py"),
65
+ `${resolveWorkspace()}/sinain-memory/graph_query.py`,
66
+ ];
67
+ const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
68
+
69
+ const results: string[] = [];
70
+ for (const dbPath of dbPaths) {
71
+ if (!existsSync(dbPath)) continue;
72
+ try {
73
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
74
+ if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
75
+ const out = execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" }).trim();
76
+ if (out) results.push(out);
77
+ } catch { /* skip failed db */ }
78
+ }
79
+
80
+ if (results.length === 0) return "";
81
+ if (results.length === 1) return results[0];
82
+
83
+ // Merge and deduplicate lines from both sources
84
+ const seen = new Set<string>();
85
+ const merged: string[] = [];
86
+ for (const block of results) {
87
+ for (const line of block.split("\n")) {
88
+ const key = line.replace(/\(confidence:.*$/, "").trim();
89
+ if (key && !seen.has(key)) {
90
+ seen.add(key);
91
+ merged.push(line);
92
+ }
93
+ }
94
+ }
95
+ return merged.slice(0, maxFacts).join("\n");
96
+ }
97
+
98
+ /** List all entities from both local and workspace knowledge graphs. */
99
+ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
100
+ const { execFileSync } = await import("node:child_process");
101
+ const { resolve, dirname } = await import("node:path");
102
+ const { fileURLToPath } = await import("node:url");
103
+
104
+ const localDir = resolveLocalMemoryDir();
105
+ const workspaceDir = `${resolveWorkspace()}/memory`;
106
+ const dbPaths = [
107
+ `${localDir}/knowledge-graph.db`,
108
+ `${workspaceDir}/knowledge-graph.db`,
109
+ ];
110
+
111
+ const __dir = dirname(fileURLToPath(import.meta.url));
112
+ const scriptCandidates = [
113
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
114
+ resolve(__dir, "..", "sinain-memory", "graph_query.py"),
115
+ `${resolveWorkspace()}/sinain-memory/graph_query.py`,
116
+ ];
117
+ const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
118
+
119
+ const allFacts: any[] = [];
120
+ for (const dbPath of dbPaths) {
121
+ if (!existsSync(dbPath)) continue;
122
+ try {
123
+ const out = execFileSync("python3", [
124
+ scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
125
+ ], { timeout: 5000, encoding: "utf-8" });
126
+ const parsed = JSON.parse(out);
127
+ if (parsed.facts) allFacts.push(...parsed.facts);
128
+ } catch { /* skip */ }
129
+ }
130
+
131
+ // Deduplicate by entityId, merge
132
+ const seen = new Set<string>();
133
+ const unique = allFacts.filter(f => {
134
+ const id = f.entityId || "";
135
+ if (seen.has(id)) return false;
136
+ seen.add(id);
137
+ return true;
138
+ });
139
+
140
+ return JSON.stringify(unique.slice(0, max));
141
+ }
142
+
143
+ /** Export knowledge facts as a portable JSON module. */
144
+ async function exportKnowledgeMulti(domain: string | null, max: number): Promise<string> {
145
+ const { execFileSync } = await import("node:child_process");
146
+ const { resolve, dirname } = await import("node:path");
147
+ const { fileURLToPath } = await import("node:url");
148
+
149
+ const localDir = resolveLocalMemoryDir();
150
+ const workspaceDir = `${resolveWorkspace()}/memory`;
151
+ const dbPaths = [
152
+ `${localDir}/knowledge-graph.db`,
153
+ `${workspaceDir}/knowledge-graph.db`,
154
+ ];
155
+
156
+ const __dir = dirname(fileURLToPath(import.meta.url));
157
+ const scriptCandidates = [
158
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
159
+ `${resolveWorkspace()}/sinain-memory/graph_query.py`,
160
+ ];
161
+ const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
162
+
163
+ const allFacts: any[] = [];
164
+ for (const dbPath of dbPaths) {
165
+ if (!existsSync(dbPath)) continue;
166
+ try {
167
+ const out = execFileSync("python3", [
168
+ scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
169
+ ], { timeout: 5000, encoding: "utf-8" });
170
+ const parsed = JSON.parse(out);
171
+ if (parsed.facts) allFacts.push(...parsed.facts);
172
+ } catch { /* skip */ }
173
+ }
174
+
175
+ // Deduplicate
176
+ const seen = new Set<string>();
177
+ let facts = allFacts.filter(f => {
178
+ const id = f.entityId || "";
179
+ if (seen.has(id)) return false;
180
+ seen.add(id);
181
+ return true;
182
+ });
183
+
184
+ // Filter by domain if specified
185
+ if (domain) {
186
+ facts = facts.filter(f => f.domain === domain);
187
+ }
188
+
189
+ return JSON.stringify({
190
+ format: "sinain-knowledge-export",
191
+ version: 1,
192
+ exportedAt: new Date().toISOString(),
193
+ domain: domain || "all",
194
+ count: facts.length,
195
+ facts: facts.slice(0, max),
196
+ }, null, 2);
197
+ }
198
+
199
+ /** Import knowledge facts from a portable JSON module into the local graph. */
200
+ async function importKnowledgeToLocal(data: string): Promise<string> {
201
+ const { execFileSync } = await import("node:child_process");
202
+ const { resolve, dirname } = await import("node:path");
203
+ const { fileURLToPath } = await import("node:url");
204
+ const { mkdirSync } = await import("node:fs");
205
+
206
+ let parsed: any;
207
+ try {
208
+ parsed = JSON.parse(data);
209
+ } catch {
210
+ return JSON.stringify({ ok: false, error: "Invalid JSON" });
211
+ }
212
+
213
+ const facts = parsed.facts || (Array.isArray(parsed) ? parsed : null);
214
+ if (!facts || !Array.isArray(facts) || facts.length === 0) {
215
+ const keys = Object.keys(parsed).join(", ");
216
+ return JSON.stringify({ ok: false, error: `No 'facts' array found. Expected sinain knowledge export format: {"facts":[...]}. Got keys: ${keys}` });
217
+ }
218
+
219
+ const localDir = resolveLocalMemoryDir();
220
+ mkdirSync(localDir, { recursive: true });
221
+ const dbPath = `${localDir}/knowledge-graph.db`;
222
+
223
+ const __dir = dirname(fileURLToPath(import.meta.url));
224
+ const scriptsDir = resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory");
225
+
226
+ // Convert facts to graph ops for knowledge_integrator
227
+ const graphOps = facts.map((f: any) => ({
228
+ op: "assert",
229
+ entity: f.entity || f.entityId?.replace(/^fact:/, "").replace(/-[a-f0-9]{12}$/, "") || "unknown",
230
+ attribute: f.attribute || "value",
231
+ value: f.value || "",
232
+ confidence: parseFloat(f.confidence || "0.7"),
233
+ domain: f.domain || "",
234
+ }));
235
+
236
+ try {
237
+ // Use triplestore directly via Python
238
+ const script = `
239
+ import json, sys
240
+ sys.path.insert(0, "${scriptsDir}")
241
+ from triplestore import TripleStore
242
+ import hashlib
243
+
244
+ db_path = "${dbPath}"
245
+ store = TripleStore(db_path)
246
+ ops = json.loads(sys.stdin.read())
247
+ stats = {"asserted": 0, "skipped": 0}
248
+
249
+ for op in ops:
250
+ entity = op.get("entity", "")
251
+ value = op.get("value", "")
252
+ if not entity or not value:
253
+ stats["skipped"] += 1
254
+ continue
255
+
256
+ h = hashlib.sha256(f"{entity}:{op.get('attribute','')}:{value}".encode()).hexdigest()[:12]
257
+ slug = entity.replace(" ", "-").lower()[:30]
258
+ entity_id = f"fact:{slug}-{h}"
259
+
260
+ # Check if already exists
261
+ existing = store.entity(entity_id)
262
+ if existing:
263
+ stats["skipped"] += 1
264
+ continue
265
+
266
+ tx = store.begin_tx("import", metadata=json.dumps({"source": "web-import"}))
267
+ store.assert_triple(tx, entity_id, "entity", entity)
268
+ store.assert_triple(tx, entity_id, "attribute", op.get("attribute", "value"))
269
+ store.assert_triple(tx, entity_id, "value", value)
270
+ store.assert_triple(tx, entity_id, "confidence", str(op.get("confidence", 0.7)))
271
+ store.assert_triple(tx, entity_id, "first_seen", "${new Date().toISOString()}")
272
+ store.assert_triple(tx, entity_id, "last_reinforced", "${new Date().toISOString()}")
273
+ store.assert_triple(tx, entity_id, "reinforce_count", "1")
274
+ if op.get("domain"):
275
+ store.assert_triple(tx, entity_id, "domain", op["domain"])
276
+ stats["asserted"] += 1
277
+
278
+ store.close()
279
+ print(json.dumps(stats))
280
+ `;
281
+
282
+ const result = execFileSync("python3", ["-c", script], {
283
+ input: JSON.stringify(graphOps),
284
+ timeout: 10_000,
285
+ encoding: "utf-8",
286
+ });
287
+
288
+ const stats = JSON.parse(result.trim());
289
+ return JSON.stringify({ ok: true, stats, imported: stats.asserted, skipped: stats.skipped });
290
+ } catch (err: any) {
291
+ return JSON.stringify({ ok: false, error: err.message?.slice(0, 200) });
292
+ }
293
+ }
294
+
35
295
  async function main() {
36
296
  log(TAG, "sinain-core starting...");
37
297
 
@@ -78,9 +338,10 @@ async function main() {
78
338
  ? new FeedbackStore(config.learningConfig.feedbackDir, config.learningConfig.retentionDays)
79
339
  : null;
80
340
 
81
- // ── Initialize trait engine ──
82
- const traitRoster = loadTraitRoster(config.traitConfig.configPath);
83
- const traitEngine = new TraitEngine(traitRoster, config.traitConfig);
341
+ // ── Initialize local knowledge pipeline ──
342
+ const localCuration = new LocalCurationService();
343
+ localCuration.distillPendingSession(); // Recover any session saved before a force-kill
344
+ localCuration.startPeriodicCuration();
84
345
 
85
346
  // ── Initialize escalation ──
86
347
  const escalator = new Escalator({
@@ -90,17 +351,7 @@ async function main() {
90
351
  openclawConfig: config.openclawConfig,
91
352
  profiler,
92
353
  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
- },
354
+ queryKnowledgeFacts: queryKnowledgeFactsMulti,
104
355
  });
105
356
 
106
357
  // ── Initialize agent loop (event-driven) ──
@@ -116,33 +367,6 @@ async function main() {
116
367
  // Handle recorder commands
117
368
  const stopResult = recorder.handleCommand(entry.record);
118
369
 
119
- // Dispatch task via subagent spawn
120
- if (entry.task || stopResult) {
121
- let task: string;
122
- let label: string | undefined;
123
-
124
- if (stopResult && stopResult.segments > 0 && entry.task) {
125
- // Recording stopped with explicit task instruction
126
- task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
127
- label = stopResult.title;
128
- } else if (stopResult && stopResult.segments > 0) {
129
- // Recording stopped without explicit task — default to cleanup/summarize
130
- task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
131
- label = stopResult.title;
132
- } else if (entry.task) {
133
- // Standalone task without recording
134
- task = entry.task;
135
- } else {
136
- task = "";
137
- }
138
-
139
- if (task) {
140
- escalator.dispatchSpawnTask(task, label).catch(err => {
141
- error(TAG, "spawn task dispatch error:", err);
142
- });
143
- }
144
- }
145
-
146
370
  // Escalation continues as normal
147
371
  escalator.onAgentAnalysis(entry, contextWindow);
148
372
  },
@@ -166,8 +390,6 @@ async function main() {
166
390
  };
167
391
  return ctx;
168
392
  } : undefined,
169
- traitEngine,
170
- traitLogDir: config.traitConfig.logDir,
171
393
  getKnowledgeDocPath: () => {
172
394
  const workspace = resolveWorkspace();
173
395
  const p = `${workspace}/memory/sinain-knowledge.md`;
@@ -308,6 +530,9 @@ async function main() {
308
530
  // ── Screen capture active flag ──
309
531
  let screenActive = true;
310
532
 
533
+ // ── Escalation pause/resume state ──
534
+ let savedEscalationMode: typeof config.escalationConfig.mode | null = null;
535
+
311
536
  // ── Create HTTP + WS server ──
312
537
  const server = createAppServer({
313
538
  config,
@@ -418,26 +643,22 @@ async function main() {
418
643
 
419
644
  // Bare agent HTTP escalation bridge
420
645
  getEscalationPending: () => escalator.getPendingHttp(),
646
+ isEscalationPaused: () => savedEscalationMode !== null,
421
647
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
422
648
 
423
- // Knowledge graph integration
649
+ // Knowledge graph integration (checks both local and workspace DBs)
424
650
  getKnowledgeDocPath: () => {
425
- const workspace = resolveWorkspace();
426
- const p = `${workspace}/memory/sinain-knowledge.md`;
427
- try { if (existsSync(p)) return p; } catch {}
651
+ // Check local first, then workspace
652
+ const localPath = `${resolveLocalMemoryDir()}/sinain-knowledge.md`;
653
+ const workspacePath = `${resolveWorkspace()}/memory/sinain-knowledge.md`;
654
+ try { if (existsSync(localPath)) return localPath; } catch {}
655
+ try { if (existsSync(workspacePath)) return workspacePath; } catch {}
428
656
  return null;
429
657
  },
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
- },
658
+ queryKnowledgeFacts: queryKnowledgeFactsMulti,
659
+ listKnowledgeEntities: listKnowledgeEntitiesMulti,
660
+ exportKnowledge: exportKnowledgeMulti,
661
+ importKnowledge: importKnowledgeToLocal,
441
662
 
442
663
  // Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
443
664
  onSpawnCommand: (text: string) => {
@@ -482,7 +703,22 @@ async function main() {
482
703
  wsHandler.updateState({ screen: screenActive ? "active" : "off" });
483
704
  return screenActive;
484
705
  },
485
- onToggleTraits: () => traitEngine.toggle(),
706
+ onToggleEscalation: () => {
707
+ if (savedEscalationMode === null) {
708
+ // Pause: save current mode, switch to off
709
+ savedEscalationMode = config.escalationConfig.mode;
710
+ escalator.setMode("off");
711
+ log(TAG, `escalation paused (was: ${savedEscalationMode})`);
712
+ return false;
713
+ } else {
714
+ // Resume: restore saved mode
715
+ const mode = savedEscalationMode;
716
+ savedEscalationMode = null;
717
+ escalator.setMode(mode);
718
+ log(TAG, `escalation resumed (mode: ${mode})`);
719
+ return true;
720
+ }
721
+ },
486
722
  });
487
723
 
488
724
  // Broadcast initial screen state so overlay gets correct status on connect
@@ -544,7 +780,6 @@ async function main() {
544
780
  log(TAG, ` mic: ${config.micEnabled ? (config.micConfig.autoStart ? "active" : "standby") : "disabled"}`);
545
781
  log(TAG, ` agent: ${config.agentConfig.enabled ? "enabled" : "disabled"}`);
546
782
  log(TAG, ` escal: ${config.escalationConfig.mode}`);
547
- log(TAG, ` traits: ${config.traitConfig.enabled ? "enabled" : "disabled"} (${traitRoster.length} traits)`);
548
783
  log(TAG, ` cost: display=${config.costDisplayEnabled ? "on" : "off"} (always logged)`);
549
784
 
550
785
  // ── Graceful shutdown ──
@@ -563,6 +798,26 @@ async function main() {
563
798
  signalCollector?.destroy();
564
799
  feedbackStore?.destroy();
565
800
  traceStore?.destroy();
801
+
802
+ // Save session knowledge — write feed items to disk FIRST (instant),
803
+ // then attempt LLM distillation. If tsx force-kills before distillation
804
+ // finishes, the saved file is recovered on next startup.
805
+ localCuration.stop();
806
+ const feedItems = feedBuffer.query(0);
807
+ try {
808
+ localCuration.savePendingSession(feedItems);
809
+ } catch (err: any) {
810
+ warn(TAG, `failed to save pending session: ${err.message?.slice(0, 100)}`);
811
+ }
812
+ try {
813
+ if (feedItems.length >= 3) {
814
+ log(TAG, `distilling session (${feedItems.length} feed items)...`);
815
+ await localCuration.distillSession(feedItems);
816
+ }
817
+ } catch (err: any) {
818
+ warn(TAG, `session distillation failed (will retry on next startup): ${err.message?.slice(0, 100)}`);
819
+ }
820
+
566
821
  await server.destroy();
567
822
  log(TAG, "goodbye");
568
823
  process.exit(0);