@geravant/sinain 1.0.18 → 1.0.19

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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * sinain-knowledge — CurationEngine
3
+ *
4
+ * Orchestrates heartbeat tick execution, curation pipeline, and context assembly.
5
+ * Decoupled from OpenClaw — uses KnowledgeStore for file I/O and ScriptRunner for
6
+ * external process execution.
7
+ */
8
+
9
+ import type { Logger, ScriptRunner } from "../data/schema.js";
10
+ import type { KnowledgeStore } from "../data/store.js";
11
+ import type { ResilienceManager } from "./resilience.js";
12
+ import type { GitSnapshotStore } from "../data/git-store.js";
13
+
14
+ // ============================================================================
15
+ // Types
16
+ // ============================================================================
17
+
18
+ export type HeartbeatResult = {
19
+ status: string;
20
+ gitBackup: string | null;
21
+ signals: unknown[];
22
+ recommendedAction: { action: string; task: string | null; confidence: number };
23
+ output: unknown | null;
24
+ skipped: boolean;
25
+ skipReason: string | null;
26
+ logWritten: boolean;
27
+ [key: string]: unknown;
28
+ };
29
+
30
+ export type ContextAssemblyOpts = {
31
+ isSubagent: boolean;
32
+ parentContextText?: string | null;
33
+ parentContextAgeMs?: number;
34
+ parentContextTtlMs: number;
35
+ heartbeatConfigured: boolean;
36
+ heartbeatTargetExists: boolean;
37
+ };
38
+
39
+ // ============================================================================
40
+ // CurationEngine
41
+ // ============================================================================
42
+
43
+ export class CurationEngine {
44
+ private curationInterval: ReturnType<typeof setInterval> | null = null;
45
+ private gitSnapshotStore: GitSnapshotStore | null = null;
46
+
47
+ constructor(
48
+ private store: KnowledgeStore,
49
+ private runScript: ScriptRunner,
50
+ private resilience: ResilienceManager,
51
+ private config: { userTimezone: string },
52
+ private logger: Logger,
53
+ ) {}
54
+
55
+ /** Attach a git-backed snapshot store for periodic saves. */
56
+ setGitSnapshotStore(gitStore: GitSnapshotStore): void {
57
+ this.gitSnapshotStore = gitStore;
58
+ }
59
+
60
+ // ── Heartbeat Tick ──────────────────────────────────────────────────────
61
+
62
+ async executeHeartbeatTick(params: {
63
+ sessionSummary: string;
64
+ idle: boolean;
65
+ }): Promise<HeartbeatResult> {
66
+ const workspaceDir = this.store.getWorkspaceDir();
67
+ const result: HeartbeatResult = {
68
+ status: "ok",
69
+ gitBackup: null,
70
+ signals: [],
71
+ recommendedAction: { action: "skip", task: null, confidence: 0 },
72
+ output: null,
73
+ skipped: false,
74
+ skipReason: null,
75
+ logWritten: false,
76
+ };
77
+
78
+ const runPythonScript = async (
79
+ args: string[],
80
+ timeoutMs = 60_000,
81
+ ): Promise<Record<string, unknown> | null> => {
82
+ try {
83
+ const out = await this.runScript(
84
+ ["uv", "run", "--with", "requests", "python3", ...args],
85
+ { timeoutMs, cwd: workspaceDir },
86
+ );
87
+ if (out.code !== 0) {
88
+ this.logger.warn(
89
+ `sinain-hud: heartbeat script failed: ${args[0]} (code ${out.code})\n${out.stderr}`,
90
+ );
91
+ return null;
92
+ }
93
+ return JSON.parse(out.stdout.trim());
94
+ } catch (err) {
95
+ this.logger.warn(
96
+ `sinain-hud: heartbeat script error: ${args[0]}: ${String(err)}`,
97
+ );
98
+ return null;
99
+ }
100
+ };
101
+
102
+ const latencyMs: Record<string, number> = {};
103
+ const heartbeatStart = Date.now();
104
+
105
+ // 1. Git backup (30s timeout)
106
+ try {
107
+ const t0 = Date.now();
108
+ const gitOut = await this.runScript(
109
+ ["bash", "sinain-memory/git_backup.sh"],
110
+ { timeoutMs: 30_000, cwd: workspaceDir },
111
+ );
112
+ latencyMs.gitBackup = Date.now() - t0;
113
+ result.gitBackup = gitOut.stdout.trim() || "nothing to commit";
114
+ } catch (err) {
115
+ this.logger.warn(`sinain-hud: git backup error: ${String(err)}`);
116
+ result.gitBackup = `error: ${String(err)}`;
117
+ }
118
+
119
+ // Current time string for memory scripts
120
+ const hbTz = this.config.userTimezone;
121
+ const currentTimeStr = new Date().toLocaleString("en-GB", {
122
+ timeZone: hbTz, weekday: "long", year: "numeric", month: "long",
123
+ day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false,
124
+ }) + ` (${hbTz})`;
125
+
126
+ // 2. Signal analysis (60s timeout)
127
+ const signalArgs = [
128
+ "sinain-memory/signal_analyzer.py",
129
+ "--memory-dir", "memory/",
130
+ "--session-summary", params.sessionSummary,
131
+ "--current-time", currentTimeStr,
132
+ ];
133
+ if (params.idle) signalArgs.push("--idle");
134
+
135
+ const signalT0 = Date.now();
136
+ const signalResult = await runPythonScript(signalArgs, 60_000);
137
+ latencyMs.signalAnalysis = Date.now() - signalT0;
138
+ if (signalResult) {
139
+ result.signals = signalResult.signals as unknown[] ?? [];
140
+ result.recommendedAction = (signalResult.recommendedAction as HeartbeatResult["recommendedAction"]) ?? {
141
+ action: "skip",
142
+ task: null,
143
+ confidence: 0,
144
+ };
145
+
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(() => {});
155
+ }
156
+
157
+ // 3. Insight synthesis (60s timeout)
158
+ const synthArgs = [
159
+ "sinain-memory/insight_synthesizer.py",
160
+ "--memory-dir", "memory/",
161
+ "--session-summary", params.sessionSummary,
162
+ "--current-time", currentTimeStr,
163
+ ];
164
+ if (params.idle) synthArgs.push("--idle");
165
+
166
+ const synthT0 = Date.now();
167
+ const synthResult = await runPythonScript(synthArgs, 60_000);
168
+ latencyMs.insightSynthesis = Date.now() - synthT0;
169
+ if (synthResult) {
170
+ if (synthResult.skip === false) {
171
+ result.output = {
172
+ suggestion: synthResult.suggestion ?? null,
173
+ insight: synthResult.insight ?? null,
174
+ };
175
+ } else {
176
+ result.skipped = true;
177
+ result.skipReason = (synthResult.skipReason as string) ?? "synthesizer skipped";
178
+ }
179
+ }
180
+
181
+ // 4. Write log entry
182
+ try {
183
+ const totalLatencyMs = Date.now() - heartbeatStart;
184
+ const logEntry = {
185
+ ts: new Date().toISOString(),
186
+ idle: params.idle,
187
+ sessionHistorySummary: params.sessionSummary,
188
+ signals: result.signals,
189
+ recommendedAction: result.recommendedAction,
190
+ output: result.output,
191
+ skipped: result.skipped,
192
+ skipReason: result.skipReason,
193
+ gitBackup: result.gitBackup,
194
+ latencyMs,
195
+ totalLatencyMs,
196
+ };
197
+
198
+ this.store.appendPlaybookLog(logEntry);
199
+ result.logWritten = true;
200
+ } catch (err) {
201
+ this.logger.warn(
202
+ `sinain-hud: failed to write heartbeat log: ${String(err)}`,
203
+ );
204
+ }
205
+
206
+ return result;
207
+ }
208
+
209
+ // ── Curation Pipeline ──────────────────────────────────────────────────
210
+
211
+ async runCurationPipeline(): Promise<void> {
212
+ const workspaceDir = this.store.getWorkspaceDir();
213
+
214
+ const runPythonScript = async (
215
+ args: string[],
216
+ timeoutMs = 90_000,
217
+ ): Promise<Record<string, unknown> | null> => {
218
+ try {
219
+ const out = await this.runScript(
220
+ ["uv", "run", "--with", "requests", "python3", ...args],
221
+ { timeoutMs, cwd: workspaceDir },
222
+ );
223
+ if (out.code !== 0) {
224
+ this.logger.warn(
225
+ `sinain-hud: curation script failed: ${args[0]} (code ${out.code})\n${out.stderr}`,
226
+ );
227
+ return null;
228
+ }
229
+ return JSON.parse(out.stdout.trim());
230
+ } catch (err) {
231
+ this.logger.warn(
232
+ `sinain-hud: curation script error: ${args[0]}: ${String(err)}`,
233
+ );
234
+ return null;
235
+ }
236
+ };
237
+
238
+ this.logger.info("sinain-hud: curation pipeline starting");
239
+ const curationLatency: Record<string, number> = {};
240
+
241
+ // Step 1: Feedback analysis
242
+ const feedbackT0 = Date.now();
243
+ const feedback = await runPythonScript([
244
+ "sinain-memory/feedback_analyzer.py",
245
+ "--memory-dir", "memory/",
246
+ "--session-summary", "periodic curation (plugin timer)",
247
+ ]);
248
+ curationLatency.feedback = Date.now() - feedbackT0;
249
+ const directive = (feedback as Record<string, unknown> | null)?.curateDirective as string ?? "stability";
250
+
251
+ // Step 2: Memory mining
252
+ const miningT0 = Date.now();
253
+ const mining = await runPythonScript([
254
+ "sinain-memory/memory_miner.py",
255
+ "--memory-dir", "memory/",
256
+ ]);
257
+ curationLatency.mining = Date.now() - miningT0;
258
+ const findings = mining?.findings ? JSON.stringify(mining.findings) : null;
259
+
260
+ // Fire-and-forget: ingest mining results
261
+ if (mining) {
262
+ runPythonScript([
263
+ "sinain-memory/triple_ingest.py",
264
+ "--memory-dir", "memory/",
265
+ "--ingest-mining", JSON.stringify(mining),
266
+ "--embed",
267
+ ], 15_000).catch(() => {});
268
+ }
269
+
270
+ // Step 3: Playbook curation
271
+ const curatorArgs = [
272
+ "sinain-memory/playbook_curator.py",
273
+ "--memory-dir", "memory/",
274
+ "--session-summary", "periodic curation (plugin timer)",
275
+ "--curate-directive", directive,
276
+ ];
277
+ if (findings) {
278
+ curatorArgs.push("--mining-findings", findings);
279
+ }
280
+ const curatorT0 = Date.now();
281
+ const curator = await runPythonScript(curatorArgs);
282
+ curationLatency.curation = Date.now() - curatorT0;
283
+
284
+ // Fire-and-forget: ingest playbook patterns
285
+ runPythonScript([
286
+ "sinain-memory/triple_ingest.py",
287
+ "--memory-dir", "memory/",
288
+ "--ingest-playbook",
289
+ "--embed",
290
+ ], 15_000).catch(() => {});
291
+
292
+ // Step 4: Update effectiveness footer
293
+ const effectiveness = (feedback as Record<string, unknown> | null)?.effectiveness;
294
+ if (effectiveness && typeof effectiveness === "object") {
295
+ try {
296
+ this.store.updateEffectivenessFooter(effectiveness as Record<string, unknown>);
297
+ } catch (err) {
298
+ this.logger.warn(`sinain-hud: effectiveness footer update failed: ${String(err)}`);
299
+ }
300
+ }
301
+
302
+ // Step 5: Regenerate effective playbook
303
+ this.store.generateEffectivePlaybook();
304
+
305
+ // Step 6: Tick evaluation
306
+ await runPythonScript([
307
+ "sinain-memory/tick_evaluator.py",
308
+ "--memory-dir", "memory/",
309
+ ], 120_000);
310
+
311
+ // Step 7: Daily eval report (once per day after 03:00 UTC)
312
+ const nowUTC = new Date();
313
+ const todayStr = nowUTC.toISOString().slice(0, 10);
314
+ if (nowUTC.getUTCHours() >= 3 && this.resilience.lastEvalReportDate !== todayStr) {
315
+ await runPythonScript([
316
+ "sinain-memory/eval_reporter.py",
317
+ "--memory-dir", "memory/",
318
+ ], 120_000);
319
+ this.resilience.lastEvalReportDate = todayStr;
320
+ }
321
+
322
+ // Step 8: Periodic snapshot save to local git repo
323
+ if (this.gitSnapshotStore) {
324
+ try {
325
+ const snapT0 = Date.now();
326
+ const hash = await this.gitSnapshotStore.save(this.store);
327
+ curationLatency.snapshotSave = Date.now() - snapT0;
328
+ this.logger.info(`sinain-hud: periodic snapshot saved → ${hash}`);
329
+ await this.gitSnapshotStore.prune();
330
+ } catch (err) {
331
+ this.logger.warn(`sinain-hud: periodic snapshot save failed: ${String(err)}`);
332
+ }
333
+ }
334
+
335
+ // Log result
336
+ const changes = (curator as Record<string, unknown> | null)?.changes ?? "unknown";
337
+ this.logger.info(
338
+ `sinain-hud: curation pipeline complete (directive=${directive}, changes=${JSON.stringify(changes)}, latency=${JSON.stringify(curationLatency)})`,
339
+ );
340
+
341
+ // Write curation log
342
+ if (curator) {
343
+ try {
344
+ const curatorChanges = (curator as Record<string, unknown>).changes as Record<string, string[]> | undefined;
345
+ const curationEntry = {
346
+ _type: "curation",
347
+ ts: new Date().toISOString(),
348
+ directive,
349
+ playbookChanges: {
350
+ added: curatorChanges?.added ?? [],
351
+ pruned: curatorChanges?.pruned ?? [],
352
+ promoted: curatorChanges?.promoted ?? [],
353
+ playbookLines: (curator as Record<string, unknown>).playbookLines ?? 0,
354
+ },
355
+ latencyMs: curationLatency,
356
+ };
357
+ this.store.appendCurationLog(curationEntry);
358
+ } catch (err) {
359
+ this.logger.warn(`sinain-hud: failed to write curation log entry: ${String(err)}`);
360
+ }
361
+ }
362
+ }
363
+
364
+ // ── Context Assembly ───────────────────────────────────────────────────
365
+
366
+ async assembleContext(opts: ContextAssemblyOpts): Promise<string[]> {
367
+ const workspaceDir = this.store.getWorkspaceDir();
368
+ const contextParts: string[] = [];
369
+
370
+ // Time awareness
371
+ const userTz = this.config.userTimezone;
372
+ const nowLocal = new Date().toLocaleString("en-GB", {
373
+ timeZone: userTz,
374
+ weekday: "long",
375
+ year: "numeric",
376
+ month: "long",
377
+ day: "numeric",
378
+ hour: "2-digit",
379
+ minute: "2-digit",
380
+ hour12: false,
381
+ });
382
+ contextParts.push(`[CURRENT TIME] ${nowLocal} (${userTz})`);
383
+
384
+ // Recovery context injection after outage
385
+ if (this.resilience.outageStartTs > 0 && !this.resilience.outageDetected && this.resilience.lastSuccessTs > this.resilience.outageStartTs) {
386
+ const outageDurationMin = Math.round((this.resilience.lastSuccessTs - this.resilience.outageStartTs) / 60_000);
387
+ this.resilience.outageStartTs = 0;
388
+ this.logger.info(`sinain-hud: injecting recovery context (outage lasted ~${outageDurationMin}min)`);
389
+ contextParts.push(
390
+ `[SYSTEM] The upstream API was unavailable for ~${outageDurationMin} minutes. ` +
391
+ `Multiple queued messages may have accumulated. Prioritize the current task, skip catch-up on stale items, and keep responses concise.`,
392
+ );
393
+ }
394
+
395
+ // Subagent: inject cached parent context
396
+ if (opts.isSubagent && opts.parentContextText) {
397
+ const cacheAgeMs = opts.parentContextAgeMs ?? 0;
398
+ if (cacheAgeMs < opts.parentContextTtlMs) {
399
+ const cacheAgeSec = Math.round(cacheAgeMs / 1000);
400
+ this.logger.info(
401
+ `sinain-hud: injected parent context for subagent (${opts.parentContextText.length} chars, ${cacheAgeSec}s old)`,
402
+ );
403
+ contextParts.push(
404
+ `[PARENT SESSION CONTEXT] The following is a summary of the recent conversation from the parent session that spawned you. Use it to understand references to code, files, or decisions discussed earlier:\n\n${opts.parentContextText}`,
405
+ );
406
+ } else {
407
+ this.logger.info(
408
+ `sinain-hud: skipped stale parent context for subagent (${Math.round(cacheAgeMs / 1000)}s old, TTL=${opts.parentContextTtlMs / 1000}s)`,
409
+ );
410
+ }
411
+ }
412
+
413
+ // Heartbeat enforcement
414
+ if (opts.heartbeatConfigured && opts.heartbeatTargetExists) {
415
+ contextParts.push(
416
+ "[HEARTBEAT PROTOCOL] HEARTBEAT.md is loaded in your project context. " +
417
+ "On every heartbeat poll, you MUST execute the full protocol defined in " +
418
+ "HEARTBEAT.md — all phases, all steps, in order. " +
419
+ "Only reply HEARTBEAT_OK if HEARTBEAT.md explicitly permits it " +
420
+ "after you have completed all mandatory steps."
421
+ );
422
+ }
423
+
424
+ // SITUATION.md
425
+ const situationContent = this.store.readSituation();
426
+ if (situationContent) contextParts.push(`[SITUATION]\n${situationContent}`);
427
+
428
+ // Knowledge transfer attribution
429
+ const effectiveContent = this.store.readEffectivePlaybook();
430
+ if (effectiveContent?.includes("[Transferred knowledge:")) {
431
+ contextParts.push(
432
+ "[KNOWLEDGE TRANSFER] Some patterns in your playbook were transferred from " +
433
+ "another sinain instance. When surfacing these, briefly cite their origin."
434
+ );
435
+ }
436
+
437
+ // Module guidance
438
+ const moduleGuidance = this.store.getActiveModuleGuidance();
439
+ if (moduleGuidance) contextParts.push(moduleGuidance);
440
+
441
+ // Knowledge graph context (10s timeout)
442
+ 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}`);
455
+ }
456
+ }
457
+ } catch {}
458
+
459
+ return contextParts;
460
+ }
461
+
462
+ // ── Service lifecycle ──────────────────────────────────────────────────
463
+
464
+ startCurationTimer(getOutageDetected: () => boolean, getWorkspaceDir: () => string | null): void {
465
+ this.curationInterval = setInterval(async () => {
466
+ if (getOutageDetected()) {
467
+ this.logger.info("sinain-hud: curation skipped — outage active");
468
+ return;
469
+ }
470
+
471
+ const wDir = getWorkspaceDir();
472
+ if (!wDir) {
473
+ this.logger.info("sinain-hud: curation skipped — no workspace dir");
474
+ return;
475
+ }
476
+
477
+ this.store.setWorkspaceDir(wDir);
478
+
479
+ try {
480
+ await this.runCurationPipeline();
481
+ } catch (err) {
482
+ this.logger.warn(`sinain-hud: curation pipeline error: ${String(err)}`);
483
+ }
484
+ }, 30 * 60 * 1000);
485
+ }
486
+
487
+ stopCurationTimer(): void {
488
+ if (this.curationInterval) {
489
+ clearInterval(this.curationInterval);
490
+ this.curationInterval = null;
491
+ }
492
+ }
493
+ }