opencode-claude-memory 1.7.6 → 1.7.8

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 (69) hide show
  1. package/README.md +104 -213
  2. package/dist/agents.d.ts +12 -0
  3. package/dist/agents.js +69 -0
  4. package/dist/config.d.ts +112 -0
  5. package/dist/config.js +79 -0
  6. package/dist/extraction/ExtractionCoordinator.d.ts +62 -0
  7. package/dist/extraction/ExtractionCoordinator.js +386 -0
  8. package/dist/extraction/autodream.d.ts +33 -0
  9. package/dist/extraction/autodream.js +85 -0
  10. package/dist/extraction/forkSession.d.ts +37 -0
  11. package/dist/extraction/forkSession.js +133 -0
  12. package/dist/extraction/lock.d.ts +29 -0
  13. package/dist/extraction/lock.js +191 -0
  14. package/dist/extraction/prompts.d.ts +5 -0
  15. package/dist/extraction/prompts.js +78 -0
  16. package/dist/extraction/state.d.ts +38 -0
  17. package/dist/extraction/state.js +159 -0
  18. package/dist/hooks/ignore.d.ts +6 -0
  19. package/dist/hooks/ignore.js +53 -0
  20. package/dist/hooks/messages.d.ts +14 -0
  21. package/dist/hooks/messages.js +96 -0
  22. package/dist/index.d.ts +7 -1
  23. package/dist/index.js +50 -633
  24. package/dist/prompt/sections.d.ts +7 -0
  25. package/dist/{prompt.js → prompt/sections.js} +16 -72
  26. package/dist/prompt/systemPrompt.d.ts +7 -0
  27. package/dist/prompt/systemPrompt.js +64 -0
  28. package/dist/recall/RecallCoordinator.d.ts +36 -0
  29. package/dist/recall/RecallCoordinator.js +140 -0
  30. package/dist/recall/format.d.ts +17 -0
  31. package/dist/{recall.js → recall/format.js} +13 -36
  32. package/dist/{recallSelector.d.ts → recall/selector.d.ts} +26 -16
  33. package/dist/recall/selector.js +92 -0
  34. package/dist/sdk.d.ts +12 -0
  35. package/dist/sdk.js +8 -0
  36. package/dist/store/MemoryStore.d.ts +41 -0
  37. package/dist/store/MemoryStore.js +105 -0
  38. package/dist/store/frontmatter.d.ts +25 -0
  39. package/dist/store/frontmatter.js +61 -0
  40. package/dist/store/indexFile.d.ts +15 -0
  41. package/dist/store/indexFile.js +153 -0
  42. package/dist/{paths.d.ts → store/paths.d.ts} +7 -6
  43. package/dist/store/paths.js +187 -0
  44. package/dist/store/scan.d.ts +29 -0
  45. package/dist/store/scan.js +112 -0
  46. package/dist/tools.d.ts +9 -0
  47. package/dist/tools.js +142 -0
  48. package/dist/util/exclusiveFile.d.ts +11 -0
  49. package/dist/util/exclusiveFile.js +108 -0
  50. package/dist/util/legacyShellHook.d.ts +3 -0
  51. package/dist/util/legacyShellHook.js +23 -0
  52. package/dist/util/log.d.ts +6 -0
  53. package/dist/util/log.js +29 -0
  54. package/dist/util/ownedSessions.d.ts +7 -0
  55. package/dist/util/ownedSessions.js +39 -0
  56. package/dist/util/timeout.d.ts +5 -0
  57. package/dist/util/timeout.js +39 -0
  58. package/package.json +15 -8
  59. package/bin/opencode-memory +0 -1436
  60. package/dist/memory.d.ts +0 -25
  61. package/dist/memory.js +0 -203
  62. package/dist/memoryScan.d.ts +0 -20
  63. package/dist/memoryScan.js +0 -106
  64. package/dist/nativeExtraction.d.ts +0 -2
  65. package/dist/nativeExtraction.js +0 -38
  66. package/dist/paths.js +0 -141
  67. package/dist/prompt.d.ts +0 -4
  68. package/dist/recall.d.ts +0 -12
  69. package/dist/recallSelector.js +0 -154
@@ -0,0 +1,62 @@
1
+ import type { AgentRegistry } from "../agents.js";
2
+ import type { MemoryConfig } from "../config.js";
3
+ import { type ChatMessage, type OpencodeClient, type PluginEvent } from "../sdk.js";
4
+ import type { MemoryStore } from "../store/MemoryStore.js";
5
+ import { type Logger } from "../util/log.js";
6
+ import type { OwnedSessions } from "../util/ownedSessions.js";
7
+ import { AutoDream } from "./autodream.js";
8
+ import { MaintenanceLock } from "./lock.js";
9
+ import { ExtractionStateStore, type SessionExtractionState } from "./state.js";
10
+ export declare const EXTRACTION_TITLE = "opencode-memory extraction";
11
+ export declare const FORK_GRACE_MS = 60000;
12
+ export declare const MAX_EXTRACTION_FAILURES = 3;
13
+ export declare const MIN_CONVERSATION_CHARS = 20;
14
+ export declare const SDK_READ_TIMEOUT_MS = 30000;
15
+ export declare function sliceNewMessages(messages: readonly ChatMessage[], state: SessionExtractionState | undefined): ChatMessage[];
16
+ export declare function trimIncompleteTail(messages: readonly ChatMessage[]): ChatMessage[];
17
+ export declare function hasExtractableUserMessage(messages: readonly ChatMessage[]): boolean;
18
+ export declare function buildConversationForExtraction(messages: readonly ChatMessage[], maxChars: number): string;
19
+ export type ExtractionCoordinatorDeps = {
20
+ store: MemoryStore;
21
+ config: MemoryConfig;
22
+ client: OpencodeClient | undefined;
23
+ directory: string;
24
+ owned: OwnedSessions;
25
+ agents: AgentRegistry;
26
+ log: Logger;
27
+ now?: () => number;
28
+ state?: ExtractionStateStore;
29
+ lock?: MaintenanceLock;
30
+ };
31
+ export declare class ExtractionCoordinator {
32
+ private readonly deps;
33
+ readonly state: ExtractionStateStore;
34
+ readonly autodream: AutoDream | undefined;
35
+ private readonly lock;
36
+ private readonly now;
37
+ private readonly timers;
38
+ private readonly busy;
39
+ private readonly inFlight;
40
+ private readonly savedByFork;
41
+ private readonly savedByMainAgent;
42
+ private queue;
43
+ private caughtUp;
44
+ private disposed;
45
+ constructor(deps: ExtractionCoordinatorDeps);
46
+ get enabled(): boolean;
47
+ onEvent(event: PluginEvent): void;
48
+ onSessionIdle(sessionID: string): void;
49
+ onSessionStatus(sessionID: string, status: string): void;
50
+ onSessionDeleted(sessionID: string): void;
51
+ recordSave(sessionID: string | undefined, fileName: string): string[] | undefined;
52
+ isOwnedSession(sessionID: string | undefined): boolean;
53
+ idle(): Promise<void>;
54
+ catchUp(): Promise<void>;
55
+ dispose(): void;
56
+ private clearTimer;
57
+ private warnLegacyShellHook;
58
+ private enqueue;
59
+ private snapshot;
60
+ private advance;
61
+ private runIncremental;
62
+ }
@@ -0,0 +1,386 @@
1
+ import { roleOf } from "../hooks/messages.js";
2
+ import { unwrapData } from "../sdk.js";
3
+ import { findLegacyShellHooks } from "../util/legacyShellHook.js";
4
+ import { getErrorMessage } from "../util/log.js";
5
+ import { TimeoutError, withDeadline } from "../util/timeout.js";
6
+ import { AutoDream } from "./autodream.js";
7
+ import { runForkSession } from "./forkSession.js";
8
+ import { MaintenanceLock } from "./lock.js";
9
+ import { buildExtractionSystemPrompt } from "./prompts.js";
10
+ import { ExtractionStateStore, migrateLegacyAutodreamState } from "./state.js";
11
+ export const EXTRACTION_TITLE = "opencode-memory extraction";
12
+ // Keep the fork in the owned-session guard past delete: its `session.idle` can arrive after the
13
+ // delete HTTP call resolved and would otherwise trigger an extraction of the fork itself.
14
+ export const FORK_GRACE_MS = 60_000;
15
+ export const MAX_EXTRACTION_FAILURES = 3;
16
+ export const MIN_CONVERSATION_CHARS = 20;
17
+ // Deadline for the plain SDK reads (`session.messages`, `session.list`); see util/timeout.ts.
18
+ export const SDK_READ_TIMEOUT_MS = 30_000;
19
+ function messageTime(message) {
20
+ const time = message.info?.time;
21
+ return {
22
+ created: typeof time?.created === "number" ? time.created : undefined,
23
+ completed: typeof time?.completed === "number" ? time.completed : undefined,
24
+ };
25
+ }
26
+ // Messages after the watermark. If the watermark message was removed (revert / compaction), fall
27
+ // back to everything created after the watermark message's own time (not the fork's finish time:
28
+ // messages that arrived while the fork ran must not be skipped).
29
+ export function sliceNewMessages(messages, state) {
30
+ if (!state)
31
+ return [...messages];
32
+ if (state.lastExtractedMessageID) {
33
+ const idx = messages.findIndex((m) => m.info.id === state.lastExtractedMessageID);
34
+ if (idx >= 0)
35
+ return messages.slice(idx + 1);
36
+ }
37
+ const boundary = state.lastMessageAt ?? state.updatedAt;
38
+ return messages.filter((m) => (messageTime(m).created ?? 0) > boundary);
39
+ }
40
+ // Drops assistant messages still being generated from the end of the slice: extracting them would
41
+ // record a watermark past content that is not final yet, and the final answer would never be seen.
42
+ export function trimIncompleteTail(messages) {
43
+ let end = messages.length;
44
+ while (end > 0) {
45
+ const message = messages[end - 1];
46
+ if (!message || roleOf(message) !== "assistant" || messageTime(message).completed !== undefined)
47
+ break;
48
+ end -= 1;
49
+ }
50
+ return messages.slice(0, end);
51
+ }
52
+ export function hasExtractableUserMessage(messages) {
53
+ return messages.some((message) => {
54
+ if (roleOf(message) !== "user" || !Array.isArray(message.parts))
55
+ return false;
56
+ return message.parts.some((part) => {
57
+ const p = part;
58
+ return p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0 && !p.synthetic;
59
+ });
60
+ });
61
+ }
62
+ export function buildConversationForExtraction(messages, maxChars) {
63
+ const lines = [];
64
+ for (const message of messages) {
65
+ const role = roleOf(message);
66
+ if (!role || !Array.isArray(message.parts))
67
+ continue;
68
+ for (const part of message.parts) {
69
+ const p = part;
70
+ if (p.type === "text" && typeof p.text === "string" && !p.synthetic) {
71
+ lines.push(`### ${role === "user" ? "User" : "Assistant"}\n${p.text}`);
72
+ }
73
+ else if (p.type === "tool" && p.tool && p.state?.status === "completed" && typeof p.state.output === "string") {
74
+ const out = p.state.output.length > 300 ? `${p.state.output.slice(0, 300)}…` : p.state.output;
75
+ lines.push(`_[tool ${p.tool}: ${out}]_`);
76
+ }
77
+ }
78
+ }
79
+ let text = lines.join("\n\n");
80
+ // Keep the TAIL (newest turns carry the new facts worth extracting), drop the oldest head.
81
+ if (text.length > maxChars) {
82
+ text = `…[older turns truncated]\n\n${text.slice(-maxChars)}`;
83
+ }
84
+ return text;
85
+ }
86
+ export class ExtractionCoordinator {
87
+ deps;
88
+ state;
89
+ autodream;
90
+ lock;
91
+ now;
92
+ timers = new Map();
93
+ busy = new Set();
94
+ inFlight = new Set();
95
+ savedByFork = new Map();
96
+ savedByMainAgent = new Set();
97
+ queue = Promise.resolve();
98
+ caughtUp = false;
99
+ disposed = false;
100
+ constructor(deps) {
101
+ this.deps = deps;
102
+ this.now = deps.now ?? Date.now;
103
+ this.state = deps.state ?? new ExtractionStateStore(deps.store.stateDir, this.now);
104
+ this.lock = deps.lock ?? new MaintenanceLock(this.state.lockPath, this.now);
105
+ this.autodream = deps.client
106
+ ? new AutoDream({ ...deps, client: deps.client, state: this.state, now: this.now, lock: this.lock })
107
+ : undefined;
108
+ }
109
+ get enabled() {
110
+ return this.deps.config.extract.enabled && this.deps.client !== undefined;
111
+ }
112
+ onEvent(event) {
113
+ if (event.type === "session.idle")
114
+ this.onSessionIdle(event.properties.sessionID);
115
+ else if (event.type === "session.deleted")
116
+ this.onSessionDeleted(event.properties.info.id);
117
+ else if (event.type === "session.status")
118
+ this.onSessionStatus(event.properties.sessionID, event.properties.status.type);
119
+ }
120
+ onSessionIdle(sessionID) {
121
+ if (!this.enabled || this.disposed || !sessionID)
122
+ return;
123
+ if (this.deps.owned.has(sessionID))
124
+ return;
125
+ this.busy.delete(sessionID);
126
+ this.clearTimer(sessionID);
127
+ const timer = setTimeout(() => {
128
+ this.timers.delete(sessionID);
129
+ void this.enqueue(sessionID);
130
+ }, this.deps.config.extract.debounceMs);
131
+ timer.unref?.();
132
+ this.timers.set(sessionID, timer);
133
+ }
134
+ // A session that starts a new turn (busy / retry) cancels its pending debounce: the previous
135
+ // turn is extracted together with the new one once the session is idle again.
136
+ onSessionStatus(sessionID, status) {
137
+ if (!sessionID || this.deps.owned.has(sessionID))
138
+ return;
139
+ if (status === "idle") {
140
+ this.busy.delete(sessionID);
141
+ return;
142
+ }
143
+ this.busy.add(sessionID);
144
+ this.clearTimer(sessionID);
145
+ }
146
+ onSessionDeleted(sessionID) {
147
+ this.clearTimer(sessionID);
148
+ this.busy.delete(sessionID);
149
+ this.savedByMainAgent.delete(sessionID);
150
+ }
151
+ // memory_save reports every write. Inside an extraction fork the list of files saved so far is
152
+ // returned so the tool result can carry the done-signal (#35); a save by the main agent marks the
153
+ // session so the next extraction round is skipped (the agent already curated its memory).
154
+ recordSave(sessionID, fileName) {
155
+ if (!sessionID)
156
+ return undefined;
157
+ const fork = this.savedByFork.get(sessionID);
158
+ if (fork) {
159
+ fork.push(fileName);
160
+ return fork;
161
+ }
162
+ if (!this.deps.owned.has(sessionID))
163
+ this.savedByMainAgent.add(sessionID);
164
+ return undefined;
165
+ }
166
+ isOwnedSession(sessionID) {
167
+ return this.deps.owned.has(sessionID);
168
+ }
169
+ // Resolves once every queued extraction has finished (tests, dispose).
170
+ idle() {
171
+ return this.queue;
172
+ }
173
+ // Sessions updated after their watermark (or never extracted) are extracted on start-up. This
174
+ // covers the TUI user who quits right after the last answer: the debounce timer died with the
175
+ // process, so the last turn would otherwise never be extracted.
176
+ async catchUp() {
177
+ if (this.caughtUp || !this.enabled)
178
+ return;
179
+ this.caughtUp = true;
180
+ const { client, config, directory, store, log } = this.deps;
181
+ if (!client)
182
+ return;
183
+ this.warnLegacyShellHook();
184
+ migrateLegacyAutodreamState(this.state, `${store.claudeConfigDir}/opencode-memory`, [
185
+ store.gitRoot ?? store.memoryRoot,
186
+ store.memoryRoot,
187
+ store.canonicalRoot,
188
+ ]);
189
+ if (config.extract.catchUpLimit <= 0)
190
+ return;
191
+ let sessions;
192
+ try {
193
+ sessions =
194
+ unwrapData(await withDeadline("session.list", SDK_READ_TIMEOUT_MS, (signal) => client.session.list({ query: { directory }, signal }))) ?? [];
195
+ }
196
+ catch (error) {
197
+ log("warn", "Extraction catch-up could not list sessions", { error: getErrorMessage(error) });
198
+ return;
199
+ }
200
+ const pending = sessions
201
+ .filter((session) => !session.parentID && !this.deps.owned.has(session.id))
202
+ .filter((session) => {
203
+ const known = this.state.getSession(session.id);
204
+ // Compare against the watermark *message* time, not the fork's finish time: a turn that
205
+ // completed while the previous fork was running must still be caught up.
206
+ const boundary = known?.lastMessageAt ?? known?.updatedAt ?? 0;
207
+ return (session.time?.updated ?? 0) > boundary;
208
+ })
209
+ .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
210
+ .slice(0, config.extract.catchUpLimit);
211
+ for (const session of pending)
212
+ await this.enqueue(session.id);
213
+ }
214
+ dispose() {
215
+ this.disposed = true;
216
+ for (const timer of this.timers.values())
217
+ clearTimeout(timer);
218
+ this.timers.clear();
219
+ this.busy.clear();
220
+ }
221
+ clearTimer(sessionID) {
222
+ const existing = this.timers.get(sessionID);
223
+ if (existing)
224
+ clearTimeout(existing);
225
+ this.timers.delete(sessionID);
226
+ }
227
+ warnLegacyShellHook() {
228
+ let hooks;
229
+ try {
230
+ hooks = findLegacyShellHooks(this.deps.config.homeDir);
231
+ }
232
+ catch {
233
+ return;
234
+ }
235
+ if (hooks.length === 0)
236
+ return;
237
+ this.deps.log("warn", "v1 opencode-memory shell hook is still installed; v2 extracts in-process, so run `opencode-memory uninstall` (or delete the marked block) to avoid a second, competing extraction", {
238
+ files: hooks,
239
+ });
240
+ }
241
+ enqueue(sessionID) {
242
+ const run = this.queue.then(() => this.runIncremental(sessionID)).catch(() => { });
243
+ this.queue = run;
244
+ return run;
245
+ }
246
+ snapshot(messages, previous) {
247
+ const fresh = trimIncompleteTail(sliceNewMessages(messages, previous));
248
+ const last = fresh[fresh.length - 1];
249
+ if (!last || !hasExtractableUserMessage(fresh))
250
+ return undefined;
251
+ return { fresh, last, lastMessageAt: messageTime(last).created };
252
+ }
253
+ // Persists the watermark unless another process already moved it past this snapshot. Runs inside
254
+ // the state lock, so the decision is made against the current on-disk state.
255
+ advance(sessionID, snapshot, extra = {}) {
256
+ let advanced = false;
257
+ this.state.update((data) => {
258
+ const current = data.sessions[sessionID];
259
+ if (current?.lastMessageAt !== undefined &&
260
+ snapshot.lastMessageAt !== undefined &&
261
+ current.lastMessageAt > snapshot.lastMessageAt) {
262
+ return;
263
+ }
264
+ advanced = true;
265
+ data.sessions[sessionID] = {
266
+ lastExtractedMessageID: snapshot.last.info.id,
267
+ ...(snapshot.lastMessageAt !== undefined ? { lastMessageAt: snapshot.lastMessageAt } : {}),
268
+ updatedAt: this.now(),
269
+ failures: 0,
270
+ ...extra,
271
+ };
272
+ AutoDream.noteSession(data.autodream, sessionID);
273
+ });
274
+ return advanced;
275
+ }
276
+ async runIncremental(sessionID) {
277
+ const { client, config, store, directory, owned, agents, log } = this.deps;
278
+ if (!client || this.disposed || this.inFlight.has(sessionID))
279
+ return;
280
+ // Re-check at dequeue time: the debounce may have fired just before the session went busy, or
281
+ // the session may have started a new turn while this job waited in the queue.
282
+ if (this.busy.has(sessionID))
283
+ return;
284
+ this.inFlight.add(sessionID);
285
+ try {
286
+ const response = await withDeadline("session.messages", SDK_READ_TIMEOUT_MS, (signal) => client.session.messages({ path: { id: sessionID }, query: { directory }, signal }));
287
+ const messages = unwrapData(response) ?? [];
288
+ if (this.busy.has(sessionID))
289
+ return;
290
+ let snapshot = this.snapshot(messages, this.state.getSession(sessionID));
291
+ if (!snapshot)
292
+ return;
293
+ if (this.savedByMainAgent.delete(sessionID)) {
294
+ if (this.advance(sessionID, snapshot))
295
+ await this.autodream?.maybeRun(sessionID);
296
+ return;
297
+ }
298
+ if (buildConversationForExtraction(snapshot.fresh, config.extract.maxConversationChars).trim().length <
299
+ MIN_CONVERSATION_CHARS) {
300
+ this.advance(sessionID, snapshot);
301
+ return;
302
+ }
303
+ // Cross-process serialisation (#30): another OpenCode process on this project is extracting or
304
+ // consolidating right now. Skip without touching the watermark; the next idle / start-up retries.
305
+ if (!this.lock.tryAcquire()) {
306
+ log("info", "Memory extraction skipped: another process holds the maintenance lock", { sessionID });
307
+ return;
308
+ }
309
+ let extracted = false;
310
+ try {
311
+ // Recompute against the state as it is *now*: while we waited for the lock another process
312
+ // may have extracted part (or all) of this slice.
313
+ const previous = this.state.getSession(sessionID);
314
+ snapshot = this.snapshot(messages, previous);
315
+ if (!snapshot)
316
+ return;
317
+ const conversation = buildConversationForExtraction(snapshot.fresh, config.extract.maxConversationChars);
318
+ if (conversation.trim().length < MIN_CONVERSATION_CHARS) {
319
+ this.advance(sessionID, snapshot);
320
+ return;
321
+ }
322
+ try {
323
+ await runForkSession({
324
+ client,
325
+ directory,
326
+ parentSessionID: sessionID,
327
+ title: EXTRACTION_TITLE,
328
+ agent: config.agents.extract,
329
+ system: buildExtractionSystemPrompt(store.manifest()),
330
+ tools: agents.toolsFor(config.agents.extract),
331
+ parts: [{ type: "text", text: conversation }],
332
+ timeoutMs: config.extract.timeoutMs,
333
+ onCreated: (forkID) => {
334
+ owned.add(forkID);
335
+ this.savedByFork.set(forkID, []);
336
+ },
337
+ onFinished: (forkID) => {
338
+ owned.release(forkID, FORK_GRACE_MS);
339
+ const cleanup = setTimeout(() => this.savedByFork.delete(forkID), FORK_GRACE_MS);
340
+ cleanup.unref?.();
341
+ },
342
+ onCleanupFailed: (forkID, stage, error) => log("warn", "Extraction fork cleanup failed; the server may still hold the fork session", {
343
+ forkID,
344
+ stage,
345
+ sessionID,
346
+ error: getErrorMessage(error),
347
+ }),
348
+ });
349
+ }
350
+ catch (error) {
351
+ const failures = (previous?.failures ?? 0) + 1;
352
+ log("error", "Memory extraction failed", { error: getErrorMessage(error), sessionID, failures });
353
+ if (failures >= MAX_EXTRACTION_FAILURES) {
354
+ // Do not stay stuck on a message that keeps failing: move on and reset the counter.
355
+ this.advance(sessionID, snapshot);
356
+ }
357
+ else {
358
+ this.state.update((data) => {
359
+ data.sessions[sessionID] = {
360
+ ...(data.sessions[sessionID] ?? previous ?? { updatedAt: 0 }),
361
+ failures,
362
+ attemptedAt: this.now(),
363
+ };
364
+ });
365
+ }
366
+ return;
367
+ }
368
+ // The watermark is committed while the lock is still held, so no other process can run
369
+ // an extraction between the fork's writes and the watermark that records them.
370
+ extracted = this.advance(sessionID, snapshot);
371
+ }
372
+ finally {
373
+ this.lock.release();
374
+ }
375
+ if (extracted)
376
+ await this.autodream?.maybeRun(sessionID);
377
+ }
378
+ catch (error) {
379
+ const detail = error instanceof TimeoutError ? error.message : getErrorMessage(error);
380
+ log("error", "Memory extraction failed", { error: detail, sessionID });
381
+ }
382
+ finally {
383
+ this.inFlight.delete(sessionID);
384
+ }
385
+ }
386
+ }
@@ -0,0 +1,33 @@
1
+ import type { AgentRegistry } from "../agents.js";
2
+ import type { MemoryConfig } from "../config.js";
3
+ import type { OpencodeClient } from "../sdk.js";
4
+ import type { MemoryStore } from "../store/MemoryStore.js";
5
+ import { type Logger } from "../util/log.js";
6
+ import type { OwnedSessions } from "../util/ownedSessions.js";
7
+ import { MaintenanceLock } from "./lock.js";
8
+ import type { AutodreamState, ExtractionStateStore } from "./state.js";
9
+ export declare const AUTODREAM_TITLE = "opencode-memory auto-dream";
10
+ export declare const AUTODREAM_FORK_GRACE_MS = 60000;
11
+ export type AutodreamGate = Pick<MemoryConfig["autodream"], "minHours" | "minSessions">;
12
+ export declare function shouldRunAutodream(state: AutodreamState, gate: AutodreamGate, now: number): boolean;
13
+ export type AutoDreamDeps = {
14
+ store: MemoryStore;
15
+ config: MemoryConfig;
16
+ client: OpencodeClient;
17
+ directory: string;
18
+ state: ExtractionStateStore;
19
+ owned: OwnedSessions;
20
+ agents: AgentRegistry;
21
+ log: Logger;
22
+ now?: () => number;
23
+ lock?: MaintenanceLock;
24
+ };
25
+ export declare class AutoDream {
26
+ private readonly deps;
27
+ private readonly now;
28
+ private readonly lock;
29
+ constructor(deps: AutoDreamDeps);
30
+ static noteSession(state: AutodreamState, sessionID: string): void;
31
+ shouldRun(): boolean;
32
+ maybeRun(parentSessionID: string): Promise<boolean>;
33
+ }
@@ -0,0 +1,85 @@
1
+ import { getErrorMessage } from "../util/log.js";
2
+ import { runForkSession } from "./forkSession.js";
3
+ import { MaintenanceLock } from "./lock.js";
4
+ import { AUTODREAM_PROMPT, AUTODREAM_USER_MESSAGE } from "./prompts.js";
5
+ export const AUTODREAM_TITLE = "opencode-memory auto-dream";
6
+ export const AUTODREAM_FORK_GRACE_MS = 60_000;
7
+ export function shouldRunAutodream(state, gate, now) {
8
+ const hoursSince = (now - state.lastConsolidatedAt) / (60 * 60 * 1000);
9
+ if (hoursSince < gate.minHours)
10
+ return false;
11
+ return state.sessionsSince.length >= gate.minSessions;
12
+ }
13
+ export class AutoDream {
14
+ deps;
15
+ now;
16
+ lock;
17
+ constructor(deps) {
18
+ this.deps = deps;
19
+ this.now = deps.now ?? Date.now;
20
+ this.lock = deps.lock ?? new MaintenanceLock(deps.state.lockPath, this.now);
21
+ }
22
+ // Called from the extraction coordinator's persisted update after a session was extracted.
23
+ static noteSession(state, sessionID) {
24
+ if (!state.sessionsSince.includes(sessionID))
25
+ state.sessionsSince.push(sessionID);
26
+ }
27
+ shouldRun() {
28
+ const { config, state } = this.deps;
29
+ if (!config.autodream.enabled)
30
+ return false;
31
+ return shouldRunAutodream(state.read().autodream, config.autodream, this.now());
32
+ }
33
+ // Runs a consolidation fork when the gate passes. Success resets the gate; failure leaves the
34
+ // gate untouched so the next extracted session retries.
35
+ async maybeRun(parentSessionID) {
36
+ if (!this.shouldRun())
37
+ return false;
38
+ if (!this.lock.tryAcquire()) {
39
+ this.deps.log("info", "Auto-dream skipped: another process holds the maintenance lock");
40
+ return false;
41
+ }
42
+ const { client, config, directory, owned, agents, state, log } = this.deps;
43
+ try {
44
+ // Re-check under the lock: another process may have consolidated while we waited to acquire.
45
+ if (!this.shouldRun())
46
+ return false;
47
+ const autodream = state.read().autodream;
48
+ log("info", "Auto-dream consolidation starting", {
49
+ sessionsSince: autodream.sessionsSince.length,
50
+ lastConsolidatedAt: autodream.lastConsolidatedAt,
51
+ });
52
+ await runForkSession({
53
+ client,
54
+ directory,
55
+ parentSessionID,
56
+ title: AUTODREAM_TITLE,
57
+ agent: config.agents.dream,
58
+ system: AUTODREAM_PROMPT,
59
+ tools: agents.toolsFor(config.agents.dream),
60
+ parts: [{ type: "text", text: AUTODREAM_USER_MESSAGE }],
61
+ timeoutMs: config.autodream.timeoutMs,
62
+ onCreated: (id) => owned.add(id),
63
+ onFinished: (id) => owned.release(id, AUTODREAM_FORK_GRACE_MS),
64
+ onCleanupFailed: (id, stage, error) => log("warn", "Auto-dream fork cleanup failed; the server may still hold the fork session", {
65
+ forkID: id,
66
+ stage,
67
+ error: getErrorMessage(error),
68
+ }),
69
+ });
70
+ state.update((data) => {
71
+ data.autodream.lastConsolidatedAt = this.now();
72
+ data.autodream.sessionsSince = [];
73
+ });
74
+ log("info", "Auto-dream consolidation completed");
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ log("error", "Auto-dream consolidation failed", { error: getErrorMessage(error) });
79
+ return false;
80
+ }
81
+ finally {
82
+ this.lock.release();
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,37 @@
1
+ import { type OpencodeClient } from "../sdk.js";
2
+ export declare const FORK_CREATE_TIMEOUT_MS = 30000;
3
+ export declare const FORK_CLEANUP_TIMEOUT_MS = 15000;
4
+ export type ForkCleanupStage = "abort" | "delete";
5
+ export type ForkSessionInput = {
6
+ client: OpencodeClient;
7
+ directory: string;
8
+ parentSessionID?: string;
9
+ title: string;
10
+ agent: string;
11
+ system?: string;
12
+ parts: Array<{
13
+ type: "text";
14
+ text: string;
15
+ }>;
16
+ tools?: Record<string, boolean>;
17
+ format?: unknown;
18
+ model?: {
19
+ providerID: string;
20
+ modelID: string;
21
+ };
22
+ timeoutMs: number;
23
+ createTimeoutMs?: number;
24
+ cleanupTimeoutMs?: number;
25
+ onCreated?: (forkID: string) => void;
26
+ onFinished?: (forkID: string) => void;
27
+ onCleanupFailed?: (forkID: string, stage: ForkCleanupStage, error: unknown) => void;
28
+ };
29
+ export declare class ForkSessionTimeoutError extends Error {
30
+ constructor(title: string, timeoutMs: number);
31
+ }
32
+ export declare function extractSessionID(response: unknown): string | undefined;
33
+ export declare function forkFailure(response: unknown): string | undefined;
34
+ export declare class ForkSessionError extends Error {
35
+ constructor(title: string, detail: string);
36
+ }
37
+ export declare function runForkSession(input: ForkSessionInput): Promise<unknown>;