@wjm200509/dsh-memory 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runner.js ADDED
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Parallel dreaming runner.
3
+ *
4
+ * Runs for different projects proceed in parallel, up to `concurrency`
5
+ * (a volatile setting); one project never has two runs at once. Runs that
6
+ * write the same user-partition note at once are kept apart by the store's
7
+ * versions: the later write conflicts and is redone on the new content.
8
+ * Policy:
9
+ * - auto: after a session ends, unless paused or `autoDream` is off. Each auto
10
+ * run consolidates ONE project; a freed slot goes first to a waiting manual
11
+ * request, then to the project whose queued session ended first;
12
+ * - failures are sorted by cause. An authentication or quota error pauses auto
13
+ * dreaming at once; a transient one (rate limit, network, 5xx) backs off and
14
+ * retries, and only a streak of them pauses. A batch that keeps failing for
15
+ * its own reasons (blocked, cut off) is set aside after a few attempts
16
+ * instead of blocking the queue. While paused, ended sessions keep queueing;
17
+ * runs already in flight finish;
18
+ * - manual (`/dream`, settings button): runs even while paused; after a
19
+ * successful manual run that started paused, it drains the whole queue and
20
+ * then resumes auto. A manual failure stays paused.
21
+ * - cross-project: the one run that sees every partition, so the one that
22
+ * moves notes between them. It runs alone (it waits for the slots to empty
23
+ * and holds new project runs meanwhile): automatically once a day after
24
+ * project runs happened (and only when there is something to compare), and
25
+ * after the settings page's manual run.
26
+ * A batch leaves the queue only after its run succeeded; a run cut off by its
27
+ * step budget advances each session only as far as the agent read it.
28
+ */
29
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
30
+ import { join } from "node:path";
31
+ import { promisify } from "node:util";
32
+ import zlib from "node:zlib";
33
+ import { isEligible, accumulate, emptyActivity, isSubstantive } from "./trigger.js";
34
+ import { sameProject } from "./paths.js";
35
+ import { DREAM_PROMPT_MARKERS } from "./dream.js";
36
+
37
+ const DAY_MS = 86_400_000;
38
+ const STATUS_LOG_SIZE = 20;
39
+ /** Slot marker of the cross-project run. */
40
+ const CROSS = Symbol("cross-project");
41
+ /** Least time between two automatic cross-project runs. */
42
+ export const CROSS_INTERVAL_MS = DAY_MS;
43
+ /** Waits after the 1st, 2nd, ... consecutive transient failure. */
44
+ export const BACKOFF_MS = [60_000, 180_000];
45
+ /** Consecutive failures that pause auto dreaming. */
46
+ export const MAX_STREAK = BACKOFF_MS.length + 1;
47
+ /** Failed (or no-progress) runs after which a session is set aside. */
48
+ export const POISON_LIMIT = 3;
49
+ /** How long a project whose directory cannot be reached is left alone. */
50
+ export const SNOOZE_MS = 600_000;
51
+ /** Dreaming sessions are small; anything bigger on disk is not one and is not worth reading. */
52
+ const MAX_DREAM_SESSION_BYTES = 8 * 1024 * 1024;
53
+
54
+ /**
55
+ * Sort a failed run's error message by what to do about it.
56
+ * - "fatal": retrying cannot help until the user acts (bad key, no quota, no access);
57
+ * - "transient": likely to pass by itself (rate limit, overload, network, timeout, 5xx);
58
+ * - "other": the batch itself is the likely cause (blocked, cut off, rejected input).
59
+ * @param {string | undefined} message
60
+ * @returns {"fatal" | "transient" | "other"}
61
+ */
62
+ export function classifyError(message) {
63
+ const text = String(message ?? "");
64
+ if (/\b(401|402|403)\b|unauthori[sz]ed|forbidden|invalid[ _-]?(api[ _-]?)?key|authenticat|insufficient|quota|billing|credit/i.test(text)) return "fatal";
65
+ if (/(?:http|status|code|error)\s*:?\s*(?:408|425|429|5\d\d)\b|\b(?:408|425|429|5\d\d)\s+(?:too many|request timeout|internal|bad gateway|service|gateway)|rate[ _-]?limit|too many requests|overload|timed? ?out|timeout|ETIMEDOUT|ECONN\w*|ENOTFOUND|EAI_AGAIN|EPIPE|socket|network|fetch failed|temporar|unavailable|capacity/i.test(text)) return "transient";
66
+ return "other";
67
+ }
68
+
69
+ /**
70
+ * @typedef {import("./state.js").LogEntry} LogEntry
71
+ * @typedef {{ ok: boolean, runs: LogEntry[] }} RunReport
72
+ */
73
+
74
+ export class DreamRunner {
75
+ /**
76
+ * @param {{ state: import("./state.js").DreamState,
77
+ * runDream: (job: { cwd?: string, sessionIds?: string[], fromSeqs?: Record<string, number>, projects?: object[], selection?: object, indexMaxBytes: number, signal?: AbortSignal }) => Promise<object>,
78
+ * resolveSelection: () => object | undefined, config: object, now?: () => number,
79
+ * listProjects?: () => Promise<Array<{ cwd: string, notes: number }>>,
80
+ * countUserNotes?: () => Promise<number>,
81
+ * onStatus?: (status: object) => void, afterRun?: () => Promise<void> }} deps
82
+ */
83
+ constructor({ state, runDream, resolveSelection, config, now = Date.now, listProjects = async () => [], countUserNotes = async () => 1, onStatus, afterRun }) {
84
+ Object.assign(this, { state, runDream, resolveSelection, config, now, listProjects, countUserNotes, onStatus, afterRun });
85
+ /** Projects with a run in flight, in start order. */
86
+ this.active = [];
87
+ /** Manual requests waiting for their project or a free slot. */
88
+ this.waiters = [];
89
+ this.tasks = new Set();
90
+ this.stopped = false;
91
+ /** Abort controllers of the runs in flight. */
92
+ this.aborts = new Set();
93
+ /** Consecutive failed runs (reset by a success). */
94
+ this.streak = 0;
95
+ /** When the last counted failure happened: runs that started before it belong to the same wave. */
96
+ this.waveEnd = -1;
97
+ /** Auto runs wait until this time after a transient failure. */
98
+ this.backoffUntil = 0;
99
+ /** Projects left alone until a time: `{ cwd, until }`. */
100
+ this.snoozed = [];
101
+ /** Pending wake-ups from `pumpLater`. */
102
+ this.pumpTimers = new Set();
103
+ /** What the last manual run found, for the settings page: `{ at, text }`. */
104
+ this.notice = null;
105
+ }
106
+
107
+ /**
108
+ * Called when the plugin unloads (including app shutdown, which also
109
+ * disposes sessions and so fires session endings). Endings still queue;
110
+ * no new run starts, runs in flight are cancelled, and a run that fails
111
+ * because services are going away is neither logged nor allowed to pause
112
+ * auto dreaming.
113
+ */
114
+ stop() {
115
+ this.stopped = true;
116
+ for (const timer of this.pumpTimers) clearTimeout(timer);
117
+ this.pumpTimers.clear();
118
+ this.cancelAll("dsh-memory is stopping");
119
+ }
120
+
121
+ /** Cancel every run in flight (plugin stopping or turned off); their batches stay queued. */
122
+ cancelAll(reason) {
123
+ for (const controller of this.aborts) controller.abort(new Error(reason));
124
+ }
125
+
126
+ /** Snapshot for the settings page; `running` is null or the projects in flight. */
127
+ status() {
128
+ return {
129
+ paused: this.state.paused,
130
+ running: this.active.length === 0 ? null : this.active.map((cwd) => (cwd === CROSS ? { scope: "cross" } : { cwd })),
131
+ pendingCount: this.state.pending().length,
132
+ log: this.state.recentLog(STATUS_LOG_SIZE),
133
+ ...(this.backoffUntil > this.now() ? { retryAt: this.backoffUntil } : {}),
134
+ ...(this.notice ? { notice: this.notice } : {})
135
+ };
136
+ }
137
+
138
+ emit() {
139
+ this.onStatus?.(this.status());
140
+ }
141
+
142
+ /** Keep a task visible to `idle()` until it settles. */
143
+ track(promise) {
144
+ this.tasks.add(promise);
145
+ promise.then(() => this.tasks.delete(promise), () => this.tasks.delete(promise));
146
+ return promise;
147
+ }
148
+
149
+ /** Resolves once every run, including runs started meanwhile, has settled. */
150
+ async idle() {
151
+ while (this.tasks.size > 0) await Promise.allSettled([...this.tasks]);
152
+ }
153
+
154
+ /** Auto path: queue an ended session and start consolidating if allowed. */
155
+ async onSessionEnded(info) {
156
+ if (!this.config.enabled.get()) return;
157
+ if (info.seq <= this.state.watermark(info.sessionId)) return;
158
+ await this.state.enqueue(info);
159
+ this.notice = null;
160
+ this.emit();
161
+ this.pump();
162
+ }
163
+
164
+ /** Whether automatic dreaming may start a run right now. */
165
+ autoAllowed() {
166
+ return !this.stopped && this.config.enabled.get() && this.config.autoDream.get() && this.state.paused === null && this.now() >= this.backoffUntil;
167
+ }
168
+
169
+ /** Call `pump()` once `ms` have passed (backoff or snooze ended). */
170
+ pumpLater(ms) {
171
+ const timer = setTimeout(() => {
172
+ this.pumpTimers.delete(timer);
173
+ this.emit();
174
+ this.pump();
175
+ }, ms);
176
+ timer.unref?.();
177
+ this.pumpTimers.add(timer);
178
+ }
179
+
180
+ /**
181
+ * Whether a run for `cwd` may start now: a free slot and no run in flight
182
+ * for that project; the cross-project run needs every slot empty and
183
+ * blocks every other run.
184
+ */
185
+ canStart(cwd) {
186
+ if (cwd === CROSS) return this.active.length === 0;
187
+ return this.active.length < this.config.concurrency.get() && !this.active.some((running) => running === CROSS || sameProject(running, cwd));
188
+ }
189
+
190
+ /** An automatic cross-project run is due: projects were consolidated since the last one, and a day has passed. */
191
+ crossDue() {
192
+ return this.state.crossProjects().length > 0 && this.now() - (this.state.crossAt ?? 0) >= CROSS_INTERVAL_MS;
193
+ }
194
+
195
+ /** Queued sessions with events past their watermark, oldest ending first; projects that were snoozed are left out. */
196
+ freshJobs(excludeSessionId) {
197
+ const now = this.now();
198
+ this.snoozed = this.snoozed.filter((entry) => entry.until > now);
199
+ return this.state.pending().filter((job) => job.sessionId !== excludeSessionId && job.seq > this.state.watermark(job.sessionId) && !this.snoozed.some((entry) => sameProject(entry.cwd, job.cwd)));
200
+ }
201
+
202
+ /** Leave a project alone for a while (its directory cannot be reached) and look again afterwards. */
203
+ snooze(cwd) {
204
+ this.snoozed.push({ cwd, until: this.now() + SNOOZE_MS });
205
+ this.pumpLater(SNOOZE_MS);
206
+ }
207
+
208
+ /** Start auto runs for waiting projects while slots are free; a due cross-project run goes first. */
209
+ pump() {
210
+ if (this.autoAllowed() && this.crossDue()) {
211
+ if (this.active.length === 0 && this.waiters.length === 0) {
212
+ this.active.push(CROSS);
213
+ this.track(this.runCross("auto", []));
214
+ }
215
+ return;
216
+ }
217
+ while (this.autoAllowed()) {
218
+ const job = this.freshJobs().find((candidate) => this.canStart(candidate.cwd) && !this.waiters.some((waiter) => sameProject(waiter.cwd, candidate.cwd)));
219
+ if (job === undefined) return;
220
+ this.active.push(job.cwd);
221
+ this.track(this.runGroup("auto", job.cwd, {}, []));
222
+ }
223
+ }
224
+
225
+ /** Wait for a slot for `cwd` and hold it; the run releases it. */
226
+ acquire(cwd) {
227
+ if (this.waiters.length === 0 && this.canStart(cwd)) {
228
+ this.active.push(cwd);
229
+ return Promise.resolve();
230
+ }
231
+ return new Promise((resolve) => this.waiters.push({ cwd, resolve }));
232
+ }
233
+
234
+ /** Free a slot: waiting manual requests first, then auto. */
235
+ release(cwd) {
236
+ const index = this.active.indexOf(cwd);
237
+ if (index !== -1) this.active.splice(index, 1);
238
+ for (const waiter of [...this.waiters]) {
239
+ if (!this.canStart(waiter.cwd)) continue;
240
+ this.waiters.splice(this.waiters.indexOf(waiter), 1);
241
+ this.active.push(waiter.cwd);
242
+ waiter.resolve();
243
+ }
244
+ this.emit();
245
+ this.pump();
246
+ }
247
+
248
+ /**
249
+ * Manual path.
250
+ * @param {{ cwd?: string, excludeSessionId?: string, cross?: boolean }} scope - project to consolidate first
251
+ * (all when omitted), a session to leave out, and whether to finish with a cross-project run.
252
+ * @returns {Promise<RunReport>}
253
+ */
254
+ runManual({ cwd, excludeSessionId, cross = false } = {}) {
255
+ return this.track((async () => {
256
+ const runs = [];
257
+ this.notice = null;
258
+ const report = await this.manual({ cwd, excludeSessionId, cross }, runs);
259
+ if (report.ok && runs.length === 0 && !this.stopped) {
260
+ this.notice = { at: this.now(), text: "没有需要整理的会话。" };
261
+ this.emit();
262
+ }
263
+ return report;
264
+ })());
265
+ }
266
+
267
+ async manual({ cwd, excludeSessionId, cross }, runs) {
268
+ if (!(await this.drain("manual", { cwd, excludeSessionId }, runs)) || this.stopped) return { ok: false, runs };
269
+ const wasPaused = this.state.paused !== null;
270
+ if (wasPaused && !(await this.drain("manual", { excludeSessionId }, runs))) return { ok: false, runs };
271
+ if (cross && !this.stopped) {
272
+ await this.acquire(CROSS);
273
+ if ((await this.runCross("manual", runs)) === "failed") return { ok: false, runs };
274
+ }
275
+ if (this.stopped) return { ok: false, runs };
276
+ if (wasPaused) {
277
+ this.streak = 0;
278
+ this.backoffUntil = 0;
279
+ await this.state.setPaused(null);
280
+ this.emit();
281
+ this.pump();
282
+ }
283
+ return { ok: true, runs };
284
+ }
285
+
286
+ /**
287
+ * Run every project in scope, in parallel, until none has sessions left;
288
+ * false once any run failed (no further batch starts after that).
289
+ */
290
+ async drain(trigger, { cwd, excludeSessionId }, runs) {
291
+ const outcome = { failed: false };
292
+ while (!outcome.failed && !this.stopped) {
293
+ const projects = [];
294
+ for (const job of this.freshJobs(excludeSessionId)) {
295
+ if (cwd !== undefined && !sameProject(job.cwd, cwd)) continue;
296
+ if (!projects.some((project) => sameProject(project, job.cwd))) projects.push(job.cwd);
297
+ }
298
+ if (projects.length === 0) break;
299
+ await Promise.all(projects.map((project) => this.drainProject(trigger, project, excludeSessionId, runs, outcome)));
300
+ }
301
+ return !outcome.failed;
302
+ }
303
+
304
+ /** Run one project's batches one after another until it has none left. */
305
+ async drainProject(trigger, cwd, excludeSessionId, runs, outcome) {
306
+ while (!outcome.failed && !this.stopped) {
307
+ await this.acquire(cwd);
308
+ const result = await this.runGroup(trigger, cwd, { excludeSessionId }, runs);
309
+ if (result === "empty") return;
310
+ if (result === "failed") outcome.failed = true;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Consolidate one project's queued sessions. The caller holds the
316
+ * project's slot; this releases it.
317
+ * @returns {Promise<"empty" | "ran" | "failed">}
318
+ */
319
+ async runGroup(trigger, groupCwd, { excludeSessionId }, runs) {
320
+ try {
321
+ const queued = this.state.pending().filter((job) => job.sessionId !== excludeSessionId);
322
+ const stale = queued.filter((job) => job.seq <= this.state.watermark(job.sessionId));
323
+ if (stale.length > 0) await this.state.complete(stale);
324
+ /* Capture the seqs this run covers: a session that ends again meanwhile is re-queued with a higher seq and must stay queued. */
325
+ let batch = this.freshJobs(excludeSessionId).filter((job) => sameProject(job.cwd, groupCwd)).map(({ sessionId, seq }) => ({ sessionId, seq }));
326
+ /* A session that already failed twice runs alone, so one bad session cannot drag the others in when it is finally set aside. */
327
+ const suspect = batch.find((job) => this.state.attempts(job.sessionId) >= 2);
328
+ if (suspect !== undefined) batch = [suspect];
329
+ if (batch.length === 0 || this.stopped) return "empty";
330
+ const sessionIds = batch.map((job) => job.sessionId);
331
+ /* Where an earlier, cut-off run left each session: this run starts there. */
332
+ const fromSeqs = Object.fromEntries(sessionIds.map((id) => [id, this.state.watermark(id)]));
333
+ this.emit();
334
+ const result = await this.invoke({ cwd: groupCwd, sessionIds, fromSeqs });
335
+ if (result.skipped) {
336
+ /* Project directory unreachable (unmounted drive): leave the batch queued and look again later. */
337
+ this.snooze(groupCwd);
338
+ return "empty";
339
+ }
340
+ if (!result.ok && (this.stopped || result.cancelled)) return "empty";
341
+ const ok = await this.finish({ trigger, cwd: groupCwd, sessionIds }, result, runs);
342
+ if (ok) {
343
+ await this.completeBatch(trigger, groupCwd, batch, fromSeqs, result, runs);
344
+ await this.state.noteProjectRun(groupCwd);
345
+ } else await this.handleFailure(trigger, groupCwd, batch, result, runs);
346
+ await this.afterRun?.().catch(() => {});
347
+ return ok ? "ran" : "failed";
348
+ } finally {
349
+ this.release(groupCwd);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Take a successful run's batch off the queue. A session the run did not
355
+ * read to the end only advances to where it got to and stays queued; one
356
+ * that makes no progress run after run is set aside.
357
+ */
358
+ async completeBatch(trigger, cwd, batch, fromSeqs, result, runs) {
359
+ const unfinished = result.unfinished ?? {};
360
+ const covered = batch.map((job) => ({ sessionId: job.sessionId, seq: job.sessionId in unfinished ? unfinished[job.sessionId] : job.seq }));
361
+ await this.state.complete(covered);
362
+ const progressed = batch.filter((job) => job.sessionId in unfinished && unfinished[job.sessionId] > fromSeqs[job.sessionId]).map((job) => job.sessionId);
363
+ await this.state.clearAttempts(progressed);
364
+ const stalled = batch.filter((job) => job.sessionId in unfinished && unfinished[job.sessionId] <= fromSeqs[job.sessionId]).map((job) => job.sessionId);
365
+ if (stalled.length > 0) {
366
+ const counts = await this.state.noteFailure(stalled);
367
+ const giveUp = batch.filter((job) => counts[job.sessionId] >= POISON_LIMIT);
368
+ if (giveUp.length > 0) await this.setAside(trigger, cwd, giveUp, `read no further in ${POISON_LIMIT} runs (step budget)`, runs);
369
+ }
370
+ }
371
+
372
+ /** Dequeue sessions that keep failing, and say so in the log. */
373
+ async setAside(trigger, cwd, jobs, why, runs) {
374
+ await this.state.complete(jobs);
375
+ const entry = { at: this.now(), trigger, cwd, sessionIds: jobs.map((job) => job.sessionId), ok: false, error: `skipped after ${POISON_LIMIT} attempts: ${why}`, skipped: true };
376
+ await this.state.appendLog(entry);
377
+ runs.push(entry);
378
+ }
379
+
380
+ /**
381
+ * What a failed run means for the queue and for auto dreaming (see the
382
+ * header): set aside a batch that keeps failing for its own reasons; pause
383
+ * on an auth/quota error, on any manual failure, or on a streak; otherwise
384
+ * back off and retry.
385
+ */
386
+ async handleFailure(trigger, cwd, batch, result, runs) {
387
+ const kind = classifyError(result.error);
388
+ if (cwd !== undefined && batch.length > 0 && kind === "other") {
389
+ const counts = await this.state.noteFailure(batch.map((job) => job.sessionId));
390
+ const giveUp = batch.filter((job) => counts[job.sessionId] >= POISON_LIMIT);
391
+ if (giveUp.length > 0) {
392
+ await this.setAside(trigger, cwd, giveUp, result.error, runs);
393
+ this.streak = 0;
394
+ return;
395
+ }
396
+ }
397
+ const since = this.now();
398
+ if (trigger === "manual" || kind === "fatal") {
399
+ await this.state.setPaused({ since, reason: result.error, kind: kind === "fatal" ? "auth" : "manual" });
400
+ return;
401
+ }
402
+ /* Failures of runs that started together (several projects in parallel) are one event, not several. */
403
+ const newWave = (result.startedAt ?? since) > this.waveEnd;
404
+ if (newWave) {
405
+ this.streak++;
406
+ this.waveEnd = since;
407
+ }
408
+ if (this.streak >= MAX_STREAK) {
409
+ await this.state.setPaused({ since, reason: `连续失败 ${this.streak} 次:${result.error}`, kind: "repeated" });
410
+ return;
411
+ }
412
+ if (!newWave) return;
413
+ const wait = BACKOFF_MS[this.streak - 1];
414
+ this.backoffUntil = since + wait;
415
+ this.pumpLater(wait);
416
+ }
417
+
418
+ /**
419
+ * The cross-project run. The caller holds the cross slot; this releases it.
420
+ * @returns {Promise<"empty" | "ran" | "failed">}
421
+ */
422
+ async runCross(trigger, runs) {
423
+ try {
424
+ if (this.stopped) return "empty";
425
+ const projects = await this.listProjects();
426
+ /* Nothing to compare: fewer than two projects have notes and the user partition is empty. An automatic run counts as done, or the due check would start it again at once. */
427
+ const nothingToDo = projects.length === 0 || (trigger === "auto" && projects.filter((project) => project.notes > 0).length < 2 && (await this.countUserNotes()) === 0);
428
+ if (nothingToDo) {
429
+ await this.state.completeCross(this.now());
430
+ return "empty";
431
+ }
432
+ this.emit();
433
+ const result = await this.invoke({ projects });
434
+ if (!result.ok && (this.stopped || result.cancelled)) return "empty";
435
+ const ok = await this.finish({ trigger, scope: "cross", sessionIds: [] }, result, runs);
436
+ if (ok) await this.state.completeCross(this.now());
437
+ else await this.handleFailure(trigger, undefined, [], result, runs);
438
+ await this.afterRun?.().catch(() => {});
439
+ return ok ? "ran" : "failed";
440
+ } catch (error) {
441
+ if (this.stopped) return "empty";
442
+ const result = { ok: false, error: error?.message ?? String(error) };
443
+ await this.finish({ trigger, scope: "cross", sessionIds: [] }, result, runs);
444
+ await this.handleFailure(trigger, undefined, [], result, runs);
445
+ return "failed";
446
+ } finally {
447
+ this.release(CROSS);
448
+ }
449
+ }
450
+
451
+ /** Call `runDream`, turning a throw into a failed result. A run cancelled by `stop()` or `cancelAll()` comes back with `cancelled`. */
452
+ async invoke(job) {
453
+ const controller = new AbortController();
454
+ const startedAt = this.now();
455
+ this.aborts.add(controller);
456
+ try {
457
+ const result = await this.runDream({ ...job, selection: this.resolveSelection(), indexMaxBytes: this.config.indexMaxBytes.get(), signal: controller.signal });
458
+ return !result.ok && controller.signal.aborted ? { ...result, startedAt, cancelled: true } : { ...result, startedAt };
459
+ } catch (error) {
460
+ return { ok: false, startedAt, error: error?.message ?? String(error), ...(controller.signal.aborted ? { cancelled: true } : {}) };
461
+ } finally {
462
+ this.aborts.delete(controller);
463
+ }
464
+ }
465
+
466
+ /** Log one run. @returns {Promise<boolean>} whether it succeeded. */
467
+ async finish(base, result, runs) {
468
+ const entry = {
469
+ at: this.now(),
470
+ ...base,
471
+ ok: result.ok,
472
+ ...(result.ok ? { changes: result.changes, issues: result.issues ?? [] } : { error: result.error }),
473
+ ...(result.ok && result.summary ? { summary: result.summary } : {}),
474
+ ...(result.dreamSessionId ? { dreamSessionId: result.dreamSessionId } : {})
475
+ };
476
+ await this.state.appendLog(entry);
477
+ runs.push(entry);
478
+ if (result.ok) {
479
+ this.streak = 0;
480
+ this.waveEnd = -1;
481
+ }
482
+ return result.ok;
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Queue sessions that ended without being noticed (host restart, idle timer
488
+ * lost): recent, cold, eligible sessions whose events past their watermark
489
+ * are worth a run (`isSubstantive`).
490
+ * @param sessionQuery - `ctx.sessionQuery`.
491
+ * @param {import("./state.js").DreamState} state
492
+ * @param {{ now?: () => number, cwd?: string, excludeSessionId?: string, maxAgeMs?: number }} [options]
493
+ * @returns {Promise<string[]>} ids newly queued.
494
+ */
495
+ export async function reconcile(sessionQuery, state, { now = Date.now, cwd, excludeSessionId, maxAgeMs = 7 * DAY_MS } = {}) {
496
+ const queued = new Set(state.pending().map((job) => job.sessionId));
497
+ const added = [];
498
+ const sessions = await sessionQuery.listSessions();
499
+ /* A full scan also forgets the watermarks of deleted sessions (an empty listing is not trusted). */
500
+ if (cwd === undefined && sessions.length > 0) await state.pruneSessions(sessions.map(({ header }) => header.id));
501
+ for (const { header, live } of sessions) {
502
+ if (live || !isEligible(header) || header.id === excludeSessionId || queued.has(header.id)) continue;
503
+ if (cwd !== undefined && !sameProject(header.cwd, cwd)) continue;
504
+ if ((header.createdAt ?? 0) < Math.max(now() - maxAgeMs, state.installedAt ?? 0)) continue;
505
+ const events = await sessionQuery.listEvents(header.id);
506
+ const last = events.at(-1);
507
+ const seq = last?.seq ?? 0;
508
+ const watermark = state.watermark(header.id);
509
+ if (seq <= watermark) continue;
510
+ if (!isSubstantive(events.filter((event) => event.seq > watermark).reduce(accumulate, emptyActivity()))) continue;
511
+ await state.enqueue({ sessionId: header.id, cwd: header.cwd, endedAt: last?.time ?? header.createdAt ?? now(), seq });
512
+ added.push(header.id);
513
+ }
514
+ return added;
515
+ }
516
+
517
+ /**
518
+ * Identify a stored hidden session as a dreaming run: its first user message
519
+ * opens with a dreaming prompt marker. Returns its creation time, or null
520
+ * when it is not ours or cannot be read.
521
+ * @param {string} dir - `<sessions>/_no-cwd/<id>`.
522
+ * @returns {Promise<number | null>}
523
+ */
524
+ async function dreamSessionCreatedAt(dir) {
525
+ let raw;
526
+ try {
527
+ const file = (await readdir(dir)).find((name) => /^session\..*\.jsonl(\.zstd)?$/.test(name));
528
+ if (file === undefined) return null;
529
+ if ((await stat(join(dir, file))).size > MAX_DREAM_SESSION_BYTES) return null;
530
+ raw = await readFile(join(dir, file));
531
+ if (file.endsWith(".zstd")) {
532
+ if (typeof zlib.zstdDecompress !== "function") return null;
533
+ raw = await promisify(zlib.zstdDecompress)(raw);
534
+ }
535
+ } catch {
536
+ return null;
537
+ }
538
+ let createdAt = 0;
539
+ for (const line of raw.toString("utf8").split("\n")) {
540
+ if (line.trim() === "") continue;
541
+ let event;
542
+ try {
543
+ event = JSON.parse(line);
544
+ } catch {
545
+ return null;
546
+ }
547
+ if (event.type === "session") createdAt = event.createdAt ?? 0;
548
+ else if (event.type === "user/message") {
549
+ const text = (event.data?.content ?? []).filter((block) => block.type === "text").map((block) => block.text).join("");
550
+ return DREAM_PROMPT_MARKERS.some((marker) => text.startsWith(marker)) ? createdAt : null;
551
+ }
552
+ }
553
+ return null;
554
+ }
555
+
556
+ /**
557
+ * Delete the stored sessions of old dreaming runs, keeping the newest `keep`.
558
+ * Runs are found on disk, not in plugin state, so sessions left by a reset or
559
+ * reinstalled plugin are cleaned too; any other hidden session is untouched.
560
+ * @param {string} sessionsRoot - `<DSH home>/sessions`.
561
+ * @param {{ keep?: number, cache?: Map<string, number | null> }} [options]
562
+ * `cache` remembers what each session id turned out to be across calls.
563
+ */
564
+ export async function pruneDreamSessions(sessionsRoot, { keep = 50, cache = new Map() } = {}) {
565
+ const root = join(sessionsRoot, "_no-cwd");
566
+ let ids;
567
+ try {
568
+ ids = (await readdir(root)).filter((id) => /^session-[\w-]+$/.test(id));
569
+ } catch {
570
+ return;
571
+ }
572
+ const ours = [];
573
+ for (const id of ids) {
574
+ if (!cache.has(id)) cache.set(id, await dreamSessionCreatedAt(join(root, id)));
575
+ const createdAt = cache.get(id);
576
+ if (createdAt !== null) ours.push({ id, createdAt });
577
+ }
578
+ ours.sort((a, b) => a.createdAt - b.createdAt);
579
+ for (const { id } of ours.slice(0, Math.max(0, ours.length - keep))) {
580
+ await rm(join(root, id), { recursive: true, force: true });
581
+ cache.delete(id);
582
+ }
583
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Which model and reasoning effort a dreaming run uses.
3
+ * A chosen provider/model may carry a chosen effort (empty = the model's own
4
+ * default); the default model follows DSH's default selection as a whole.
5
+ * @param config - live config references (`dreamProvider`, `dreamModel`, `dreamEffort`).
6
+ * @param [agentDefaultModel] - `ctx.agentDefaultModel`, when mounted.
7
+ * @returns {{ provider: string, model: string, reasoningEffort?: string } | undefined}
8
+ */
9
+ export function resolveSelection(config, agentDefaultModel) {
10
+ const provider = config.dreamProvider.get();
11
+ const model = config.dreamModel.get();
12
+ if (provider === "" || model === "") return agentDefaultModel?.currentSelection();
13
+ const effort = config.dreamEffort.get();
14
+ return effort === "" ? { provider, model } : { provider, model, reasoningEffort: effort };
15
+ }