abmind 0.2.5 → 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 (41) hide show
  1. package/dist/src/fts-utils.d.ts +47 -0
  2. package/dist/src/fts-utils.d.ts.map +1 -0
  3. package/dist/src/fts-utils.js +65 -0
  4. package/dist/src/fts-utils.js.map +1 -0
  5. package/dist/src/memory-index.d.ts +0 -16
  6. package/dist/src/memory-index.d.ts.map +1 -1
  7. package/dist/src/memory-index.js +1 -60
  8. package/dist/src/memory-index.js.map +1 -1
  9. package/dist/src/recall-boosts.d.ts +15 -0
  10. package/dist/src/recall-boosts.d.ts.map +1 -0
  11. package/dist/src/recall-boosts.js +159 -0
  12. package/dist/src/recall-boosts.js.map +1 -0
  13. package/dist/src/recall-engine.d.ts +0 -2
  14. package/dist/src/recall-engine.d.ts.map +1 -1
  15. package/dist/src/recall-engine.js +1 -151
  16. package/dist/src/recall-engine.js.map +1 -1
  17. package/dist/src/sleep/audit.d.ts +6 -0
  18. package/dist/src/sleep/audit.d.ts.map +1 -1
  19. package/dist/src/sleep/audit.js +6 -8
  20. package/dist/src/sleep/audit.js.map +1 -1
  21. package/dist/src/sleep/catchup.d.ts +19 -0
  22. package/dist/src/sleep/catchup.d.ts.map +1 -0
  23. package/dist/src/sleep/catchup.js +159 -0
  24. package/dist/src/sleep/catchup.js.map +1 -0
  25. package/dist/src/sleep/llm-budget.d.ts +29 -0
  26. package/dist/src/sleep/llm-budget.d.ts.map +1 -0
  27. package/dist/src/sleep/llm-budget.js +92 -0
  28. package/dist/src/sleep/llm-budget.js.map +1 -0
  29. package/dist/src/sleep/locks.d.ts +9 -5
  30. package/dist/src/sleep/locks.d.ts.map +1 -1
  31. package/dist/src/sleep/locks.js +8 -11
  32. package/dist/src/sleep/locks.js.map +1 -1
  33. package/dist/src/sleep/orchestrator.d.ts +4 -20
  34. package/dist/src/sleep/orchestrator.d.ts.map +1 -1
  35. package/dist/src/sleep/orchestrator.js +12 -492
  36. package/dist/src/sleep/orchestrator.js.map +1 -1
  37. package/dist/src/sleep/state.d.ts +26 -17
  38. package/dist/src/sleep/state.d.ts.map +1 -1
  39. package/dist/src/sleep/state.js +58 -20
  40. package/dist/src/sleep/state.js.map +1 -1
  41. package/package.json +1 -1
@@ -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"; }
@@ -52,16 +46,6 @@ export class SleepInitError extends Error {
52
46
  export class SleepTimeoutError extends Error {
53
47
  constructor(message) { super(message); this.name = "SleepTimeoutError"; }
54
48
  }
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
- }
65
49
  export function parseArgs(argv) {
66
50
  const args = argv.slice(2);
67
51
  const parsed = { dryRun: false, verbose: false, force: false };
@@ -80,473 +64,9 @@ export function parseArgs(argv) {
80
64
  }
81
65
  return parsed;
82
66
  }
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
- }
67
+ // ── Subagent invocation ─────────────────────────────────────────────────────
68
+ /** LLM call entry for sleep steps — caller provides the runtime via RunOpts. */
146
69
  // ── 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
- }
241
- /**
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.
247
- */
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
- }
548
- }
549
- }
550
70
  // ── Main orchestration ──────────────────────────────────────────────────────
551
71
  /**
552
72
  * Run the full sleep cycle. Extracted from main() for testability (#175).