@geravant/sinain 1.1.0 → 1.2.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.
@@ -485,4 +485,121 @@ export class KnowledgeStore {
485
485
  writeFileSync(tmpPath, content, "utf-8");
486
486
  renameSync(tmpPath, situationPath);
487
487
  }
488
+
489
+ // ── Knowledge Document ─────────────────────────────────────────────────
490
+
491
+ /**
492
+ * Render sinain-knowledge.md — the portable knowledge document (<8KB).
493
+ * Combines: playbook (working memory) + top graph facts (long-term memory)
494
+ * + recent session digests + active module guidance.
495
+ */
496
+ renderKnowledgeDoc(): boolean {
497
+ try {
498
+ const parts: string[] = [];
499
+ const now = new Date().toISOString();
500
+
501
+ parts.push(`# Sinain Knowledge`);
502
+ parts.push(`<!-- exported: ${now}, version: 3 -->\n`);
503
+
504
+ // Playbook (working memory)
505
+ const playbook = this.readPlaybook();
506
+ if (playbook) {
507
+ // Strip header/footer comments for the doc
508
+ const body = playbook
509
+ .split("\n")
510
+ .filter((l) => !l.trim().startsWith("<!--"))
511
+ .join("\n")
512
+ .trim();
513
+ if (body) {
514
+ parts.push(`## Playbook (Working Memory)\n${body}\n`);
515
+ }
516
+ }
517
+
518
+ // Recent session digests
519
+ const digestsPath = join(this.workspaceDir, "memory", "session-digests.jsonl");
520
+ if (existsSync(digestsPath)) {
521
+ try {
522
+ const lines = readFileSync(digestsPath, "utf-8")
523
+ .split("\n")
524
+ .filter((l) => l.trim())
525
+ .slice(-5);
526
+ if (lines.length > 0) {
527
+ parts.push(`## Recent Sessions`);
528
+ for (const line of lines) {
529
+ try {
530
+ const d = JSON.parse(line);
531
+ if (d.whatHappened) {
532
+ parts.push(`- ${d.whatHappened}`);
533
+ }
534
+ } catch {}
535
+ }
536
+ parts.push("");
537
+ }
538
+ } catch {}
539
+ }
540
+
541
+ // Active module guidance (brief)
542
+ const guidance = this.getActiveModuleGuidance();
543
+ if (guidance && guidance.length > 20) {
544
+ // Truncate to keep doc under 8KB
545
+ const truncated = guidance.slice(0, 2000);
546
+ parts.push(`## Active Modules\n${truncated}\n`);
547
+ }
548
+
549
+ const doc = parts.join("\n").trim() + "\n";
550
+ const docPath = join(this.workspaceDir, "memory", "sinain-knowledge.md");
551
+ const memDir = join(this.workspaceDir, "memory");
552
+ if (!existsSync(memDir)) mkdirSync(memDir, { recursive: true });
553
+ writeFileSync(docPath, doc, "utf-8");
554
+ this.logger.info(`sinain-hud: rendered sinain-knowledge.md (${doc.length} chars)`);
555
+ return true;
556
+ } catch (err) {
557
+ this.logger.warn(`sinain-hud: failed to render knowledge doc: ${String(err)}`);
558
+ return false;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Extract entity keywords from SITUATION.md for dynamic graph enrichment.
564
+ * Returns lowercase entity names found in the situation text.
565
+ */
566
+ extractSituationEntities(): string[] {
567
+ const situation = this.readSituation();
568
+ if (!situation) return [];
569
+
570
+ const entities: Set<string> = new Set();
571
+
572
+ // Extract from "Active Application:" line
573
+ const appMatch = situation.match(/Active Application:\s*(.+)/i);
574
+ if (appMatch) {
575
+ const app = appMatch[1].trim().toLowerCase().replace(/\s+/g, "-");
576
+ if (app.length > 2) entities.add(app);
577
+ }
578
+
579
+ // Extract from "Detected Errors:" section
580
+ const errorMatch = situation.match(/Detected Errors:[\s\S]*?(?=\n##|\n---|\Z)/i);
581
+ if (errorMatch) {
582
+ // Look for common error type patterns
583
+ const errorPatterns = errorMatch[0].matchAll(
584
+ /(?:Error|Exception|TypeError|SyntaxError|ReferenceError|HTTP\s*\d{3}|ENOENT|EACCES|timeout)/gi,
585
+ );
586
+ for (const m of errorPatterns) {
587
+ entities.add(m[0].toLowerCase());
588
+ }
589
+ }
590
+
591
+ // Extract technology keywords
592
+ const techKeywords = [
593
+ "react-native", "react", "flutter", "swift", "kotlin", "python",
594
+ "typescript", "javascript", "node", "docker", "kubernetes",
595
+ "intellij", "vscode", "xcode", "metro", "gradle", "cocoapods",
596
+ "openrouter", "anthropic", "gemini", "openclaw", "sinain",
597
+ ];
598
+ const lowerSituation = situation.toLowerCase();
599
+ for (const kw of techKeywords) {
600
+ if (lowerSituation.includes(kw)) entities.add(kw);
601
+ }
602
+
603
+ return [...entities].slice(0, 10);
604
+ }
488
605
  }
@@ -184,26 +184,106 @@ server.tool(
184
184
  },
185
185
  );
186
186
 
187
- // 8. sinain_knowledge_query
187
+ // 8. sinain_get_knowledge
188
+ server.tool(
189
+ "sinain_get_knowledge",
190
+ "Get the portable knowledge document (playbook + long-term facts + recent sessions)",
191
+ {},
192
+ async () => {
193
+ try {
194
+ // Read pre-rendered knowledge doc (fast, no subprocess)
195
+ const docPath = resolve(MEMORY_DIR, "sinain-knowledge.md");
196
+ if (existsSync(docPath)) {
197
+ const content = readFileSync(docPath, "utf-8");
198
+ return textResult(stripPrivateTags(content));
199
+ }
200
+ // Fallback: read playbook directly
201
+ const playbookPath = resolve(MEMORY_DIR, "sinain-playbook.md");
202
+ if (existsSync(playbookPath)) {
203
+ return textResult(stripPrivateTags(readFileSync(playbookPath, "utf-8")));
204
+ }
205
+ return textResult("No knowledge document available yet");
206
+ } catch (err: any) {
207
+ return textResult(`Error reading knowledge: ${err.message}`);
208
+ }
209
+ },
210
+ );
211
+
212
+ // 8b. sinain_knowledge_query (graph query — entity-based lookup)
188
213
  server.tool(
189
214
  "sinain_knowledge_query",
190
- "Query the knowledge graph / memory triples for relevant context",
215
+ "Query the knowledge graph for facts about specific entities/domains",
191
216
  {
192
- context: z.string(),
193
- max_chars: z.number().optional().default(1500),
217
+ entities: z.array(z.string()).optional().default([]),
218
+ max_facts: z.number().optional().default(5),
194
219
  },
195
- async ({ context, max_chars }) => {
220
+ async ({ entities, max_facts }) => {
196
221
  try {
197
- const scriptPath = resolve(SCRIPTS_DIR, "triple_query.py");
198
- const output = await runScript([
199
- scriptPath,
200
- "--memory-dir", MEMORY_DIR,
201
- "--context", context,
202
- "--max-chars", String(max_chars),
203
- ]);
222
+ const dbPath = resolve(MEMORY_DIR, "knowledge-graph.db");
223
+ const scriptPath = resolve(SCRIPTS_DIR, "graph_query.py");
224
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(max_facts)];
225
+ if (entities.length > 0) {
226
+ args.push("--entities", JSON.stringify(entities));
227
+ }
228
+ const output = await runScript(args);
204
229
  return textResult(stripPrivateTags(output));
205
230
  } catch (err: any) {
206
- return textResult(`Error querying knowledge: ${err.message}`);
231
+ return textResult(`Error querying graph: ${err.message}`);
232
+ }
233
+ },
234
+ );
235
+
236
+ // 8c. sinain_distill_session
237
+ server.tool(
238
+ "sinain_distill_session",
239
+ "Distill the current session into knowledge (playbook updates + graph facts)",
240
+ {
241
+ session_summary: z.string().optional().default("Bare agent session distillation"),
242
+ },
243
+ async ({ session_summary }) => {
244
+ const results: string[] = [];
245
+
246
+ try {
247
+ // Fetch feed items from sinain-core
248
+ const coreUrl = process.env.SINAIN_CORE_URL || "http://localhost:9500";
249
+ const feedResp = await fetch(`${coreUrl}/feed?after=0`).then(r => r.json());
250
+ const historyResp = await fetch(`${coreUrl}/agent/history?limit=10`).then(r => r.json());
251
+
252
+ const feedItems = (feedResp as any).messages ?? [];
253
+ const agentHistory = (historyResp as any).results ?? [];
254
+
255
+ if (feedItems.length < 3) {
256
+ return textResult("Not enough feed items to distill (need >3)");
257
+ }
258
+
259
+ // Step 1: Distill
260
+ const transcript = JSON.stringify([...feedItems, ...agentHistory].slice(0, 100));
261
+ const meta = JSON.stringify({ ts: new Date().toISOString(), sessionKey: session_summary });
262
+
263
+ const distillOutput = await runScript([
264
+ resolve(SCRIPTS_DIR, "session_distiller.py"),
265
+ "--memory-dir", MEMORY_DIR,
266
+ "--transcript", transcript,
267
+ "--session-meta", meta,
268
+ ], 30_000);
269
+ results.push(`[session_distiller] ${distillOutput.trim().slice(0, 500)}`);
270
+
271
+ const digest = JSON.parse(distillOutput.trim());
272
+ if (digest.isEmpty || digest.error) {
273
+ return textResult(`Distillation skipped: ${digest.error || "empty session"}`);
274
+ }
275
+
276
+ // Step 2: Integrate
277
+ const integrateOutput = await runScript([
278
+ resolve(SCRIPTS_DIR, "knowledge_integrator.py"),
279
+ "--memory-dir", MEMORY_DIR,
280
+ "--digest", JSON.stringify(digest),
281
+ ], 60_000);
282
+ results.push(`[knowledge_integrator] ${integrateOutput.trim().slice(0, 500)}`);
283
+
284
+ return textResult(stripPrivateTags(results.join("\n\n")));
285
+ } catch (err: any) {
286
+ return textResult(`Distillation error: ${err.message}`);
207
287
  }
208
288
  },
209
289
  );
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """Graph Query — entity-based lookup of knowledge graph facts.
3
+
4
+ Thin wrapper around triplestore.py for querying facts by entity/domain.
5
+ Used by sinain-core (via HTTP endpoint) and sinain-mcp-server (via subprocess).
6
+
7
+ Usage:
8
+ python3 graph_query.py --db memory/knowledge-graph.db \
9
+ --entities '["react-native", "metro-bundler"]' \
10
+ [--max-facts 5] [--format text|json]
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ def query_facts_by_entities(
20
+ db_path: str,
21
+ entities: list[str],
22
+ max_facts: int = 5,
23
+ ) -> list[dict]:
24
+ """Query knowledge graph for facts related to specified entities/domains."""
25
+ if not Path(db_path).exists():
26
+ return []
27
+
28
+ try:
29
+ from triplestore import TripleStore
30
+ store = TripleStore(db_path)
31
+
32
+ # Find fact entity_ids that match the requested domains or entity names
33
+ placeholders = ",".join(["?" for _ in entities])
34
+ # Match by domain attribute OR by entity name substring
35
+ like_clauses = " OR ".join([f"entity_id LIKE ?" for _ in entities])
36
+ entity_likes = [f"fact:{e}%" for e in entities]
37
+
38
+ rows = store._conn.execute(
39
+ f"""SELECT DISTINCT entity_id FROM triples
40
+ WHERE NOT retracted AND (
41
+ (attribute = 'domain' AND value IN ({placeholders}))
42
+ OR ({like_clauses})
43
+ )
44
+ LIMIT ?""",
45
+ (*entities, *entity_likes, max_facts * 3),
46
+ ).fetchall()
47
+
48
+ fact_ids = [r["entity_id"] for r in rows]
49
+
50
+ # Load full attributes for each fact, sorted by confidence
51
+ facts = []
52
+ for fid in fact_ids:
53
+ attrs = store.entity(fid)
54
+ if not attrs:
55
+ continue
56
+ fact = {"entityId": fid}
57
+ for a in attrs:
58
+ fact[a["attribute"]] = a["value"]
59
+ facts.append(fact)
60
+
61
+ # Sort by confidence descending
62
+ facts.sort(key=lambda f: float(f.get("confidence", "0")), reverse=True)
63
+ store.close()
64
+ return facts[:max_facts]
65
+ except Exception as e:
66
+ print(f"[warn] Graph query failed: {e}", file=sys.stderr)
67
+ return []
68
+
69
+
70
+ def query_top_facts(db_path: str, limit: int = 30) -> list[dict]:
71
+ """Query top-N facts by confidence for knowledge doc rendering."""
72
+ if not Path(db_path).exists():
73
+ return []
74
+
75
+ try:
76
+ from triplestore import TripleStore
77
+ store = TripleStore(db_path)
78
+
79
+ rows = store._conn.execute(
80
+ """SELECT entity_id, CAST(value AS REAL) as conf
81
+ FROM triples
82
+ WHERE attribute = 'confidence' AND NOT retracted
83
+ AND entity_id LIKE 'fact:%'
84
+ ORDER BY conf DESC
85
+ LIMIT ?""",
86
+ (limit,),
87
+ ).fetchall()
88
+
89
+ facts = []
90
+ for row in rows:
91
+ fid = row["entity_id"]
92
+ attrs = store.entity(fid)
93
+ if not attrs:
94
+ continue
95
+ fact = {"entityId": fid}
96
+ for a in attrs:
97
+ fact[a["attribute"]] = a["value"]
98
+ facts.append(fact)
99
+
100
+ store.close()
101
+ return facts
102
+ except Exception as e:
103
+ print(f"[warn] Graph top-facts query failed: {e}", file=sys.stderr)
104
+ return []
105
+
106
+
107
+ def format_facts_text(facts: list[dict], max_chars: int = 500) -> str:
108
+ """Format facts as human-readable text for escalation message injection."""
109
+ if not facts:
110
+ return ""
111
+
112
+ lines = []
113
+ total = 0
114
+ for f in facts:
115
+ value = f.get("value", "")
116
+ conf = f.get("confidence", "?")
117
+ count = f.get("reinforce_count", "1")
118
+ domain = f.get("domain", "")
119
+
120
+ line = f"- {value} (confidence: {conf}, confirmed {count}x)"
121
+ if domain:
122
+ line = f"- [{domain}] {value} (confidence: {conf}, confirmed {count}x)"
123
+
124
+ if total + len(line) > max_chars:
125
+ break
126
+ lines.append(line)
127
+ total += len(line)
128
+
129
+ return "\n".join(lines)
130
+
131
+
132
+ def domain_fact_counts(db_path: str) -> dict[str, int]:
133
+ """Count facts per domain for module emergence detection."""
134
+ if not Path(db_path).exists():
135
+ return {}
136
+
137
+ try:
138
+ from triplestore import TripleStore
139
+ store = TripleStore(db_path)
140
+
141
+ rows = store._conn.execute(
142
+ """SELECT value, COUNT(DISTINCT entity_id) as cnt
143
+ FROM triples
144
+ WHERE attribute = 'domain' AND NOT retracted
145
+ GROUP BY value
146
+ ORDER BY cnt DESC""",
147
+ ).fetchall()
148
+
149
+ store.close()
150
+ return {r["value"]: r["cnt"] for r in rows}
151
+ except Exception:
152
+ return {}
153
+
154
+
155
+ def main() -> None:
156
+ parser = argparse.ArgumentParser(description="Graph Query")
157
+ parser.add_argument("--db", required=True, help="Path to knowledge-graph.db")
158
+ parser.add_argument("--entities", default=None, help="JSON array of entity/domain names")
159
+ parser.add_argument("--top", type=int, default=None, help="Query top-N facts by confidence")
160
+ parser.add_argument("--domain-counts", action="store_true", help="Show fact counts per domain")
161
+ parser.add_argument("--max-facts", type=int, default=5, help="Maximum facts to return")
162
+ parser.add_argument("--format", choices=["text", "json"], default="json", help="Output format")
163
+ args = parser.parse_args()
164
+
165
+ if args.domain_counts:
166
+ counts = domain_fact_counts(args.db)
167
+ print(json.dumps(counts, indent=2))
168
+ return
169
+
170
+ if args.top is not None:
171
+ facts = query_top_facts(args.db, limit=args.top)
172
+ elif args.entities:
173
+ entities = json.loads(args.entities)
174
+ facts = query_facts_by_entities(args.db, entities, max_facts=args.max_facts)
175
+ else:
176
+ facts = query_top_facts(args.db, limit=args.max_facts)
177
+
178
+ if args.format == "text":
179
+ print(format_facts_text(facts))
180
+ else:
181
+ print(json.dumps({"facts": facts, "count": len(facts)}, indent=2, ensure_ascii=False))
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()