@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 +4 -2
- package/install.js +78 -12
- package/package.json +1 -1
- package/sinain-agent/CLAUDE.md +14 -7
- package/sinain-core/src/escalation/escalator.ts +19 -3
- package/sinain-core/src/escalation/message-builder.ts +47 -0
- package/sinain-core/src/index.ts +30 -0
- package/sinain-core/src/server.ts +39 -0
- package/sinain-knowledge/curation/engine.ts +137 -24
- package/sinain-knowledge/data/git-store.ts +24 -0
- package/sinain-knowledge/data/store.ts +117 -0
- package/sinain-mcp-server/index.ts +93 -13
- package/sinain-memory/graph_query.py +185 -0
- package/sinain-memory/knowledge_integrator.py +450 -0
- package/sinain-memory/memory-config.json +3 -1
- package/sinain-memory/session_distiller.py +162 -0
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
|
|
564
|
-
|
|
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.
|
|
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
|
-
//
|
|
266
|
+
// Knowledge snapshot repo (optional)
|
|
267
|
+
await setupSnapshotRepo();
|
|
268
|
+
|
|
269
|
+
// Start / restart gateway
|
|
245
270
|
try {
|
|
246
|
-
execSync("openclaw
|
|
247
|
-
console.log(" ✓ Gateway
|
|
271
|
+
execSync("openclaw gateway restart --background", { stdio: "pipe" });
|
|
272
|
+
console.log(" ✓ Gateway restarted");
|
|
248
273
|
} catch {
|
|
249
274
|
try {
|
|
250
|
-
execSync("openclaw
|
|
251
|
-
console.log(" ✓ Gateway
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
package/sinain-agent/CLAUDE.md
CHANGED
|
@@ -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
|
-
- `
|
|
16
|
-
- `
|
|
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 `
|
|
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 `
|
|
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
|
|
73
|
-
- `
|
|
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
|
-
|
|
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.
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
542
|
+
// Knowledge document (pre-rendered, synchronous — replaces triple_query.py)
|
|
543
|
+
const knowledgeDocPath = join(workspaceDir, "memory", "sinain-knowledge.md");
|
|
442
544
|
try {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
/**
|