@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 +1 -1
- package/sinain-agent/CLAUDE.md +50 -0
- package/sinain-agent/run.sh +7 -3
- package/sinain-core/src/index.ts +297 -26
- package/sinain-core/src/learning/local-curation.ts +373 -0
- package/sinain-core/src/server.ts +197 -0
- package/sinain-mcp-server/index.ts +34 -4
- package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
- package/sinain-memory/eval/retrieval_benchmark.jsonl +12 -0
- package/sinain-memory/eval/retrieval_evaluator.py +186 -0
- package/sinain-memory/graph_query.py +34 -1
- package/sinain-memory/knowledge_integrator.py +54 -0
- package/sinain-memory/triplestore.py +76 -5
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalCurationService — local-first knowledge pipeline for sinain-core.
|
|
3
|
+
*
|
|
4
|
+
* Runs the same Python scripts as the OpenClaw server-side pipeline,
|
|
5
|
+
* but triggered locally: on session end (SIGINT/SIGTERM) and periodically.
|
|
6
|
+
*
|
|
7
|
+
* This ensures knowledge persists between bare-agent sessions even when
|
|
8
|
+
* no OpenClaw gateway is available.
|
|
9
|
+
*
|
|
10
|
+
* Memory directory: SINAIN_MEMORY_DIR (default: ~/.sinain/memory)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, appendFileSync } from "node:fs";
|
|
15
|
+
import { resolve, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import type { FeedItem } from "../types.js";
|
|
18
|
+
import { log, warn, error } from "../log.js";
|
|
19
|
+
|
|
20
|
+
const TAG = "local-curation";
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
/** Resolve the sinain-memory Python scripts directory. */
|
|
24
|
+
function resolveScriptsDir(): string {
|
|
25
|
+
// Look for sinain-memory scripts in known locations
|
|
26
|
+
const candidates = [
|
|
27
|
+
resolve(__dirname, "..", "..", "..", "sinain-hud-plugin", "sinain-memory"),
|
|
28
|
+
resolve(__dirname, "..", "..", "sinain-memory"),
|
|
29
|
+
resolve(process.env.HOME || "", ".sinain", "sinain-memory"),
|
|
30
|
+
];
|
|
31
|
+
for (const dir of candidates) {
|
|
32
|
+
if (existsSync(resolve(dir, "session_distiller.py"))) {
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return candidates[0]; // Fallback
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve the local memory directory. */
|
|
40
|
+
function resolveMemoryDir(): string {
|
|
41
|
+
const raw = process.env.SINAIN_MEMORY_DIR
|
|
42
|
+
|| process.env.OPENCLAW_WORKSPACE_DIR
|
|
43
|
+
|| `${process.env.HOME}/.sinain/memory`;
|
|
44
|
+
const expanded = raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
|
|
45
|
+
|
|
46
|
+
// If pointing to workspace, use the memory subdirectory
|
|
47
|
+
if (expanded.endsWith("/workspace") || expanded.endsWith("/workspace/")) {
|
|
48
|
+
return resolve(expanded, "memory");
|
|
49
|
+
}
|
|
50
|
+
return expanded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class LocalCurationService {
|
|
54
|
+
private memoryDir: string;
|
|
55
|
+
private scriptsDir: string;
|
|
56
|
+
private sessionStartTs: number;
|
|
57
|
+
private curationTimer: ReturnType<typeof setInterval> | null = null;
|
|
58
|
+
|
|
59
|
+
constructor() {
|
|
60
|
+
this.memoryDir = resolveMemoryDir();
|
|
61
|
+
this.scriptsDir = resolveScriptsDir();
|
|
62
|
+
this.sessionStartTs = Date.now();
|
|
63
|
+
|
|
64
|
+
// Ensure memory directory exists
|
|
65
|
+
for (const subdir of ["", "playbook-logs", "playbook-archive", "eval-logs", "eval-reports"]) {
|
|
66
|
+
const dir = resolve(this.memoryDir, subdir);
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log(TAG, `memory: ${this.memoryDir}`);
|
|
71
|
+
log(TAG, `scripts: ${this.scriptsDir}`);
|
|
72
|
+
log(TAG, `scripts available: ${existsSync(resolve(this.scriptsDir, "session_distiller.py"))}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Start periodic curation (30-minute timer). */
|
|
76
|
+
startPeriodicCuration(): void {
|
|
77
|
+
if (this.curationTimer) return;
|
|
78
|
+
const intervalMs = 30 * 60 * 1000; // 30 minutes
|
|
79
|
+
this.curationTimer = setInterval(() => {
|
|
80
|
+
this.runCurationPipeline();
|
|
81
|
+
}, intervalMs);
|
|
82
|
+
log(TAG, `periodic curation started (every 30 min)`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stop periodic curation. */
|
|
86
|
+
stop(): void {
|
|
87
|
+
if (this.curationTimer) {
|
|
88
|
+
clearInterval(this.curationTimer);
|
|
89
|
+
this.curationTimer = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Save feed items to disk for deferred distillation.
|
|
95
|
+
* Called during shutdown — instant (no LLM), survives tsx force-kill.
|
|
96
|
+
*/
|
|
97
|
+
savePendingSession(feedItems: FeedItem[]): void {
|
|
98
|
+
if (feedItems.length < 3) {
|
|
99
|
+
log(TAG, `skipping save — only ${feedItems.length} feed items`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pendingPath = resolve(this.memoryDir, "pending-session.json");
|
|
104
|
+
const data = {
|
|
105
|
+
ts: new Date().toISOString(),
|
|
106
|
+
sessionKey: "local-session",
|
|
107
|
+
durationMs: Date.now() - this.sessionStartTs,
|
|
108
|
+
items: feedItems.map(item => ({
|
|
109
|
+
text: item.text,
|
|
110
|
+
ts: item.ts,
|
|
111
|
+
source: item.source || "unknown",
|
|
112
|
+
channel: item.channel || "agent",
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
writeFileSync(pendingPath, JSON.stringify(data), "utf-8");
|
|
117
|
+
log(TAG, `saved ${feedItems.length} feed items to pending-session.json`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Distill a previously saved pending session (from a prior shutdown).
|
|
122
|
+
* Called on startup — runs LLM distillation when there's no time pressure.
|
|
123
|
+
*/
|
|
124
|
+
distillPendingSession(): void {
|
|
125
|
+
const pendingPath = resolve(this.memoryDir, "pending-session.json");
|
|
126
|
+
if (!existsSync(pendingPath)) return;
|
|
127
|
+
|
|
128
|
+
let data: any;
|
|
129
|
+
try {
|
|
130
|
+
data = JSON.parse(readFileSync(pendingPath, "utf-8"));
|
|
131
|
+
} catch {
|
|
132
|
+
warn(TAG, "corrupt pending-session.json — removing");
|
|
133
|
+
unlinkSync(pendingPath);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const items: FeedItem[] = data.items || [];
|
|
138
|
+
if (items.length < 3) {
|
|
139
|
+
log(TAG, `pending session too small (${items.length} items) — removing`);
|
|
140
|
+
unlinkSync(pendingPath);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
log(TAG, `distilling pending session: ${items.length} items from ${data.ts}`);
|
|
145
|
+
|
|
146
|
+
// Remove pending file first so a crash here doesn't loop
|
|
147
|
+
unlinkSync(pendingPath);
|
|
148
|
+
|
|
149
|
+
this.runDistillation(items, {
|
|
150
|
+
ts: data.ts,
|
|
151
|
+
sessionKey: data.sessionKey || "local-session",
|
|
152
|
+
durationMs: data.durationMs || 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Distill the current session on shutdown.
|
|
158
|
+
* First saves feed items to disk (instant), then attempts LLM distillation.
|
|
159
|
+
* If tsx kills the process before LLM finishes, the saved file will be
|
|
160
|
+
* picked up on next startup via distillPendingSession().
|
|
161
|
+
*/
|
|
162
|
+
async distillSession(feedItems: FeedItem[]): Promise<void> {
|
|
163
|
+
if (feedItems.length < 3) {
|
|
164
|
+
log(TAG, `skipping distillation — only ${feedItems.length} feed items`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Step 0: Save to disk FIRST — survives force-kill
|
|
169
|
+
this.savePendingSession(feedItems);
|
|
170
|
+
|
|
171
|
+
const sessionMeta = {
|
|
172
|
+
ts: new Date().toISOString(),
|
|
173
|
+
sessionKey: "local-session",
|
|
174
|
+
durationMs: Date.now() - this.sessionStartTs,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const transcript = feedItems.map(item => ({
|
|
178
|
+
text: item.text,
|
|
179
|
+
ts: item.ts,
|
|
180
|
+
source: item.source || "unknown",
|
|
181
|
+
channel: item.channel || "agent",
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
// Step 1+2: Attempt LLM distillation (may be killed by tsx)
|
|
185
|
+
if (this.runDistillation(transcript, sessionMeta)) {
|
|
186
|
+
// Success — remove the pending file since we distilled in-place
|
|
187
|
+
const pendingPath = resolve(this.memoryDir, "pending-session.json");
|
|
188
|
+
try { unlinkSync(pendingPath); } catch { /* already gone */ }
|
|
189
|
+
}
|
|
190
|
+
// If killed here, pending-session.json remains for next startup
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run the actual distillation pipeline (session_distiller + knowledge_integrator).
|
|
195
|
+
* Returns true if distillation succeeded.
|
|
196
|
+
*/
|
|
197
|
+
private runDistillation(transcript: any[], sessionMeta: { ts: string; sessionKey: string; durationMs: number }): boolean {
|
|
198
|
+
if (!existsSync(resolve(this.scriptsDir, "session_distiller.py"))) {
|
|
199
|
+
warn(TAG, "session_distiller.py not found — skipping distillation");
|
|
200
|
+
this.writeDailyNotesFallback(transcript as any);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
log(TAG, `distilling session: ${transcript.length} items, ${Math.round(sessionMeta.durationMs / 60000)} min`);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Step 1: Distill session into a SessionDigest
|
|
208
|
+
const digestJson = execFileSync("python3", [
|
|
209
|
+
resolve(this.scriptsDir, "session_distiller.py"),
|
|
210
|
+
"--memory-dir", this.memoryDir,
|
|
211
|
+
"--transcript", JSON.stringify(transcript),
|
|
212
|
+
"--session-meta", JSON.stringify(sessionMeta),
|
|
213
|
+
], {
|
|
214
|
+
timeout: 30_000,
|
|
215
|
+
encoding: "utf-8",
|
|
216
|
+
env: { ...process.env, PYTHONPATH: this.scriptsDir },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const digest = JSON.parse(digestJson);
|
|
220
|
+
|
|
221
|
+
if (digest.isEmpty || digest.error) {
|
|
222
|
+
log(TAG, `distillation skipped: ${digest.error || "empty session"}`);
|
|
223
|
+
this.writeDailyNotesFallback(transcript as any);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
log(TAG, `distilled: "${(digest.whatHappened || "").slice(0, 80)}..."`);
|
|
228
|
+
|
|
229
|
+
// Write daily session notes
|
|
230
|
+
this.writeDailyNotes(digest, transcript as any);
|
|
231
|
+
|
|
232
|
+
// Step 2: Integrate into playbook + knowledge graph
|
|
233
|
+
try {
|
|
234
|
+
const integratorOutput = execFileSync("python3", [
|
|
235
|
+
resolve(this.scriptsDir, "knowledge_integrator.py"),
|
|
236
|
+
"--memory-dir", this.memoryDir,
|
|
237
|
+
"--digest", JSON.stringify(digest),
|
|
238
|
+
], {
|
|
239
|
+
timeout: 30_000,
|
|
240
|
+
encoding: "utf-8",
|
|
241
|
+
env: { ...process.env, PYTHONPATH: this.scriptsDir },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = JSON.parse(integratorOutput);
|
|
245
|
+
log(TAG, `knowledge integrated: ${JSON.stringify(result.graphStats || result)}`);
|
|
246
|
+
} catch (err: any) {
|
|
247
|
+
warn(TAG, `knowledge integration failed: ${err.message?.slice(0, 200)}`);
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
} catch (err: any) {
|
|
251
|
+
warn(TAG, `distillation failed: ${err.message?.slice(0, 200)}`);
|
|
252
|
+
this.writeDailyNotesFallback(transcript as any);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Run the periodic curation pipeline (feedback → mining → curation). */
|
|
258
|
+
private runCurationPipeline(): void {
|
|
259
|
+
log(TAG, "running periodic curation...");
|
|
260
|
+
|
|
261
|
+
const scripts = [
|
|
262
|
+
"feedback_analyzer.py",
|
|
263
|
+
"memory_miner.py",
|
|
264
|
+
"playbook_curator.py",
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
for (const script of scripts) {
|
|
268
|
+
const scriptPath = resolve(this.scriptsDir, script);
|
|
269
|
+
if (!existsSync(scriptPath)) {
|
|
270
|
+
warn(TAG, `${script} not found — skipping`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
execFileSync("python3", [
|
|
276
|
+
scriptPath,
|
|
277
|
+
"--memory-dir", this.memoryDir,
|
|
278
|
+
], {
|
|
279
|
+
timeout: 60_000,
|
|
280
|
+
encoding: "utf-8",
|
|
281
|
+
env: { ...process.env, PYTHONPATH: this.scriptsDir },
|
|
282
|
+
});
|
|
283
|
+
log(TAG, ` ✓ ${script}`);
|
|
284
|
+
} catch (err: any) {
|
|
285
|
+
warn(TAG, ` ✗ ${script}: ${err.message?.slice(0, 100)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
log(TAG, "periodic curation complete");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Write distilled session notes to daily file. */
|
|
293
|
+
private writeDailyNotes(digest: any, feedItems: FeedItem[]): void {
|
|
294
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
295
|
+
const notesPath = resolve(this.memoryDir, `${date}.md`);
|
|
296
|
+
|
|
297
|
+
const sections = [
|
|
298
|
+
`## Session ${new Date().toISOString().slice(11, 16)} UTC`,
|
|
299
|
+
"",
|
|
300
|
+
`### Summary`,
|
|
301
|
+
digest.whatHappened || "(no summary)",
|
|
302
|
+
"",
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
if (digest.patterns?.length > 0) {
|
|
306
|
+
sections.push("### Patterns Observed");
|
|
307
|
+
for (const p of digest.patterns) {
|
|
308
|
+
sections.push(`- ${typeof p === "string" ? p : p.pattern || JSON.stringify(p)}`);
|
|
309
|
+
}
|
|
310
|
+
sections.push("");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (digest.entities?.length > 0) {
|
|
314
|
+
sections.push("### Entities");
|
|
315
|
+
sections.push(digest.entities.map((e: string) => `\`${e}\``).join(", "));
|
|
316
|
+
sections.push("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (digest.preferences?.length > 0) {
|
|
320
|
+
sections.push("### User Preferences");
|
|
321
|
+
for (const p of digest.preferences) {
|
|
322
|
+
sections.push(`- ${typeof p === "string" ? p : JSON.stringify(p)}`);
|
|
323
|
+
}
|
|
324
|
+
sections.push("");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Append (don't overwrite — multiple sessions per day)
|
|
328
|
+
const content = sections.join("\n") + "\n---\n\n";
|
|
329
|
+
appendFileSync(notesPath, content, "utf-8");
|
|
330
|
+
log(TAG, `daily notes written: ${notesPath}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Fallback: write raw feed summary when distillation fails. */
|
|
334
|
+
private writeDailyNotesFallback(feedItems: FeedItem[]): void {
|
|
335
|
+
if (feedItems.length < 3) return;
|
|
336
|
+
|
|
337
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
338
|
+
const notesPath = resolve(this.memoryDir, `${date}.md`);
|
|
339
|
+
|
|
340
|
+
const agentItems = feedItems.filter(i => i.source === "openclaw" || i.text.startsWith("[🤖]") || i.text.startsWith("[🔧"));
|
|
341
|
+
const audioItems = feedItems.filter(i => i.text.startsWith("[🔊]"));
|
|
342
|
+
|
|
343
|
+
const sections = [
|
|
344
|
+
`## Session ${new Date().toISOString().slice(11, 16)} UTC (raw — distillation unavailable)`,
|
|
345
|
+
"",
|
|
346
|
+
`${feedItems.length} feed items, ${audioItems.length} audio, ${agentItems.length} agent responses`,
|
|
347
|
+
"",
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
// Include agent responses (most valuable)
|
|
351
|
+
if (agentItems.length > 0) {
|
|
352
|
+
sections.push("### Agent Responses");
|
|
353
|
+
for (const item of agentItems.slice(-10)) {
|
|
354
|
+
sections.push(`- ${item.text.slice(0, 200)}`);
|
|
355
|
+
}
|
|
356
|
+
sections.push("");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Include audio highlights (first 5 non-trivial)
|
|
360
|
+
const meaningfulAudio = audioItems.filter(i => i.text.length > 20 && !i.text.includes("Thank you") && !i.text.includes("Okay"));
|
|
361
|
+
if (meaningfulAudio.length > 0) {
|
|
362
|
+
sections.push("### Audio Highlights");
|
|
363
|
+
for (const item of meaningfulAudio.slice(0, 10)) {
|
|
364
|
+
sections.push(`- ${item.text}`);
|
|
365
|
+
}
|
|
366
|
+
sections.push("");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const content = sections.join("\n") + "\n---\n\n";
|
|
370
|
+
appendFileSync(notesPath, content, "utf-8");
|
|
371
|
+
log(TAG, `daily notes (fallback) written: ${notesPath}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -12,6 +12,142 @@ import { log, error } from "./log.js";
|
|
|
12
12
|
const TAG = "server";
|
|
13
13
|
const MAX_SENSE_BODY = 2 * 1024 * 1024;
|
|
14
14
|
|
|
15
|
+
const KNOWLEDGE_UI_HTML = `<!DOCTYPE html>
|
|
16
|
+
<html><head>
|
|
17
|
+
<meta charset="utf-8"><title>Sinain Knowledge</title>
|
|
18
|
+
<style>
|
|
19
|
+
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; margin: 0; padding: 20px; }
|
|
20
|
+
h1 { color: #00ff88; font-size: 18px; }
|
|
21
|
+
h2 { color: #00cc66; font-size: 14px; margin-top: 20px; }
|
|
22
|
+
.card { background: #16213e; border-radius: 8px; padding: 12px; margin: 8px 0; border-left: 3px solid #00ff88; }
|
|
23
|
+
.card .domain { color: #00ff88; font-size: 11px; text-transform: uppercase; }
|
|
24
|
+
.card .value { margin-top: 4px; }
|
|
25
|
+
.card .meta { color: #888; font-size: 11px; margin-top: 4px; }
|
|
26
|
+
.controls { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
|
|
27
|
+
button { background: #00ff88; color: #1a1a2e; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; }
|
|
28
|
+
button:hover { background: #00cc66; }
|
|
29
|
+
button.secondary { background: #333; color: #ccc; }
|
|
30
|
+
input, select { background: #16213e; color: #e0e0e0; border: 1px solid #333; padding: 8px; border-radius: 4px; }
|
|
31
|
+
#status { color: #00ff88; font-size: 12px; margin: 8px 0; }
|
|
32
|
+
#facts { max-height: 70vh; overflow-y: auto; }
|
|
33
|
+
.import-area { margin: 16px 0; }
|
|
34
|
+
textarea { width: 100%; height: 100px; background: #16213e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 12px; }
|
|
35
|
+
</style>
|
|
36
|
+
</head><body>
|
|
37
|
+
<h1>Sinain Knowledge Graph</h1>
|
|
38
|
+
<div class="controls">
|
|
39
|
+
<input id="search" type="text" placeholder="Search entities..." oninput="filterFacts()">
|
|
40
|
+
<select id="domainFilter" onchange="filterFacts()"><option value="">All domains</option></select>
|
|
41
|
+
<button onclick="loadFacts()">Refresh</button>
|
|
42
|
+
<button onclick="exportKnowledge()" class="secondary">Export</button>
|
|
43
|
+
<button onclick="exportDomain()" class="secondary">Export Domain</button>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="status">Loading...</div>
|
|
46
|
+
<div id="facts"></div>
|
|
47
|
+
|
|
48
|
+
<h2>Import Knowledge</h2>
|
|
49
|
+
<div class="import-area">
|
|
50
|
+
<textarea id="importData" placeholder="Paste exported JSON here, or enter a URL to fetch from another sinain instance..."></textarea>
|
|
51
|
+
<div class="controls">
|
|
52
|
+
<button onclick="importKnowledge()">Import</button>
|
|
53
|
+
<button onclick="importFromUrl()" class="secondary">Import from URL</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
let allFacts = [];
|
|
59
|
+
|
|
60
|
+
async function loadFacts() {
|
|
61
|
+
document.getElementById('status').textContent = 'Loading...';
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch('/knowledge/entities?max=200');
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
allFacts = typeof data.entities === 'string' ? JSON.parse(data.entities) : data.entities;
|
|
66
|
+
const domains = [...new Set(allFacts.map(f => f.domain).filter(Boolean))].sort();
|
|
67
|
+
const sel = document.getElementById('domainFilter');
|
|
68
|
+
sel.innerHTML = '<option value="">All domains (' + allFacts.length + ')</option>' +
|
|
69
|
+
domains.map(d => '<option value="' + d + '">' + d + ' (' + allFacts.filter(f=>f.domain===d).length + ')</option>').join('');
|
|
70
|
+
document.getElementById('status').textContent = allFacts.length + ' entities loaded';
|
|
71
|
+
filterFacts();
|
|
72
|
+
} catch (e) { document.getElementById('status').textContent = 'Error: ' + e.message; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function filterFacts() {
|
|
76
|
+
const q = document.getElementById('search').value.toLowerCase();
|
|
77
|
+
const domain = document.getElementById('domainFilter').value;
|
|
78
|
+
const filtered = allFacts.filter(f => {
|
|
79
|
+
if (domain && f.domain !== domain) return false;
|
|
80
|
+
if (q) {
|
|
81
|
+
const text = JSON.stringify(f).toLowerCase();
|
|
82
|
+
return text.includes(q);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
document.getElementById('facts').innerHTML = filtered.map(f =>
|
|
87
|
+
'<div class="card">' +
|
|
88
|
+
'<span class="domain">' + (f.domain||'general') + '</span>' +
|
|
89
|
+
'<div class="value">' + esc(f.entity || f.entityId || '?') + ': ' + esc(f.value||'') + '</div>' +
|
|
90
|
+
'<div class="meta">confidence: ' + (f.confidence||'?') + ' | confirmed: ' + (f.reinforce_count||1) + 'x | id: ' + esc(f.entityId||'') + '</div>' +
|
|
91
|
+
'</div>'
|
|
92
|
+
).join('');
|
|
93
|
+
document.getElementById('status').textContent = filtered.length + ' of ' + allFacts.length + ' entities';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
97
|
+
|
|
98
|
+
async function exportKnowledge() {
|
|
99
|
+
window.open('/knowledge/export?max=500', '_blank');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function exportDomain() {
|
|
103
|
+
const domain = document.getElementById('domainFilter').value;
|
|
104
|
+
if (!domain) { alert('Select a domain first'); return; }
|
|
105
|
+
window.open('/knowledge/export?domain=' + encodeURIComponent(domain) + '&max=500', '_blank');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function importKnowledge() {
|
|
109
|
+
const el = document.getElementById('status');
|
|
110
|
+
const data = document.getElementById('importData').value.trim();
|
|
111
|
+
if (!data) { el.textContent = 'Error: paste JSON data first'; return; }
|
|
112
|
+
el.textContent = 'Importing...';
|
|
113
|
+
try {
|
|
114
|
+
JSON.parse(data); // validate JSON first
|
|
115
|
+
} catch(e) { el.textContent = 'Error: invalid JSON — ' + e.message; return; }
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch('/knowledge/import', { method: 'POST', body: data });
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
console.log('Import response:', text);
|
|
120
|
+
const result = JSON.parse(text);
|
|
121
|
+
el.textContent = result.ok
|
|
122
|
+
? 'Imported ' + (result.imported||0) + ' facts, skipped ' + (result.skipped||0)
|
|
123
|
+
: 'Error: ' + (result.error||'unknown');
|
|
124
|
+
if (result.ok) { document.getElementById('importData').value = ''; loadFacts(); }
|
|
125
|
+
} catch (e) { el.textContent = 'Import failed: ' + e.message; console.error(e); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function importFromUrl() {
|
|
129
|
+
const el = document.getElementById('status');
|
|
130
|
+
const input = document.getElementById('importData').value.trim();
|
|
131
|
+
if (!input.startsWith('http')) { el.textContent = 'Error: enter a URL starting with http'; return; }
|
|
132
|
+
el.textContent = 'Fetching from ' + input + '...';
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(input);
|
|
135
|
+
if (!res.ok) { el.textContent = 'Fetch failed: HTTP ' + res.status; return; }
|
|
136
|
+
const data = await res.text();
|
|
137
|
+
el.textContent = 'Fetched ' + data.length + ' bytes, importing...';
|
|
138
|
+
const importRes = await fetch('/knowledge/import', { method: 'POST', body: data });
|
|
139
|
+
const result = await importRes.json();
|
|
140
|
+
el.textContent = result.ok
|
|
141
|
+
? 'Imported ' + (result.imported||0) + ' facts from URL, skipped ' + (result.skipped||0)
|
|
142
|
+
: 'Error: ' + (result.error||'unknown');
|
|
143
|
+
if (result.ok) { document.getElementById('importData').value = ''; loadFacts(); }
|
|
144
|
+
} catch (e) { el.textContent = 'Fetch error: ' + e.message + ' (CORS may block cross-origin URLs — use export file instead)'; console.error(e); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
loadFacts();
|
|
148
|
+
</script>
|
|
149
|
+
</body></html>`;
|
|
150
|
+
|
|
15
151
|
/** Server epoch — lets clients detect restarts. */
|
|
16
152
|
const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
17
153
|
|
|
@@ -41,6 +177,9 @@ export interface ServerDeps {
|
|
|
41
177
|
respondEscalation?: (id: string, response: string) => any;
|
|
42
178
|
getKnowledgeDocPath?: () => string | null;
|
|
43
179
|
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
180
|
+
listKnowledgeEntities?: (max: number) => Promise<string>;
|
|
181
|
+
exportKnowledge?: (domain: string | null, max: number) => Promise<string>;
|
|
182
|
+
importKnowledge?: (data: string) => Promise<string>;
|
|
44
183
|
onSpawnCommand?: (text: string) => void;
|
|
45
184
|
getSpawnPending?: () => { id: string; task: string; label: string; ts: number } | null;
|
|
46
185
|
respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
|
|
@@ -272,6 +411,64 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
272
411
|
return;
|
|
273
412
|
}
|
|
274
413
|
|
|
414
|
+
if (req.method === "GET" && url.pathname === "/knowledge/entities") {
|
|
415
|
+
// List all entities in the knowledge graph
|
|
416
|
+
const max = Math.min(parseInt(url.searchParams.get("max") || "50"), 200);
|
|
417
|
+
if (deps.listKnowledgeEntities) {
|
|
418
|
+
try {
|
|
419
|
+
const entities = await deps.listKnowledgeEntities(max);
|
|
420
|
+
res.end(JSON.stringify({ ok: true, entities }));
|
|
421
|
+
} catch (err) {
|
|
422
|
+
res.end(JSON.stringify({ ok: true, entities: [], error: String(err) }));
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
res.end(JSON.stringify({ ok: true, entities: [] }));
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (req.method === "GET" && url.pathname === "/knowledge/export") {
|
|
431
|
+
// Export knowledge module (filterable by domain)
|
|
432
|
+
const domain = url.searchParams.get("domain") || null;
|
|
433
|
+
const max = Math.min(parseInt(url.searchParams.get("max") || "100"), 500);
|
|
434
|
+
if (deps.exportKnowledge) {
|
|
435
|
+
try {
|
|
436
|
+
const data = await deps.exportKnowledge(domain, max);
|
|
437
|
+
res.setHeader("Content-Type", "application/json");
|
|
438
|
+
res.setHeader("Content-Disposition", `attachment; filename="sinain-knowledge-${domain || "all"}.json"`);
|
|
439
|
+
res.end(data);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
res.end(JSON.stringify({ ok: false, error: "export not available" }));
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (req.method === "POST" && url.pathname === "/knowledge/import") {
|
|
450
|
+
// Import knowledge module
|
|
451
|
+
const body = await readBody(req, 1_000_000); // 1MB max
|
|
452
|
+
if (deps.importKnowledge) {
|
|
453
|
+
try {
|
|
454
|
+
const result = await deps.importKnowledge(body);
|
|
455
|
+
res.end(result);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
res.end(JSON.stringify({ ok: false, error: "import not available" }));
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (req.method === "GET" && url.pathname === "/knowledge/ui") {
|
|
466
|
+
// Simple web UI for browsing and transferring knowledge
|
|
467
|
+
res.setHeader("Content-Type", "text/html");
|
|
468
|
+
res.end(KNOWLEDGE_UI_HTML);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
275
472
|
// ── /traces ──
|
|
276
473
|
if (req.method === "GET" && url.pathname === "/traces") {
|
|
277
474
|
const after = parseInt(url.searchParams.get("after") || "0");
|
|
@@ -203,19 +203,30 @@ server.tool(
|
|
|
203
203
|
);
|
|
204
204
|
|
|
205
205
|
// 8. sinain_get_knowledge
|
|
206
|
+
// Queries sinain-core's /knowledge API which merges both local and workspace DBs.
|
|
207
|
+
// Falls back to reading the workspace knowledge doc directly if sinain-core is unreachable.
|
|
206
208
|
server.tool(
|
|
207
209
|
"sinain_get_knowledge",
|
|
208
|
-
"Get the portable knowledge document (playbook + long-term facts
|
|
210
|
+
"Get the portable knowledge document (playbook + long-term facts from both local and workspace databases)",
|
|
209
211
|
{},
|
|
210
212
|
async () => {
|
|
213
|
+
// Try sinain-core API first (merges both DBs)
|
|
214
|
+
try {
|
|
215
|
+
const data = await coreRequest("GET", "/knowledge");
|
|
216
|
+
if (data.ok && data.content) {
|
|
217
|
+
return textResult(stripPrivateTags(data.content));
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// sinain-core unreachable — fall through to local files
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fallback: read workspace files directly
|
|
211
224
|
try {
|
|
212
|
-
// Read pre-rendered knowledge doc (fast, no subprocess)
|
|
213
225
|
const docPath = resolve(MEMORY_DIR, "sinain-knowledge.md");
|
|
214
226
|
if (existsSync(docPath)) {
|
|
215
227
|
const content = readFileSync(docPath, "utf-8");
|
|
216
228
|
return textResult(stripPrivateTags(content));
|
|
217
229
|
}
|
|
218
|
-
// Fallback: read playbook directly
|
|
219
230
|
const playbookPath = resolve(MEMORY_DIR, "sinain-playbook.md");
|
|
220
231
|
if (existsSync(playbookPath)) {
|
|
221
232
|
return textResult(stripPrivateTags(readFileSync(playbookPath, "utf-8")));
|
|
@@ -228,14 +239,33 @@ server.tool(
|
|
|
228
239
|
);
|
|
229
240
|
|
|
230
241
|
// 8b. sinain_knowledge_query (graph query — entity-based lookup)
|
|
242
|
+
// Queries sinain-core's /knowledge/facts API which merges both local and workspace DBs.
|
|
243
|
+
// Falls back to local graph_query.py (workspace DB only) if sinain-core is unreachable.
|
|
231
244
|
server.tool(
|
|
232
245
|
"sinain_knowledge_query",
|
|
233
|
-
"Query the knowledge graph for facts about specific entities/domains",
|
|
246
|
+
"Query the knowledge graph for facts about specific entities/domains (searches both local and workspace databases)",
|
|
234
247
|
{
|
|
235
248
|
entities: z.array(z.string()).optional().default([]),
|
|
236
249
|
max_facts: z.number().optional().default(5),
|
|
237
250
|
},
|
|
238
251
|
async ({ entities, max_facts }) => {
|
|
252
|
+
// Try sinain-core API first (merges both local + workspace DBs)
|
|
253
|
+
if (entities.length > 0) {
|
|
254
|
+
try {
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
entities: entities.join(","),
|
|
257
|
+
max: String(max_facts),
|
|
258
|
+
});
|
|
259
|
+
const data = await coreRequest("GET", `/knowledge/facts?${params}`);
|
|
260
|
+
if (data.ok && data.facts) {
|
|
261
|
+
return textResult(stripPrivateTags(data.facts));
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// sinain-core unreachable — fall through to local script
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Fallback: query workspace DB directly via graph_query.py
|
|
239
269
|
try {
|
|
240
270
|
const dbPath = resolve(MEMORY_DIR, "knowledge-graph.db");
|
|
241
271
|
const scriptPath = resolve(SCRIPTS_DIR, "graph_query.py");
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{"query": "OCR pipeline stalls on macOS 14", "expected_entities": ["fact:ocr-backpressure", "fact:sck-capture"], "category": "error-resolution"}
|
|
2
|
+
{"query": "camera conflicts with screen capture", "expected_entities": ["fact:camera-conflict", "fact:coremediaio"], "category": "error-resolution"}
|
|
3
|
+
{"query": "audio gain not applied in pipeline", "expected_entities": ["fact:audio-gain"], "category": "bug-fix"}
|
|
4
|
+
{"query": "Flutter ProviderNotFoundException in secondary window", "expected_entities": ["fact:flutter-provider", "fact:multi-window"], "category": "error-resolution"}
|
|
5
|
+
{"query": "user prefers concise Telegram messages", "expected_entities": ["fact:telegram-preference"], "category": "user-preference"}
|
|
6
|
+
{"query": "PyObjC performRequests_error_ returns bool not tuple", "expected_entities": ["fact:pyobjc-api"], "category": "bug-fix"}
|
|
7
|
+
{"query": "ScreenCaptureKit zero-copy IOSurface", "expected_entities": ["fact:sck-capture", "fact:iosurface"], "category": "tool-knowledge"}
|
|
8
|
+
{"query": "OpenClaw gateway workspace not initialized", "expected_entities": ["fact:workspace-init"], "category": "error-resolution"}
|
|
9
|
+
{"query": "react-native metro bundler cache invalidation", "expected_entities": ["fact:react-native-metro"], "category": "tool-knowledge"}
|
|
10
|
+
{"query": "sinain agent session key format", "expected_entities": ["fact:session-key"], "category": "tool-knowledge"}
|
|
11
|
+
{"query": "what was the OCR backend last month", "expected_entities": ["fact:ocr-backend"], "category": "temporal"}
|
|
12
|
+
{"query": "when did we switch from CGDisplayCreateImage to ScreenCaptureKit", "expected_entities": ["fact:sck-capture", "fact:cgdisplay-deprecation"], "category": "temporal"}
|