abmind 0.2.5 → 0.2.7-alpha.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/SUPPORTED-SURFACE.md +20 -2
- package/dist/cli/abmind-hook-recall.js +17 -40
- package/dist/cli/abmind-hook-recall.js.map +1 -1
- package/dist/cli/abmind-hook-store.js +10 -31
- package/dist/cli/abmind-hook-store.js.map +1 -1
- package/dist/cli/abmind-hook-wakeup.js +17 -3
- package/dist/cli/abmind-hook-wakeup.js.map +1 -1
- package/dist/cli/abmind-install.js +19 -19
- package/dist/cli/abmind-install.js.map +1 -1
- package/dist/cli/abmind-restore.js +10 -0
- package/dist/cli/abmind-restore.js.map +1 -1
- package/dist/cli/abmind-sleep.d.ts +9 -5
- package/dist/cli/abmind-sleep.d.ts.map +1 -1
- package/dist/cli/abmind-sleep.js +61 -14
- package/dist/cli/abmind-sleep.js.map +1 -1
- package/dist/cli/hook-lifecycle-adapter.d.ts +11 -0
- package/dist/cli/hook-lifecycle-adapter.d.ts.map +1 -0
- package/dist/cli/hook-lifecycle-adapter.js +33 -0
- package/dist/cli/hook-lifecycle-adapter.js.map +1 -0
- package/dist/src/context-engine.d.ts +13 -3
- package/dist/src/context-engine.d.ts.map +1 -1
- package/dist/src/context-engine.js +22 -5
- package/dist/src/context-engine.js.map +1 -1
- package/dist/src/context-orchestrator.d.ts +20 -2
- package/dist/src/context-orchestrator.d.ts.map +1 -1
- package/dist/src/context-orchestrator.js +11 -3
- package/dist/src/context-orchestrator.js.map +1 -1
- package/dist/src/context-tier-renderer.d.ts +8 -1
- package/dist/src/context-tier-renderer.d.ts.map +1 -1
- package/dist/src/context-tier-renderer.js +7 -2
- package/dist/src/context-tier-renderer.js.map +1 -1
- package/dist/src/ensure-initialized.d.ts +5 -0
- package/dist/src/ensure-initialized.d.ts.map +1 -1
- package/dist/src/ensure-initialized.js +19 -28
- package/dist/src/ensure-initialized.js.map +1 -1
- package/dist/src/fts-utils.d.ts +47 -0
- package/dist/src/fts-utils.d.ts.map +1 -0
- package/dist/src/fts-utils.js +65 -0
- package/dist/src/fts-utils.js.map +1 -0
- package/dist/src/host-integration/identity.d.ts +10 -0
- package/dist/src/host-integration/identity.d.ts.map +1 -0
- package/dist/src/host-integration/identity.js +66 -0
- package/dist/src/host-integration/identity.js.map +1 -0
- package/dist/src/host-integration/index.d.ts +5 -0
- package/dist/src/host-integration/index.d.ts.map +1 -0
- package/dist/src/host-integration/index.js +4 -0
- package/dist/src/host-integration/index.js.map +1 -0
- package/dist/src/host-integration/lifecycle.d.ts +14 -0
- package/dist/src/host-integration/lifecycle.d.ts.map +1 -0
- package/dist/src/host-integration/lifecycle.js +192 -0
- package/dist/src/host-integration/lifecycle.js.map +1 -0
- package/dist/src/host-integration/render.d.ts +5 -0
- package/dist/src/host-integration/render.d.ts.map +1 -0
- package/dist/src/host-integration/render.js +20 -0
- package/dist/src/host-integration/render.js.map +1 -0
- package/dist/src/host-integration/types.d.ts +107 -0
- package/dist/src/host-integration/types.d.ts.map +1 -0
- package/dist/src/host-integration/types.js +2 -0
- package/dist/src/host-integration/types.js.map +1 -0
- package/dist/src/imemory-system.d.ts +1 -1
- package/dist/src/imemory-system.d.ts.map +1 -1
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/maintenance-service.d.ts +11 -0
- package/dist/src/maintenance-service.d.ts.map +1 -1
- package/dist/src/maintenance-service.js +43 -0
- package/dist/src/maintenance-service.js.map +1 -1
- package/dist/src/memory-index.d.ts +0 -16
- package/dist/src/memory-index.d.ts.map +1 -1
- package/dist/src/memory-index.js +1 -60
- package/dist/src/memory-index.js.map +1 -1
- package/dist/src/memory-manager.d.ts +6 -2
- package/dist/src/memory-manager.d.ts.map +1 -1
- package/dist/src/memory-manager.js +9 -5
- package/dist/src/memory-manager.js.map +1 -1
- package/dist/src/message-store.d.ts +15 -3
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +28 -9
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/openclaw-plugin/index.d.ts.map +1 -1
- package/dist/src/openclaw-plugin/index.js +3 -2
- package/dist/src/openclaw-plugin/index.js.map +1 -1
- package/dist/src/pi-plugin/abtars-client.d.ts +22 -0
- package/dist/src/pi-plugin/abtars-client.d.ts.map +1 -0
- package/dist/src/pi-plugin/abtars-client.js +153 -0
- package/dist/src/pi-plugin/abtars-client.js.map +1 -0
- package/dist/src/pi-plugin/abtars-tools.d.ts +8 -0
- package/dist/src/pi-plugin/abtars-tools.d.ts.map +1 -0
- package/dist/src/pi-plugin/abtars-tools.js +317 -0
- package/dist/src/pi-plugin/abtars-tools.js.map +1 -0
- package/dist/src/pi-plugin/identity.d.ts +8 -0
- package/dist/src/pi-plugin/identity.d.ts.map +1 -0
- package/dist/src/pi-plugin/identity.js +37 -0
- package/dist/src/pi-plugin/identity.js.map +1 -0
- package/dist/src/pi-plugin/index.d.ts +3 -0
- package/dist/src/pi-plugin/index.d.ts.map +1 -0
- package/dist/src/pi-plugin/index.js +139 -0
- package/dist/src/pi-plugin/index.js.map +1 -0
- package/dist/src/pi-plugin/messages.d.ts +4 -0
- package/dist/src/pi-plugin/messages.d.ts.map +1 -0
- package/dist/src/pi-plugin/messages.js +36 -0
- package/dist/src/pi-plugin/messages.js.map +1 -0
- package/dist/src/pi-plugin/pi-types.d.ts +126 -0
- package/dist/src/pi-plugin/pi-types.d.ts.map +1 -0
- package/dist/src/pi-plugin/pi-types.js +15 -0
- package/dist/src/pi-plugin/pi-types.js.map +1 -0
- package/dist/src/pi-plugin/runtime.d.ts +21 -0
- package/dist/src/pi-plugin/runtime.d.ts.map +1 -0
- package/dist/src/pi-plugin/runtime.js +60 -0
- package/dist/src/pi-plugin/runtime.js.map +1 -0
- package/dist/src/pi-plugin/tools.d.ts +11 -0
- package/dist/src/pi-plugin/tools.d.ts.map +1 -0
- package/dist/src/pi-plugin/tools.js +136 -0
- package/dist/src/pi-plugin/tools.js.map +1 -0
- package/dist/src/recall-boosts.d.ts +15 -0
- package/dist/src/recall-boosts.d.ts.map +1 -0
- package/dist/src/recall-boosts.js +159 -0
- package/dist/src/recall-boosts.js.map +1 -0
- package/dist/src/recall-engine.d.ts +0 -2
- package/dist/src/recall-engine.d.ts.map +1 -1
- package/dist/src/recall-engine.js +1 -151
- package/dist/src/recall-engine.js.map +1 -1
- package/dist/src/reconcile.d.ts +2 -0
- package/dist/src/reconcile.d.ts.map +1 -1
- package/dist/src/reconcile.js +21 -4
- package/dist/src/reconcile.js.map +1 -1
- package/dist/src/session-context.d.ts +1 -0
- package/dist/src/session-context.d.ts.map +1 -1
- package/dist/src/session-context.js +42 -23
- package/dist/src/session-context.js.map +1 -1
- package/dist/src/sleep/audit.d.ts +6 -0
- package/dist/src/sleep/audit.d.ts.map +1 -1
- package/dist/src/sleep/audit.js +6 -8
- package/dist/src/sleep/audit.js.map +1 -1
- package/dist/src/sleep/basic.d.ts +1 -1
- package/dist/src/sleep/basic.d.ts.map +1 -1
- package/dist/src/sleep/basic.js +5 -2
- package/dist/src/sleep/basic.js.map +1 -1
- package/dist/src/sleep/catchup.d.ts +17 -0
- package/dist/src/sleep/catchup.d.ts.map +1 -0
- package/dist/src/sleep/catchup.js +141 -0
- package/dist/src/sleep/catchup.js.map +1 -0
- package/dist/src/sleep/contracts.d.ts +112 -0
- package/dist/src/sleep/contracts.d.ts.map +1 -0
- package/dist/src/sleep/contracts.js +25 -0
- package/dist/src/sleep/contracts.js.map +1 -0
- package/dist/src/sleep/llm-budget.d.ts +49 -0
- package/dist/src/sleep/llm-budget.d.ts.map +1 -0
- package/dist/src/sleep/llm-budget.js +106 -0
- package/dist/src/sleep/llm-budget.js.map +1 -0
- package/dist/src/sleep/locks.d.ts +9 -5
- package/dist/src/sleep/locks.d.ts.map +1 -1
- package/dist/src/sleep/locks.js +8 -11
- package/dist/src/sleep/locks.js.map +1 -1
- package/dist/src/sleep/orchestrator.d.ts +20 -86
- package/dist/src/sleep/orchestrator.d.ts.map +1 -1
- package/dist/src/sleep/orchestrator.js +291 -755
- package/dist/src/sleep/orchestrator.js.map +1 -1
- package/dist/src/sleep/state.d.ts +15 -18
- package/dist/src/sleep/state.d.ts.map +1 -1
- package/dist/src/sleep/state.js +24 -19
- package/dist/src/sleep/state.js.map +1 -1
- package/dist/src/sleep/test-harness.d.ts +3 -1
- package/dist/src/sleep/test-harness.d.ts.map +1 -1
- package/dist/src/sleep/test-harness.js +3 -2
- package/dist/src/sleep/test-harness.js.map +1 -1
- package/dist/src/soul-seeder.d.ts +36 -0
- package/dist/src/soul-seeder.d.ts.map +1 -0
- package/dist/src/soul-seeder.js +69 -0
- package/dist/src/soul-seeder.js.map +1 -0
- package/dist/src/wake-up-builder.d.ts +3 -1
- package/dist/src/wake-up-builder.d.ts.map +1 -1
- package/dist/src/wake-up-builder.js +29 -5
- package/dist/src/wake-up-builder.js.map +1 -1
- package/package.json +20 -2
|
@@ -1,585 +1,109 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* abmind sleep orchestrator —
|
|
3
|
+
* abmind sleep orchestrator — host-neutral memory-maintenance engine (#1353).
|
|
4
4
|
*
|
|
5
|
-
* Called via runSleepCycle(
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Called via runSleepCycle(options). Gathers system state, runs through a
|
|
6
|
+
* pipeline of prompt-driven steps (gc-noise, daily-summary, extract-memories,
|
|
7
|
+
* retrospective, retro-derive, etc.), persists audit log, returns a structured
|
|
8
|
+
* SleepRunResult.
|
|
9
|
+
*
|
|
10
|
+
* abmind owns: step ordering, shared variables/outputs, wired memory
|
|
11
|
+
* maintenance, essential-step/continuation rules, LLM-call budget, durable
|
|
12
|
+
* checkpoints/resume/catch-up/watermark, and the final domain result.
|
|
13
|
+
*
|
|
14
|
+
* The embedding host owns: scheduling, admission, model/provider transport,
|
|
15
|
+
* agent/session lifecycle, cancellation on shutdown, and delivery. The host
|
|
16
|
+
* never reads sleep_*.lock — SleepRunResult is the only supported way to
|
|
17
|
+
* learn what happened.
|
|
8
18
|
*
|
|
9
19
|
* Library-only — no CLI entry point here. Standalone entry lives in
|
|
10
20
|
* cli/abmind-sleep.ts.
|
|
11
|
-
*
|
|
12
|
-
* Flags (passed via RunOpts.flags):
|
|
13
|
-
* --dry-run Gather state + build prompts, print to stdout, skip LLM calls
|
|
14
|
-
* --verbose Detailed logging at each orchestration step
|
|
15
|
-
* --force Run housekeeping even if no messages since last sleep
|
|
16
21
|
*/
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
17
23
|
import { localISO } from "../local-time.js";
|
|
18
24
|
import { getAbmindEnv } from "../env-schema.js";
|
|
19
|
-
import { join,
|
|
25
|
+
import { join, dirname } from "node:path";
|
|
20
26
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import {
|
|
22
|
-
import { atomicWriteSync } from "../atomic-write.js";
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
28
|
import { MemoryManager } from "../memory-manager.js";
|
|
24
29
|
import { loadMemoryConfig } from "../memory-config.js";
|
|
25
30
|
import { SleepStateGatherer } from "../sleep-state-gatherer.js";
|
|
26
31
|
import { loadSleepSteps, buildSleepVars, substituteVars } from "../sleep-pipeline.js";
|
|
27
32
|
import { buildDailySummary, writeDailyFile, LLMUnavailableError } from "../sleep-pipeline.js";
|
|
28
33
|
import { extractFromDaily } from "../sleep-pipeline.js";
|
|
29
|
-
import { logInfo, logWarn
|
|
30
|
-
import { redactSecrets } from "../redact-secrets.js";
|
|
34
|
+
import { logInfo, logWarn } from "../mem-logger.js";
|
|
31
35
|
import { localDate } from "../local-time.js";
|
|
32
36
|
import { parseLevel, DEFAULT_LEVEL } from "./levels.js";
|
|
37
|
+
import { readStateFile, writeStateFile, runWiredPreTasks, formatWiredResults } from "./state.js";
|
|
38
|
+
import { buildSnapshotSummary, writeAuditLog } from "./audit.js";
|
|
39
|
+
import { toDateStr, toIsoDate, scanPreviousLocks } from "./locks.js";
|
|
40
|
+
import { redactSecrets } from "../redact-secrets.js";
|
|
41
|
+
import { TransportUnavailableError, LlmBudget, sendToRuntime, MAX_DOMAIN_RETRIES } from "./llm-budget.js";
|
|
42
|
+
import { ESSENTIAL_STEPS, failedEssentials, runCatchUp } from "./catchup.js";
|
|
43
|
+
import { emitSleepEvent } from "./contracts.js";
|
|
33
44
|
const TAG = "abmind-sleep";
|
|
34
|
-
/** Format a timestamp as YYYYMMDD (for lock file names). */
|
|
35
|
-
function toDateStr(ts) {
|
|
36
|
-
const d = new Date(ts);
|
|
37
|
-
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
|
38
|
-
}
|
|
39
|
-
/** Format a timestamp as YYYY-MM-DD (for daily file paths). */
|
|
40
|
-
function toIsoDate(ts) {
|
|
41
|
-
const d = new Date(ts);
|
|
42
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
43
|
-
}
|
|
44
45
|
/** Steps whose failure blocks watermark advance. Public so tests can derive reject targets. */
|
|
45
|
-
export
|
|
46
|
-
const CATCHUP_MAX_AGE_DAYS = 3;
|
|
46
|
+
export { ESSENTIAL_STEPS } from "./catchup.js";
|
|
47
47
|
/** Thrown by runSleepCycle when memory layer fails to initialize. */
|
|
48
48
|
export class SleepInitError extends Error {
|
|
49
49
|
constructor(message) { super(message); this.name = "SleepInitError"; }
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
function fireOnStep(handler, e) {
|
|
58
|
-
if (!handler)
|
|
59
|
-
return;
|
|
60
|
-
try {
|
|
61
|
-
handler(e);
|
|
62
|
-
}
|
|
63
|
-
catch { /* host display only — never fail the cycle */ }
|
|
64
|
-
}
|
|
65
|
-
export function parseArgs(argv) {
|
|
66
|
-
const args = argv.slice(2);
|
|
67
|
-
const parsed = { dryRun: false, verbose: false, force: false };
|
|
68
|
-
for (const arg of args) {
|
|
69
|
-
switch (arg) {
|
|
70
|
-
case "--dry-run":
|
|
71
|
-
parsed.dryRun = true;
|
|
72
|
-
break;
|
|
73
|
-
case "--verbose":
|
|
74
|
-
parsed.verbose = true;
|
|
75
|
-
break;
|
|
76
|
-
case "--force":
|
|
77
|
-
parsed.force = true;
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return parsed;
|
|
82
|
-
}
|
|
83
|
-
// getPrimaryUserId moved to SleepDataAccess in memory package
|
|
84
|
-
function readStateFile(path) {
|
|
85
|
-
try {
|
|
86
|
-
if (!existsSync(path))
|
|
87
|
-
return null;
|
|
88
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
89
|
-
if (typeof raw !== "object" || raw === null || !raw.steps)
|
|
90
|
-
return null;
|
|
91
|
-
// Backfill defaults for legacy lock files
|
|
92
|
-
if (!raw.status)
|
|
93
|
-
raw.status = "ongoing";
|
|
94
|
-
if (raw.llmCalls == null)
|
|
95
|
-
raw.llmCalls = 0;
|
|
96
|
-
return raw;
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
function writeStateFile(path, state) {
|
|
103
|
-
atomicWriteSync(path, JSON.stringify(state, null, 2));
|
|
104
|
-
}
|
|
105
|
-
// ── Wired pre-tasks (delegated to abmind MaintenanceService) ────────────────
|
|
106
|
-
async function runWiredPreTasks(sleepData, memoryDir, memory) {
|
|
107
|
-
const r = await memory.maintenance.runPreSleepTasks(memory, sleepData);
|
|
108
|
-
// Bridge-side: log rotation (not memory's concern)
|
|
109
|
-
let logsDeleted = 0;
|
|
110
|
-
try {
|
|
111
|
-
const logsDir = join(memoryDir, "..", "logs");
|
|
112
|
-
if (existsSync(logsDir)) {
|
|
113
|
-
const cutoff = Date.now() - 7 * 86400000;
|
|
114
|
-
for (const f of readdirSync(logsDir)) {
|
|
115
|
-
if (!f.startsWith("bridge-") || !f.endsWith(".log"))
|
|
116
|
-
continue;
|
|
117
|
-
const match = f.match(/bridge-(\d{4}-\d{2}-\d{2})\.log/);
|
|
118
|
-
if (match && new Date(match[1]).getTime() < cutoff) {
|
|
119
|
-
unlinkSync(join(logsDir, f));
|
|
120
|
-
logsDeleted++;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
logWarn(TAG, `[WIRED] log rotation: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
-
}
|
|
128
|
-
return { purged: r.purged, deduped: r.deduped, embedded: r.embedded, anomaliesFixed: r.anomaliesFixed, walOk: r.walOk, ftsOk: r.ftsOk, logsDeleted };
|
|
129
|
-
}
|
|
130
|
-
function formatWiredResults(r) {
|
|
131
|
-
const parts = [];
|
|
132
|
-
if (r.purged > 0)
|
|
133
|
-
parts.push(`${r.purged} garbage purged`);
|
|
134
|
-
if (r.deduped > 0)
|
|
135
|
-
parts.push(`${r.deduped} dupes deleted`);
|
|
136
|
-
parts.push(`WAL ${r.walOk ? "ok" : "FAILED"}`);
|
|
137
|
-
parts.push(`FTS ${r.ftsOk ? "ok" : "FAILED"}`);
|
|
138
|
-
if (r.embedded > 0)
|
|
139
|
-
parts.push(`${r.embedded} embedded`);
|
|
140
|
-
if (r.anomaliesFixed > 0)
|
|
141
|
-
parts.push(`${r.anomaliesFixed} anomalies fixed`);
|
|
142
|
-
if (r.logsDeleted > 0)
|
|
143
|
-
parts.push(`${r.logsDeleted} old logs deleted`);
|
|
144
|
-
return parts.length > 0 ? parts.join(", ") : "nothing to do";
|
|
145
|
-
}
|
|
146
|
-
// ── Transport ───────────────────────────────────────────────────────────────
|
|
147
|
-
// (SleepRuntime is imported at the top of the file.)
|
|
148
|
-
const MAX_RETRIES = 3;
|
|
149
|
-
const TRANSPORT_MAX_RETRIES = 6;
|
|
150
|
-
const DEFAULT_TRANSPORT_RETRY_WINDOW_MS = 8 * 60_000; // 8 minutes
|
|
151
|
-
/** Thrown by sendWithRetry when runtime.complete() throws on every attempt within the retry window.
|
|
152
|
-
* Extends LLMUnavailableError so buildDailySummary and extractFromDaily rethrow it naturally
|
|
153
|
-
* (those functions check instanceof LLMUnavailableError to propagate errors up). */
|
|
154
|
-
export class ModelUnavailableError extends LLMUnavailableError {
|
|
155
|
-
constructor(stepName) {
|
|
156
|
-
super(`Model unavailable for step "${stepName}" after retry window`);
|
|
157
|
-
this.name = "ModelUnavailableError";
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
/** Budget tracker — shared across all sendWithRetry calls in a sleep cycle. */
|
|
161
|
-
class LlmBudget {
|
|
162
|
-
state;
|
|
163
|
-
statePath;
|
|
164
|
-
exhausted = false;
|
|
165
|
-
constructor(state, statePath) {
|
|
166
|
-
this.state = state;
|
|
167
|
-
this.statePath = statePath;
|
|
168
|
-
}
|
|
169
|
-
/** Increment counter, return false if budget exhausted. */
|
|
170
|
-
consume() {
|
|
171
|
-
this.state.llmCalls = (this.state.llmCalls ?? 0) + 1;
|
|
172
|
-
writeStateFile(this.statePath, this.state);
|
|
173
|
-
if (this.state.llmCalls > getAbmindEnv().sleepMaxLlmCalls) {
|
|
174
|
-
this.exhausted = true;
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
get calls() { return this.state.llmCalls ?? 0; }
|
|
180
|
-
}
|
|
181
|
-
async function sendWithRetry(runtime, prompt, stepName, _verbose, budget, delayMs = 6000, transportBackoffMs = (n) => Math.min(30_000 * Math.pow(2, n - 1), 120_000), transportRetryWindowMs = DEFAULT_TRANSPORT_RETRY_WINDOW_MS, nowFn = Date.now) {
|
|
182
|
-
const deadline = nowFn() + transportRetryWindowMs;
|
|
183
|
-
let emptyAttempts = 0;
|
|
184
|
-
let transportAttempts = 0;
|
|
185
|
-
// Budget-exhaustion pre-check: if we are already over the cap, bail immediately.
|
|
186
|
-
// This mirrors the old pre-loop consume() guard without burning a call.
|
|
187
|
-
if (budget?.exhausted) {
|
|
188
|
-
logWarn(TAG, `[BUDGET] LLM call limit (${getAbmindEnv().sleepMaxLlmCalls}) reached at step ${stepName} — suspending`);
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
while (true) {
|
|
192
|
-
try {
|
|
193
|
-
const result = await runtime.complete(prompt);
|
|
194
|
-
// Real call reached the model — count it now (success OR empty, never a throw).
|
|
195
|
-
if (budget && !budget.consume()) {
|
|
196
|
-
logWarn(TAG, `[BUDGET] LLM call limit (${getAbmindEnv().sleepMaxLlmCalls}) reached at step ${stepName} — suspending`);
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
if (!result || !result.trim()) {
|
|
200
|
-
emptyAttempts++;
|
|
201
|
-
logWarn(TAG, `Step ${stepName} attempt ${emptyAttempts}/${MAX_RETRIES} returned empty response`);
|
|
202
|
-
if (emptyAttempts >= MAX_RETRIES) {
|
|
203
|
-
logError(TAG, `Step ${stepName} failed after ${MAX_RETRIES} attempts (empty), skipping`);
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
if (delayMs > 0)
|
|
207
|
-
await new Promise(r => setTimeout(r, delayMs));
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
return result;
|
|
211
|
-
}
|
|
212
|
-
catch (err) {
|
|
213
|
-
// Transport failure — model unreachable. Do NOT consume budget (no tokens used).
|
|
214
|
-
transportAttempts++;
|
|
215
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
216
|
-
const backoff = transportBackoffMs(transportAttempts);
|
|
217
|
-
const timeRemaining = deadline - nowFn();
|
|
218
|
-
logWarn(TAG, `Step ${stepName} transport fail (${transportAttempts}/${TRANSPORT_MAX_RETRIES}): ${msg}`);
|
|
219
|
-
if (transportAttempts >= TRANSPORT_MAX_RETRIES || timeRemaining <= backoff) {
|
|
220
|
-
logError(TAG, `Step ${stepName} — model unavailable after ${transportAttempts} attempt(s), ${Math.round(transportRetryWindowMs / 60_000)}min window exhausted`);
|
|
221
|
-
throw new ModelUnavailableError(stepName);
|
|
222
|
-
}
|
|
223
|
-
logInfo(TAG, `[SLEEP] Model unreachable — waiting ${Math.round(backoff / 1000)}s before retry (step ${stepName})`);
|
|
224
|
-
if (backoff > 0)
|
|
225
|
-
await new Promise(r => setTimeout(r, backoff));
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
// ── Audit trail helpers ─────────────────────────────────────────────────────
|
|
230
|
-
function buildSnapshotSummary(snapshot) {
|
|
231
|
-
return [
|
|
232
|
-
`Working dirs: ${snapshot.workingDirs.length}`,
|
|
233
|
-
`Messages: ${snapshot.dbStats.messageCount}`,
|
|
234
|
-
`Embeddings: ${snapshot.dbStats.embeddingCount}`,
|
|
235
|
-
`Extracted memories: ${snapshot.dbStats.extractedMemoryCount}`,
|
|
236
|
-
`Disk: ${(snapshot.diskUsageBytes / 1024 / 1024).toFixed(1)} MB / ${(snapshot.diskBudgetBytes / 1024 / 1024).toFixed(0)} MB`,
|
|
237
|
-
`Topics: ${snapshot.topicFiles.length}`,
|
|
238
|
-
`FTS5: messages=${snapshot.fts5Health.messages_fts}, extracted=${snapshot.fts5Health.extracted_memories_fts}, original=${snapshot.fts5Health.extracted_memories_original_fts}`,
|
|
239
|
-
].join(", ");
|
|
240
|
-
}
|
|
51
|
+
// ── In-process concurrency guard (#1353) ────────────────────────────────────
|
|
52
|
+
// The durable lock file protects against another OS process using the same
|
|
53
|
+
// abmind home. This guards a second invocation within THIS process (e.g. two
|
|
54
|
+
// overlapping calls from a host that forgot to serialize). A key is the
|
|
55
|
+
// resolved memory directory — one run per home, per process.
|
|
56
|
+
const activeRunsByMemoryDir = new Set();
|
|
241
57
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* matching for common patterns like "consolidated 3 files", "pruned 42
|
|
246
|
-
* messages", etc. Returns 0 for any count that can't be parsed.
|
|
58
|
+
* Run the full sleep cycle against a host-injected runtime. Returns a
|
|
59
|
+
* structured SleepRunResult projected from the authoritative on-disk state —
|
|
60
|
+
* hosts never need to read the lock file themselves.
|
|
247
61
|
*/
|
|
248
|
-
function
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
{
|
|
265
|
-
key: "filesConsolidated",
|
|
266
|
-
regexes: [
|
|
267
|
-
/consolidat\w*\s+(\d+)\s+(?:file|dir|working)/i,
|
|
268
|
-
/(\d+)\s+(?:file|dir|working\s*dir)\w*\s+consolidat/i,
|
|
269
|
-
/files?\s+consolidated\s*:\s*(\d+)/i,
|
|
270
|
-
],
|
|
271
|
-
},
|
|
272
|
-
{
|
|
273
|
-
key: "messagesPruned",
|
|
274
|
-
regexes: [
|
|
275
|
-
/prun\w*\s+(\d+)\s+message/i,
|
|
276
|
-
/(\d+)\s+message\w*\s+prun/i,
|
|
277
|
-
/(?:delet|remov)\w*\s+(\d+)\s+message/i,
|
|
278
|
-
/(\d+)\s+message\w*\s+(?:delet|remov)/i,
|
|
279
|
-
/messages?\s+pruned\s*:\s*(\d+)/i,
|
|
280
|
-
],
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
key: "embeddingsRemoved",
|
|
284
|
-
regexes: [
|
|
285
|
-
/(?:remov|delet|clean)\w*\s+(\d+)\s+embedding/i,
|
|
286
|
-
/(\d+)\s+embedding\w*\s+(?:remov|delet|clean)/i,
|
|
287
|
-
/embeddings?\s+removed\s*:\s*(\d+)/i,
|
|
288
|
-
],
|
|
289
|
-
},
|
|
290
|
-
{
|
|
291
|
-
key: "sessionsCleaned",
|
|
292
|
-
regexes: [
|
|
293
|
-
/(?:clean|delet|remov)\w*\s+(\d+)\s+session/i,
|
|
294
|
-
/(\d+)\s+session\w*\s+(?:clean|delet|remov)/i,
|
|
295
|
-
/sessions?\s+cleaned\s*:\s*(\d+)/i,
|
|
296
|
-
],
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
key: "topicsMerged",
|
|
300
|
-
regexes: [
|
|
301
|
-
/merg\w*\s+(\d+)\s+topic/i,
|
|
302
|
-
/(\d+)\s+topic\w*\s+merg/i,
|
|
303
|
-
/topics?\s+merged\s*:\s*(\d+)/i,
|
|
304
|
-
],
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
key: "topicsDeleted",
|
|
308
|
-
regexes: [
|
|
309
|
-
/delet\w*\s+(\d+)\s+topic/i,
|
|
310
|
-
/(\d+)\s+topic\w*\s+delet/i,
|
|
311
|
-
/topics?\s+deleted\s*:\s*(\d+)/i,
|
|
312
|
-
],
|
|
313
|
-
},
|
|
314
|
-
];
|
|
315
|
-
for (const { key, regexes } of patterns) {
|
|
316
|
-
for (const regex of regexes) {
|
|
317
|
-
const match = text.match(regex);
|
|
318
|
-
if (match?.[1]) {
|
|
319
|
-
const parsed = parseInt(match[1], 10);
|
|
320
|
-
if (!isNaN(parsed) && parsed >= 0) {
|
|
321
|
-
defaults[key] = parsed;
|
|
322
|
-
break;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return defaults;
|
|
328
|
-
}
|
|
329
|
-
function writeAuditLog(memoryDir, entry) {
|
|
330
|
-
const sleepDir = join(memoryDir, "sleep");
|
|
331
|
-
mkdirSync(sleepDir, { recursive: true });
|
|
332
|
-
const suffix = [
|
|
333
|
-
``,
|
|
334
|
-
`---`,
|
|
335
|
-
``,
|
|
336
|
-
`## CLI Wrapper`,
|
|
337
|
-
``,
|
|
338
|
-
`**Timestamp:** ${entry.timestamp}`,
|
|
339
|
-
`**Model:** ${entry.model}`,
|
|
340
|
-
``,
|
|
341
|
-
`### State Snapshot`,
|
|
342
|
-
`${entry.stateSnapshotSummary}`,
|
|
343
|
-
``,
|
|
344
|
-
`### Outcomes`,
|
|
345
|
-
`- Files consolidated: ${entry.outcomes.filesConsolidated}`,
|
|
346
|
-
`- Messages pruned: ${entry.outcomes.messagesPruned}`,
|
|
347
|
-
`- Embeddings removed: ${entry.outcomes.embeddingsRemoved}`,
|
|
348
|
-
`- Sessions cleaned: ${entry.outcomes.sessionsCleaned}`,
|
|
349
|
-
`- Topics merged: ${entry.outcomes.topicsMerged}`,
|
|
350
|
-
`- Topics deleted: ${entry.outcomes.topicsDeleted}`,
|
|
351
|
-
entry.error ? `\n### Error\n${entry.error}` : "",
|
|
352
|
-
].join("\n");
|
|
353
|
-
// Find the subagent's audit file and append to it
|
|
354
|
-
const today = localDate().replace(/-/g, "");
|
|
355
|
-
try {
|
|
356
|
-
const files = readdirSync(sleepDir)
|
|
357
|
-
.filter(f => f.startsWith(`sleep_${today}`) && f.endsWith(".md"))
|
|
358
|
-
.sort();
|
|
359
|
-
if (files.length > 0) {
|
|
360
|
-
const target = join(sleepDir, files[files.length - 1]);
|
|
361
|
-
const existingLines = readFileSync(target, "utf-8").split("\n").length;
|
|
362
|
-
if (existingLines < 50) {
|
|
363
|
-
logWarn(TAG, `Sleep audit suspiciously short (${existingLines} lines) — subagent may have truncated`);
|
|
364
|
-
}
|
|
365
|
-
appendFileSync(target, redactSecrets(suffix), "utf-8");
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
catch { /* fall through to standalone */ }
|
|
370
|
-
// Fallback: no subagent file found — write standalone
|
|
371
|
-
const now = new Date();
|
|
372
|
-
const dateStr = localDate().replace(/-/g, "");
|
|
373
|
-
const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, "");
|
|
374
|
-
const filename = `sleep_${dateStr}_${timeStr}.md`;
|
|
375
|
-
writeFileSync(join(sleepDir, filename), redactSecrets(`# Sleep Audit Log${suffix}`), "utf-8");
|
|
376
|
-
}
|
|
377
|
-
function scanPreviousLocks(sleepDir, todayStr) {
|
|
378
|
-
if (!existsSync(sleepDir))
|
|
379
|
-
return [];
|
|
380
|
-
const locks = [];
|
|
381
|
-
const todayMs = dateStrToMs(todayStr);
|
|
382
|
-
for (const f of readdirSync(sleepDir)) {
|
|
383
|
-
const m = f.match(/^sleep_(\d{8})\.lock$/);
|
|
384
|
-
if (!m || m[1] === todayStr)
|
|
385
|
-
continue;
|
|
386
|
-
const state = readStateFile(join(sleepDir, f));
|
|
387
|
-
if (!state)
|
|
388
|
-
continue;
|
|
389
|
-
const ageDays = Math.round((todayMs - dateStrToMs(m[1])) / 86400000);
|
|
390
|
-
if (ageDays > 0)
|
|
391
|
-
locks.push({ path: join(sleepDir, f), dateStr: m[1], state, ageDays });
|
|
392
|
-
}
|
|
393
|
-
return locks.sort((a, b) => b.dateStr.localeCompare(a.dateStr)); // newest first
|
|
394
|
-
}
|
|
395
|
-
function dateStrToMs(ds) {
|
|
396
|
-
return new Date(`${ds.slice(0, 4)}-${ds.slice(4, 6)}-${ds.slice(6, 8)}T00:00:00`).getTime();
|
|
397
|
-
}
|
|
398
|
-
function dateStrToFormatted(ds) {
|
|
399
|
-
return `${ds.slice(0, 4)}-${ds.slice(4, 6)}-${ds.slice(6, 8)}`;
|
|
400
|
-
}
|
|
401
|
-
function failedEssentials(state) {
|
|
402
|
-
const failed = [];
|
|
403
|
-
for (const name of ESSENTIAL_STEPS) {
|
|
404
|
-
const s = state.steps[name];
|
|
405
|
-
if (!s || s.status === "failed" || s.status === "timeout" || s.status === "pending") {
|
|
406
|
-
failed.push(name);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return failed;
|
|
410
|
-
}
|
|
411
|
-
async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs = 6000, onStep) {
|
|
412
|
-
for (const lock of locks) {
|
|
413
|
-
if (lock.ageDays > CATCHUP_MAX_AGE_DAYS) {
|
|
414
|
-
logError(TAG, `[CATCH-UP] Abandoning stale lock ${basename(lock.path)} — ${lock.ageDays} days old, data unrecoverable`);
|
|
415
|
-
unlinkSync(lock.path);
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
const needed = failedEssentials(lock.state);
|
|
419
|
-
if (needed.length === 0) {
|
|
420
|
-
logInfo(TAG, `[CATCH-UP] Cleaning up completed lock ${basename(lock.path)}`);
|
|
421
|
-
unlinkSync(lock.path);
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
logInfo(TAG, `[CATCH-UP] ${basename(lock.path)} — recovering: ${needed.join(", ")}`);
|
|
425
|
-
// 04a — daily summary with date-range
|
|
426
|
-
if (needed.includes("daily-summary")) {
|
|
427
|
-
const start = Date.now();
|
|
428
|
-
try {
|
|
429
|
-
const ctxWindow = getAbmindEnv().sleepCtxWindow;
|
|
430
|
-
const userId = sleepData.getPrimaryUserId();
|
|
431
|
-
const dayStart = dateStrToMs(lock.dateStr);
|
|
432
|
-
const dayEnd = dayStart + 86400000;
|
|
433
|
-
const summary = await buildDailySummary(sleepData.getDb(), (p) => sendWithRetry(runtime, p, "catch-up-04a", flags.verbose, budget, retryDelayMs).then(r => { if (r === null)
|
|
434
|
-
throw new LLMUnavailableError(); return r; }), {
|
|
435
|
-
ctxWindow, memoryDir: memoryConfig.memoryDir, userId, watermarkTs: 0,
|
|
436
|
-
dateRange: { startTs: dayStart, endTs: dayEnd },
|
|
437
|
-
});
|
|
438
|
-
if (summary) {
|
|
439
|
-
writeDailyFile(memoryConfig.memoryDir, dateStrToFormatted(lock.dateStr), summary);
|
|
440
|
-
lock.state.steps["daily-summary"] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
441
|
-
}
|
|
442
|
-
else {
|
|
443
|
-
lock.state.steps["daily-summary"] = { status: "skipped" };
|
|
444
|
-
}
|
|
445
|
-
logInfo(TAG, `[CATCH-UP] ✓ 04a-daily-summary for ${lock.dateStr} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
|
|
446
|
-
// #895: emit terminal event for catch-up daily-summary
|
|
447
|
-
fireOnStep(onStep, {
|
|
448
|
-
name: "daily-summary", filename: "catch-up",
|
|
449
|
-
index: 0, total: 0,
|
|
450
|
-
phase: summary ? "done" : "skipped",
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
catch (err) {
|
|
454
|
-
logWarn(TAG, `[CATCH-UP] ✗ 04a-daily-summary for ${lock.dateStr}: ${err instanceof Error ? err.message : String(err)}`);
|
|
455
|
-
lock.state.steps["daily-summary"] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
456
|
-
// #895: emit failed for catch-up daily-summary
|
|
457
|
-
fireOnStep(onStep, {
|
|
458
|
-
name: "daily-summary", filename: "catch-up",
|
|
459
|
-
index: 0, total: 0,
|
|
460
|
-
phase: "failed",
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
writeStateFile(lock.path, lock.state);
|
|
464
|
-
}
|
|
465
|
-
// 04b — extract memories from daily (needs daily file to exist)
|
|
466
|
-
if (needed.includes("extract-memories")) {
|
|
467
|
-
const dailyPath = join(memoryConfig.memoryDir, "daily", `daily_${dateStrToFormatted(lock.dateStr)}.md`);
|
|
468
|
-
if (!existsSync(dailyPath)) {
|
|
469
|
-
logInfo(TAG, `[CATCH-UP] ⏭ 04b — no daily file for ${lock.dateStr}`);
|
|
470
|
-
lock.state.steps["extract-memories"] = { status: "skipped" };
|
|
471
|
-
// #895: emit skip when no daily file for catch-up
|
|
472
|
-
fireOnStep(onStep, {
|
|
473
|
-
name: "extract-memories", filename: "catch-up",
|
|
474
|
-
index: 0, total: 0,
|
|
475
|
-
phase: "skipped",
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
else {
|
|
479
|
-
const start = Date.now();
|
|
480
|
-
try {
|
|
481
|
-
const userId = sleepData.getPrimaryUserId();
|
|
482
|
-
const result = await extractFromDaily(dailyPath, userId, (p) => sendWithRetry(runtime, p, "catch-up-04b", flags.verbose, budget, retryDelayMs).then(r => { if (r === null)
|
|
483
|
-
throw new LLMUnavailableError(); return r; }));
|
|
484
|
-
lock.state.steps["extract-memories"] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
485
|
-
logInfo(TAG, `[CATCH-UP] ✓ 04b-extract-memories for ${lock.dateStr} (${((Date.now() - start) / 1000).toFixed(1)}s) — ${result.slice(0, 80)}`);
|
|
486
|
-
// #895: emit done for catch-up extract-memories
|
|
487
|
-
fireOnStep(onStep, {
|
|
488
|
-
name: "extract-memories", filename: "catch-up",
|
|
489
|
-
index: 0, total: 0,
|
|
490
|
-
phase: "done",
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
catch (err) {
|
|
494
|
-
logWarn(TAG, `[CATCH-UP] ✗ 04b for ${lock.dateStr}: ${err instanceof Error ? err.message : String(err)}`);
|
|
495
|
-
lock.state.steps["extract-memories"] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
496
|
-
// #895: emit failed for catch-up extract-memories
|
|
497
|
-
fireOnStep(onStep, {
|
|
498
|
-
name: "extract-memories", filename: "catch-up",
|
|
499
|
-
index: 0, total: 0,
|
|
500
|
-
phase: "failed",
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
writeStateFile(lock.path, lock.state);
|
|
505
|
-
}
|
|
506
|
-
// Prompt-driven essentials (retrospective — now includes extraction)
|
|
507
|
-
for (const stepName of ["retrospective"]) {
|
|
508
|
-
if (!needed.includes(stepName))
|
|
509
|
-
continue;
|
|
510
|
-
const step = steps.find(s => s.name === stepName);
|
|
511
|
-
if (!step) {
|
|
512
|
-
logWarn(TAG, `[CATCH-UP] Step file not found: ${stepName}`);
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
const start = Date.now();
|
|
516
|
-
const response = await sendWithRetry(runtime, step.rawPrompt, `catch-up-${stepName}`, flags.verbose, budget, retryDelayMs);
|
|
517
|
-
if (response) {
|
|
518
|
-
lock.state.steps[stepName] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
519
|
-
logInfo(TAG, `[CATCH-UP] ✓ ${stepName} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
|
|
520
|
-
// #895: emit done for catch-up retrospective
|
|
521
|
-
fireOnStep(onStep, {
|
|
522
|
-
name: stepName, filename: "catch-up",
|
|
523
|
-
index: 0, total: 0,
|
|
524
|
-
phase: "done",
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
else {
|
|
528
|
-
lock.state.steps[stepName] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
529
|
-
logWarn(TAG, `[CATCH-UP] ✗ ${stepName}`);
|
|
530
|
-
// #895: emit failed for catch-up retrospective
|
|
531
|
-
fireOnStep(onStep, {
|
|
532
|
-
name: stepName, filename: "catch-up",
|
|
533
|
-
index: 0, total: 0,
|
|
534
|
-
phase: "failed",
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
writeStateFile(lock.path, lock.state);
|
|
538
|
-
}
|
|
539
|
-
// Final check: all essentials recovered?
|
|
540
|
-
const stillFailing = failedEssentials(lock.state);
|
|
541
|
-
if (stillFailing.length === 0) {
|
|
542
|
-
logInfo(TAG, `[CATCH-UP] ✅ ${basename(lock.path)} — all essentials recovered, lock deleted`);
|
|
543
|
-
unlinkSync(lock.path);
|
|
544
|
-
}
|
|
545
|
-
else {
|
|
546
|
-
logWarn(TAG, `[CATCH-UP] ${basename(lock.path)} — still failing: ${stillFailing.join(", ")} (failing ${lock.ageDays} day(s))`);
|
|
547
|
-
}
|
|
62
|
+
export async function runSleepCycle(options) {
|
|
63
|
+
const now = options.now ?? Date.now;
|
|
64
|
+
const timeoutMs = options.timeoutMs ?? getAbmindEnv().sleepTimeoutMin * 60 * 1000;
|
|
65
|
+
const domainRetryDelayMs = options.domainRetryDelayMs ?? 6000;
|
|
66
|
+
const betweenStepBackoffMs = options.betweenStepBackoffMs ?? ((n) => [10, 30, 60][Math.min(n, 2)] * 1000);
|
|
67
|
+
const runtime = options.runtime;
|
|
68
|
+
const startedAt = now();
|
|
69
|
+
// ── Cancellation: combine caller signal + wall-clock timeout ──
|
|
70
|
+
const internalController = new AbortController();
|
|
71
|
+
const timeoutHandle = setTimeout(() => internalController.abort(new SleepTimeoutReason()), timeoutMs);
|
|
72
|
+
const onCallerAbort = () => internalController.abort(options.signal?.reason);
|
|
73
|
+
if (options.signal) {
|
|
74
|
+
if (options.signal.aborted)
|
|
75
|
+
internalController.abort(options.signal.reason);
|
|
76
|
+
else
|
|
77
|
+
options.signal.addEventListener("abort", onCallerAbort);
|
|
548
78
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const backoffMs = opts.backoffMs ?? ((n) => [10, 30, 60][Math.min(n, 2)] * 1000);
|
|
564
|
-
const retryDelayMs = opts.retryDelayMs ?? 6000;
|
|
565
|
-
const transportBackoffMs = opts.transportBackoffMs ?? ((n) => Math.min(30_000 * Math.pow(2, n - 1), 120_000));
|
|
566
|
-
const transportRetryWindowMs = opts.transportRetryWindowMs ?? DEFAULT_TRANSPORT_RETRY_WINDOW_MS;
|
|
567
|
-
const runtime = opts.runtime;
|
|
568
|
-
if (flags.verbose) {
|
|
569
|
-
logInfo(TAG, "Verbose mode enabled");
|
|
79
|
+
const signal = internalController.signal;
|
|
80
|
+
const cleanupCancellation = () => {
|
|
81
|
+
clearTimeout(timeoutHandle);
|
|
82
|
+
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
83
|
+
};
|
|
84
|
+
const memoryConfig = { ...loadMemoryConfig(), ...options.memoryConfigOverride };
|
|
85
|
+
// #1353: in-process concurrency guard — claimed synchronously, before any
|
|
86
|
+
// await, so two overlapping calls in the same process cannot both pass the
|
|
87
|
+
// check. This protects a single process invoking runSleepCycle twice; the
|
|
88
|
+
// durable PID-based lock (below) protects against a second OS process.
|
|
89
|
+
const memoryDirKey = memoryConfig.memoryDir;
|
|
90
|
+
if (activeRunsByMemoryDir.has(memoryDirKey)) {
|
|
91
|
+
cleanupCancellation();
|
|
92
|
+
return alreadyRunningResult(startedAt, now());
|
|
570
93
|
}
|
|
571
|
-
|
|
94
|
+
activeRunsByMemoryDir.add(memoryDirKey);
|
|
572
95
|
const memory = new MemoryManager(memoryConfig);
|
|
573
96
|
try {
|
|
574
97
|
await memory.initialize();
|
|
575
98
|
}
|
|
576
99
|
catch (err) {
|
|
100
|
+
activeRunsByMemoryDir.delete(memoryDirKey);
|
|
101
|
+
cleanupCancellation();
|
|
577
102
|
throw new SleepInitError(`Failed to initialize MemoryManager: ${err instanceof Error ? err.message : String(err)}`);
|
|
578
103
|
}
|
|
579
|
-
//
|
|
104
|
+
// #1353: async, library-native preflight — no execSync, no CLI subprocess.
|
|
580
105
|
try {
|
|
581
|
-
|
|
582
|
-
execSync("abmind doctor --fix", { timeout: 30_000, stdio: "ignore" });
|
|
106
|
+
await memory.maintenance.runPreflight();
|
|
583
107
|
}
|
|
584
108
|
catch { /* non-fatal — proceed with sleep */ }
|
|
585
109
|
try {
|
|
@@ -591,14 +115,13 @@ export async function runSleepCycle(opts) {
|
|
|
591
115
|
}
|
|
592
116
|
catch { /* */ }
|
|
593
117
|
const { metaSet, metaIncrement, metaGetInt } = await import("../meta-store.js");
|
|
594
|
-
// Record attempt
|
|
595
118
|
metaSet(db, "sleep_last_attempt_ts", Date.now());
|
|
596
119
|
metaIncrement(db, "sleep_total_runs");
|
|
597
|
-
// State file path — use opts.now for deterministic today derivation
|
|
598
120
|
const dateStr = toDateStr(now());
|
|
599
121
|
const statePath = join(memoryConfig.memoryDir, "sleep", `sleep_${dateStr}.lock`);
|
|
600
122
|
const existingState = readStateFile(statePath);
|
|
601
|
-
// #518: PID guard —
|
|
123
|
+
// #518 + #1353: durable PID guard — protects against another OS process
|
|
124
|
+
// using the same abmind home.
|
|
602
125
|
if (existingState?.status === "ongoing") {
|
|
603
126
|
let alive = false;
|
|
604
127
|
try {
|
|
@@ -608,42 +131,42 @@ export async function runSleepCycle(opts) {
|
|
|
608
131
|
catch { }
|
|
609
132
|
if (alive) {
|
|
610
133
|
logInfo(TAG, `[SLEEP] Already running (pid ${existingState.pid}) — skipping`);
|
|
611
|
-
return
|
|
134
|
+
return alreadyRunningResult(startedAt, now());
|
|
612
135
|
}
|
|
613
136
|
logWarn(TAG, `[SLEEP] Stale lock (pid ${existingState.pid} dead) — claiming`);
|
|
614
137
|
}
|
|
615
|
-
//
|
|
616
|
-
const isResume = !
|
|
138
|
+
// Fresh cycle discards prior state (budget + steps)
|
|
139
|
+
const isResume = !options.fresh && existingState !== null && Object.values(existingState.steps).some(s => s.status === "ok");
|
|
140
|
+
const priorRunId = existingState?.runId;
|
|
141
|
+
const runId = randomUUID();
|
|
142
|
+
const totalStepsForEvent = loadSleepSteps().length;
|
|
143
|
+
emitSleepEvent(options.onEvent, { type: "cycle_started", runId, totalSteps: totalStepsForEvent, resumed: isResume });
|
|
617
144
|
// Gather state
|
|
618
|
-
|
|
619
|
-
const cronFn = undefined;
|
|
620
|
-
const gatherer = new SleepStateGatherer(memory, memoryConfig, cronFn);
|
|
145
|
+
const gatherer = new SleepStateGatherer(memory, memoryConfig, undefined);
|
|
621
146
|
const snapshot = await gatherer.gather();
|
|
622
|
-
if (
|
|
623
|
-
logInfo(TAG, `State gathered: ${buildSnapshotSummary(snapshot)}`);
|
|
624
|
-
// Guardrail: skip if no messages since last sleep (unless --force or resuming)
|
|
147
|
+
// Guardrail: skip if no messages since last sleep (unless resuming)
|
|
625
148
|
const msgCount = snapshot.dbStats.messagesSinceLastSleep;
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
149
|
+
// #1353: manual runs (e.g. "/sleep now") still run housekeeping even with
|
|
150
|
+
// zero new messages — matches the previous flags.force escape hatch.
|
|
151
|
+
const forceHousekeeping = options.mode === "manual";
|
|
152
|
+
if (msgCount === 0 && !isResume && !forceHousekeeping) {
|
|
153
|
+
logInfo(TAG, `[SLEEP] No messages since last sleep — nothing to process.`);
|
|
629
154
|
const sleepDir = join(memoryConfig.memoryDir, "sleep");
|
|
630
155
|
mkdirSync(sleepDir, { recursive: true });
|
|
631
|
-
const
|
|
156
|
+
const noWorkDateStr = localDate().replace(/-/g, "");
|
|
632
157
|
const timeStr = new Date().toTimeString().slice(0, 5).replace(/:/g, "");
|
|
633
|
-
writeFileSync(join(sleepDir, `sleep_${
|
|
634
|
-
|
|
158
|
+
writeFileSync(join(sleepDir, `sleep_${noWorkDateStr}_${timeStr}.md`), `# Sleep Audit Log\n\n## No work — 0 messages since last sleep\n`, "utf-8");
|
|
159
|
+
const result = noWorkResult(runId, startedAt, now());
|
|
160
|
+
emitSleepEvent(options.onEvent, { type: "cycle_finished", runId, result });
|
|
161
|
+
return result;
|
|
635
162
|
}
|
|
636
|
-
// Wired pre-tasks (always run — fast, idempotent)
|
|
163
|
+
// Wired pre-tasks (always run — fast, idempotent, abmind-owned only)
|
|
637
164
|
logInfo(TAG, `[SLEEP] Running wired pre-tasks${isResume ? " (resume)" : ""}...`);
|
|
638
165
|
const wiredResults = await runWiredPreTasks(sleepData, memoryConfig.memoryDir, memory);
|
|
639
166
|
logInfo(TAG, `[SLEEP] Wired: ${formatWiredResults(wiredResults)}`);
|
|
640
|
-
// Build candidate lists for conditional prompts
|
|
641
167
|
const candidates = sleepData.buildSleepCandidates(getAbmindEnv().sleepModelName ?? "unknown");
|
|
642
|
-
logInfo(TAG, `[SLEEP] Candidates: topics=${candidates.untaggedMemories ? "yes" : "none"}, promote=${candidates.promotionCandidates ? "yes" : "none"}, contradict=${candidates.contradictions ? "yes" : "none"}, merge=${candidates.mergeCandidates ? "yes" : "none"}, translate=${candidates.translationIssues ? "yes" : "none"}, emotion-ctx=${candidates.emotionContextGaps ? "yes" : "none"}, feedback=${candidates.recallFeedback ? "yes" : "none"}`);
|
|
643
|
-
// Load step files + build vars
|
|
644
168
|
const vars = buildSleepVars(snapshot);
|
|
645
169
|
vars.WIRED_RESULTS = formatWiredResults(wiredResults);
|
|
646
|
-
// Inject candidate lists as template variables
|
|
647
170
|
vars.UNTAGGED_MEMORIES = candidates.untaggedMemories || "No untagged memories found.";
|
|
648
171
|
vars.PROMOTION_CANDIDATES = candidates.promotionCandidates || "No promotion candidates found.";
|
|
649
172
|
vars.CONTRADICTION_WARNINGS = candidates.contradictions || "";
|
|
@@ -651,7 +174,6 @@ export async function runSleepCycle(opts) {
|
|
|
651
174
|
vars.TRANSLATION_ISSUES = candidates.translationIssues || "No translation issues found.";
|
|
652
175
|
vars.EMOTION_CONTEXT_GAPS = candidates.emotionContextGaps || "No emotion context gaps found.";
|
|
653
176
|
vars.RECALL_FEEDBACK = candidates.recallFeedback || "No recalls happened today.";
|
|
654
|
-
// Skill dedup candidates (for step 10 skill-review)
|
|
655
177
|
{
|
|
656
178
|
const { detectSkillDuplicates, formatDedupCandidates } = await import("./skill-dedup.js");
|
|
657
179
|
const abtarsHome = process.env["ABTARS_HOME"] ?? join(process.env["HOME"] ?? "", ".abtars");
|
|
@@ -660,11 +182,9 @@ export async function runSleepCycle(opts) {
|
|
|
660
182
|
const dedupCandidates = detectSkillDuplicates(coreSkillsDir, selfSkillsDir);
|
|
661
183
|
vars.DEDUP_CANDIDATES = formatDedupCandidates(dedupCandidates) || "No skill duplicates or overlaps detected.";
|
|
662
184
|
}
|
|
663
|
-
vars.WIRED_RESULTS = formatWiredResults(wiredResults);
|
|
664
185
|
vars.RESUME_CONTEXT = isResume
|
|
665
186
|
? `This is a RESUMED sleep cycle. Steps already completed: ${Object.entries(existingState.steps).filter(([, s]) => s.status === "ok" || s.status === "skipped").map(([k]) => k).join(", ")}. Only pending/failed steps will run.`
|
|
666
187
|
: "Fresh sleep cycle — all steps will run.";
|
|
667
|
-
// Pre-query messages for retro (watermark-scoped, noise-stripped)
|
|
668
188
|
const lastSleepTs = snapshot.lastSleepTimestamp ?? 0;
|
|
669
189
|
try {
|
|
670
190
|
const garbagePath = join(memoryConfig.memoryDir, "garbage.json");
|
|
@@ -690,10 +210,9 @@ export async function runSleepCycle(opts) {
|
|
|
690
210
|
catch {
|
|
691
211
|
vars.CLEAN_MESSAGES = "Error loading messages — use abmind recall to search.";
|
|
692
212
|
}
|
|
693
|
-
|
|
694
|
-
vars.MESSAGES_SINCE_WATERMARK = vars.CLEAN_MESSAGES; // same data, different name for gc-noise
|
|
213
|
+
vars.MESSAGES_SINCE_WATERMARK = vars.CLEAN_MESSAGES;
|
|
695
214
|
vars.RETRO_PATH = join(memoryConfig.memoryDir, "daily", `daily_${toIsoDate(now())}.md`);
|
|
696
|
-
vars.DAILY_PATH = vars.RETRO_PATH;
|
|
215
|
+
vars.DAILY_PATH = vars.RETRO_PATH;
|
|
697
216
|
try {
|
|
698
217
|
const { getLatestConsolidationFile } = await import("../consolidation-search.js");
|
|
699
218
|
const latest = getLatestConsolidationFile(memoryConfig.memoryDir, "weekly");
|
|
@@ -702,68 +221,41 @@ export async function runSleepCycle(opts) {
|
|
|
702
221
|
catch {
|
|
703
222
|
vars.CONSOLIDATION_PATH = "No consolidation files yet.";
|
|
704
223
|
}
|
|
705
|
-
|
|
706
|
-
const todayIso = new Date(now()).toISOString().slice(0, 10); // YYYY-MM-DD
|
|
224
|
+
const todayIso = new Date(now()).toISOString().slice(0, 10);
|
|
707
225
|
const weeklyDir = join(memoryConfig.memoryDir, "weekly");
|
|
708
226
|
const quarterlyDir = join(memoryConfig.memoryDir, "quarterly");
|
|
709
227
|
mkdirSync(weeklyDir, { recursive: true });
|
|
710
228
|
mkdirSync(quarterlyDir, { recursive: true });
|
|
711
|
-
const month = new Date(now()).getMonth();
|
|
229
|
+
const month = new Date(now()).getMonth();
|
|
712
230
|
const isQuarterBoundary = month % 3 === 0 && new Date(now()).getDate() <= 7;
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
else {
|
|
717
|
-
vars.CONSOLIDATION_OUTPUT_PATH = join(weeklyDir, `weekly_${todayIso}.md`);
|
|
718
|
-
}
|
|
231
|
+
vars.CONSOLIDATION_OUTPUT_PATH = isQuarterBoundary
|
|
232
|
+
? join(quarterlyDir, `quarterly_${todayIso}.md`)
|
|
233
|
+
: join(weeklyDir, `weekly_${todayIso}.md`);
|
|
719
234
|
const steps = loadSleepSteps();
|
|
720
|
-
// Merge snapshot vars + bridge vars into one map for JIT substitution
|
|
721
235
|
const snapshotVars = buildSleepVars(snapshot);
|
|
722
236
|
for (const [k, v] of Object.entries(snapshotVars))
|
|
723
237
|
vars[k] = vars[k] ?? v;
|
|
724
|
-
// #895: Fire onCycleStart once before the first step — hosts use this to
|
|
725
|
-
// create a stepped card or reset per-night state. fireOnStep() already
|
|
726
|
-
// swallows handler errors so a misbehaving host can never break the cycle.
|
|
727
|
-
try {
|
|
728
|
-
opts.onCycleStart?.({ totalSteps: steps.length });
|
|
729
|
-
}
|
|
730
|
-
catch { /* host display only */ }
|
|
731
|
-
// Progress protocol — emit PROGRESS:<pct>:<label> on stdout
|
|
732
238
|
const totalSteps = steps.length;
|
|
733
239
|
let stepIndex = 0;
|
|
734
|
-
const emitProgress = (label) => {
|
|
735
|
-
const pct = Math.round((stepIndex / totalSteps) * 100);
|
|
736
|
-
process.stdout.write(`PROGRESS:${pct}:${label}\n`);
|
|
737
|
-
};
|
|
738
|
-
if (flags.dryRun) {
|
|
739
|
-
for (const step of steps)
|
|
740
|
-
process.stdout.write(`\n--- ${step.filename} ---\n${substituteVars(step.rawPrompt, vars)}\n`);
|
|
741
|
-
return { ok: true, failCount: 0 };
|
|
742
|
-
}
|
|
743
240
|
// Skip logic — candidate-driven (empty = skip)
|
|
744
241
|
const skipSet = new Set();
|
|
745
|
-
|
|
746
|
-
// Precedence: opts.level > SLEEP_QUALITY env (legacy bridge path) > DEFAULT_LEVEL.
|
|
747
|
-
const quality = opts.level ?? (getAbmindEnv().sleepQuality ? parseLevel(getAbmindEnv().sleepQuality) : DEFAULT_LEVEL);
|
|
242
|
+
const quality = options.level ?? (getAbmindEnv().sleepQuality ? parseLevel(getAbmindEnv().sleepQuality) : DEFAULT_LEVEL);
|
|
748
243
|
const curationDay = getAbmindEnv().sleepCurationDay;
|
|
749
244
|
const today = new Date(now()).toLocaleDateString("en", { weekday: "long" }).toLowerCase();
|
|
750
245
|
const isCurationDay = today === curationDay;
|
|
751
246
|
const BUDGET_ONLY = new Set(["gc-noise", "daily-summary", "extract-memories"]);
|
|
752
247
|
const BUDGET_CURATION = new Set([...BUDGET_ONLY, "retrospective", "retro-derive"]);
|
|
753
|
-
const WEEKLY_ONLY = new Set(["memory-maintenance", "translation",
|
|
754
|
-
"skill-review", "consolidation", "rem-synthesis"]);
|
|
248
|
+
const WEEKLY_ONLY = new Set(["memory-maintenance", "translation", "skill-review", "consolidation", "rem-synthesis"]);
|
|
755
249
|
if (quality === "budget" && !isCurationDay) {
|
|
756
|
-
for (const step of steps)
|
|
250
|
+
for (const step of steps)
|
|
757
251
|
if (!BUDGET_ONLY.has(step.name))
|
|
758
252
|
skipSet.add(step.name);
|
|
759
|
-
}
|
|
760
253
|
logInfo(TAG, `[SLEEP] Quality=budget — only essential extraction`);
|
|
761
254
|
}
|
|
762
255
|
else if (quality === "budget" && isCurationDay) {
|
|
763
|
-
for (const step of steps)
|
|
256
|
+
for (const step of steps)
|
|
764
257
|
if (!BUDGET_CURATION.has(step.name))
|
|
765
258
|
skipSet.add(step.name);
|
|
766
|
-
}
|
|
767
259
|
logInfo(TAG, `[SLEEP] Quality=budget (curation day) — adds retro + derive`);
|
|
768
260
|
}
|
|
769
261
|
else if (quality === "normal" && !isCurationDay) {
|
|
@@ -775,19 +267,14 @@ export async function runSleepCycle(opts) {
|
|
|
775
267
|
logInfo(TAG, `[SLEEP] Quality=normal (curation day) — all steps`);
|
|
776
268
|
}
|
|
777
269
|
else {
|
|
778
|
-
// ultimate: all steps every night
|
|
779
270
|
logInfo(TAG, `[SLEEP] Quality=${quality}${isCurationDay ? " (curation day)" : ""} — all eligible`);
|
|
780
271
|
}
|
|
781
|
-
// Candidate-driven skips (empty = nothing to do)
|
|
782
272
|
if (!candidates.recallFeedback)
|
|
783
273
|
skipSet.add("feedback");
|
|
784
|
-
// memory-maintenance: skip if ALL three inputs are empty
|
|
785
274
|
if (!candidates.untaggedMemories && !candidates.mergeCandidates && !candidates.emotionContextGaps)
|
|
786
275
|
skipSet.add("memory-maintenance");
|
|
787
|
-
// promotion candidates are optional input to retro-derive — don't skip the step for it
|
|
788
276
|
if (!candidates.translationIssues)
|
|
789
277
|
skipSet.add("translation");
|
|
790
|
-
// Legacy skip names (old prompt files)
|
|
791
278
|
if (snapshot.topicFiles.length === 0)
|
|
792
279
|
skipSet.add("topic-reorg");
|
|
793
280
|
if (snapshot.dbStats.extractedMemoryCount < 10) {
|
|
@@ -807,10 +294,11 @@ export async function runSleepCycle(opts) {
|
|
|
807
294
|
skipSet.add("gc-noise");
|
|
808
295
|
}
|
|
809
296
|
catch { /* */ }
|
|
810
|
-
// Initialize state file
|
|
297
|
+
// Initialize state file with the new run identity.
|
|
811
298
|
const state = existingState ?? {
|
|
812
299
|
status: "ongoing",
|
|
813
300
|
pid: process.pid,
|
|
301
|
+
runId,
|
|
814
302
|
startedAt: now(),
|
|
815
303
|
llmCalls: 0,
|
|
816
304
|
wiredResults,
|
|
@@ -818,30 +306,37 @@ export async function runSleepCycle(opts) {
|
|
|
818
306
|
};
|
|
819
307
|
state.status = "ongoing";
|
|
820
308
|
state.pid = process.pid;
|
|
309
|
+
state.runId = runId;
|
|
310
|
+
if (priorRunId)
|
|
311
|
+
state.priorRunId = priorRunId;
|
|
821
312
|
state.wiredResults = wiredResults;
|
|
822
|
-
// 20-min wall-clock timeout
|
|
823
|
-
const timeoutHandle = setTimeout(() => {
|
|
824
|
-
logError(TAG, `[SLEEP] ⏰ ${Math.round(timeoutMs / 60000)}-minute timeout reached — aborting`);
|
|
825
|
-
throw new SleepTimeoutError(`Sleep cycle timeout after ${Math.round(timeoutMs / 60000)} minutes`);
|
|
826
|
-
}, timeoutMs);
|
|
827
|
-
// Resolve model name for logging. Bridge wraps and passes via env; library default is "unknown".
|
|
828
313
|
const modelUsed = getAbmindEnv().sleepModelName;
|
|
829
314
|
let dreamySucceeded = true;
|
|
830
315
|
let dailySummaryPath = null;
|
|
316
|
+
let cancelled = false;
|
|
317
|
+
/** Persist a checkpoint-safe cancellation marker and record the reason. */
|
|
318
|
+
const persistCancelled = () => {
|
|
319
|
+
state.status = "suspended";
|
|
320
|
+
writeStateFile(statePath, state);
|
|
321
|
+
cancelled = true;
|
|
322
|
+
};
|
|
831
323
|
try {
|
|
832
|
-
// ── LLM call budget (hard safety limit) ──
|
|
833
|
-
// On resume: reset llmCalls to completed step count (don't carry stale counter)
|
|
834
324
|
if (isResume) {
|
|
835
325
|
const completedCount = Object.values(state.steps).filter(s => s.status === "ok").length;
|
|
836
326
|
state.llmCalls = completedCount;
|
|
837
327
|
}
|
|
838
328
|
const budget = new LlmBudget(state, statePath);
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
329
|
+
// Checkpoint boundary: before preflight/wired maintenance already ran above.
|
|
330
|
+
if (signal.aborted) {
|
|
331
|
+
persistCancelled();
|
|
332
|
+
}
|
|
333
|
+
if (!cancelled) {
|
|
334
|
+
const sleepDir = join(memoryConfig.memoryDir, "sleep");
|
|
335
|
+
const previousLocks = scanPreviousLocks(sleepDir, dateStr);
|
|
336
|
+
if (previousLocks.length > 0) {
|
|
337
|
+
logInfo(TAG, `[CATCH-UP] Found ${previousLocks.length} previous lock(s)`);
|
|
338
|
+
await runCatchUp(previousLocks, sleepData, memoryConfig, steps, runtime, runId, signal, budget, 6000, options.onEvent);
|
|
339
|
+
}
|
|
845
340
|
}
|
|
846
341
|
// Housekeeping: move misplaced daily/consolidation_* to weekly/ (#640)
|
|
847
342
|
try {
|
|
@@ -850,9 +345,9 @@ export async function runSleepCycle(opts) {
|
|
|
850
345
|
for (const f of readdirSync(dailyDir).filter(fn => fn.startsWith("consolidation_"))) {
|
|
851
346
|
const m = f.match(/consolidation_(\d{4})-(\d{2})-week(\d)/);
|
|
852
347
|
if (m) {
|
|
853
|
-
const [, year,
|
|
348
|
+
const [, year, month2, week] = m;
|
|
854
349
|
const day = (parseInt(week) - 1) * 7 + 1;
|
|
855
|
-
const approxDate = `${year}-${
|
|
350
|
+
const approxDate = `${year}-${month2}-${String(Math.min(day, 28)).padStart(2, "0")}`;
|
|
856
351
|
const dest = join(weeklyDir, `weekly_${approxDate}.md`);
|
|
857
352
|
if (!existsSync(dest)) {
|
|
858
353
|
const { renameSync } = await import("node:fs");
|
|
@@ -866,52 +361,50 @@ export async function runSleepCycle(opts) {
|
|
|
866
361
|
catch (err) {
|
|
867
362
|
logWarn(TAG, `[HOUSEKEEPING] consolidation migration failed: ${err}`);
|
|
868
363
|
}
|
|
869
|
-
emitProgress("starting");
|
|
870
364
|
let consecutiveFailures = 0;
|
|
871
|
-
|
|
872
|
-
const stepLogDir = join(sleepDir, dateStr);
|
|
365
|
+
const stepLogDir = join(sleepDir(memoryConfig.memoryDir), dateStr);
|
|
873
366
|
mkdirSync(stepLogDir, { recursive: true });
|
|
874
|
-
// Load Dreamy identity (context injection, prepended to first step only)
|
|
875
367
|
const userSoul = join(memoryConfig.memoryDir, "..", "prompts", "sleep", "SOUL-Dreamy.md");
|
|
876
368
|
const pkgSoul = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "prompts", "sleep", "SOUL-Dreamy.md");
|
|
877
369
|
const soulPath = existsSync(userSoul) ? userSoul : pkgSoul;
|
|
878
370
|
let soulPrefix = existsSync(soulPath) ? readFileSync(soulPath, "utf-8") + "\n\n---\n\n" : "";
|
|
879
371
|
for (const step of steps) {
|
|
880
|
-
|
|
372
|
+
if (cancelled)
|
|
373
|
+
break;
|
|
374
|
+
// Checkpoint boundary: before each step's model/mutation work.
|
|
375
|
+
if (signal.aborted) {
|
|
376
|
+
persistCancelled();
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
881
379
|
if (budget.exhausted) {
|
|
882
380
|
logWarn(TAG, `[BUDGET] Suspending sleep — ${budget.calls}/${getAbmindEnv().sleepMaxLlmCalls} LLM calls used`);
|
|
883
381
|
state.status = "suspended";
|
|
884
382
|
writeStateFile(statePath, state);
|
|
885
383
|
break;
|
|
886
384
|
}
|
|
887
|
-
emitProgress(step.name);
|
|
888
385
|
stepIndex++;
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
// best-effort — a throwing handler never breaks the cycle.
|
|
892
|
-
fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "start" });
|
|
893
|
-
// Resume: skip already completed steps
|
|
386
|
+
const essential = ESSENTIAL_STEPS.has(step.name);
|
|
387
|
+
emitSleepEvent(options.onEvent, { type: "step_started", runId, stepId: step.name, index: stepIndex, total: totalSteps });
|
|
894
388
|
if (isResume && existingState?.steps[step.name]?.status === "ok") {
|
|
895
389
|
logInfo(TAG, `[SLEEP] ⏭ ${step.name} — already done (resume)`);
|
|
896
|
-
|
|
390
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, existingState.steps[step.name]) });
|
|
897
391
|
continue;
|
|
898
392
|
}
|
|
899
393
|
if (isResume && existingState?.steps[step.name]?.status === "skipped") {
|
|
900
394
|
logInfo(TAG, `[SLEEP] ⏭ ${step.name} — skipped (resume)`);
|
|
901
|
-
|
|
395
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, existingState.steps[step.name]) });
|
|
902
396
|
continue;
|
|
903
397
|
}
|
|
904
|
-
// Skip logic (essential steps always run)
|
|
905
398
|
if (step.skippable && skipSet.has(step.name)) {
|
|
906
399
|
logInfo(TAG, `[SLEEP] ⏭ ${step.name} — skipped`);
|
|
907
|
-
state.steps[step.name] = { status: "skipped" };
|
|
400
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
908
401
|
writeStateFile(statePath, state);
|
|
909
|
-
|
|
402
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, state.steps[step.name]) });
|
|
910
403
|
continue;
|
|
911
404
|
}
|
|
912
405
|
const start = Date.now();
|
|
913
406
|
logInfo(TAG, `[SLEEP] → ${step.name}`);
|
|
914
|
-
state.steps[step.name] = { status: "pending" };
|
|
407
|
+
state.steps[step.name] = { status: "pending", essential };
|
|
915
408
|
writeStateFile(statePath, state);
|
|
916
409
|
// Code-driven steps
|
|
917
410
|
if (step.name === "daily-summary") {
|
|
@@ -919,57 +412,44 @@ export async function runSleepCycle(opts) {
|
|
|
919
412
|
const ctxWindow = getAbmindEnv().sleepCtxWindow;
|
|
920
413
|
const userId = sleepData.getPrimaryUserId();
|
|
921
414
|
const watermarkTs = sleepData.getExtractionWatermark(userId);
|
|
922
|
-
// Determine target date from first unprocessed message
|
|
923
415
|
const firstMsgTs = sleepData.getFirstMessageAfter(userId, watermarkTs);
|
|
924
416
|
const firstMsgDate = firstMsgTs ? new Date(firstMsgTs) : new Date(now());
|
|
925
417
|
const targetDate = `${firstMsgDate.getFullYear()}-${String(firstMsgDate.getMonth() + 1).padStart(2, "0")}-${String(firstMsgDate.getDate()).padStart(2, "0")}`;
|
|
926
|
-
const summary = await buildDailySummary(sleepData.getDb(), (p) =>
|
|
418
|
+
const summary = await buildDailySummary(sleepData.getDb(), (p) => sendToRuntime(runtime, p, "daily-summary", runId, signal, budget, domainRetryDelayMs).then(r => { if (r === null)
|
|
927
419
|
throw new LLMUnavailableError(); return r; }), {
|
|
928
420
|
ctxWindow, memoryDir: memoryConfig.memoryDir, userId, watermarkTs,
|
|
929
421
|
});
|
|
930
422
|
if (summary) {
|
|
931
423
|
dailySummaryPath = writeDailyFile(memoryConfig.memoryDir, targetDate, summary);
|
|
932
|
-
state.steps[step.name] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10, path: dailySummaryPath };
|
|
424
|
+
state.steps[step.name] = { status: "ok", essential, duration: Math.round((Date.now() - start) / 100) / 10, path: dailySummaryPath };
|
|
933
425
|
writeFileSync(join(stepLogDir, `${String(stepIndex).padStart(2, "0")}-${step.name}.md`), redactSecrets(summary), "utf-8");
|
|
934
426
|
}
|
|
935
427
|
else {
|
|
936
|
-
state.steps[step.name] = { status: "skipped" };
|
|
428
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
937
429
|
}
|
|
938
430
|
}
|
|
939
431
|
catch (err) {
|
|
940
|
-
logWarn(TAG, `[SLEEP]
|
|
941
|
-
state.steps[step.name] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
432
|
+
logWarn(TAG, `[SLEEP] daily-summary failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
433
|
+
state.steps[step.name] = { status: "failed", essential, duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
942
434
|
dreamySucceeded = false;
|
|
943
435
|
writeStateFile(statePath, state);
|
|
944
|
-
|
|
945
|
-
if (err instanceof
|
|
946
|
-
logWarn(TAG, `[SLEEP] ${step.name} —
|
|
436
|
+
emitSleepEvent(options.onEvent, { type: "step_failed", runId, step: toSummary(step.name, "failed", essential, state.steps[step.name]) });
|
|
437
|
+
if (err instanceof TransportUnavailableError) {
|
|
438
|
+
logWarn(TAG, `[SLEEP] ${step.name} — runtime rejected, stopping cycle (not advancing to next phase)`);
|
|
947
439
|
break;
|
|
948
440
|
}
|
|
949
441
|
}
|
|
950
442
|
if (state.steps[step.name]?.status !== "failed") {
|
|
951
|
-
// success/skip path: persist state + log + fire event
|
|
952
443
|
writeStateFile(statePath, state);
|
|
444
|
+
const s = state.steps[step.name];
|
|
445
|
+
emitSleepEvent(options.onEvent, s.status === "ok"
|
|
446
|
+
? { type: "step_completed", runId, step: toSummary(step.name, "completed", essential, s) }
|
|
447
|
+
: { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, s) });
|
|
953
448
|
}
|
|
954
449
|
logInfo(TAG, `[SLEEP] ${state.steps[step.name]?.status === "ok" ? "✓" : "✗"} ${step.name} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
|
|
955
|
-
// #895: emit terminal event for code-driven daily-summary (failure already fired in catch)
|
|
956
|
-
if (state.steps[step.name]?.status !== "failed") {
|
|
957
|
-
fireOnStep(opts.onStep, {
|
|
958
|
-
name: step.name,
|
|
959
|
-
filename: step.filename,
|
|
960
|
-
index: stepIndex,
|
|
961
|
-
total: totalSteps,
|
|
962
|
-
phase: state.steps[step.name]?.status === "ok" ? "done"
|
|
963
|
-
: state.steps[step.name]?.status === "skipped" ? "skipped"
|
|
964
|
-
: "failed",
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
450
|
continue;
|
|
968
451
|
}
|
|
969
452
|
if (step.name === "extract-memories") {
|
|
970
|
-
// Resume path: if daily-summary already completed in a prior run, the
|
|
971
|
-
// in-memory dailySummaryPath is null. Recover it from the lock's
|
|
972
|
-
// recorded path so extract-memories can still run. #181.
|
|
973
453
|
if (!dailySummaryPath) {
|
|
974
454
|
const priorPath = state.steps["daily-summary"]?.path;
|
|
975
455
|
if (priorPath && existsSync(priorPath)) {
|
|
@@ -978,47 +458,38 @@ export async function runSleepCycle(opts) {
|
|
|
978
458
|
}
|
|
979
459
|
}
|
|
980
460
|
if (!dailySummaryPath) {
|
|
981
|
-
state.steps[step.name] = { status: "skipped" };
|
|
461
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
982
462
|
writeStateFile(statePath, state);
|
|
983
463
|
logInfo(TAG, `[SLEEP] ⏭ ${step.name} — no daily summary`);
|
|
984
|
-
|
|
985
|
-
fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
|
|
464
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, state.steps[step.name]) });
|
|
986
465
|
continue;
|
|
987
466
|
}
|
|
988
467
|
try {
|
|
989
468
|
const userId = sleepData.getPrimaryUserId();
|
|
990
|
-
const result = await extractFromDaily(dailySummaryPath, userId, (p) =>
|
|
469
|
+
const result = await extractFromDaily(dailySummaryPath, userId, (p) => sendToRuntime(runtime, p, "extract-memories", runId, signal, budget, domainRetryDelayMs).then(r => { if (r === null)
|
|
991
470
|
throw new LLMUnavailableError(); return r; }));
|
|
992
|
-
state.steps[step.name] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
471
|
+
state.steps[step.name] = { status: "ok", essential, duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
993
472
|
writeFileSync(join(stepLogDir, `${String(stepIndex).padStart(2, "0")}-${step.name}.md`), redactSecrets(result), "utf-8");
|
|
994
473
|
logInfo(TAG, `[SLEEP] ✓ ${step.name} (${((Date.now() - start) / 1000).toFixed(1)}s) — ${result.slice(0, 80)}`);
|
|
995
474
|
}
|
|
996
475
|
catch (err) {
|
|
997
|
-
logWarn(TAG, `[SLEEP]
|
|
998
|
-
state.steps[step.name] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
476
|
+
logWarn(TAG, `[SLEEP] extract-memories failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
477
|
+
state.steps[step.name] = { status: "failed", essential, duration: Math.round((Date.now() - start) / 100) / 10 };
|
|
999
478
|
dreamySucceeded = false;
|
|
1000
479
|
writeStateFile(statePath, state);
|
|
1001
|
-
|
|
1002
|
-
if (err instanceof
|
|
1003
|
-
logWarn(TAG, `[SLEEP] ${step.name} —
|
|
480
|
+
emitSleepEvent(options.onEvent, { type: "step_failed", runId, step: toSummary(step.name, "failed", essential, state.steps[step.name]) });
|
|
481
|
+
if (err instanceof TransportUnavailableError) {
|
|
482
|
+
logWarn(TAG, `[SLEEP] ${step.name} — runtime rejected, stopping cycle (not advancing to next phase)`);
|
|
1004
483
|
break;
|
|
1005
484
|
}
|
|
1006
485
|
}
|
|
1007
486
|
if (state.steps[step.name]?.status !== "failed") {
|
|
1008
487
|
writeStateFile(statePath, state);
|
|
1009
|
-
|
|
1010
|
-
fireOnStep(opts.onStep, {
|
|
1011
|
-
name: step.name,
|
|
1012
|
-
filename: step.filename,
|
|
1013
|
-
index: stepIndex,
|
|
1014
|
-
total: totalSteps,
|
|
1015
|
-
phase: state.steps[step.name]?.status === "ok" ? "done" : "failed",
|
|
1016
|
-
});
|
|
488
|
+
emitSleepEvent(options.onEvent, { type: "step_completed", runId, step: toSummary(step.name, "completed", essential, state.steps[step.name]) });
|
|
1017
489
|
}
|
|
1018
490
|
continue;
|
|
1019
491
|
}
|
|
1020
492
|
// Standard prompt-driven step — JIT substitution
|
|
1021
|
-
// Populate contradiction+graph vars JIT (needs today's extractions to exist)
|
|
1022
493
|
if (step.name === "contradiction-and-graph") {
|
|
1023
494
|
try {
|
|
1024
495
|
const todayStart = new Date(now());
|
|
@@ -1026,15 +497,13 @@ export async function runSleepCycle(opts) {
|
|
|
1026
497
|
const memDb = memory.getDatabase();
|
|
1027
498
|
const newRows = (memDb?.prepare(`SELECT id, content_en, memory_type, topic, trust FROM extracted_memories WHERE created_at >= ? AND memory_type != 'observation' ORDER BY created_at DESC LIMIT 30`).all(todayStart.getTime()) ?? []);
|
|
1028
499
|
if (newRows.length === 0) {
|
|
1029
|
-
state.steps[step.name] = { status: "skipped" };
|
|
500
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
1030
501
|
writeStateFile(statePath, state);
|
|
1031
502
|
logInfo(TAG, `[SLEEP] ⏭ ${step.name} — no new extractions today`);
|
|
1032
|
-
|
|
1033
|
-
fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
|
|
503
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, state.steps[step.name]) });
|
|
1034
504
|
continue;
|
|
1035
505
|
}
|
|
1036
506
|
vars.NEW_EXTRACTIONS = newRows.map(r => `[id=${r.id}] (${r.memory_type}, trust=${r.trust}) ${r.content_en}`).join("\n");
|
|
1037
|
-
// Find candidates: FTS5 top-5 per new memory, capped at 20 total
|
|
1038
507
|
const candidateIds = new Set();
|
|
1039
508
|
const candidateRows = [];
|
|
1040
509
|
for (const nr of newRows.slice(0, 5)) {
|
|
@@ -1058,67 +527,64 @@ export async function runSleepCycle(opts) {
|
|
|
1058
527
|
}
|
|
1059
528
|
catch (err) {
|
|
1060
529
|
logWarn(TAG, `[SLEEP] contradiction-and-graph var prep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1061
|
-
state.steps[step.name] = { status: "skipped" };
|
|
530
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
1062
531
|
writeStateFile(statePath, state);
|
|
1063
|
-
|
|
1064
|
-
fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
|
|
532
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, state.steps[step.name]) });
|
|
1065
533
|
continue;
|
|
1066
534
|
}
|
|
1067
535
|
}
|
|
1068
|
-
// Populate REM sample vars JIT
|
|
1069
536
|
if (step.name === "rem-synthesis") {
|
|
1070
537
|
try {
|
|
1071
538
|
const memDb = memory.getDatabase();
|
|
1072
539
|
const sample = memDb?.prepare(`SELECT id, content_en, memory_type, created_at FROM extracted_memories WHERE trust >= 2 AND memory_type != 'observation' AND valid_to IS NULL ORDER BY RANDOM() LIMIT 10`).all() ?? [];
|
|
1073
540
|
if (sample.length < 5) {
|
|
1074
|
-
state.steps[step.name] = { status: "skipped" };
|
|
541
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
1075
542
|
writeStateFile(statePath, state);
|
|
1076
543
|
logInfo(TAG, `[SLEEP] ⏭ ${step.name} — not enough memories for REM`);
|
|
1077
|
-
|
|
1078
|
-
fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
|
|
544
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, state.steps[step.name]) });
|
|
1079
545
|
continue;
|
|
1080
546
|
}
|
|
1081
547
|
vars.REM_SAMPLE = sample.map(r => `[${r.memory_type}, ${new Date(r.created_at).toISOString().slice(0, 10)}] ${r.content_en}`).join("\n");
|
|
1082
548
|
}
|
|
1083
549
|
catch {
|
|
1084
|
-
state.steps[step.name] = { status: "skipped" };
|
|
550
|
+
state.steps[step.name] = { status: "skipped", essential };
|
|
1085
551
|
writeStateFile(statePath, state);
|
|
1086
|
-
|
|
1087
|
-
fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
|
|
552
|
+
emitSleepEvent(options.onEvent, { type: "step_skipped", runId, step: toSummary(step.name, "skipped", essential, state.steps[step.name]) });
|
|
1088
553
|
continue;
|
|
1089
554
|
}
|
|
1090
555
|
}
|
|
1091
556
|
const prompt = substituteVars(step.rawPrompt, vars);
|
|
1092
557
|
const fullPrompt = soulPrefix + prompt;
|
|
1093
558
|
if (soulPrefix)
|
|
1094
|
-
soulPrefix = "";
|
|
1095
|
-
const ctxBefore = -1;
|
|
559
|
+
soulPrefix = "";
|
|
1096
560
|
let response;
|
|
1097
561
|
try {
|
|
1098
|
-
response = await
|
|
562
|
+
response = await sendToRuntime(runtime, fullPrompt, step.name, runId, signal, budget, domainRetryDelayMs);
|
|
1099
563
|
}
|
|
1100
564
|
catch (err) {
|
|
1101
|
-
if (err instanceof
|
|
565
|
+
if (err instanceof TransportUnavailableError) {
|
|
1102
566
|
const duration = Date.now() - start;
|
|
1103
|
-
logWarn(TAG, `[SLEEP] ${step.name} —
|
|
1104
|
-
state.steps[step.name] = { status: "failed", duration: Math.round(duration / 100) / 10
|
|
567
|
+
logWarn(TAG, `[SLEEP] ${step.name} — runtime rejected, stopping cycle (not advancing to next phase)`);
|
|
568
|
+
state.steps[step.name] = { status: "failed", essential, duration: Math.round(duration / 100) / 10 };
|
|
1105
569
|
dreamySucceeded = false;
|
|
1106
570
|
writeStateFile(statePath, state);
|
|
1107
|
-
|
|
571
|
+
emitSleepEvent(options.onEvent, { type: "step_failed", runId, step: toSummary(step.name, "failed", essential, state.steps[step.name]) });
|
|
1108
572
|
break;
|
|
1109
573
|
}
|
|
1110
574
|
throw err;
|
|
1111
575
|
}
|
|
1112
|
-
const ctxAfter = -1;
|
|
1113
576
|
const duration = Date.now() - start;
|
|
577
|
+
// Checkpoint boundary: after the awaited call, before applying its output.
|
|
578
|
+
if (signal.aborted) {
|
|
579
|
+
persistCancelled();
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
1114
582
|
if (response) {
|
|
1115
|
-
state.steps[step.name] = { status: "ok", duration: Math.round(duration / 100) / 10
|
|
583
|
+
state.steps[step.name] = { status: "ok", essential, duration: Math.round(duration / 100) / 10 };
|
|
1116
584
|
writeFileSync(join(stepLogDir, `${String(stepIndex).padStart(2, "0")}-${step.name}.md`), redactSecrets(response), "utf-8");
|
|
1117
|
-
// Generic output chaining + explicit aliases
|
|
1118
585
|
vars[step.name.toUpperCase().replace(/-/g, "_") + "_OUTPUT"] = response;
|
|
1119
586
|
if (step.name === "retrospective")
|
|
1120
587
|
vars.RETRO_CONTENT = response;
|
|
1121
|
-
// #515: parse CONTRADICT directives from step 15 and invalidate old memories
|
|
1122
588
|
if (step.name === "contradiction-and-graph") {
|
|
1123
589
|
const memDb = memory.getDatabase();
|
|
1124
590
|
if (memDb) {
|
|
@@ -1130,24 +596,22 @@ export async function runSleepCycle(opts) {
|
|
|
1130
596
|
if (changes > 0)
|
|
1131
597
|
logInfo(TAG, `[SLEEP] Invalidated memory #${oldId} (contradicted)`);
|
|
1132
598
|
}
|
|
1133
|
-
// #520: parse RELATION directives → entity_graph
|
|
1134
599
|
const relationRe = /RELATION\s+entity_a="([^"]+)"\s+entity_b="([^"]+)"\s+rel="([^"]+)"/g;
|
|
1135
600
|
let rm;
|
|
1136
601
|
while ((rm = relationRe.exec(response)) !== null) {
|
|
1137
602
|
const [, a, b, rel] = rm;
|
|
1138
603
|
memDb.prepare(`INSERT INTO entity_graph (entity_a, entity_b, relation, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(entity_a, entity_b, relation) DO UPDATE SET last_seen_at = ?`).run(a, b, rel, Date.now(), Date.now(), Date.now());
|
|
1139
604
|
}
|
|
1140
|
-
// #529: age out stale event memories via decay (recall_count / age_days < threshold)
|
|
1141
605
|
const EVENT_MIN_AGE_DAYS = 7;
|
|
1142
|
-
const DECAY_THRESHOLD = 0.1;
|
|
1143
|
-
const
|
|
1144
|
-
const
|
|
606
|
+
const DECAY_THRESHOLD = 0.1;
|
|
607
|
+
const nowMs = Date.now();
|
|
608
|
+
const decayCandidates = memDb.prepare(`SELECT id, recall_count, created_at FROM extracted_memories WHERE memory_type = 'event' AND valid_to IS NULL AND created_at < ?`).all(nowMs - EVENT_MIN_AGE_DAYS * 86400_000);
|
|
1145
609
|
let agedCount = 0;
|
|
1146
|
-
for (const m of
|
|
1147
|
-
const ageDays = (
|
|
610
|
+
for (const m of decayCandidates) {
|
|
611
|
+
const ageDays = (nowMs - m.created_at) / 86400_000;
|
|
1148
612
|
const score = m.recall_count / ageDays;
|
|
1149
613
|
if (score < DECAY_THRESHOLD) {
|
|
1150
|
-
memDb.prepare("UPDATE extracted_memories SET valid_to = ? WHERE id = ?").run(
|
|
614
|
+
memDb.prepare("UPDATE extracted_memories SET valid_to = ? WHERE id = ?").run(nowMs, m.id);
|
|
1151
615
|
agedCount++;
|
|
1152
616
|
}
|
|
1153
617
|
}
|
|
@@ -1157,57 +621,61 @@ export async function runSleepCycle(opts) {
|
|
|
1157
621
|
}
|
|
1158
622
|
}
|
|
1159
623
|
else {
|
|
1160
|
-
state.steps[step.name] = { status: "failed", duration: Math.round(duration / 100) / 10, attempts:
|
|
624
|
+
state.steps[step.name] = { status: "failed", essential, duration: Math.round(duration / 100) / 10, attempts: MAX_DOMAIN_RETRIES };
|
|
1161
625
|
dreamySucceeded = false;
|
|
1162
626
|
}
|
|
1163
627
|
writeStateFile(statePath, state);
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
filename: step.filename,
|
|
1168
|
-
index: stepIndex,
|
|
1169
|
-
total: totalSteps,
|
|
1170
|
-
phase: response ? "done" : "failed",
|
|
1171
|
-
});
|
|
628
|
+
emitSleepEvent(options.onEvent, response
|
|
629
|
+
? { type: "step_completed", runId, step: toSummary(step.name, "completed", essential, state.steps[step.name]) }
|
|
630
|
+
: { type: "step_failed", runId, step: toSummary(step.name, "failed", essential, state.steps[step.name]) });
|
|
1172
631
|
logInfo(TAG, `[SLEEP] ${response ? "✓" : "✗"} ${step.name} (${(duration / 1000).toFixed(1)}s, ${response?.length ?? 0} chars)`);
|
|
1173
|
-
// Backoff between steps: 10s → 30s → 60s on consecutive failures, reset on success
|
|
1174
632
|
if (response) {
|
|
1175
633
|
consecutiveFailures = 0;
|
|
1176
634
|
}
|
|
1177
635
|
else {
|
|
1178
636
|
consecutiveFailures++;
|
|
1179
637
|
}
|
|
1180
|
-
const isEssential =
|
|
638
|
+
const isEssential = essential;
|
|
1181
639
|
if (!isEssential) {
|
|
1182
|
-
const delayMs =
|
|
1183
|
-
if (delayMs > 0) {
|
|
640
|
+
const delayMs = betweenStepBackoffMs(consecutiveFailures);
|
|
641
|
+
if (delayMs > 0 && consecutiveFailures > 0) {
|
|
1184
642
|
logInfo(TAG, `[SLEEP] Waiting ${Math.round(delayMs / 1000)}s before next step`);
|
|
1185
643
|
await new Promise(r => setTimeout(r, delayMs));
|
|
1186
644
|
}
|
|
1187
645
|
}
|
|
646
|
+
// Checkpoint boundary: between step mutations.
|
|
647
|
+
if (signal.aborted) {
|
|
648
|
+
persistCancelled();
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
1188
651
|
}
|
|
1189
652
|
}
|
|
1190
653
|
finally {
|
|
1191
|
-
|
|
1192
|
-
|
|
654
|
+
cleanupCancellation();
|
|
655
|
+
}
|
|
656
|
+
if (cancelled) {
|
|
657
|
+
const result = projectResult(runId, "cancelled", startedAt, now(), state, /* watermarkAdvanced */ false, /* resumable */ true);
|
|
658
|
+
emitSleepEvent(options.onEvent, { type: "cycle_finished", runId, result });
|
|
659
|
+
return result;
|
|
1193
660
|
}
|
|
1194
661
|
// Set final status
|
|
1195
662
|
if (state.status === "ongoing") {
|
|
1196
663
|
state.status = dreamySucceeded ? "completed" : "failed";
|
|
1197
664
|
writeStateFile(statePath, state);
|
|
1198
665
|
}
|
|
1199
|
-
//
|
|
1200
|
-
|
|
666
|
+
// Checkpoint boundary: before watermark advance.
|
|
667
|
+
let watermarkAdvanced = false;
|
|
668
|
+
if (dreamySucceeded && !signal.aborted) {
|
|
1201
669
|
try {
|
|
1202
670
|
const count = sleepData.advanceExtractionWatermarks();
|
|
671
|
+
watermarkAdvanced = count > 0;
|
|
1203
672
|
logInfo(TAG, `[SLEEP] Extraction watermark advanced for ${count} chat(s)`);
|
|
1204
673
|
}
|
|
1205
674
|
catch { /* non-fatal */ }
|
|
1206
675
|
}
|
|
1207
|
-
else {
|
|
676
|
+
else if (!dreamySucceeded) {
|
|
1208
677
|
logWarn(TAG, "[SLEEP] Watermark NOT advanced — essential steps failed, messages preserved for catch-up");
|
|
1209
678
|
}
|
|
1210
|
-
// Write audit
|
|
1211
679
|
const stepEntries = Object.entries(state.steps);
|
|
1212
680
|
const okCount = stepEntries.filter(([, s]) => s.status === "ok").length;
|
|
1213
681
|
const failCount = stepEntries.filter(([, s]) => s.status === "failed" || s.status === "timeout").length;
|
|
@@ -1226,10 +694,8 @@ export async function runSleepCycle(opts) {
|
|
|
1226
694
|
catch (err) {
|
|
1227
695
|
process.stderr.write(`Warning: Failed to write audit — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1228
696
|
}
|
|
1229
|
-
// Wired post-task: flush old messages (keep max 500, age out >7 days, garbage 12h)
|
|
1230
697
|
if (dreamySucceeded) {
|
|
1231
698
|
try {
|
|
1232
|
-
// Flush garbage-marked messages
|
|
1233
699
|
const garbagePath = join(memoryConfig.memoryDir, "garbage.json");
|
|
1234
700
|
if (existsSync(garbagePath)) {
|
|
1235
701
|
const raw = JSON.parse(readFileSync(garbagePath, "utf-8"));
|
|
@@ -1243,7 +709,6 @@ export async function runSleepCycle(opts) {
|
|
|
1243
709
|
writeFileSync(garbagePath, "[]");
|
|
1244
710
|
}
|
|
1245
711
|
}
|
|
1246
|
-
// Age out + cap
|
|
1247
712
|
const { agedOut, capped } = sleepData.flushOldMessages({ maxAgeDays: 7, maxCount: 500 });
|
|
1248
713
|
if (agedOut > 0)
|
|
1249
714
|
logInfo(TAG, `[SLEEP] Flushed ${agedOut} messages >7d`);
|
|
@@ -1254,9 +719,7 @@ export async function runSleepCycle(opts) {
|
|
|
1254
719
|
logWarn(TAG, `[WIRED] flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1255
720
|
}
|
|
1256
721
|
}
|
|
1257
|
-
emitProgress("done");
|
|
1258
722
|
logInfo(TAG, `[SLEEP] 🏁 ${okCount} ok, ${failCount} failed, ${skipCount} skipped | wired: ${formatWiredResults(wiredResults)} | ${totalDuration.toFixed(0)}s total`);
|
|
1259
|
-
// Record result in _meta (#447)
|
|
1260
723
|
if (failCount === 0) {
|
|
1261
724
|
metaSet(db, "sleep_last_success_ts", Date.now());
|
|
1262
725
|
metaSet(db, "sleep_consecutive_failures", 0);
|
|
@@ -1266,13 +729,86 @@ export async function runSleepCycle(opts) {
|
|
|
1266
729
|
metaSet(db, "sleep_consecutive_failures", prev + 1);
|
|
1267
730
|
metaSet(db, "sleep_last_fail_reason", `${failCount} step(s) failed`);
|
|
1268
731
|
}
|
|
1269
|
-
|
|
732
|
+
const terminalStatus = failCount === 0
|
|
733
|
+
? "completed"
|
|
734
|
+
: failedEssentials(state).length > 0
|
|
735
|
+
? (okCount > 0 || skipCount > 0 ? "partial" : "failed")
|
|
736
|
+
: "partial";
|
|
737
|
+
const result = projectResult(runId, terminalStatus, startedAt, now(), state, watermarkAdvanced, failedEssentials(state).length > 0);
|
|
738
|
+
emitSleepEvent(options.onEvent, { type: "cycle_finished", runId, result });
|
|
739
|
+
return result;
|
|
1270
740
|
}
|
|
1271
741
|
finally {
|
|
742
|
+
activeRunsByMemoryDir.delete(memoryDirKey);
|
|
1272
743
|
memory.close();
|
|
1273
744
|
}
|
|
1274
745
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
746
|
+
function sleepDir(memoryDir) {
|
|
747
|
+
return join(memoryDir, "sleep");
|
|
748
|
+
}
|
|
749
|
+
/** Internal marker for a timeout-triggered abort (never surfaced as a public error type). */
|
|
750
|
+
class SleepTimeoutReason extends Error {
|
|
751
|
+
constructor() { super("sleep cycle timeout"); this.name = "SleepTimeoutReason"; }
|
|
752
|
+
}
|
|
753
|
+
function toSummary(id, status, essential, s) {
|
|
754
|
+
return {
|
|
755
|
+
id,
|
|
756
|
+
status,
|
|
757
|
+
essential,
|
|
758
|
+
attempts: s?.attempts ?? 1,
|
|
759
|
+
durationMs: s?.duration != null ? Math.round(s.duration * 1000) : undefined,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function projectResult(runId, status, startedAt, finishedAt, state, watermarkAdvanced, resumable) {
|
|
763
|
+
const steps = Object.entries(state.steps).map(([id, s]) => toSummary(id, s.status === "ok" ? "completed" : s.status === "timeout" ? "timeout" : s.status === "skipped" ? "skipped" : "failed", s.essential ?? ESSENTIAL_STEPS.has(id), s));
|
|
764
|
+
const essentialFailures = failedEssentials(state);
|
|
765
|
+
const okCount = steps.filter(s => s.status === "completed").length;
|
|
766
|
+
const failCount = steps.filter(s => s.status === "failed" || s.status === "timeout").length;
|
|
767
|
+
const skipCount = steps.filter(s => s.status === "skipped").length;
|
|
768
|
+
const report = `Sleep ${status} — ${okCount} completed, ${failCount} failed, ${skipCount} skipped (of ${steps.length}).`
|
|
769
|
+
+ (essentialFailures.length > 0 ? ` Essential failures: ${essentialFailures.join(", ")}.` : "");
|
|
770
|
+
return {
|
|
771
|
+
runId,
|
|
772
|
+
status,
|
|
773
|
+
startedAt,
|
|
774
|
+
finishedAt,
|
|
775
|
+
llmCalls: state.llmCalls ?? 0,
|
|
776
|
+
steps,
|
|
777
|
+
essentialFailures,
|
|
778
|
+
resumable,
|
|
779
|
+
watermarkAdvanced,
|
|
780
|
+
report,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function alreadyRunningResult(startedAt, finishedAt) {
|
|
784
|
+
return {
|
|
785
|
+
runId: "",
|
|
786
|
+
status: "already_running",
|
|
787
|
+
startedAt,
|
|
788
|
+
finishedAt,
|
|
789
|
+
llmCalls: 0,
|
|
790
|
+
steps: [],
|
|
791
|
+
essentialFailures: [],
|
|
792
|
+
resumable: false,
|
|
793
|
+
watermarkAdvanced: false,
|
|
794
|
+
report: "Sleep cycle already running — no-op.",
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function noWorkResult(runId, startedAt, finishedAt) {
|
|
798
|
+
return {
|
|
799
|
+
runId,
|
|
800
|
+
status: "no_work",
|
|
801
|
+
startedAt,
|
|
802
|
+
finishedAt,
|
|
803
|
+
llmCalls: 0,
|
|
804
|
+
steps: [],
|
|
805
|
+
essentialFailures: [],
|
|
806
|
+
resumable: false,
|
|
807
|
+
watermarkAdvanced: false,
|
|
808
|
+
report: "No messages since last sleep — nothing to process.",
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
// CLI entry + isDirectRun removed — the standalone entry point lives in
|
|
812
|
+
// cli/abmind-sleep.ts. Library consumers call runSleepCycle(options) directly
|
|
813
|
+
// with their own SleepRuntime.
|
|
1278
814
|
//# sourceMappingURL=orchestrator.js.map
|