@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.
package/index.ts CHANGED
@@ -560,8 +560,10 @@ export default function sinainHudPlugin(api: OpenClawPluginApi): void {
560
560
  `sinain-hud: session summary written (${toolCount} tools, ${Math.round(durationMs / 1000)}s)`,
561
561
  );
562
562
 
563
- // Fire-and-forget: ingest session summary into triple store
564
- if (state.workspaceDir) {
563
+ // Fire-and-forget: ingest session summary into knowledge graph
564
+ // NOTE: Main knowledge integration happens in heartbeat tick (session_distiller + knowledge_integrator).
565
+ // This is a lightweight best-effort path for when agent_end fires (won't fire on kill).
566
+ if (false && state.workspaceDir) {
565
567
  api.runtime.system.runCommandWithTimeout(
566
568
  ["uv", "run", "--with", "requests", "python3",
567
569
  "sinain-memory/triple_ingest.py",
package/install.js CHANGED
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import os from "os";
5
5
  import { execSync } from "child_process";
6
+ import { randomBytes } from "crypto";
6
7
 
7
8
  const HOME = os.homedir();
8
9
  const PLUGIN_DIR = path.join(HOME, ".openclaw/extensions/sinain-hud");
@@ -18,7 +19,9 @@ const SKILL = path.join(PKG_DIR, "SKILL.md");
18
19
 
19
20
  console.log("\nInstalling sinain plugin...");
20
21
 
21
- // 1. Stage plugin files to local ~/.openclaw (used by both paths)
22
+ // 1. Copy plugin files (remove stale extensions/sinain dir if present from old installs)
23
+ const stalePluginDir = path.join(HOME, ".openclaw/extensions/sinain");
24
+ if (fs.existsSync(stalePluginDir)) fs.rmSync(stalePluginDir, { recursive: true, force: true });
22
25
  fs.mkdirSync(PLUGIN_DIR, { recursive: true });
23
26
  fs.copyFileSync(path.join(PKG_DIR, "index.ts"), path.join(PLUGIN_DIR, "index.ts"));
24
27
  fs.copyFileSync(path.join(PKG_DIR, "openclaw.plugin.json"), path.join(PLUGIN_DIR, "openclaw.plugin.json"));
@@ -115,6 +118,8 @@ async function installNemoClaw({ sandboxName }) {
115
118
  sessionKey: "agent:main:sinain"
116
119
  }
117
120
  };
121
+ // Remove stale "sinain" entry if present from a previous install
122
+ delete cfg.plugins.entries["sinain"];
118
123
  if (!cfg.plugins.allow.includes("sinain-hud")) cfg.plugins.allow.push("sinain-hud");
119
124
  cfg.agents ??= {};
120
125
  cfg.agents.defaults ??= {};
@@ -145,6 +150,19 @@ async function installNemoClaw({ sandboxName }) {
145
150
  }
146
151
  }
147
152
 
153
+ // Knowledge snapshot repo (optional) — inside sandbox
154
+ const snapshotUrl = process.env.SINAIN_SNAPSHOT_REPO;
155
+ if (snapshotUrl) {
156
+ try {
157
+ await checkRepoPrivacy(snapshotUrl);
158
+ run(`ssh -T openshell-${sandboxName} 'mkdir -p ~/.sinain/knowledge-snapshots && cd ~/.sinain/knowledge-snapshots && ([ -d .git ] || (git init && git config user.name sinain-knowledge && git config user.email sinain@local)) && (git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${snapshotUrl}" || git remote add origin "${snapshotUrl}") && (git fetch origin && git checkout -B main origin/main) 2>/dev/null || true'`);
159
+ console.log(" ✓ Snapshot repo configured in sandbox");
160
+ } catch (e) {
161
+ console.error("\n ✗ Snapshot repo setup aborted:", e.message, "\n");
162
+ process.exit(1);
163
+ }
164
+ }
165
+
148
166
  // Restart openclaw gateway inside sandbox (kill existing PID + start fresh)
149
167
  try {
150
168
  // Find the gateway PID, kill it, then start a new instance detached
@@ -201,6 +219,10 @@ async function installLocal() {
201
219
  sessionKey: "agent:main:sinain"
202
220
  }
203
221
  };
222
+ // Remove stale "sinain" entry if present from a previous install
223
+ delete cfg.plugins.entries["sinain"];
224
+ cfg.plugins.allow ??= [];
225
+ if (!cfg.plugins.allow.includes("sinain-hud")) cfg.plugins.allow.push("sinain-hud");
204
226
  cfg.agents ??= {};
205
227
  cfg.agents.defaults ??= {};
206
228
  cfg.agents.defaults.sandbox ??= {};
@@ -241,25 +263,69 @@ async function installLocal() {
241
263
  }
242
264
  }
243
265
 
244
- // Reload gateway
266
+ // Knowledge snapshot repo (optional)
267
+ await setupSnapshotRepo();
268
+
269
+ // Start / restart gateway
245
270
  try {
246
- execSync("openclaw reload", { stdio: "pipe" });
247
- console.log(" ✓ Gateway reloaded");
271
+ execSync("openclaw gateway restart --background", { stdio: "pipe" });
272
+ console.log(" ✓ Gateway restarted");
248
273
  } catch {
249
274
  try {
250
- execSync("openclaw stop && sleep 1 && openclaw start --background", { stdio: "pipe" });
251
- console.log(" ✓ Gateway restarted");
275
+ execSync("openclaw gateway start --background", { stdio: "pipe" });
276
+ console.log(" ✓ Gateway started");
252
277
  } catch {
253
278
  console.warn(" ⚠ Could not start gateway — run: openclaw gateway");
254
279
  }
255
280
  }
256
281
 
257
- console.log(`
258
- sinain installed successfully.
259
- Plugin config: ~/.openclaw/openclaw.json
260
- Auth token: check your Brev dashboard 'Gateway Token'
261
- Then run ./setup-nemoclaw.sh on your Mac.
262
- `);
282
+ console.log("\n✓ sinain installed successfully.");
283
+ console.log(" Plugin config: ~/.openclaw/openclaw.json");
284
+ console.log(` Auth token: ${authToken}`);
285
+ console.log(" Next: run 'openclaw gateway' in a new terminal, then run ./setup-nemoclaw.sh on your Mac.\n");
286
+ }
287
+
288
+ // ── Knowledge snapshot repo setup (shared) ──────────────────────────────────
289
+
290
+ const SNAPSHOT_DIR = path.join(HOME, ".sinain", "knowledge-snapshots");
291
+
292
+ async function setupSnapshotRepo() {
293
+ const snapshotUrl = process.env.SINAIN_SNAPSHOT_REPO;
294
+ if (!snapshotUrl) return;
295
+
296
+ try {
297
+ await checkRepoPrivacy(snapshotUrl);
298
+ fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
299
+
300
+ if (!fs.existsSync(path.join(SNAPSHOT_DIR, ".git"))) {
301
+ execSync(`git init "${SNAPSHOT_DIR}"`, { stdio: "pipe" });
302
+ execSync(`git -C "${SNAPSHOT_DIR}" config user.name "sinain-knowledge"`, { stdio: "pipe" });
303
+ execSync(`git -C "${SNAPSHOT_DIR}" config user.email "sinain@local"`, { stdio: "pipe" });
304
+ }
305
+
306
+ // Set remote (add or update)
307
+ try {
308
+ run_capture(`git -C "${SNAPSHOT_DIR}" remote get-url origin`);
309
+ execSync(`git -C "${SNAPSHOT_DIR}" remote set-url origin "${snapshotUrl}"`, { stdio: "pipe" });
310
+ } catch {
311
+ execSync(`git -C "${SNAPSHOT_DIR}" remote add origin "${snapshotUrl}"`, { stdio: "pipe" });
312
+ }
313
+
314
+ // Pull existing snapshots if remote has content
315
+ try {
316
+ execSync(`git -C "${SNAPSHOT_DIR}" fetch origin`, { stdio: "pipe", timeout: 15_000 });
317
+ execSync(`git -C "${SNAPSHOT_DIR}" checkout -B main origin/main`, { stdio: "pipe" });
318
+ console.log(" ✓ Snapshot repo restored from", snapshotUrl);
319
+ } catch {
320
+ console.log(" ✓ Snapshot repo configured (empty remote)");
321
+ }
322
+ } catch (e) {
323
+ if (e.message?.startsWith("SECURITY") || e.message?.startsWith("Refusing")) {
324
+ console.error("\n ✗ Snapshot repo setup aborted:", e.message, "\n");
325
+ process.exit(1);
326
+ }
327
+ console.warn(" ⚠ Snapshot repo setup failed:", e.message);
328
+ }
263
329
  }
264
330
 
265
331
  // ── Detection ────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "sinain — AI overlay system for macOS (npx @geravant/sinain start)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,8 +12,10 @@ You have MCP tools from `sinain-mcp-server`:
12
12
  - `sinain_get_feedback` — get feedback signals from recent escalations
13
13
  - `sinain_post_feed` — push an arbitrary message to the HUD
14
14
  - `sinain_health` — check system health
15
- - `sinain_knowledge_query` — query the knowledge graph for relevant context
16
- - `sinain_heartbeat_tick` — run the full curation pipeline (git backup, signals, insights, playbook)
15
+ - `sinain_get_knowledge` — get the portable knowledge document (playbook + long-term facts + sessions)
16
+ - `sinain_knowledge_query` — query the knowledge graph for facts about specific entities/domains
17
+ - `sinain_distill_session` — explicitly distill the current session into knowledge updates
18
+ - `sinain_heartbeat_tick` — run the heartbeat pipeline (git backup, signals, distillation, insights)
17
19
  - `sinain_module_guidance` — get active module guidance
18
20
 
19
21
  ## Main Loop
@@ -23,7 +25,7 @@ Your primary job is an escalation response loop:
23
25
  1. Call `sinain_get_escalation` to check for pending escalations
24
26
  2. If an escalation is present:
25
27
  a. Read the escalation message carefully — it contains screen OCR, audio transcripts, app context, and the local agent's digest
26
- b. Optionally call `sinain_knowledge_query` with key context from the escalation to enrich your response
28
+ b. Optionally call `sinain_get_knowledge` to read the knowledge document, or `sinain_knowledge_query` with specific entities to enrich your response
27
29
  c. Optionally call `sinain_module_guidance` to get active module instructions
28
30
  d. Craft a response and call `sinain_respond` with the escalation ID and your response
29
31
  3. If no escalation is pending, wait a few seconds and poll again
@@ -47,10 +49,12 @@ When responding to escalations:
47
49
  2. The tool runs the full pipeline automatically:
48
50
  - Git backup of memory directory
49
51
  - Signal analysis (detects opportunities from session patterns)
52
+ - **Session distillation** — fetches new feed items from sinain-core, distills patterns/learnings
53
+ - **Knowledge integration** — updates playbook (working memory) and knowledge graph (long-term memory)
50
54
  - Insight synthesis (generates suggestions from accumulated patterns)
51
- - Playbook curation (updates effective playbook based on feedback)
52
55
  3. If the result contains a suggestion or insight, post it to the HUD via `sinain_post_feed`
53
- 4. Optionally call `sinain_get_feedback` to review recent escalation scores
56
+ 4. Optionally call `sinain_get_knowledge` to review the portable knowledge document
57
+ 5. Optionally call `sinain_get_feedback` to review recent escalation scores
54
58
 
55
59
  ## Spawning Background Tasks
56
60
 
@@ -69,8 +73,11 @@ Rules:
69
73
  ## Files You Manage
70
74
 
71
75
  Your working memory lives at `~/.openclaw/workspace/memory/`:
72
- - `playbook.md` — your effective playbook (updated by curation pipeline)
73
- - `triplestore.db` — knowledge graph (SQLite, managed by Python scripts)
76
+ - `sinain-playbook.md` — your effective playbook (working memory, updated by knowledge integrator)
77
+ - `knowledge-graph.db` — long-term knowledge graph (SQLite, curated facts with confidence tracking)
78
+ - `sinain-knowledge.md` — portable knowledge document (<8KB, playbook + top graph facts + recent sessions)
79
+ - `session-digests.jsonl` — session distillation history
80
+ - `distill-state.json` — watermark for what's been distilled
74
81
  - `playbook-logs/YYYY-MM-DD.jsonl` — decision logs
75
82
 
76
83
  ## Privacy
@@ -9,7 +9,7 @@ import { OpenClawWsClient } from "./openclaw-ws.js";
9
9
  import { EscalationSlot } from "./escalation-slot.js";
10
10
  import type { SlotEntry, QueueFeedbackCtx } from "./escalation-slot.js";
11
11
  import { shouldEscalate, calculateEscalationScore } from "./scorer.js";
12
- import { isCodingContext, buildEscalationMessage } from "./message-builder.js";
12
+ import { isCodingContext, buildEscalationMessage, fetchKnowledgeFacts } from "./message-builder.js";
13
13
  import { loadPendingTasks, savePendingTasks, type PendingTaskEntry } from "../util/task-store.js";
14
14
  import { log, warn, error } from "../log.js";
15
15
 
@@ -32,6 +32,7 @@ export interface EscalatorDeps {
32
32
  profiler?: Profiler;
33
33
  feedbackStore?: FeedbackStore;
34
34
  signalCollector?: SignalCollector;
35
+ queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
35
36
  }
36
37
 
37
38
  /**
@@ -159,7 +160,7 @@ export class Escalator {
159
160
  * Called after every agent analysis tick.
160
161
  * Decides whether to escalate and enqueues the message for delivery.
161
162
  */
162
- onAgentAnalysis(entry: AgentEntry, contextWindow: ContextWindow): void {
163
+ async onAgentAnalysis(entry: AgentEntry, contextWindow: ContextWindow): Promise<void> {
163
164
  // Skip WS escalations when circuit is open (HTTP transport bypasses this)
164
165
  const transport = this.deps.escalationConfig.transport;
165
166
  if (this.wsClient.isCircuitOpen && transport !== "http") {
@@ -198,7 +199,7 @@ export class Escalator {
198
199
  this.lastEscalationContext = contextWindow;
199
200
 
200
201
  const escalationReason = score.reasons.join(", ");
201
- const message = buildEscalationMessage(
202
+ let message = buildEscalationMessage(
202
203
  entry.digest,
203
204
  contextWindow,
204
205
  entry,
@@ -206,6 +207,21 @@ export class Escalator {
206
207
  escalationReason,
207
208
  );
208
209
 
210
+ // Enrich with long-term knowledge facts (best-effort, 5s max)
211
+ if (this.deps.queryKnowledgeFacts) {
212
+ try {
213
+ const knowledgeSection = await fetchKnowledgeFacts(
214
+ contextWindow, entry.digest, this.deps.queryKnowledgeFacts,
215
+ );
216
+ if (knowledgeSection) {
217
+ message = message + "\n\n" + knowledgeSection;
218
+ log(TAG, `knowledge enrichment injected (${knowledgeSection.length} chars)`);
219
+ }
220
+ } catch (err) {
221
+ log(TAG, `knowledge enrichment failed: ${String(err)}`);
222
+ }
223
+ }
224
+
209
225
  const slotId = createHash("sha256").update(this.deps.openclawConfig.sessionKey + entry.ts).digest("hex").slice(0, 16);
210
226
  const slotEntry: SlotEntry = {
211
227
  id: slotId,
@@ -292,6 +292,53 @@ the local analyzer reported idle/no-change. Provide a PROACTIVE response:
292
292
  return sections.join("\n\n");
293
293
  }
294
294
 
295
+ /**
296
+ * Fetch relevant long-term knowledge facts for the current escalation context.
297
+ * Extracts entities from the context and queries the knowledge graph.
298
+ * Returns a formatted section or empty string if no relevant facts found.
299
+ */
300
+ export async function fetchKnowledgeFacts(
301
+ context: ContextWindow,
302
+ digest: string,
303
+ queryFn?: (entities: string[], maxFacts: number) => Promise<string>,
304
+ ): Promise<string> {
305
+ if (!queryFn) return "";
306
+
307
+ // Extract entities from context: current app + error types + domain keywords
308
+ const entities: string[] = [];
309
+ const app = normalizeAppName(context.currentApp).toLowerCase().replace(/\s+/g, "-");
310
+ if (app && app.length > 2) entities.push(app);
311
+
312
+ // Extract error type keywords from digest
313
+ const errorPatterns = digest.matchAll(
314
+ /(?:Error|Exception|TypeError|SyntaxError|ReferenceError|HTTP\s*\d{3}|ENOENT|EACCES)/gi,
315
+ );
316
+ for (const m of errorPatterns) {
317
+ entities.push(m[0].toLowerCase());
318
+ }
319
+
320
+ // Extract technology keywords
321
+ const techKeywords = [
322
+ "react-native", "react", "flutter", "swift", "kotlin", "python",
323
+ "typescript", "node", "docker", "metro", "gradle", "cocoapods",
324
+ "intellij", "vscode", "xcode", "sinain",
325
+ ];
326
+ const lowerDigest = digest.toLowerCase();
327
+ for (const kw of techKeywords) {
328
+ if (lowerDigest.includes(kw)) entities.push(kw);
329
+ }
330
+
331
+ if (entities.length === 0) return "";
332
+
333
+ try {
334
+ const factsText = await queryFn(entities.slice(0, 5), 5);
335
+ if (factsText && factsText.trim().length > 20) {
336
+ return `## Past Experience\nBased on long-term knowledge relevant to this context:\n${factsText.trim()}`;
337
+ }
338
+ } catch {}
339
+ return "";
340
+ }
341
+
295
342
  /**
296
343
  * Format a compact inline feedback section for escalation messages.
297
344
  * Shows recent performance so the agent can calibrate its response style.
@@ -78,6 +78,17 @@ async function main() {
78
78
  openclawConfig: config.openclawConfig,
79
79
  profiler,
80
80
  feedbackStore: feedbackStore ?? undefined,
81
+ queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
82
+ const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
83
+ const dbPath = `${workspace}/memory/knowledge-graph.db`;
84
+ const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
85
+ try {
86
+ const { execFileSync } = await import("node:child_process");
87
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
88
+ if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
89
+ return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
90
+ } catch { return ""; }
91
+ },
81
92
  });
82
93
 
83
94
  // ── Initialize agent loop (event-driven) ──
@@ -382,6 +393,25 @@ async function main() {
382
393
  // Bare agent HTTP escalation bridge
383
394
  getEscalationPending: () => escalator.getPendingHttp(),
384
395
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
396
+
397
+ // Knowledge graph integration
398
+ getKnowledgeDocPath: () => {
399
+ const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
400
+ const p = `${workspace}/memory/sinain-knowledge.md`;
401
+ try { if (require("node:fs").existsSync(p)) return p; } catch {}
402
+ return null;
403
+ },
404
+ queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
405
+ const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
406
+ const dbPath = `${workspace}/memory/knowledge-graph.db`;
407
+ const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
408
+ try {
409
+ const { execFileSync } = await import("node:child_process");
410
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
411
+ if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
412
+ return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
413
+ } catch { return ""; }
414
+ },
385
415
  });
386
416
 
387
417
  // ── Wire overlay profiling ──
@@ -36,6 +36,8 @@ export interface ServerDeps {
36
36
  feedbackStore?: FeedbackStore;
37
37
  getEscalationPending?: () => any;
38
38
  respondEscalation?: (id: string, response: string) => any;
39
+ getKnowledgeDocPath?: () => string | null;
40
+ queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
39
41
  }
40
42
 
41
43
  function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
@@ -207,6 +209,43 @@ export function createAppServer(deps: ServerDeps) {
207
209
  return;
208
210
  }
209
211
 
212
+ // ── /knowledge ──
213
+ if (req.method === "GET" && url.pathname === "/knowledge") {
214
+ // Return portable knowledge document
215
+ const knowledgePath = deps.getKnowledgeDocPath?.();
216
+ if (knowledgePath) {
217
+ try {
218
+ const { readFileSync } = await import("node:fs");
219
+ const content = readFileSync(knowledgePath, "utf-8");
220
+ res.end(JSON.stringify({ ok: true, content }));
221
+ } catch {
222
+ res.end(JSON.stringify({ ok: true, content: "" }));
223
+ }
224
+ } else {
225
+ res.end(JSON.stringify({ ok: true, content: "" }));
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (req.method === "GET" && url.pathname === "/knowledge/facts") {
231
+ // Query knowledge graph for entity-matched facts
232
+ const entitiesParam = url.searchParams.get("entities") || "";
233
+ const maxFacts = Math.min(parseInt(url.searchParams.get("max") || "5"), 20);
234
+ const entities = entitiesParam.split(",").map(e => e.trim()).filter(Boolean);
235
+
236
+ if (deps.queryKnowledgeFacts) {
237
+ try {
238
+ const facts = await deps.queryKnowledgeFacts(entities, maxFacts);
239
+ res.end(JSON.stringify({ ok: true, facts }));
240
+ } catch (err) {
241
+ res.end(JSON.stringify({ ok: true, facts: [], error: String(err) }));
242
+ }
243
+ } else {
244
+ res.end(JSON.stringify({ ok: true, facts: [] }));
245
+ }
246
+ return;
247
+ }
248
+
210
249
  // ── /traces ──
211
250
  if (req.method === "GET" && url.pathname === "/traces") {
212
251
  const after = parseInt(url.searchParams.get("after") || "0");
@@ -6,11 +6,37 @@
6
6
  * external process execution.
7
7
  */
8
8
 
9
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
9
12
  import type { Logger, ScriptRunner } from "../data/schema.js";
10
13
  import type { KnowledgeStore } from "../data/store.js";
11
14
  import type { ResilienceManager } from "./resilience.js";
12
15
  import type { GitSnapshotStore } from "../data/git-store.js";
13
16
 
17
+ // ============================================================================
18
+ // Distillation State
19
+ // ============================================================================
20
+
21
+ interface DistillState {
22
+ lastDistilledFeedId: number;
23
+ lastDistilledTs: string;
24
+ }
25
+
26
+ function readDistillState(workspaceDir: string): DistillState {
27
+ const p = join(workspaceDir, "memory", "distill-state.json");
28
+ try {
29
+ if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
30
+ } catch {}
31
+ return { lastDistilledFeedId: 0, lastDistilledTs: "" };
32
+ }
33
+
34
+ function writeDistillState(workspaceDir: string, state: DistillState): void {
35
+ const dir = join(workspaceDir, "memory");
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ writeFileSync(join(dir, "distill-state.json"), JSON.stringify(state, null, 2), "utf-8");
38
+ }
39
+
14
40
  // ============================================================================
15
41
  // Types
16
42
  // ============================================================================
@@ -43,14 +69,17 @@ export type ContextAssemblyOpts = {
43
69
  export class CurationEngine {
44
70
  private curationInterval: ReturnType<typeof setInterval> | null = null;
45
71
  private gitSnapshotStore: GitSnapshotStore | null = null;
72
+ private sinainCoreUrl: string;
46
73
 
47
74
  constructor(
48
75
  private store: KnowledgeStore,
49
76
  private runScript: ScriptRunner,
50
77
  private resilience: ResilienceManager,
51
- private config: { userTimezone: string },
78
+ private config: { userTimezone: string; sinainCoreUrl?: string },
52
79
  private logger: Logger,
53
- ) {}
80
+ ) {
81
+ this.sinainCoreUrl = config.sinainCoreUrl || process.env.SINAIN_CORE_URL || "http://localhost:9500";
82
+ }
54
83
 
55
84
  /** Attach a git-backed snapshot store for periodic saves. */
56
85
  setGitSnapshotStore(gitStore: GitSnapshotStore): void {
@@ -143,15 +172,87 @@ export class CurationEngine {
143
172
  confidence: 0,
144
173
  };
145
174
 
146
- // Fire-and-forget: ingest signal into triple store
147
- const tickTs = new Date().toISOString();
148
- runPythonScript([
149
- "sinain-memory/triple_ingest.py",
150
- "--memory-dir", "memory/",
151
- "--tick-ts", tickTs,
152
- "--signal-result", JSON.stringify(signalResult),
153
- "--embed",
154
- ], 15_000).catch(() => {});
175
+ }
176
+
177
+ // 2b. Session distillation check — fetch new feed items and distill if needed
178
+ try {
179
+ const distillState = readDistillState(workspaceDir);
180
+ const feedUrl = `${this.sinainCoreUrl}/feed?after=${distillState.lastDistilledFeedId}`;
181
+ const historyUrl = `${this.sinainCoreUrl}/agent/history?limit=10`;
182
+
183
+ const [feedResp, historyResp] = await Promise.all([
184
+ fetch(feedUrl).then(r => r.json()).catch(() => null),
185
+ fetch(historyUrl).then(r => r.json()).catch(() => null),
186
+ ]);
187
+
188
+ const feedItems = (feedResp as { messages?: unknown[] })?.messages ?? [];
189
+ const agentHistory = (historyResp as { results?: unknown[] })?.results ?? [];
190
+
191
+ // Only distill if there's meaningful new content (>3 non-trivial items)
192
+ const significantItems = (feedItems as Array<{ text?: string }>).filter(
193
+ (item) => item.text && !item.text.startsWith("[PERIODIC]") && item.text.length > 20,
194
+ );
195
+
196
+ if (significantItems.length > 3) {
197
+ this.logger.info(
198
+ `sinain-hud: distillation check — ${significantItems.length} new feed items since id=${distillState.lastDistilledFeedId}`,
199
+ );
200
+
201
+ // Combine feed + agent history as transcript
202
+ const transcript = JSON.stringify([...significantItems, ...agentHistory].slice(0, 100));
203
+ const sessionMeta = JSON.stringify({
204
+ ts: new Date().toISOString(),
205
+ sessionKey: params.sessionSummary,
206
+ feedItemCount: significantItems.length,
207
+ });
208
+
209
+ // Step 1: Distill
210
+ const distillT0 = Date.now();
211
+ const digest = await runPythonScript([
212
+ "sinain-memory/session_distiller.py",
213
+ "--memory-dir", "memory/",
214
+ "--transcript", transcript,
215
+ "--session-meta", sessionMeta,
216
+ ], 30_000);
217
+ latencyMs.distillation = Date.now() - distillT0;
218
+
219
+ if (digest && !digest.isEmpty && !digest.error) {
220
+ // Step 2: Integrate into playbook + knowledge graph
221
+ const integrateT0 = Date.now();
222
+ const integration = await runPythonScript([
223
+ "sinain-memory/knowledge_integrator.py",
224
+ "--memory-dir", "memory/",
225
+ "--digest", JSON.stringify(digest),
226
+ ], 60_000);
227
+ latencyMs.integration = Date.now() - integrateT0;
228
+
229
+ if (integration) {
230
+ this.logger.info(
231
+ `sinain-hud: knowledge integrated — changes=${JSON.stringify(integration.changes ?? {})}, graph=${JSON.stringify(integration.graphStats ?? {})}`,
232
+ );
233
+ result.distillation = { digest, integration };
234
+
235
+ // Regenerate effective playbook after integration
236
+ this.store.generateEffectivePlaybook();
237
+
238
+ // Render knowledge doc
239
+ this.store.renderKnowledgeDoc();
240
+ }
241
+ } else {
242
+ this.logger.info(`sinain-hud: distillation produced empty/error result, skipping integration`);
243
+ }
244
+
245
+ // Update watermark — always, even if distillation was empty, to advance past these items
246
+ const maxFeedId = (feedItems as Array<{ id?: number }>).reduce(
247
+ (max, item) => Math.max(max, item.id ?? 0), distillState.lastDistilledFeedId,
248
+ );
249
+ writeDistillState(workspaceDir, {
250
+ lastDistilledFeedId: maxFeedId,
251
+ lastDistilledTs: new Date().toISOString(),
252
+ });
253
+ }
254
+ } catch (err) {
255
+ this.logger.warn(`sinain-hud: distillation check error: ${String(err)}`);
155
256
  }
156
257
 
157
258
  // 3. Insight synthesis (60s timeout)
@@ -438,20 +539,32 @@ export class CurationEngine {
438
539
  const moduleGuidance = this.store.getActiveModuleGuidance();
439
540
  if (moduleGuidance) contextParts.push(moduleGuidance);
440
541
 
441
- // Knowledge graph context (10s timeout)
542
+ // Knowledge document (pre-rendered, synchronous — replaces triple_query.py)
543
+ const knowledgeDocPath = join(workspaceDir, "memory", "sinain-knowledge.md");
442
544
  try {
443
- const ragResult = await this.runScript(
444
- ["uv", "run", "--with", "requests", "python3",
445
- "sinain-memory/triple_query.py",
446
- "--memory-dir", "memory",
447
- "--context", "current session",
448
- "--max-chars", "1500"],
449
- { timeoutMs: 10_000, cwd: workspaceDir },
450
- );
451
- if (ragResult.code === 0) {
452
- const parsed = JSON.parse(ragResult.stdout.trim());
453
- if (parsed.context && parsed.context.length > 50) {
454
- contextParts.push(`[KNOWLEDGE GRAPH CONTEXT]\n${parsed.context}`);
545
+ if (existsSync(knowledgeDocPath)) {
546
+ const knowledgeDoc = readFileSync(knowledgeDocPath, "utf-8");
547
+ if (knowledgeDoc.length > 50) {
548
+ contextParts.push(`[KNOWLEDGE]\n${knowledgeDoc}`);
549
+ }
550
+ }
551
+ } catch {}
552
+
553
+ // Dynamic graph enrichment for situation-specific facts (10s timeout)
554
+ try {
555
+ const situationEntities = this.store.extractSituationEntities();
556
+ if (situationEntities.length > 0) {
557
+ const graphResult = await this.runScript(
558
+ ["uv", "run", "--with", "requests", "python3",
559
+ "sinain-memory/graph_query.py",
560
+ "--db", "memory/knowledge-graph.db",
561
+ "--entities", JSON.stringify(situationEntities),
562
+ "--max-facts", "5",
563
+ "--format", "text"],
564
+ { timeoutMs: 10_000, cwd: workspaceDir },
565
+ );
566
+ if (graphResult.code === 0 && graphResult.stdout.trim().length > 20) {
567
+ contextParts.push(`[SITUATION-SPECIFIC KNOWLEDGE]\n${graphResult.stdout.trim()}`);
455
568
  }
456
569
  }
457
570
  } catch {}
@@ -193,9 +193,33 @@ export class GitSnapshotStore {
193
193
  this.git("commit", "-m", message);
194
194
  const hash = this.git("rev-parse", "--short", "HEAD");
195
195
  this.logger.info(`sinain-knowledge: snapshot saved → ${hash}`);
196
+ await this.push();
196
197
  return hash;
197
198
  }
198
199
 
200
+ // ── Push ─────────────────────────────────────────────────────────────────
201
+
202
+ private async push(): Promise<void> {
203
+ try {
204
+ const remotes = this.git("remote");
205
+ if (!remotes) return;
206
+
207
+ // Use a longer timeout for network operations
208
+ execFileSync("git", ["push", "origin", "HEAD"], {
209
+ cwd: this.repoPath,
210
+ encoding: "utf-8",
211
+ timeout: 30_000,
212
+ stdio: "pipe",
213
+ });
214
+ this.logger.info("sinain-knowledge: snapshot pushed to remote");
215
+ } catch (err) {
216
+ // Warn only if there IS a remote but push failed
217
+ this.logger.warn(
218
+ `sinain-knowledge: push failed (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
219
+ );
220
+ }
221
+ }
222
+
199
223
  // ── List ─────────────────────────────────────────────────────────────────
200
224
 
201
225
  /**