abmind 0.2.5-alpha.0 → 0.2.5

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 (80) 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/index.d.ts +5 -3
  47. package/dist/src/index.d.ts.map +1 -1
  48. package/dist/src/index.js +3 -1
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/memory-db.d.ts.map +1 -1
  51. package/dist/src/memory-db.js +13 -5
  52. package/dist/src/memory-db.js.map +1 -1
  53. package/dist/src/memory-manager.d.ts +8 -0
  54. package/dist/src/memory-manager.d.ts.map +1 -1
  55. package/dist/src/memory-manager.js +8 -0
  56. package/dist/src/memory-manager.js.map +1 -1
  57. package/dist/src/ollama-embed.d.ts.map +1 -1
  58. package/dist/src/ollama-embed.js +11 -1
  59. package/dist/src/ollama-embed.js.map +1 -1
  60. package/dist/src/sleep/audit.d.ts +27 -0
  61. package/dist/src/sleep/audit.d.ts.map +1 -0
  62. package/dist/src/sleep/audit.js +162 -0
  63. package/dist/src/sleep/audit.js.map +1 -0
  64. package/dist/src/sleep/locks.d.ts +15 -0
  65. package/dist/src/sleep/locks.d.ts.map +1 -0
  66. package/dist/src/sleep/locks.js +45 -0
  67. package/dist/src/sleep/locks.js.map +1 -0
  68. package/dist/src/sleep/orchestrator.d.ts +33 -0
  69. package/dist/src/sleep/orchestrator.d.ts.map +1 -1
  70. package/dist/src/sleep/orchestrator.js +186 -22
  71. package/dist/src/sleep/orchestrator.js.map +1 -1
  72. package/dist/src/sleep/sleep-prompt-loader.d.ts +0 -5
  73. package/dist/src/sleep/sleep-prompt-loader.d.ts.map +1 -1
  74. package/dist/src/sleep/sleep-prompt-loader.js +0 -12
  75. package/dist/src/sleep/sleep-prompt-loader.js.map +1 -1
  76. package/dist/src/sleep/state.d.ts +47 -0
  77. package/dist/src/sleep/state.d.ts.map +1 -0
  78. package/dist/src/sleep/state.js +45 -0
  79. package/dist/src/sleep/state.js.map +1 -0
  80. package/package.json +2 -5
@@ -52,6 +52,16 @@ export class SleepInitError extends Error {
52
52
  export class SleepTimeoutError extends Error {
53
53
  constructor(message) { super(message); this.name = "SleepTimeoutError"; }
54
54
  }
55
+ /** Best-effort onStep invoker — swallow 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
+ }
55
65
  export function parseArgs(argv) {
56
66
  const args = argv.slice(2);
57
67
  const parsed = { dryRun: false, verbose: false, force: false };
@@ -136,6 +146,17 @@ function formatWiredResults(r) {
136
146
  // ── Transport ───────────────────────────────────────────────────────────────
137
147
  // (SleepRuntime is imported at the top of the file.)
138
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
+ }
139
160
  /** Budget tracker — shared across all sendWithRetry calls in a sleep cycle. */
140
161
  class LlmBudget {
141
162
  state;
@@ -157,17 +178,28 @@ class LlmBudget {
157
178
  }
158
179
  get calls() { return this.state.llmCalls ?? 0; }
159
180
  }
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
- }
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) {
166
192
  try {
167
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
+ }
168
199
  if (!result || !result.trim()) {
169
- logWarn(TAG, `Step ${stepName} attempt ${attempt}/${MAX_RETRIES} returned empty response`);
170
- if (attempt === MAX_RETRIES) {
200
+ emptyAttempts++;
201
+ logWarn(TAG, `Step ${stepName} attempt ${emptyAttempts}/${MAX_RETRIES} returned empty response`);
202
+ if (emptyAttempts >= MAX_RETRIES) {
171
203
  logError(TAG, `Step ${stepName} failed after ${MAX_RETRIES} attempts (empty), skipping`);
172
204
  return null;
173
205
  }
@@ -178,17 +210,21 @@ async function sendWithRetry(runtime, prompt, stepName, _verbose, budget, delayM
178
210
  return result;
179
211
  }
180
212
  catch (err) {
213
+ // Transport failure — model unreachable. Do NOT consume budget (no tokens used).
214
+ transportAttempts++;
181
215
  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;
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);
186
222
  }
187
- if (delayMs > 0)
188
- await new Promise(r => setTimeout(r, delayMs));
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));
189
226
  }
190
227
  }
191
- return null;
192
228
  }
193
229
  // ── Audit trail helpers ─────────────────────────────────────────────────────
194
230
  function buildSnapshotSummary(snapshot) {
@@ -372,7 +408,7 @@ function failedEssentials(state) {
372
408
  }
373
409
  return failed;
374
410
  }
375
- async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs = 6000) {
411
+ async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs = 6000, onStep) {
376
412
  for (const lock of locks) {
377
413
  if (lock.ageDays > CATCHUP_MAX_AGE_DAYS) {
378
414
  logError(TAG, `[CATCH-UP] Abandoning stale lock ${basename(lock.path)} — ${lock.ageDays} days old, data unrecoverable`);
@@ -407,10 +443,22 @@ async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime,
407
443
  lock.state.steps["daily-summary"] = { status: "skipped" };
408
444
  }
409
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
+ });
410
452
  }
411
453
  catch (err) {
412
454
  logWarn(TAG, `[CATCH-UP] ✗ 04a-daily-summary for ${lock.dateStr}: ${err instanceof Error ? err.message : String(err)}`);
413
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
+ });
414
462
  }
415
463
  writeStateFile(lock.path, lock.state);
416
464
  }
@@ -420,6 +468,12 @@ async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime,
420
468
  if (!existsSync(dailyPath)) {
421
469
  logInfo(TAG, `[CATCH-UP] ⏭ 04b — no daily file for ${lock.dateStr}`);
422
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
+ });
423
477
  }
424
478
  else {
425
479
  const start = Date.now();
@@ -429,10 +483,22 @@ async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime,
429
483
  throw new LLMUnavailableError(); return r; }));
430
484
  lock.state.steps["extract-memories"] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
431
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
+ });
432
492
  }
433
493
  catch (err) {
434
494
  logWarn(TAG, `[CATCH-UP] ✗ 04b for ${lock.dateStr}: ${err instanceof Error ? err.message : String(err)}`);
435
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
+ });
436
502
  }
437
503
  }
438
504
  writeStateFile(lock.path, lock.state);
@@ -451,10 +517,22 @@ async function runCatchUp(locks, sleepData, memoryConfig, steps, flags, runtime,
451
517
  if (response) {
452
518
  lock.state.steps[stepName] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
453
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
+ });
454
526
  }
455
527
  else {
456
528
  lock.state.steps[stepName] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
457
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
+ });
458
536
  }
459
537
  writeStateFile(lock.path, lock.state);
460
538
  }
@@ -484,6 +562,8 @@ export async function runSleepCycle(opts) {
484
562
  const timeoutMs = opts.timeoutMs ?? getAbmindEnv().sleepTimeoutMin * 60 * 1000;
485
563
  const backoffMs = opts.backoffMs ?? ((n) => [10, 30, 60][Math.min(n, 2)] * 1000);
486
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;
487
567
  const runtime = opts.runtime;
488
568
  if (flags.verbose) {
489
569
  logInfo(TAG, "Verbose mode enabled");
@@ -641,6 +721,13 @@ export async function runSleepCycle(opts) {
641
721
  const snapshotVars = buildSleepVars(snapshot);
642
722
  for (const [k, v] of Object.entries(snapshotVars))
643
723
  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 */ }
644
731
  // Progress protocol — emit PROGRESS:<pct>:<label> on stdout
645
732
  const totalSteps = steps.length;
646
733
  let stepIndex = 0;
@@ -754,7 +841,7 @@ export async function runSleepCycle(opts) {
754
841
  const previousLocks = scanPreviousLocks(sleepDir, dateStr);
755
842
  if (previousLocks.length > 0) {
756
843
  logInfo(TAG, `[CATCH-UP] Found ${previousLocks.length} previous lock(s)`);
757
- await runCatchUp(previousLocks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs);
844
+ await runCatchUp(previousLocks, sleepData, memoryConfig, steps, flags, runtime, budget, retryDelayMs, opts.onStep);
758
845
  }
759
846
  // Housekeeping: move misplaced daily/consolidation_* to weekly/ (#640)
760
847
  try {
@@ -799,13 +886,19 @@ export async function runSleepCycle(opts) {
799
886
  }
800
887
  emitProgress(step.name);
801
888
  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" });
802
893
  // Resume: skip already completed steps
803
894
  if (isResume && existingState?.steps[step.name]?.status === "ok") {
804
895
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — already done (resume)`);
896
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
805
897
  continue;
806
898
  }
807
899
  if (isResume && existingState?.steps[step.name]?.status === "skipped") {
808
900
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — skipped (resume)`);
901
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
809
902
  continue;
810
903
  }
811
904
  // Skip logic (essential steps always run)
@@ -813,6 +906,7 @@ export async function runSleepCycle(opts) {
813
906
  logInfo(TAG, `[SLEEP] ⏭ ${step.name} — skipped`);
814
907
  state.steps[step.name] = { status: "skipped" };
815
908
  writeStateFile(statePath, state);
909
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "skipped" });
816
910
  continue;
817
911
  }
818
912
  const start = Date.now();
@@ -829,7 +923,7 @@ export async function runSleepCycle(opts) {
829
923
  const firstMsgTs = sleepData.getFirstMessageAfter(userId, watermarkTs);
830
924
  const firstMsgDate = firstMsgTs ? new Date(firstMsgTs) : new Date(now());
831
925
  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)
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)
833
927
  throw new LLMUnavailableError(); return r; }), {
834
928
  ctxWindow, memoryDir: memoryConfig.memoryDir, userId, watermarkTs,
835
929
  });
@@ -846,9 +940,30 @@ export async function runSleepCycle(opts) {
846
940
  logWarn(TAG, `[SLEEP] 04a failed: ${err instanceof Error ? err.message : String(err)}`);
847
941
  state.steps[step.name] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
848
942
  dreamySucceeded = false;
943
+ 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)`);
947
+ break;
948
+ }
949
+ }
950
+ if (state.steps[step.name]?.status !== "failed") {
951
+ // success/skip path: persist state + log + fire event
952
+ writeStateFile(statePath, state);
849
953
  }
850
- writeStateFile(statePath, state);
851
954
  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
+ }
852
967
  continue;
853
968
  }
854
969
  if (step.name === "extract-memories") {
@@ -866,11 +981,13 @@ export async function runSleepCycle(opts) {
866
981
  state.steps[step.name] = { status: "skipped" };
867
982
  writeStateFile(statePath, state);
868
983
  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" });
869
986
  continue;
870
987
  }
871
988
  try {
872
989
  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)
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)
874
991
  throw new LLMUnavailableError(); return r; }));
875
992
  state.steps[step.name] = { status: "ok", duration: Math.round((Date.now() - start) / 100) / 10 };
876
993
  writeFileSync(join(stepLogDir, `${String(stepIndex).padStart(2, "0")}-${step.name}.md`), redactSecrets(result), "utf-8");
@@ -880,8 +997,24 @@ export async function runSleepCycle(opts) {
880
997
  logWarn(TAG, `[SLEEP] 04b failed: ${err instanceof Error ? err.message : String(err)}`);
881
998
  state.steps[step.name] = { status: "failed", duration: Math.round((Date.now() - start) / 100) / 10 };
882
999
  dreamySucceeded = false;
1000
+ 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)`);
1004
+ break;
1005
+ }
1006
+ }
1007
+ if (state.steps[step.name]?.status !== "failed") {
1008
+ 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
+ });
883
1017
  }
884
- writeStateFile(statePath, state);
885
1018
  continue;
886
1019
  }
887
1020
  // Standard prompt-driven step — JIT substitution
@@ -896,6 +1029,8 @@ export async function runSleepCycle(opts) {
896
1029
  state.steps[step.name] = { status: "skipped" };
897
1030
  writeStateFile(statePath, state);
898
1031
  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" });
899
1034
  continue;
900
1035
  }
901
1036
  vars.NEW_EXTRACTIONS = newRows.map(r => `[id=${r.id}] (${r.memory_type}, trust=${r.trust}) ${r.content_en}`).join("\n");
@@ -925,6 +1060,8 @@ export async function runSleepCycle(opts) {
925
1060
  logWarn(TAG, `[SLEEP] contradiction-and-graph var prep failed: ${err instanceof Error ? err.message : String(err)}`);
926
1061
  state.steps[step.name] = { status: "skipped" };
927
1062
  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" });
928
1065
  continue;
929
1066
  }
930
1067
  }
@@ -937,6 +1074,8 @@ export async function runSleepCycle(opts) {
937
1074
  state.steps[step.name] = { status: "skipped" };
938
1075
  writeStateFile(statePath, state);
939
1076
  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" });
940
1079
  continue;
941
1080
  }
942
1081
  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 +1083,8 @@ export async function runSleepCycle(opts) {
944
1083
  catch {
945
1084
  state.steps[step.name] = { status: "skipped" };
946
1085
  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" });
947
1088
  continue;
948
1089
  }
949
1090
  }
@@ -952,7 +1093,22 @@ export async function runSleepCycle(opts) {
952
1093
  if (soulPrefix)
953
1094
  soulPrefix = ""; // only prepend to first step
954
1095
  const ctxBefore = -1;
955
- const response = await sendWithRetry(runtime, fullPrompt, step.name, flags.verbose, budget, retryDelayMs);
1096
+ let response;
1097
+ try {
1098
+ response = await sendWithRetry(runtime, fullPrompt, step.name, flags.verbose, budget, retryDelayMs, transportBackoffMs, transportRetryWindowMs, now);
1099
+ }
1100
+ catch (err) {
1101
+ if (err instanceof ModelUnavailableError) {
1102
+ 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 };
1105
+ dreamySucceeded = false;
1106
+ writeStateFile(statePath, state);
1107
+ fireOnStep(opts.onStep, { name: step.name, filename: step.filename, index: stepIndex, total: totalSteps, phase: "failed" });
1108
+ break;
1109
+ }
1110
+ throw err;
1111
+ }
956
1112
  const ctxAfter = -1;
957
1113
  const duration = Date.now() - start;
958
1114
  if (response) {
@@ -1005,6 +1161,14 @@ export async function runSleepCycle(opts) {
1005
1161
  dreamySucceeded = false;
1006
1162
  }
1007
1163
  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
+ });
1008
1172
  logInfo(TAG, `[SLEEP] ${response ? "✓" : "✗"} ${step.name} (${(duration / 1000).toFixed(1)}s, ${response?.length ?? 0} chars)`);
1009
1173
  // Backoff between steps: 10s → 30s → 60s on consecutive failures, reset on success
1010
1174
  if (response) {