@geravant/sinain 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,56 @@ When responding to escalations:
55
55
  4. Optionally call `sinain_get_knowledge` to review the portable knowledge document
56
56
  5. Optionally call `sinain_get_feedback` to review recent escalation scores
57
57
 
58
+ ## Knowledge System
59
+
60
+ Knowledge is stored in a **dual-database** architecture with two SQLite triplestore databases:
61
+
62
+ | Database | Path | Written by |
63
+ |----------|------|------------|
64
+ | **Local** | `~/.sinain/memory/knowledge-graph.db` | `LocalCurationService` (session distillation on shutdown, periodic curation every 30 min) |
65
+ | **Workspace** | `~/.openclaw/workspace/memory/knowledge-graph.db` | Heartbeat curation scripts (`sinain_heartbeat_tick`) |
66
+
67
+ ### Knowledge Tools
68
+
69
+ | Tool | What it does |
70
+ |------|-------------|
71
+ | `sinain_get_knowledge` | Read the portable knowledge document (playbook + top facts) from workspace |
72
+ | `sinain_knowledge_query` | Query facts by entity/keyword — queries **both** DBs via sinain-core API |
73
+ | `sinain_distill_session` | Explicitly distill current session into knowledge updates |
74
+
75
+ ### HTTP Knowledge API (sinain-core, port 9500)
76
+
77
+ These endpoints query **both** databases and merge results:
78
+
79
+ | Endpoint | Purpose |
80
+ |----------|---------|
81
+ | `GET /knowledge` | Portable knowledge document |
82
+ | `GET /knowledge/facts?entities=X&max=N` | Query facts by keyword tags |
83
+ | `GET /knowledge/entities?max=N` | List all entities with attributes |
84
+ | `GET /knowledge/export?domain=X&max=N` | Export facts as portable JSON |
85
+ | `POST /knowledge/import` | Import facts (deduplicates automatically) |
86
+ | `GET /knowledge/ui` | Web UI for browsing/managing knowledge |
87
+
88
+ ### How Knowledge Flows
89
+
90
+ ```
91
+ Session (screen + audio) → LocalCurationService → Local DB
92
+ ↓ (queried together)
93
+ Heartbeat tick → curation scripts ──────────→ Workspace DB
94
+
95
+ Knowledge API (localhost:9500) ← merges both DBs ← queries
96
+ ```
97
+
98
+ - **Local DB** gets real-time session knowledge (audio transcripts, screen patterns, German lessons, etc.)
99
+ - **Workspace DB** gets heartbeat-curated knowledge (playbook patterns, feedback analysis)
100
+ - The Knowledge API merges both — use `sinain_knowledge_query` for combined results
101
+ - Facts have confidence decay (60-day half-life) — reinforcement resets the clock
102
+ - Export/import via `/knowledge/export` → `/knowledge/import` enables cross-instance transfer
103
+
104
+ ### Using Knowledge in Escalation Responses
105
+
106
+ When responding to escalations, call `sinain_knowledge_query` with relevant entities to enrich your response with long-term knowledge. Example: if the user is working on German grammar, query `sinain_knowledge_query({ entities: ["german", "grammar"] })` to retrieve previously learned patterns.
107
+
58
108
  ## Spawning Background Tasks
59
109
 
60
110
  When an escalation suggests deeper research would help:
@@ -221,9 +221,13 @@ Call sinain_get_escalation to see the full context, then call sinain_respond wit
221
221
  Response guidelines: 5-10 sentences, address errors first, reference specific screen/audio context, never NO_REPLY. Max 4000 chars for coding context, 3000 otherwise.'
222
222
 
223
223
  HEARTBEAT_PROMPT='You are the sinain HUD agent. Run the heartbeat cycle:
224
- 1. Call sinain_heartbeat_tick with a brief session summary
225
- 2. If the result contains a suggestion, post it to HUD via sinain_post_feed
226
- 3. Call sinain_get_feedback to review recent scores'
224
+ 1. Call sinain_heartbeat_tick with a brief session summary (runs signal analysis, session distillation, knowledge integration, insight synthesis)
225
+ 2. If the result contains a suggestion or insight, post it to HUD via sinain_post_feed
226
+ 3. Call sinain_get_knowledge to review the merged knowledge document (draws from both local and workspace databases)
227
+ 4. Optionally call sinain_knowledge_query with relevant entities to check long-term knowledge state
228
+ 5. Call sinain_get_feedback to review recent escalation scores
229
+
230
+ Knowledge context: sinain-core maintains two knowledge databases — local (session distillation) and workspace (heartbeat curation). The knowledge tools query both via the sinain-core API. Facts have confidence decay (60-day half-life).'
227
231
 
228
232
  # --- Main loop ---
229
233
 
@@ -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);