abmind 0.2.5-alpha.0 → 0.2.6

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 (104) hide show
  1. package/SUPPORTED-SURFACE.md +52 -0
  2. package/dist/cli/abmind-backup.js +1 -1
  3. package/dist/cli/abmind-backup.js.map +1 -1
  4. package/dist/cli/abmind-deps.d.ts +8 -0
  5. package/dist/cli/abmind-deps.d.ts.map +1 -0
  6. package/dist/cli/abmind-deps.js +55 -0
  7. package/dist/cli/abmind-deps.js.map +1 -0
  8. package/dist/cli/abmind-doctor.js +9 -7
  9. package/dist/cli/abmind-doctor.js.map +1 -1
  10. package/dist/cli/abmind-embed.js +2 -3
  11. package/dist/cli/abmind-embed.js.map +1 -1
  12. package/dist/cli/abmind-expand.d.ts.map +1 -1
  13. package/dist/cli/abmind-expand.js +2 -3
  14. package/dist/cli/abmind-expand.js.map +1 -1
  15. package/dist/cli/abmind-install.js +9 -23
  16. package/dist/cli/abmind-install.js.map +1 -1
  17. package/dist/cli/abmind-passwd.js +2 -3
  18. package/dist/cli/abmind-passwd.js.map +1 -1
  19. package/dist/cli/abmind-secrets.d.ts.map +1 -1
  20. package/dist/cli/abmind-secrets.js +2 -3
  21. package/dist/cli/abmind-secrets.js.map +1 -1
  22. package/dist/cli/abmind-status-runtime.js +10 -12
  23. package/dist/cli/abmind-status-runtime.js.map +1 -1
  24. package/dist/cli/abmind-update-args.d.ts +26 -0
  25. package/dist/cli/abmind-update-args.d.ts.map +1 -0
  26. package/dist/cli/abmind-update-args.js +56 -0
  27. package/dist/cli/abmind-update-args.js.map +1 -0
  28. package/dist/cli/abmind-update.d.ts +13 -5
  29. package/dist/cli/abmind-update.d.ts.map +1 -1
  30. package/dist/cli/abmind-update.js +151 -147
  31. package/dist/cli/abmind-update.js.map +1 -1
  32. package/dist/cli/abmind.js +6 -10
  33. package/dist/cli/abmind.js.map +1 -1
  34. package/dist/cli/banner.d.ts +6 -0
  35. package/dist/cli/banner.d.ts.map +1 -1
  36. package/dist/cli/banner.js +14 -22
  37. package/dist/cli/banner.js.map +1 -1
  38. package/dist/cli/lib/native-dep.d.ts +15 -0
  39. package/dist/cli/lib/native-dep.d.ts.map +1 -0
  40. package/dist/cli/lib/native-dep.js +25 -0
  41. package/dist/cli/lib/native-dep.js.map +1 -0
  42. package/dist/src/context-orchestrator.d.ts +38 -2
  43. package/dist/src/context-orchestrator.d.ts.map +1 -1
  44. package/dist/src/context-orchestrator.js +84 -12
  45. package/dist/src/context-orchestrator.js.map +1 -1
  46. package/dist/src/fts-utils.d.ts +47 -0
  47. package/dist/src/fts-utils.d.ts.map +1 -0
  48. package/dist/src/fts-utils.js +65 -0
  49. package/dist/src/fts-utils.js.map +1 -0
  50. package/dist/src/index.d.ts +5 -3
  51. package/dist/src/index.d.ts.map +1 -1
  52. package/dist/src/index.js +3 -1
  53. package/dist/src/index.js.map +1 -1
  54. package/dist/src/memory-db.d.ts.map +1 -1
  55. package/dist/src/memory-db.js +13 -5
  56. package/dist/src/memory-db.js.map +1 -1
  57. package/dist/src/memory-index.d.ts +0 -16
  58. package/dist/src/memory-index.d.ts.map +1 -1
  59. package/dist/src/memory-index.js +1 -60
  60. package/dist/src/memory-index.js.map +1 -1
  61. package/dist/src/memory-manager.d.ts +8 -0
  62. package/dist/src/memory-manager.d.ts.map +1 -1
  63. package/dist/src/memory-manager.js +8 -0
  64. package/dist/src/memory-manager.js.map +1 -1
  65. package/dist/src/ollama-embed.d.ts.map +1 -1
  66. package/dist/src/ollama-embed.js +11 -1
  67. package/dist/src/ollama-embed.js.map +1 -1
  68. package/dist/src/recall-boosts.d.ts +15 -0
  69. package/dist/src/recall-boosts.d.ts.map +1 -0
  70. package/dist/src/recall-boosts.js +159 -0
  71. package/dist/src/recall-boosts.js.map +1 -0
  72. package/dist/src/recall-engine.d.ts +0 -2
  73. package/dist/src/recall-engine.d.ts.map +1 -1
  74. package/dist/src/recall-engine.js +1 -151
  75. package/dist/src/recall-engine.js.map +1 -1
  76. package/dist/src/sleep/audit.d.ts +33 -0
  77. package/dist/src/sleep/audit.d.ts.map +1 -0
  78. package/dist/src/sleep/audit.js +160 -0
  79. package/dist/src/sleep/audit.js.map +1 -0
  80. package/dist/src/sleep/catchup.d.ts +19 -0
  81. package/dist/src/sleep/catchup.d.ts.map +1 -0
  82. package/dist/src/sleep/catchup.js +159 -0
  83. package/dist/src/sleep/catchup.js.map +1 -0
  84. package/dist/src/sleep/llm-budget.d.ts +29 -0
  85. package/dist/src/sleep/llm-budget.d.ts.map +1 -0
  86. package/dist/src/sleep/llm-budget.js +92 -0
  87. package/dist/src/sleep/llm-budget.js.map +1 -0
  88. package/dist/src/sleep/locks.d.ts +19 -0
  89. package/dist/src/sleep/locks.d.ts.map +1 -0
  90. package/dist/src/sleep/locks.js +42 -0
  91. package/dist/src/sleep/locks.js.map +1 -0
  92. package/dist/src/sleep/orchestrator.d.ts +18 -1
  93. package/dist/src/sleep/orchestrator.d.ts.map +1 -1
  94. package/dist/src/sleep/orchestrator.js +104 -420
  95. package/dist/src/sleep/orchestrator.js.map +1 -1
  96. package/dist/src/sleep/sleep-prompt-loader.d.ts +0 -5
  97. package/dist/src/sleep/sleep-prompt-loader.d.ts.map +1 -1
  98. package/dist/src/sleep/sleep-prompt-loader.js +0 -12
  99. package/dist/src/sleep/sleep-prompt-loader.js.map +1 -1
  100. package/dist/src/sleep/state.d.ts +56 -0
  101. package/dist/src/sleep/state.d.ts.map +1 -0
  102. package/dist/src/sleep/state.js +83 -0
  103. package/dist/src/sleep/state.js.map +1 -0
  104. package/package.json +2 -5
@@ -16,10 +16,9 @@
16
16
  */
17
17
  import { localISO } from "../local-time.js";
18
18
  import { getAbmindEnv } from "../env-schema.js";
19
- import { join, basename, dirname } from "node:path";
19
+ import { join, dirname } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
- import { appendFileSync, mkdirSync, existsSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
22
- import { atomicWriteSync } from "../atomic-write.js";
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
23
22
  import { MemoryManager } from "../memory-manager.js";
24
23
  import { loadMemoryConfig } from "../memory-config.js";
25
24
  import { SleepStateGatherer } from "../sleep-state-gatherer.js";
@@ -27,23 +26,18 @@ import { loadSleepSteps, buildSleepVars, substituteVars } from "../sleep-pipelin
27
26
  import { buildDailySummary, writeDailyFile, LLMUnavailableError } from "../sleep-pipeline.js";
28
27
  import { extractFromDaily } from "../sleep-pipeline.js";
29
28
  import { logInfo, logWarn, logError } from "../mem-logger.js";
30
- import { redactSecrets } from "../redact-secrets.js";
31
29
  import { localDate } from "../local-time.js";
32
30
  import { parseLevel, DEFAULT_LEVEL } from "./levels.js";
31
+ import { readStateFile, writeStateFile, runWiredPreTasks, formatWiredResults, fireOnStep } from "./state.js";
32
+ import { buildSnapshotSummary, writeAuditLog } from "./audit.js";
33
+ import { toDateStr, toIsoDate, scanPreviousLocks } from "./locks.js";
34
+ import { redactSecrets } from "../redact-secrets.js";
35
+ import { ModelUnavailableError, LlmBudget, sendWithRetry, DEFAULT_TRANSPORT_RETRY_WINDOW_MS, MAX_RETRIES } from "./llm-budget.js";
36
+ import { runCatchUp } from "./catchup.js";
33
37
  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
38
  /** 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;
39
+ // Re-exported from catchup.ts kept here for backward compat of any direct orchestrator imports.
40
+ export { ESSENTIAL_STEPS } from "./catchup.js";
47
41
  /** Thrown by runSleepCycle when memory layer fails to initialize. */
48
42
  export class SleepInitError extends Error {
49
43
  constructor(message) { super(message); this.name = "SleepInitError"; }
@@ -70,405 +64,9 @@ export function parseArgs(argv) {
70
64
  }
71
65
  return parsed;
72
66
  }
73
- // getPrimaryUserId moved to SleepDataAccess in memory package
74
- function readStateFile(path) {
75
- try {
76
- if (!existsSync(path))
77
- return null;
78
- const raw = JSON.parse(readFileSync(path, "utf-8"));
79
- if (typeof raw !== "object" || raw === null || !raw.steps)
80
- return null;
81
- // Backfill defaults for legacy lock files
82
- if (!raw.status)
83
- raw.status = "ongoing";
84
- if (raw.llmCalls == null)
85
- raw.llmCalls = 0;
86
- return raw;
87
- }
88
- catch {
89
- return null;
90
- }
91
- }
92
- function writeStateFile(path, state) {
93
- atomicWriteSync(path, JSON.stringify(state, null, 2));
94
- }
95
- // ── Wired pre-tasks (delegated to abmind MaintenanceService) ────────────────
96
- async function runWiredPreTasks(sleepData, memoryDir, memory) {
97
- const r = await memory.maintenance.runPreSleepTasks(memory, sleepData);
98
- // Bridge-side: log rotation (not memory's concern)
99
- let logsDeleted = 0;
100
- try {
101
- const logsDir = join(memoryDir, "..", "logs");
102
- if (existsSync(logsDir)) {
103
- const cutoff = Date.now() - 7 * 86400000;
104
- for (const f of readdirSync(logsDir)) {
105
- if (!f.startsWith("bridge-") || !f.endsWith(".log"))
106
- continue;
107
- const match = f.match(/bridge-(\d{4}-\d{2}-\d{2})\.log/);
108
- if (match && new Date(match[1]).getTime() < cutoff) {
109
- unlinkSync(join(logsDir, f));
110
- logsDeleted++;
111
- }
112
- }
113
- }
114
- }
115
- catch (err) {
116
- logWarn(TAG, `[WIRED] log rotation: ${err instanceof Error ? err.message : String(err)}`);
117
- }
118
- return { purged: r.purged, deduped: r.deduped, embedded: r.embedded, anomaliesFixed: r.anomaliesFixed, walOk: r.walOk, ftsOk: r.ftsOk, logsDeleted };
119
- }
120
- function formatWiredResults(r) {
121
- const parts = [];
122
- if (r.purged > 0)
123
- parts.push(`${r.purged} garbage purged`);
124
- if (r.deduped > 0)
125
- parts.push(`${r.deduped} dupes deleted`);
126
- parts.push(`WAL ${r.walOk ? "ok" : "FAILED"}`);
127
- parts.push(`FTS ${r.ftsOk ? "ok" : "FAILED"}`);
128
- if (r.embedded > 0)
129
- parts.push(`${r.embedded} embedded`);
130
- if (r.anomaliesFixed > 0)
131
- parts.push(`${r.anomaliesFixed} anomalies fixed`);
132
- if (r.logsDeleted > 0)
133
- parts.push(`${r.logsDeleted} old logs deleted`);
134
- return parts.length > 0 ? parts.join(", ") : "nothing to do";
135
- }
67
+ // ── Subagent invocation ─────────────────────────────────────────────────────
68
+ /** LLM call entry for sleep steps — caller provides the runtime via RunOpts. */
136
69
  // ── Transport ───────────────────────────────────────────────────────────────
137
- // (SleepRuntime is imported at the top of the file.)
138
- const MAX_RETRIES = 3;
139
- /** Budget tracker — shared across all sendWithRetry calls in a sleep cycle. */
140
- class LlmBudget {
141
- state;
142
- statePath;
143
- exhausted = false;
144
- constructor(state, statePath) {
145
- this.state = state;
146
- this.statePath = statePath;
147
- }
148
- /** Increment counter, return false if budget exhausted. */
149
- consume() {
150
- this.state.llmCalls = (this.state.llmCalls ?? 0) + 1;
151
- writeStateFile(this.statePath, this.state);
152
- if (this.state.llmCalls > getAbmindEnv().sleepMaxLlmCalls) {
153
- this.exhausted = true;
154
- return false;
155
- }
156
- return true;
157
- }
158
- get calls() { return this.state.llmCalls ?? 0; }
159
- }
160
- async function sendWithRetry(runtime, prompt, stepName, _verbose, budget, delayMs = 6000) {
161
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
162
- if (budget && !budget.consume()) {
163
- logWarn(TAG, `[BUDGET] LLM call limit (${getAbmindEnv().sleepMaxLlmCalls}) reached at step ${stepName} — suspending`);
164
- return null;
165
- }
166
- try {
167
- const result = await runtime.complete(prompt);
168
- if (!result || !result.trim()) {
169
- logWarn(TAG, `Step ${stepName} attempt ${attempt}/${MAX_RETRIES} returned empty response`);
170
- if (attempt === MAX_RETRIES) {
171
- logError(TAG, `Step ${stepName} failed after ${MAX_RETRIES} attempts (empty), skipping`);
172
- return null;
173
- }
174
- if (delayMs > 0)
175
- await new Promise(r => setTimeout(r, delayMs));
176
- continue;
177
- }
178
- return result;
179
- }
180
- catch (err) {
181
- const msg = err instanceof Error ? err.message : String(err);
182
- logWarn(TAG, `Step ${stepName} attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
183
- if (attempt === MAX_RETRIES) {
184
- logError(TAG, `Step ${stepName} failed after ${MAX_RETRIES} attempts, skipping`);
185
- return null;
186
- }
187
- if (delayMs > 0)
188
- await new Promise(r => setTimeout(r, delayMs));
189
- }
190
- }
191
- return null;
192
- }
193
- // ── Audit trail helpers ─────────────────────────────────────────────────────
194
- function buildSnapshotSummary(snapshot) {
195
- return [
196
- `Working dirs: ${snapshot.workingDirs.length}`,
197
- `Messages: ${snapshot.dbStats.messageCount}`,
198
- `Embeddings: ${snapshot.dbStats.embeddingCount}`,
199
- `Extracted memories: ${snapshot.dbStats.extractedMemoryCount}`,
200
- `Disk: ${(snapshot.diskUsageBytes / 1024 / 1024).toFixed(1)} MB / ${(snapshot.diskBudgetBytes / 1024 / 1024).toFixed(0)} MB`,
201
- `Topics: ${snapshot.topicFiles.length}`,
202
- `FTS5: messages=${snapshot.fts5Health.messages_fts}, extracted=${snapshot.fts5Health.extracted_memories_fts}, original=${snapshot.fts5Health.extracted_memories_original_fts}`,
203
- ].join(", ");
204
- }
205
- /**
206
- * Parse outcome counts from the subagent's free-form text response.
207
- *
208
- * The subagent response is unstructured text, so we use best-effort regex
209
- * matching for common patterns like "consolidated 3 files", "pruned 42
210
- * messages", etc. Returns 0 for any count that can't be parsed.
211
- */
212
- function parseOutcomesFromResponse(response) {
213
- const defaults = {
214
- filesConsolidated: 0,
215
- messagesPruned: 0,
216
- embeddingsRemoved: 0,
217
- sessionsCleaned: 0,
218
- topicsMerged: 0,
219
- topicsDeleted: 0,
220
- };
221
- if (!response)
222
- return defaults;
223
- const text = response.toLowerCase();
224
- // Each pattern array contains regexes to try in order for a given outcome.
225
- // We take the first match found. Patterns cover both "verb N noun" and
226
- // "N noun verb" orderings that an LLM might produce.
227
- const patterns = [
228
- {
229
- key: "filesConsolidated",
230
- regexes: [
231
- /consolidat\w*\s+(\d+)\s+(?:file|dir|working)/i,
232
- /(\d+)\s+(?:file|dir|working\s*dir)\w*\s+consolidat/i,
233
- /files?\s+consolidated\s*:\s*(\d+)/i,
234
- ],
235
- },
236
- {
237
- key: "messagesPruned",
238
- regexes: [
239
- /prun\w*\s+(\d+)\s+message/i,
240
- /(\d+)\s+message\w*\s+prun/i,
241
- /(?:delet|remov)\w*\s+(\d+)\s+message/i,
242
- /(\d+)\s+message\w*\s+(?:delet|remov)/i,
243
- /messages?\s+pruned\s*:\s*(\d+)/i,
244
- ],
245
- },
246
- {
247
- key: "embeddingsRemoved",
248
- regexes: [
249
- /(?:remov|delet|clean)\w*\s+(\d+)\s+embedding/i,
250
- /(\d+)\s+embedding\w*\s+(?:remov|delet|clean)/i,
251
- /embeddings?\s+removed\s*:\s*(\d+)/i,
252
- ],
253
- },
254
- {
255
- key: "sessionsCleaned",
256
- regexes: [
257
- /(?:clean|delet|remov)\w*\s+(\d+)\s+session/i,
258
- /(\d+)\s+session\w*\s+(?:clean|delet|remov)/i,
259
- /sessions?\s+cleaned\s*:\s*(\d+)/i,
260
- ],
261
- },
262
- {
263
- key: "topicsMerged",
264
- regexes: [
265
- /merg\w*\s+(\d+)\s+topic/i,
266
- /(\d+)\s+topic\w*\s+merg/i,
267
- /topics?\s+merged\s*:\s*(\d+)/i,
268
- ],
269
- },
270
- {
271
- key: "topicsDeleted",
272
- regexes: [
273
- /delet\w*\s+(\d+)\s+topic/i,
274
- /(\d+)\s+topic\w*\s+delet/i,
275
- /topics?\s+deleted\s*:\s*(\d+)/i,
276
- ],
277
- },
278
- ];
279
- for (const { key, regexes } of patterns) {
280
- for (const regex of regexes) {
281
- const match = text.match(regex);
282
- if (match?.[1]) {
283
- const parsed = parseInt(match[1], 10);
284
- if (!isNaN(parsed) && parsed >= 0) {
285
- defaults[key] = parsed;
286
- break;
287
- }
288
- }
289
- }
290
- }
291
- return defaults;
292
- }
293
- function writeAuditLog(memoryDir, entry) {
294
- const sleepDir = join(memoryDir, "sleep");
295
- mkdirSync(sleepDir, { recursive: true });
296
- const suffix = [
297
- ``,
298
- `---`,
299
- ``,
300
- `## CLI Wrapper`,
301
- ``,
302
- `**Timestamp:** ${entry.timestamp}`,
303
- `**Model:** ${entry.model}`,
304
- ``,
305
- `### State Snapshot`,
306
- `${entry.stateSnapshotSummary}`,
307
- ``,
308
- `### Outcomes`,
309
- `- Files consolidated: ${entry.outcomes.filesConsolidated}`,
310
- `- Messages pruned: ${entry.outcomes.messagesPruned}`,
311
- `- Embeddings removed: ${entry.outcomes.embeddingsRemoved}`,
312
- `- Sessions cleaned: ${entry.outcomes.sessionsCleaned}`,
313
- `- Topics merged: ${entry.outcomes.topicsMerged}`,
314
- `- Topics deleted: ${entry.outcomes.topicsDeleted}`,
315
- entry.error ? `\n### Error\n${entry.error}` : "",
316
- ].join("\n");
317
- // Find the subagent's audit file and append to it
318
- const today = localDate().replace(/-/g, "");
319
- try {
320
- const files = readdirSync(sleepDir)
321
- .filter(f => f.startsWith(`sleep_${today}`) && f.endsWith(".md"))
322
- .sort();
323
- if (files.length > 0) {
324
- const target = join(sleepDir, files[files.length - 1]);
325
- const existingLines = readFileSync(target, "utf-8").split("\n").length;
326
- if (existingLines < 50) {
327
- logWarn(TAG, `Sleep audit suspiciously short (${existingLines} lines) — subagent may have truncated`);
328
- }
329
- appendFileSync(target, redactSecrets(suffix), "utf-8");
330
- return;
331
- }
332
- }
333
- catch { /* fall through to standalone */ }
334
- // Fallback: no subagent file found — write standalone
335
- const now = new Date();
336
- const dateStr = localDate().replace(/-/g, "");
337
- const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, "");
338
- const filename = `sleep_${dateStr}_${timeStr}.md`;
339
- writeFileSync(join(sleepDir, filename), redactSecrets(`# Sleep Audit Log${suffix}`), "utf-8");
340
- }
341
- function scanPreviousLocks(sleepDir, todayStr) {
342
- if (!existsSync(sleepDir))
343
- return [];
344
- const locks = [];
345
- const todayMs = dateStrToMs(todayStr);
346
- for (const f of readdirSync(sleepDir)) {
347
- const m = f.match(/^sleep_(\d{8})\.lock$/);
348
- if (!m || m[1] === todayStr)
349
- continue;
350
- const state = readStateFile(join(sleepDir, f));
351
- if (!state)
352
- continue;
353
- const ageDays = Math.round((todayMs - dateStrToMs(m[1])) / 86400000);
354
- if (ageDays > 0)
355
- locks.push({ path: join(sleepDir, f), dateStr: m[1], state, ageDays });
356
- }
357
- return locks.sort((a, b) => b.dateStr.localeCompare(a.dateStr)); // newest first
358
- }
359
- function dateStrToMs(ds) {
360
- return new Date(`${ds.slice(0, 4)}-${ds.slice(4, 6)}-${ds.slice(6, 8)}T00:00:00`).getTime();
361
- }
362
- function dateStrToFormatted(ds) {
363
- return `${ds.slice(0, 4)}-${ds.slice(4, 6)}-${ds.slice(6, 8)}`;
364
- }
365
- function failedEssentials(state) {
366
- const failed = [];
367
- for (const name of ESSENTIAL_STEPS) {
368
- const s = state.steps[name];
369
- if (!s || s.status === "failed" || s.status === "timeout" || s.status === "pending") {
370
- failed.push(name);
371
- }
372
- }
373
- return failed;
374
- }
375
- async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs = 6000) {
376
- for (const lock of locks) {
377
- if (lock.ageDays > CATCHUP_MAX_AGE_DAYS) {
378
- logError(TAG, `[CATCH-UP] Abandoning stale lock ${basename(lock.path)} — ${lock.ageDays} days old, data unrecoverable`);
379
- unlinkSync(lock.path);
380
- continue;
381
- }
382
- const needed = failedEssentials(lock.state);
383
- if (needed.length === 0) {
384
- logInfo(TAG, `[CATCH-UP] Cleaning up completed lock ${basename(lock.path)}`);
385
- unlinkSync(lock.path);
386
- continue;
387
- }
388
- logInfo(TAG, `[CATCH-UP] ${basename(lock.path)} — recovering: ${needed.join(", ")}`);
389
- // 04a — daily summary with date-range
390
- if (needed.includes("daily-summary")) {
391
- const start = Date.now();
392
- try {
393
- const ctxWindow = getAbmindEnv().sleepCtxWindow;
394
- const userId = sleepData.getPrimaryUserId();
395
- const dayStart = dateStrToMs(lock.dateStr);
396
- const dayEnd = dayStart + 86400000;
397
- const summary = await buildDailySummary(sleepData.getDb(), (p) => sendWithRetry(runtime, p, "catch-up-04a", flags.verbose, budget, retryDelayMs).then(r => { if (r === null)
398
- throw new LLMUnavailableError(); return r; }), {
399
- ctxWindow, memoryDir: memoryConfig.memoryDir, userId, watermarkTs: 0,
400
- dateRange: { startTs: dayStart, endTs: dayEnd },
401
- });
402
- if (summary) {
403
- writeDailyFile(memoryConfig.memoryDir, dateStrToFormatted(lock.dateStr), summary);
404
- lock.state.steps["daily-summary"] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
405
- }
406
- else {
407
- lock.state.steps["daily-summary"] = { status: "skipped" };
408
- }
409
- logInfo(TAG, `[CATCH-UP] ✓ 04a-daily-summary for ${lock.dateStr} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
410
- }
411
- catch (err) {
412
- logWarn(TAG, `[CATCH-UP] ✗ 04a-daily-summary for ${lock.dateStr}: ${err instanceof Error ? err.message : String(err)}`);
413
- lock.state.steps["daily-summary"] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
414
- }
415
- writeStateFile(lock.path, lock.state);
416
- }
417
- // 04b — extract memories from daily (needs daily file to exist)
418
- if (needed.includes("extract-memories")) {
419
- const dailyPath = join(memoryConfig.memoryDir, "daily", `daily_${dateStrToFormatted(lock.dateStr)}.md`);
420
- if (!existsSync(dailyPath)) {
421
- logInfo(TAG, `[CATCH-UP] ⏭ 04b — no daily file for ${lock.dateStr}`);
422
- lock.state.steps["extract-memories"] = { status: "skipped" };
423
- }
424
- else {
425
- const start = Date.now();
426
- try {
427
- const userId = sleepData.getPrimaryUserId();
428
- const result = await extractFromDaily(dailyPath, userId, (p) => sendWithRetry(runtime, p, "catch-up-04b", flags.verbose, budget, retryDelayMs).then(r => { if (r === null)
429
- throw new LLMUnavailableError(); return r; }));
430
- lock.state.steps["extract-memories"] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
431
- logInfo(TAG, `[CATCH-UP] ✓ 04b-extract-memories for ${lock.dateStr} (${((Date.now() - start) / 1000).toFixed(1)}s) — ${result.slice(0, 80)}`);
432
- }
433
- catch (err) {
434
- logWarn(TAG, `[CATCH-UP] ✗ 04b for ${lock.dateStr}: ${err instanceof Error ? err.message : String(err)}`);
435
- lock.state.steps["extract-memories"] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
436
- }
437
- }
438
- writeStateFile(lock.path, lock.state);
439
- }
440
- // Prompt-driven essentials (retrospective — now includes extraction)
441
- for (const stepName of ["retrospective"]) {
442
- if (!needed.includes(stepName))
443
- continue;
444
- const step = steps.find(s => s.name === stepName);
445
- if (!step) {
446
- logWarn(TAG, `[CATCH-UP] Step file not found: ${stepName}`);
447
- continue;
448
- }
449
- const start = Date.now();
450
- const response = await sendWithRetry(runtime, step.rawPrompt, `catch-up-${stepName}`, flags.verbose, budget, retryDelayMs);
451
- if (response) {
452
- lock.state.steps[stepName] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
453
- logInfo(TAG, `[CATCH-UP] ✓ ${stepName} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
454
- }
455
- else {
456
- lock.state.steps[stepName] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
457
- logWarn(TAG, `[CATCH-UP] ✗ ${stepName}`);
458
- }
459
- writeStateFile(lock.path, lock.state);
460
- }
461
- // Final check: all essentials recovered?
462
- const stillFailing = failedEssentials(lock.state);
463
- if (stillFailing.length === 0) {
464
- logInfo(TAG, `[CATCH-UP] ✅ ${basename(lock.path)} — all essentials recovered, lock deleted`);
465
- unlinkSync(lock.path);
466
- }
467
- else {
468
- logWarn(TAG, `[CATCH-UP] ${basename(lock.path)} — still failing: ${stillFailing.join(", ")} (failing ${lock.ageDays} day(s))`);
469
- }
470
- }
471
- }
472
70
  // ── Main orchestration ──────────────────────────────────────────────────────
473
71
  /**
474
72
  * Run the full sleep cycle. Extracted from main() for testability (#175).
@@ -484,6 +82,8 @@ export async function runSleepCycle(opts) {
484
82
  const timeoutMs = opts.timeoutMs ?? getAbmindEnv().sleepTimeoutMin * 60 * 1000;
485
83
  const backoffMs = opts.backoffMs ?? ((n) => [10, 30, 60][Math.min(n, 2)] * 1000);
486
84
  const retryDelayMs = opts.retryDelayMs ?? 6000;
85
+ const transportBackoffMs = opts.transportBackoffMs ?? ((n) => Math.min(30_000 * Math.pow(2, n - 1), 120_000));
86
+ const transportRetryWindowMs = opts.transportRetryWindowMs ?? DEFAULT_TRANSPORT_RETRY_WINDOW_MS;
487
87
  const runtime = opts.runtime;
488
88
  if (flags.verbose) {
489
89
  logInfo(TAG, "Verbose mode enabled");
@@ -641,6 +241,13 @@ export async function runSleepCycle(opts) {
641
241
  const snapshotVars = buildSleepVars(snapshot);
642
242
  for (const [k, v] of Object.entries(snapshotVars))
643
243
  vars[k] = vars[k] ?? v;
244
+ // #895: Fire onCycleStart once before the first step — hosts use this to
245
+ // create a stepped card or reset per-night state. fireOnStep() already
246
+ // swallows handler errors so a misbehaving host can never break the cycle.
247
+ try {
248
+ opts.onCycleStart?.({ totalSteps: steps.length });
249
+ }
250
+ catch { /* host display only */ }
644
251
  // Progress protocol — emit PROGRESS:<pct>:<label> on stdout
645
252
  const totalSteps = steps.length;
646
253
  let stepIndex = 0;
@@ -754,7 +361,7 @@ export async function runSleepCycle(opts) {
754
361
  const previousLocks = scanPreviousLocks(sleepDir, dateStr);
755
362
  if (previousLocks.length > 0) {
756
363
  logInfo(TAG, `[CATCH-UP] Found ${previousLocks.length} previous lock(s)`);
757
- await runCatchUp(previousLocks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs);
364
+ await runCatchUp(previousLocks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs, opts.onStep);
758
365
  }
759
366
  // Housekeeping: move misplaced daily/consolidation_* to weekly/ (#640)
760
367
  try {
@@ -799,13 +406,19 @@ export async function runSleepCycle(opts) {
799
406
  }
800
407
  emitProgress(step.name);
801
408
  stepIndex++;
409
+ // #895: "start" fires once per step (before skip checks) so every step
410
+ // emits exactly one start + one terminal (done/skipped/failed). Display-only,
411
+ // best-effort — a throwing handler never breaks the cycle.
412
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "start" });
802
413
  // Resume: skip already completed steps
803
414
  if (isResume && existingState?.steps[step.name]?.status === "ok") {
804
415
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — already done (resume)`);
416
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
805
417
  continue;
806
418
  }
807
419
  if (isResume && existingState?.steps[step.name]?.status === "skipped") {
808
420
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — skipped (resume)`);
421
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
809
422
  continue;
810
423
  }
811
424
  // Skip logic (essential steps always run)
@@ -813,6 +426,7 @@ export async function runSleepCycle(opts) {
813
426
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — skipped`);
814
427
  state.steps[step.name] = { status: "skipped" };
815
428
  writeStateFile(statePath, state);
429
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
816
430
  continue;
817
431
  }
818
432
  const start = Date.now();
@@ -829,7 +443,7 @@ export async function runSleepCycle(opts) {
829
443
  const firstMsgTs = sleepData.getFirstMessageAfter(userId, watermarkTs);
830
444
  const firstMsgDate = firstMsgTs ? new Date(firstMsgTs) : new Date(now());
831
445
  const targetDate = `${firstMsgDate.getFullYear()}-${String(firstMsgDate.getMonth() + 1).padStart(2, "0")}-${String(firstMsgDate.getDate()).padStart(2, "0")}`;
832
- const summary = await buildDailySummary(sleepData.getDb(), (p) => sendWithRetry(runtime, p, "daily-summary", flags.verbose, budget, retryDelayMs).then(r => { if (r === null)
446
+ const summary = await buildDailySummary(sleepData.getDb(), (p) => sendWithRetry(runtime, p, "daily-summary", flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now).then(r => { if (r === null)
833
447
  throw new LLMUnavailableError(); return r; }), {
834
448
  ctxWindow, memoryDir: memoryConfig.memoryDir, userId, watermarkTs,
835
449
  });
@@ -846,9 +460,30 @@ export async function runSleepCycle(opts) {
846
460
  logWarn(TAG, `[SLEEP] 04a failed: ${err instanceof Error ? err.message : String(err)}`);
847
461
  state.steps[step.name] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
848
462
  dreamySucceeded = false;
463
+ writeStateFile(statePath, state);
464
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
465
+ if (err instanceof ModelUnavailableError) {
466
+ logWarn(TAG, `[SLEEP] ${step.name} — model unavailable, stopping cycle (not advancing to next phase)`);
467
+ break;
468
+ }
469
+ }
470
+ if (state.steps[step.name]?.status !== "failed") {
471
+ // success/skip path: persist state + log + fire event
472
+ writeStateFile(statePath, state);
849
473
  }
850
- writeStateFile(statePath, state);
851
474
  logInfo(TAG, `[SLEEP] ${state.steps[step.name]?.status === "ok" ? "✓" : "✗"} ${step.name} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
475
+ // #895: emit terminal event for code-driven daily-summary (failure already fired in catch)
476
+ if (state.steps[step.name]?.status !== "failed") {
477
+ fireOnStep(opts.onStep, {
478
+ name: step.name,
479
+ filename: step.filename,
480
+ index: stepIndex,
481
+ total: totalSteps,
482
+ phase: state.steps[step.name]?.status === "ok" ? "done"
483
+ : state.steps[step.name]?.status === "skipped" ? "skipped"
484
+ : "failed",
485
+ });
486
+ }
852
487
  continue;
853
488
  }
854
489
  if (step.name === "extract-memories") {
@@ -866,11 +501,13 @@ export async function runSleepCycle(opts) {
866
501
  state.steps[step.name] = { status: "skipped" };
867
502
  writeStateFile(statePath, state);
868
503
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — no daily summary`);
504
+ // #895: emit skip when there's no daily summary to extract from
505
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
869
506
  continue;
870
507
  }
871
508
  try {
872
509
  const userId = sleepData.getPrimaryUserId();
873
- const result = await extractFromDaily(dailySummaryPath, userId, (p) => sendWithRetry(runtime, p, "04b-extract", flags.verbose, budget, retryDelayMs).then(r => { if (r === null)
510
+ const result = await extractFromDaily(dailySummaryPath, userId, (p) => sendWithRetry(runtime, p, "04b-extract", flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now).then(r => { if (r === null)
874
511
  throw new LLMUnavailableError(); return r; }));
875
512
  state.steps[step.name] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
876
513
  writeFileSync(join(stepLogDir, `${String(stepIndex).padStart(2, "0")}-${step.name}.md`), redactSecrets(result), "utf-8");
@@ -880,8 +517,24 @@ export async function runSleepCycle(opts) {
880
517
  logWarn(TAG, `[SLEEP] 04b failed: ${err instanceof Error ? err.message : String(err)}`);
881
518
  state.steps[step.name] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
882
519
  dreamySucceeded = false;
520
+ writeStateFile(statePath, state);
521
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
522
+ if (err instanceof ModelUnavailableError) {
523
+ logWarn(TAG, `[SLEEP] ${step.name} — model unavailable, stopping cycle (not advancing to next phase)`);
524
+ break;
525
+ }
526
+ }
527
+ if (state.steps[step.name]?.status !== "failed") {
528
+ writeStateFile(statePath, state);
529
+ // #895: emit terminal event for code-driven extract-memories (failure already fired in catch)
530
+ fireOnStep(opts.onStep, {
531
+ name: step.name,
532
+ filename: step.filename,
533
+ index: stepIndex,
534
+ total: totalSteps,
535
+ phase: state.steps[step.name]?.status === "ok" ? "done" : "failed",
536
+ });
883
537
  }
884
- writeStateFile(statePath, state);
885
538
  continue;
886
539
  }
887
540
  // Standard prompt-driven step — JIT substitution
@@ -896,6 +549,8 @@ export async function runSleepCycle(opts) {
896
549
  state.steps[step.name] = { status: "skipped" };
897
550
  writeStateFile(statePath, state);
898
551
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — no new extractions today`);
552
+ // #895: emit skip when there's no work for contradiction-and-graph
553
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
899
554
  continue;
900
555
  }
901
556
  vars.NEW_EXTRACTIONS = newRows.map(r => `[id=${r.id}] (${r.memory_type}, trust=${r.trust}) ${r.content_en}`).join("\n");
@@ -925,6 +580,8 @@ export async function runSleepCycle(opts) {
925
580
  logWarn(TAG, `[SLEEP] contradiction-and-graph var prep failed: ${err instanceof Error ? err.message : String(err)}`);
926
581
  state.steps[step.name] = { status: "skipped" };
927
582
  writeStateFile(statePath, state);
583
+ // #895: emit skip on var-prep failure
584
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
928
585
  continue;
929
586
  }
930
587
  }
@@ -937,6 +594,8 @@ export async function runSleepCycle(opts) {
937
594
  state.steps[step.name] = { status: "skipped" };
938
595
  writeStateFile(statePath, state);
939
596
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — not enough memories for REM`);
597
+ // #895: emit skip when not enough memories for REM
598
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
940
599
  continue;
941
600
  }
942
601
  vars.REM_SAMPLE = sample.map(r => `[${r.memory_type}, ${new Date(r.created_at).toISOString().slice(0, 10)}] ${r.content_en}`).join("\n");
@@ -944,6 +603,8 @@ export async function runSleepCycle(opts) {
944
603
  catch {
945
604
  state.steps[step.name] = { status: "skipped" };
946
605
  writeStateFile(statePath, state);
606
+ // #895: emit skip on var-prep failure for rem-synthesis
607
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
947
608
  continue;
948
609
  }
949
610
  }
@@ -952,7 +613,22 @@ export async function runSleepCycle(opts) {
952
613
  if (soulPrefix)
953
614
  soulPrefix = ""; // only prepend to first step
954
615
  const ctxBefore = -1;
955
- const response = await sendWithRetry(runtime, fullPrompt, step.name, flags.verbose, budget, retryDelayMs);
616
+ let response;
617
+ try {
618
+ response = await sendWithRetry(runtime, fullPrompt, step.name, flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now);
619
+ }
620
+ catch (err) {
621
+ if (err instanceof ModelUnavailableError) {
622
+ const duration = Date.now() - start;
623
+ logWarn(TAG, `[SLEEP] ${step.name} — model unavailable, stopping cycle (not advancing to next phase)`);
624
+ state.steps[step.name] = { status: "failed", duration: Math.round(duration / 100) / 10, ctxBefore, ctxAfter: -1 };
625
+ dreamySucceeded = false;
626
+ writeStateFile(statePath, state);
627
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
628
+ break;
629
+ }
630
+ throw err;
631
+ }
956
632
  const ctxAfter = -1;
957
633
  const duration = Date.now() - start;
958
634
  if (response) {
@@ -1005,6 +681,14 @@ export async function runSleepCycle(opts) {
1005
681
  dreamySucceeded = false;
1006
682
  }
1007
683
  writeStateFile(statePath, state);
684
+ // #895: emit terminal event for the standard prompt-driven step
685
+ fireOnStep(opts.onStep, {
686
+ name: step.name,
687
+ filename: step.filename,
688
+ index: stepIndex,
689
+ total: totalSteps,
690
+ phase: response ? "done" : "failed",
691
+ });
1008
692
  logInfo(TAG, `[SLEEP] ${response ? "✓" : "✗"} ${step.name} (${(duration / 1000).toFixed(1)}s, ${response?.length ?? 0} chars)`);
1009
693
  // Backoff between steps: 10s → 30s → 60s on consecutive failures, reset on success
1010
694
  if (response) {