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.
Files changed (177) hide show
  1. package/SUPPORTED-SURFACE.md +20 -2
  2. package/dist/cli/abmind-hook-recall.js +17 -40
  3. package/dist/cli/abmind-hook-recall.js.map +1 -1
  4. package/dist/cli/abmind-hook-store.js +10 -31
  5. package/dist/cli/abmind-hook-store.js.map +1 -1
  6. package/dist/cli/abmind-hook-wakeup.js +17 -3
  7. package/dist/cli/abmind-hook-wakeup.js.map +1 -1
  8. package/dist/cli/abmind-install.js +19 -19
  9. package/dist/cli/abmind-install.js.map +1 -1
  10. package/dist/cli/abmind-restore.js +10 -0
  11. package/dist/cli/abmind-restore.js.map +1 -1
  12. package/dist/cli/abmind-sleep.d.ts +9 -5
  13. package/dist/cli/abmind-sleep.d.ts.map +1 -1
  14. package/dist/cli/abmind-sleep.js +61 -14
  15. package/dist/cli/abmind-sleep.js.map +1 -1
  16. package/dist/cli/hook-lifecycle-adapter.d.ts +11 -0
  17. package/dist/cli/hook-lifecycle-adapter.d.ts.map +1 -0
  18. package/dist/cli/hook-lifecycle-adapter.js +33 -0
  19. package/dist/cli/hook-lifecycle-adapter.js.map +1 -0
  20. package/dist/src/context-engine.d.ts +13 -3
  21. package/dist/src/context-engine.d.ts.map +1 -1
  22. package/dist/src/context-engine.js +22 -5
  23. package/dist/src/context-engine.js.map +1 -1
  24. package/dist/src/context-orchestrator.d.ts +20 -2
  25. package/dist/src/context-orchestrator.d.ts.map +1 -1
  26. package/dist/src/context-orchestrator.js +11 -3
  27. package/dist/src/context-orchestrator.js.map +1 -1
  28. package/dist/src/context-tier-renderer.d.ts +8 -1
  29. package/dist/src/context-tier-renderer.d.ts.map +1 -1
  30. package/dist/src/context-tier-renderer.js +7 -2
  31. package/dist/src/context-tier-renderer.js.map +1 -1
  32. package/dist/src/ensure-initialized.d.ts +5 -0
  33. package/dist/src/ensure-initialized.d.ts.map +1 -1
  34. package/dist/src/ensure-initialized.js +19 -28
  35. package/dist/src/ensure-initialized.js.map +1 -1
  36. package/dist/src/fts-utils.d.ts +47 -0
  37. package/dist/src/fts-utils.d.ts.map +1 -0
  38. package/dist/src/fts-utils.js +65 -0
  39. package/dist/src/fts-utils.js.map +1 -0
  40. package/dist/src/host-integration/identity.d.ts +10 -0
  41. package/dist/src/host-integration/identity.d.ts.map +1 -0
  42. package/dist/src/host-integration/identity.js +66 -0
  43. package/dist/src/host-integration/identity.js.map +1 -0
  44. package/dist/src/host-integration/index.d.ts +5 -0
  45. package/dist/src/host-integration/index.d.ts.map +1 -0
  46. package/dist/src/host-integration/index.js +4 -0
  47. package/dist/src/host-integration/index.js.map +1 -0
  48. package/dist/src/host-integration/lifecycle.d.ts +14 -0
  49. package/dist/src/host-integration/lifecycle.d.ts.map +1 -0
  50. package/dist/src/host-integration/lifecycle.js +192 -0
  51. package/dist/src/host-integration/lifecycle.js.map +1 -0
  52. package/dist/src/host-integration/render.d.ts +5 -0
  53. package/dist/src/host-integration/render.d.ts.map +1 -0
  54. package/dist/src/host-integration/render.js +20 -0
  55. package/dist/src/host-integration/render.js.map +1 -0
  56. package/dist/src/host-integration/types.d.ts +107 -0
  57. package/dist/src/host-integration/types.d.ts.map +1 -0
  58. package/dist/src/host-integration/types.js +2 -0
  59. package/dist/src/host-integration/types.js.map +1 -0
  60. package/dist/src/imemory-system.d.ts +1 -1
  61. package/dist/src/imemory-system.d.ts.map +1 -1
  62. package/dist/src/index.d.ts +6 -4
  63. package/dist/src/index.d.ts.map +1 -1
  64. package/dist/src/index.js +3 -1
  65. package/dist/src/index.js.map +1 -1
  66. package/dist/src/maintenance-service.d.ts +11 -0
  67. package/dist/src/maintenance-service.d.ts.map +1 -1
  68. package/dist/src/maintenance-service.js +43 -0
  69. package/dist/src/maintenance-service.js.map +1 -1
  70. package/dist/src/memory-index.d.ts +0 -16
  71. package/dist/src/memory-index.d.ts.map +1 -1
  72. package/dist/src/memory-index.js +1 -60
  73. package/dist/src/memory-index.js.map +1 -1
  74. package/dist/src/memory-manager.d.ts +6 -2
  75. package/dist/src/memory-manager.d.ts.map +1 -1
  76. package/dist/src/memory-manager.js +9 -5
  77. package/dist/src/memory-manager.js.map +1 -1
  78. package/dist/src/message-store.d.ts +15 -3
  79. package/dist/src/message-store.d.ts.map +1 -1
  80. package/dist/src/message-store.js +28 -9
  81. package/dist/src/message-store.js.map +1 -1
  82. package/dist/src/openclaw-plugin/index.d.ts.map +1 -1
  83. package/dist/src/openclaw-plugin/index.js +3 -2
  84. package/dist/src/openclaw-plugin/index.js.map +1 -1
  85. package/dist/src/pi-plugin/abtars-client.d.ts +22 -0
  86. package/dist/src/pi-plugin/abtars-client.d.ts.map +1 -0
  87. package/dist/src/pi-plugin/abtars-client.js +153 -0
  88. package/dist/src/pi-plugin/abtars-client.js.map +1 -0
  89. package/dist/src/pi-plugin/abtars-tools.d.ts +8 -0
  90. package/dist/src/pi-plugin/abtars-tools.d.ts.map +1 -0
  91. package/dist/src/pi-plugin/abtars-tools.js +317 -0
  92. package/dist/src/pi-plugin/abtars-tools.js.map +1 -0
  93. package/dist/src/pi-plugin/identity.d.ts +8 -0
  94. package/dist/src/pi-plugin/identity.d.ts.map +1 -0
  95. package/dist/src/pi-plugin/identity.js +37 -0
  96. package/dist/src/pi-plugin/identity.js.map +1 -0
  97. package/dist/src/pi-plugin/index.d.ts +3 -0
  98. package/dist/src/pi-plugin/index.d.ts.map +1 -0
  99. package/dist/src/pi-plugin/index.js +139 -0
  100. package/dist/src/pi-plugin/index.js.map +1 -0
  101. package/dist/src/pi-plugin/messages.d.ts +4 -0
  102. package/dist/src/pi-plugin/messages.d.ts.map +1 -0
  103. package/dist/src/pi-plugin/messages.js +36 -0
  104. package/dist/src/pi-plugin/messages.js.map +1 -0
  105. package/dist/src/pi-plugin/pi-types.d.ts +126 -0
  106. package/dist/src/pi-plugin/pi-types.d.ts.map +1 -0
  107. package/dist/src/pi-plugin/pi-types.js +15 -0
  108. package/dist/src/pi-plugin/pi-types.js.map +1 -0
  109. package/dist/src/pi-plugin/runtime.d.ts +21 -0
  110. package/dist/src/pi-plugin/runtime.d.ts.map +1 -0
  111. package/dist/src/pi-plugin/runtime.js +60 -0
  112. package/dist/src/pi-plugin/runtime.js.map +1 -0
  113. package/dist/src/pi-plugin/tools.d.ts +11 -0
  114. package/dist/src/pi-plugin/tools.d.ts.map +1 -0
  115. package/dist/src/pi-plugin/tools.js +136 -0
  116. package/dist/src/pi-plugin/tools.js.map +1 -0
  117. package/dist/src/recall-boosts.d.ts +15 -0
  118. package/dist/src/recall-boosts.d.ts.map +1 -0
  119. package/dist/src/recall-boosts.js +159 -0
  120. package/dist/src/recall-boosts.js.map +1 -0
  121. package/dist/src/recall-engine.d.ts +0 -2
  122. package/dist/src/recall-engine.d.ts.map +1 -1
  123. package/dist/src/recall-engine.js +1 -151
  124. package/dist/src/recall-engine.js.map +1 -1
  125. package/dist/src/reconcile.d.ts +2 -0
  126. package/dist/src/reconcile.d.ts.map +1 -1
  127. package/dist/src/reconcile.js +21 -4
  128. package/dist/src/reconcile.js.map +1 -1
  129. package/dist/src/session-context.d.ts +1 -0
  130. package/dist/src/session-context.d.ts.map +1 -1
  131. package/dist/src/session-context.js +42 -23
  132. package/dist/src/session-context.js.map +1 -1
  133. package/dist/src/sleep/audit.d.ts +6 -0
  134. package/dist/src/sleep/audit.d.ts.map +1 -1
  135. package/dist/src/sleep/audit.js +6 -8
  136. package/dist/src/sleep/audit.js.map +1 -1
  137. package/dist/src/sleep/basic.d.ts +1 -1
  138. package/dist/src/sleep/basic.d.ts.map +1 -1
  139. package/dist/src/sleep/basic.js +5 -2
  140. package/dist/src/sleep/basic.js.map +1 -1
  141. package/dist/src/sleep/catchup.d.ts +17 -0
  142. package/dist/src/sleep/catchup.d.ts.map +1 -0
  143. package/dist/src/sleep/catchup.js +141 -0
  144. package/dist/src/sleep/catchup.js.map +1 -0
  145. package/dist/src/sleep/contracts.d.ts +112 -0
  146. package/dist/src/sleep/contracts.d.ts.map +1 -0
  147. package/dist/src/sleep/contracts.js +25 -0
  148. package/dist/src/sleep/contracts.js.map +1 -0
  149. package/dist/src/sleep/llm-budget.d.ts +49 -0
  150. package/dist/src/sleep/llm-budget.d.ts.map +1 -0
  151. package/dist/src/sleep/llm-budget.js +106 -0
  152. package/dist/src/sleep/llm-budget.js.map +1 -0
  153. package/dist/src/sleep/locks.d.ts +9 -5
  154. package/dist/src/sleep/locks.d.ts.map +1 -1
  155. package/dist/src/sleep/locks.js +8 -11
  156. package/dist/src/sleep/locks.js.map +1 -1
  157. package/dist/src/sleep/orchestrator.d.ts +20 -86
  158. package/dist/src/sleep/orchestrator.d.ts.map +1 -1
  159. package/dist/src/sleep/orchestrator.js +291 -755
  160. package/dist/src/sleep/orchestrator.js.map +1 -1
  161. package/dist/src/sleep/state.d.ts +15 -18
  162. package/dist/src/sleep/state.d.ts.map +1 -1
  163. package/dist/src/sleep/state.js +24 -19
  164. package/dist/src/sleep/state.js.map +1 -1
  165. package/dist/src/sleep/test-harness.d.ts +3 -1
  166. package/dist/src/sleep/test-harness.d.ts.map +1 -1
  167. package/dist/src/sleep/test-harness.js +3 -2
  168. package/dist/src/sleep/test-harness.js.map +1 -1
  169. package/dist/src/soul-seeder.d.ts +36 -0
  170. package/dist/src/soul-seeder.d.ts.map +1 -0
  171. package/dist/src/soul-seeder.js +69 -0
  172. package/dist/src/soul-seeder.js.map +1 -0
  173. package/dist/src/wake-up-builder.d.ts +3 -1
  174. package/dist/src/wake-up-builder.d.ts.map +1 -1
  175. package/dist/src/wake-up-builder.js +29 -5
  176. package/dist/src/wake-up-builder.js.map +1 -1
  177. package/package.json +20 -2
@@ -1,585 +1,109 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * abmind sleep orchestrator — library function for overnight memory maintenance.
3
+ * abmind sleep orchestrator — host-neutral memory-maintenance engine (#1353).
4
4
  *
5
- * Called via runSleepCycle({ runtime, level, ... }). Gathers system state,
6
- * runs through a pipeline of prompt-driven steps (gc-noise, daily-summary,
7
- * extract-memories, retrospective, retro-derive, etc.), persists audit log, returns result.
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, basename, dirname } from "node:path";
25
+ import { join, dirname } from "node:path";
20
26
  import { fileURLToPath } from "node:url";
21
- import { appendFileSync, mkdirSync, existsSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
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, logError } from "../mem-logger.js";
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 const ESSENTIAL_STEPS = new Set(["daily-summary", "extract-memories", "retrospective"]);
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
- /** Thrown by runSleepCycle when the wall-clock timeout expires before completion. */
52
- export class SleepTimeoutError extends Error {
53
- constructor(message) { super(message); this.name = "SleepTimeoutError"; }
54
- }
55
- /** Best-effort onStep invokerswallow handler errors so a display callback
56
- * can never break the pipeline. */
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 directoryone run per home, per process.
56
+ const activeRunsByMemoryDir = new Set();
241
57
  /**
242
- * Parse outcome counts from the subagent's free-form text response.
243
- *
244
- * The subagent response is unstructured text, so we use best-effort regex
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 parseOutcomesFromResponse(response) {
249
- const defaults = {
250
- filesConsolidated: 0,
251
- messagesPruned: 0,
252
- embeddingsRemoved: 0,
253
- sessionsCleaned: 0,
254
- topicsMerged: 0,
255
- topicsDeleted: 0,
256
- };
257
- if (!response)
258
- return defaults;
259
- const text = response.toLowerCase();
260
- // Each pattern array contains regexes to try in order for a given outcome.
261
- // We take the first match found. Patterns cover both "verb N noun" and
262
- // "N noun verb" orderings that an LLM might produce.
263
- const patterns = [
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
- // ── Main orchestration ──────────────────────────────────────────────────────
551
- /**
552
- * Run the full sleep cycle. Extracted from main() for testability (#175).
553
- * - Deterministic time injection via opts.now for decision sites
554
- * - Throws SleepInitError / SleepTimeoutError instead of process.exit
555
- * - Returns { ok, failCount } for observable outcomes
556
- *
557
- * Default args preserve current main() behavior exactly.
558
- */
559
- export async function runSleepCycle(opts) {
560
- const flags = opts.flags ?? parseArgs(process.argv);
561
- const now = opts.now ?? Date.now;
562
- const timeoutMs = opts.timeoutMs ?? getAbmindEnv().sleepTimeoutMin * 60 * 1000;
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
- const memoryConfig = { ...loadMemoryConfig(), ...opts.memoryConfigOverride };
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
- // Step 0: run abmind doctor --fix to repair any FTS corruption before sleep steps
104
+ // #1353: async, library-native preflight no execSync, no CLI subprocess.
580
105
  try {
581
- const { execSync } = await import("node:child_process");
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 — prevent concurrent sleep execution
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 { ok: true, failCount: 0 };
134
+ return alreadyRunningResult(startedAt, now());
612
135
  }
613
136
  logWarn(TAG, `[SLEEP] Stale lock (pid ${existingState.pid} dead) — claiming`);
614
137
  }
615
- // #1082: Fresh cycle discards prior state (budget + steps)
616
- const isResume = !opts.fresh && existingState !== null && Object.values(existingState.steps).some(s => s.status === "ok");
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
- // cron integration is caller-owned (bridge-only). Abmind library runs without cron awareness.
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 (flags.verbose)
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
- if (msgCount === 0 && !flags.force && !isResume) {
627
- logInfo(TAG, `[SLEEP] No messages since last sleep nothing to process. Use --force to run housekeeping anyway.`);
628
- // Write minimal audit marker so hasSleepAuditToday() returns true (prevents bedtime re-trigger)
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 dateStr = localDate().replace(/-/g, "");
156
+ const noWorkDateStr = localDate().replace(/-/g, "");
632
157
  const timeStr = new Date().toTimeString().slice(0, 5).replace(/:/g, "");
633
- writeFileSync(join(sleepDir, `sleep_${dateStr}_${timeStr}.md`), `# Sleep Audit Log\n\n## No work — 0 messages since last sleep\n`, "utf-8");
634
- return { ok: true, failCount: 0 };
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
- // Set remaining missing vars
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; // step 03 appends retro to the daily file
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
- // Output path for consolidation — weekly or quarterly
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(); // 0-based
229
+ const month = new Date(now()).getMonth();
712
230
  const isQuarterBoundary = month % 3 === 0 && new Date(now()).getDate() <= 7;
713
- if (isQuarterBoundary) {
714
- vars.CONSOLIDATION_OUTPUT_PATH = join(quarterlyDir, `quarterly_${todayIso}.md`);
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
- // Level tiering controls which prompts are eligible.
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
- // ── Catch-up: recover failed essentials from previous days ──
840
- const sleepDir = join(memoryConfig.memoryDir, "sleep");
841
- const previousLocks = scanPreviousLocks(sleepDir, dateStr);
842
- if (previousLocks.length > 0) {
843
- logInfo(TAG, `[CATCH-UP] Found ${previousLocks.length} previous lock(s)`);
844
- await runCatchUp(previousLocks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs, opts.onStep);
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, month, week] = m;
348
+ const [, year, month2, week] = m;
854
349
  const day = (parseInt(week) - 1) * 7 + 1;
855
- const approxDate = `${year}-${month}-${String(Math.min(day, 28)).padStart(2, "0")}`;
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
- // Create day directory for per-step logs
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
- // Hard safety: LLM call budget exhausted → suspend
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
- // #895: "start" fires once per step (before skip checks) so every step
890
- // emits exactly one start + one terminal (done/skipped/failed). Display-only,
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
- fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
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
- fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
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
- fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
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) => sendWithRetry(runtime, p, "daily-summary", flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now).then(r => { if (r === null)
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] 04a failed: ${err instanceof Error ? err.message : String(err)}`);
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
- fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
945
- if (err instanceof ModelUnavailableError) {
946
- logWarn(TAG, `[SLEEP] ${step.name} — model unavailable, stopping cycle (not advancing to next phase)`);
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
- // #895: emit skip when there's no daily summary to extract from
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) => sendWithRetry(runtime, p, "04b-extract", flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now).then(r => { if (r === null)
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] 04b failed: ${err instanceof Error ? err.message : String(err)}`);
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
- fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
1002
- if (err instanceof ModelUnavailableError) {
1003
- logWarn(TAG, `[SLEEP] ${step.name} — model unavailable, stopping cycle (not advancing to next phase)`);
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
- // #895: emit terminal event for code-driven extract-memories (failure already fired in catch)
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
- // #895: emit skip when there's no work for contradiction-and-graph
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
- // #895: emit skip on var-prep failure
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
- // #895: emit skip when not enough memories for REM
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
- // #895: emit skip on var-prep failure for rem-synthesis
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 = ""; // only prepend to first step
1095
- const ctxBefore = -1;
559
+ soulPrefix = "";
1096
560
  let response;
1097
561
  try {
1098
- response = await sendWithRetry(runtime, fullPrompt, step.name, flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now);
562
+ response = await sendToRuntime(runtime, fullPrompt, step.name, runId, signal, budget, domainRetryDelayMs);
1099
563
  }
1100
564
  catch (err) {
1101
- if (err instanceof ModelUnavailableError) {
565
+ if (err instanceof TransportUnavailableError) {
1102
566
  const duration = Date.now() - start;
1103
- logWarn(TAG, `[SLEEP] ${step.name} — model unavailable, stopping cycle (not advancing to next phase)`);
1104
- state.steps[step.name] = { status: "failed", duration: Math.round(duration / 100) / 10, ctxBefore, ctxAfter: -1 };
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
- fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
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, ctxBefore, ctxAfter };
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; // below this score → expire
1143
- const now = Date.now();
1144
- const candidates = memDb.prepare(`SELECT id, recall_count, created_at FROM extracted_memories WHERE memory_type = 'event' AND valid_to IS NULL AND created_at < ?`).all(now - EVENT_MIN_AGE_DAYS * 86400_000);
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 candidates) {
1147
- const ageDays = (now - m.created_at) / 86400_000;
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(now, m.id);
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: MAX_RETRIES, ctxBefore, ctxAfter };
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
- // #895: emit terminal event for the standard prompt-driven step
1165
- fireOnStep(opts.onStep, {
1166
- name: step.name,
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 = step.name.startsWith("04") || false;
638
+ const isEssential = essential;
1181
639
  if (!isEssential) {
1182
- const delayMs = backoffMs(consecutiveFailures);
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
- clearTimeout(timeoutHandle);
1192
- // Runtime lifecycle is caller-owned — no shutdown call from the library.
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
- // Advance extraction watermark — only when all steps succeeded
1200
- if (dreamySucceeded) {
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
- return { ok: failCount === 0, failCount };
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
- // CLI entry + isDirectRun removed — the standalone entry point now lives in
1276
- // cli/abmind.ts as the `abmind sleep` subcommand. Library consumers call
1277
- // runSleepCycle(opts) directly with their own SleepRuntime.
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