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.
- package/dist/src/fts-utils.d.ts +47 -0
- package/dist/src/fts-utils.d.ts.map +1 -0
- package/dist/src/fts-utils.js +65 -0
- package/dist/src/fts-utils.js.map +1 -0
- package/dist/src/memory-index.d.ts +0 -16
- package/dist/src/memory-index.d.ts.map +1 -1
- package/dist/src/memory-index.js +1 -60
- package/dist/src/memory-index.js.map +1 -1
- package/dist/src/recall-boosts.d.ts +15 -0
- package/dist/src/recall-boosts.d.ts.map +1 -0
- package/dist/src/recall-boosts.js +159 -0
- package/dist/src/recall-boosts.js.map +1 -0
- package/dist/src/recall-engine.d.ts +0 -2
- package/dist/src/recall-engine.d.ts.map +1 -1
- package/dist/src/recall-engine.js +1 -151
- package/dist/src/recall-engine.js.map +1 -1
- package/dist/src/sleep/audit.d.ts +6 -0
- package/dist/src/sleep/audit.d.ts.map +1 -1
- package/dist/src/sleep/audit.js +6 -8
- package/dist/src/sleep/audit.js.map +1 -1
- package/dist/src/sleep/catchup.d.ts +19 -0
- package/dist/src/sleep/catchup.d.ts.map +1 -0
- package/dist/src/sleep/catchup.js +159 -0
- package/dist/src/sleep/catchup.js.map +1 -0
- package/dist/src/sleep/llm-budget.d.ts +29 -0
- package/dist/src/sleep/llm-budget.d.ts.map +1 -0
- package/dist/src/sleep/llm-budget.js +92 -0
- package/dist/src/sleep/llm-budget.js.map +1 -0
- package/dist/src/sleep/locks.d.ts +9 -5
- package/dist/src/sleep/locks.d.ts.map +1 -1
- package/dist/src/sleep/locks.js +8 -11
- package/dist/src/sleep/locks.js.map +1 -1
- package/dist/src/sleep/orchestrator.d.ts +4 -20
- package/dist/src/sleep/orchestrator.d.ts.map +1 -1
- package/dist/src/sleep/orchestrator.js +12 -492
- package/dist/src/sleep/orchestrator.js.map +1 -1
- package/dist/src/sleep/state.d.ts +26 -17
- package/dist/src/sleep/state.d.ts.map +1 -1
- package/dist/src/sleep/state.js +58 -20
- package/dist/src/sleep/state.js.map +1 -1
- 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,
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import {
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
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).
|