opencode-claude-memory 1.7.7 → 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 -214
  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 -665
  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 -31
  61. package/dist/memory.js +0 -224
  62. package/dist/memoryScan.d.ts +0 -20
  63. package/dist/memoryScan.js +0 -106
  64. package/dist/nativeExtraction.d.ts +0 -9
  65. package/dist/nativeExtraction.js +0 -80
  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,133 @@
1
+ // Lifecycle of a plugin-owned child session: create → prompt (with timeout) → abort on timeout →
2
+ // delete (best-effort). Shared by recall selection, extraction and auto-dream.
3
+ //
4
+ // Every stage has its own deadline and AbortSignal: the SDK client disables fetch timeouts, so a
5
+ // hung `create`, `abort` or `delete` would otherwise pin the caller (and with it the extraction
6
+ // queue and the maintenance lock) forever. `onFinished` always fires, even when cleanup timed out.
7
+ import { unwrapData } from "../sdk.js";
8
+ import { TimeoutError, withDeadline } from "../util/timeout.js";
9
+ export const FORK_CREATE_TIMEOUT_MS = 30_000;
10
+ export const FORK_CLEANUP_TIMEOUT_MS = 15_000;
11
+ export class ForkSessionTimeoutError extends Error {
12
+ constructor(title, timeoutMs) {
13
+ super(`${title} timed out after ${timeoutMs}ms`);
14
+ this.name = "ForkSessionTimeoutError";
15
+ }
16
+ }
17
+ export function extractSessionID(response) {
18
+ const data = unwrapData(response);
19
+ if (!data || typeof data !== "object")
20
+ return undefined;
21
+ const id = data.id ?? data.sessionID;
22
+ return typeof id === "string" ? id : undefined;
23
+ }
24
+ function describeError(error) {
25
+ if (error && typeof error === "object") {
26
+ const named = error;
27
+ const message = named.data?.message ?? named.message;
28
+ if (typeof message === "string") {
29
+ return typeof named.name === "string" ? `${named.name}: ${message}` : message;
30
+ }
31
+ try {
32
+ return JSON.stringify(error);
33
+ }
34
+ catch {
35
+ return String(error);
36
+ }
37
+ }
38
+ return String(error);
39
+ }
40
+ // The SDK client never throws (`ThrowOnError = false`): HTTP failures arrive as `{ error }` and a
41
+ // model failure inside the fork arrives as a normal assistant message carrying `info.error`. Both
42
+ // must count as a failed fork, otherwise the caller would advance its watermark over nothing.
43
+ export function forkFailure(response) {
44
+ if (!response || typeof response !== "object")
45
+ return undefined;
46
+ const transport = response.error;
47
+ if (transport !== undefined && transport !== null)
48
+ return describeError(transport);
49
+ const data = unwrapData(response);
50
+ const modelError = data && typeof data === "object" ? data.info?.error : undefined;
51
+ if (modelError !== undefined && modelError !== null)
52
+ return describeError(modelError);
53
+ return undefined;
54
+ }
55
+ export class ForkSessionError extends Error {
56
+ constructor(title, detail) {
57
+ super(`${title}: ${detail}`);
58
+ this.name = "ForkSessionError";
59
+ }
60
+ }
61
+ // Security: forks run on raw, potentially untrusted transcript content (fetched pages, tool output).
62
+ // Callers restrict `tools` to the memory tools; the per-stage deadlines guarantee the caller gets
63
+ // control back even when the fork hangs on a permission prompt or the server stops answering.
64
+ export async function runForkSession(input) {
65
+ const { client, directory } = input;
66
+ const createTimeoutMs = input.createTimeoutMs ?? FORK_CREATE_TIMEOUT_MS;
67
+ const cleanupTimeoutMs = input.cleanupTimeoutMs ?? FORK_CLEANUP_TIMEOUT_MS;
68
+ const created = await withDeadline(`${input.title} session.create`, createTimeoutMs, (signal) => client.session.create({
69
+ body: { parentID: input.parentSessionID, title: input.title },
70
+ query: { directory },
71
+ signal,
72
+ }));
73
+ const createFailure = forkFailure(created);
74
+ if (createFailure)
75
+ throw new ForkSessionError(input.title, `session.create failed (${createFailure})`);
76
+ const forkID = extractSessionID(created);
77
+ if (!forkID)
78
+ throw new ForkSessionError(input.title, "session.create returned no session id");
79
+ input.onCreated?.(forkID);
80
+ // `format` (structured output) is accepted by the server but missing from the v1 SDK body type.
81
+ const body = {
82
+ agent: input.agent,
83
+ parts: input.parts,
84
+ };
85
+ if (input.system !== undefined)
86
+ body.system = input.system;
87
+ if (input.tools !== undefined)
88
+ body.tools = input.tools;
89
+ if (input.format !== undefined)
90
+ body.format = input.format;
91
+ if (input.model !== undefined)
92
+ body.model = input.model;
93
+ const cleanup = async (stage) => {
94
+ try {
95
+ await withDeadline(`${input.title} session.${stage}`, cleanupTimeoutMs, (signal) => stage === "abort"
96
+ ? client.session.abort({ path: { id: forkID }, query: { directory }, signal })
97
+ : client.session.delete({ path: { id: forkID }, query: { directory }, signal }));
98
+ }
99
+ catch (error) {
100
+ input.onCleanupFailed?.(forkID, stage, error);
101
+ }
102
+ };
103
+ let timedOut = false;
104
+ try {
105
+ let response;
106
+ try {
107
+ response = await withDeadline(`${input.title} session.prompt`, input.timeoutMs, (signal) => client.session.prompt({ path: { id: forkID }, query: { directory }, body: body, signal }));
108
+ }
109
+ catch (error) {
110
+ if (error instanceof TimeoutError) {
111
+ timedOut = true;
112
+ throw new ForkSessionTimeoutError(input.title, input.timeoutMs);
113
+ }
114
+ throw error;
115
+ }
116
+ const failure = forkFailure(response);
117
+ if (failure)
118
+ throw new ForkSessionError(input.title, failure);
119
+ return response;
120
+ }
121
+ finally {
122
+ try {
123
+ // On timeout the server is still running the fork: stop it before deleting the session so it
124
+ // does not keep inserting parts for a row that no longer exists.
125
+ if (timedOut)
126
+ await cleanup("abort");
127
+ await cleanup("delete");
128
+ }
129
+ finally {
130
+ input.onFinished?.(forkID);
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,29 @@
1
+ export declare const MAINTENANCE_STALE_LOCK_MS: number;
2
+ export declare const MAINTENANCE_HEARTBEAT_MS: number;
3
+ export declare const LOCK_INIT_GRACE_MS = 5000;
4
+ export declare const REAP_LOCK_STALE_MS = 30000;
5
+ export type LockContent = {
6
+ pid: number;
7
+ token: string;
8
+ startedAt: number;
9
+ heartbeatAt: number;
10
+ };
11
+ export declare class MaintenanceLock {
12
+ readonly lockPath: string;
13
+ private readonly now;
14
+ private readonly pid;
15
+ private readonly alive;
16
+ private token;
17
+ private heartbeat;
18
+ constructor(lockPath: string, now?: () => number, pid?: number, alive?: (pid: number) => boolean);
19
+ get held(): boolean;
20
+ private readHolder;
21
+ private isLive;
22
+ isHeld(): boolean;
23
+ tryAcquire(): boolean;
24
+ private reap;
25
+ refresh(): boolean;
26
+ release(): void;
27
+ private startHeartbeat;
28
+ private stopHeartbeat;
29
+ }
@@ -0,0 +1,191 @@
1
+ // Cross-process mutex for memory maintenance (extraction forks and auto-dream) on one project, so two
2
+ // OpenCode processes on the same repository never write memory files concurrently (#30).
3
+ //
4
+ // Protocol:
5
+ // - The lock file is created atomically with its full content (`createExclusiveSync`), so no
6
+ // process ever sees a half-written lock. Content: `{ pid, token, startedAt, heartbeatAt }`.
7
+ // - Every acquisition has a unique token. `release()` and `refresh()` only touch the file while it
8
+ // still carries this token, so a slow old holder can never remove or overwrite its successor.
9
+ // - A live holder refreshes `heartbeatAt` every MAINTENANCE_HEARTBEAT_MS; a lock is stale when its
10
+ // holder is dead or its heartbeat is older than MAINTENANCE_STALE_LOCK_MS. A fixed wall-clock
11
+ // limit is *not* used: a fork that legitimately runs long keeps its lock alive by heartbeat.
12
+ // - A stale lock is reaped under a second, short-lived reap lock: only one process removes it,
13
+ // and only after re-reading it and confirming it is still the same stale content. A lock that
14
+ // was just created by someone else can therefore not be removed by a concurrent reaper.
15
+ // - Contention is skipped, not queued: the extraction watermark stays put and the next idle /
16
+ // start-up retries.
17
+ import { randomBytes } from "node:crypto";
18
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { createExclusiveSync, fileAgeMs, unlinkWithRetry } from "../util/exclusiveFile.js";
20
+ export const MAINTENANCE_STALE_LOCK_MS = 10 * 60 * 1000;
21
+ export const MAINTENANCE_HEARTBEAT_MS = 60 * 1000;
22
+ // A lock file whose content cannot be parsed is treated as "being written" for this long (real
23
+ // clock, from its mtime) before it counts as garbage.
24
+ export const LOCK_INIT_GRACE_MS = 5_000;
25
+ export const REAP_LOCK_STALE_MS = 30_000;
26
+ function isProcessAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch (error) {
32
+ return error.code === "EPERM";
33
+ }
34
+ }
35
+ export class MaintenanceLock {
36
+ lockPath;
37
+ now;
38
+ pid;
39
+ alive;
40
+ token;
41
+ heartbeat;
42
+ constructor(lockPath, now = Date.now, pid = process.pid, alive = isProcessAlive) {
43
+ this.lockPath = lockPath;
44
+ this.now = now;
45
+ this.pid = pid;
46
+ this.alive = alive;
47
+ }
48
+ get held() {
49
+ return this.token !== undefined;
50
+ }
51
+ readHolder() {
52
+ let raw;
53
+ try {
54
+ raw = readFileSync(this.lockPath, "utf-8");
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ if (typeof parsed.pid !== "number" || typeof parsed.token !== "string")
62
+ return "unreadable";
63
+ const startedAt = typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
64
+ const heartbeatAt = typeof parsed.heartbeatAt === "number" ? parsed.heartbeatAt : startedAt;
65
+ return { pid: parsed.pid, token: parsed.token, startedAt, heartbeatAt };
66
+ }
67
+ catch {
68
+ return "unreadable";
69
+ }
70
+ }
71
+ isLive(holder) {
72
+ if (holder === undefined)
73
+ return false;
74
+ if (holder === "unreadable") {
75
+ const age = fileAgeMs(this.lockPath);
76
+ return age !== undefined && age < LOCK_INIT_GRACE_MS;
77
+ }
78
+ if (holder.pid !== this.pid && !this.alive(holder.pid))
79
+ return false;
80
+ return this.now() - holder.heartbeatAt < MAINTENANCE_STALE_LOCK_MS;
81
+ }
82
+ isHeld() {
83
+ return this.isLive(this.readHolder());
84
+ }
85
+ tryAcquire() {
86
+ if (this.held)
87
+ return true;
88
+ const token = randomBytes(8).toString("hex");
89
+ for (let attempt = 0; attempt < 2; attempt++) {
90
+ const now = this.now();
91
+ const content = { pid: this.pid, token, startedAt: now, heartbeatAt: now };
92
+ if (createExclusiveSync(this.lockPath, JSON.stringify(content))) {
93
+ this.token = token;
94
+ this.startHeartbeat();
95
+ return true;
96
+ }
97
+ const holder = this.readHolder();
98
+ if (this.isLive(holder))
99
+ return false;
100
+ if (!this.reap(holder))
101
+ return false;
102
+ }
103
+ return false;
104
+ }
105
+ // Removes a stale lock under the reap lock; returns false when someone else is reaping or the
106
+ // lock changed hands meanwhile (a fresh holder must never be removed).
107
+ reap(seen) {
108
+ const reapPath = `${this.lockPath}.reap`;
109
+ if (!createExclusiveSync(reapPath, String(this.pid))) {
110
+ const age = fileAgeMs(reapPath);
111
+ if (age === undefined || age < REAP_LOCK_STALE_MS)
112
+ return false;
113
+ try {
114
+ unlinkSync(reapPath);
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ if (!createExclusiveSync(reapPath, String(this.pid)))
120
+ return false;
121
+ }
122
+ try {
123
+ const current = this.readHolder();
124
+ if (current === undefined)
125
+ return true;
126
+ if (this.isLive(current))
127
+ return false;
128
+ if (current !== "unreadable" && seen !== "unreadable" && seen !== undefined && current.token !== seen.token) {
129
+ return false;
130
+ }
131
+ unlinkSync(this.lockPath);
132
+ return true;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ finally {
138
+ try {
139
+ unlinkSync(reapPath);
140
+ }
141
+ catch {
142
+ // already gone
143
+ }
144
+ }
145
+ }
146
+ // Rewrites the heartbeat while the file still carries this acquisition's token.
147
+ refresh() {
148
+ if (!this.token)
149
+ return false;
150
+ const holder = this.readHolder();
151
+ if (holder === undefined || holder === "unreadable" || holder.token !== this.token)
152
+ return false;
153
+ const next = { ...holder, heartbeatAt: this.now() };
154
+ const tmp = `${this.lockPath}.${this.pid}.${this.token}.hb`;
155
+ try {
156
+ writeFileSync(tmp, JSON.stringify(next), "utf-8");
157
+ renameSync(tmp, this.lockPath);
158
+ return true;
159
+ }
160
+ catch {
161
+ try {
162
+ unlinkSync(tmp);
163
+ }
164
+ catch {
165
+ // nothing to clean
166
+ }
167
+ return false;
168
+ }
169
+ }
170
+ release() {
171
+ this.stopHeartbeat();
172
+ const token = this.token;
173
+ this.token = undefined;
174
+ if (!token)
175
+ return;
176
+ const holder = this.readHolder();
177
+ if (holder === undefined || holder === "unreadable" || holder.token !== token)
178
+ return;
179
+ unlinkWithRetry(this.lockPath);
180
+ }
181
+ startHeartbeat() {
182
+ this.stopHeartbeat();
183
+ this.heartbeat = setInterval(() => void this.refresh(), MAINTENANCE_HEARTBEAT_MS);
184
+ this.heartbeat.unref?.();
185
+ }
186
+ stopHeartbeat() {
187
+ if (this.heartbeat)
188
+ clearInterval(this.heartbeat);
189
+ this.heartbeat = undefined;
190
+ }
191
+ }
@@ -0,0 +1,5 @@
1
+ export declare const EXTRACT_PROMPT = "You are now acting as the memory extraction subagent. The conversation below is reviewed for anything worth remembering for future sessions.\n\n## What to save\n\nUse the `memory_save` tool to persist memories. There are four types:\n\n1. **user** \u2014 Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.\n2. **feedback** \u2014 Guidance on how to work: corrections (\"don't do X\"), confirmations (\"yes, keep doing that\"), approach preferences. Include *why* so edge cases can be judged.\n3. **project** \u2014 Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.\n4. **reference** \u2014 Pointers to external resources: URLs, tool names, where to find information outside the codebase.\n\n## What NOT to save\n\n- Code patterns, architecture, file structure \u2014 derivable from the codebase\n- Git history, recent changes \u2014 use `git log`/`git blame`\n- Debugging solutions \u2014 the fix is in the code\n- Anything already in AGENTS.md / project config files\n- Ephemeral task details or current conversation context\n- Information that is already covered by an existing memory (see the list below) unless you are updating it with something new\n\n## How to save\n\nFor each memory worth saving, call `memory_save` with:\n- `file_name`: descriptive slug (e.g., `user_role`, `feedback_testing_approach`)\n- `name`: short title\n- `description`: one-line description (used for relevance matching in future sessions)\n- `type`: one of user, feedback, project, reference\n- `content`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.\n\n## Instructions\n\n1. Analyze the conversation for memorable information\n2. The existing memories are listed below \u2014 do not duplicate them. To update one, call `memory_read` first and re-save it under the same `file_name` with the merged content\n3. Save each distinct memory as a separate entry\n4. If the conversation was trivial (e.g., just \"hello\" or a quick lookup), save nothing \u2014 that's fine\n5. Be selective: 0-3 memories per session is typical. Quality over quantity.\n6. Do NOT save a memory about the extraction process itself.\n7. Each `memory_save` call persists immediately and its result lists everything saved so far in this run. Never save the same `file_name` twice in one run. When nothing else is worth saving, stop calling tools and reply with a one-line summary.";
2
+ export declare const EXTRACT_EXISTING_MEMORIES_HEADING = "## Existing memories";
3
+ export declare function buildExtractionSystemPrompt(manifest: string): string;
4
+ export declare const AUTODREAM_PROMPT = "You are performing an auto-dream memory consolidation pass.\n\nGoal: tighten and de-duplicate memory files so future sessions can orient faster.\n\n## Available tools\n- memory_list\n- memory_search\n- memory_read\n- memory_save\n- memory_delete\n\n## Phase 1 \u2014 Orient\n1. Use memory_list to inspect current memory inventory.\n2. Identify overlapping or stale entries that can be merged/updated/deleted.\n\n## Phase 2 \u2014 Consolidate\n1. Merge duplicates into a single stronger memory using memory_save.\n2. Rewrite vague descriptions so retrieval is easier and more precise.\n3. For feedback/project entries, ensure content is structured as:\n - main rule/fact\n - **Why:**\n - **How to apply:**\n\n## Phase 3 \u2014 Prune\n1. Delete memories that are clearly obsolete, contradictory, or low-value.\n2. Keep total memory set concise and high signal.\n\n## Guardrails\n- Do NOT invent facts.\n- If confidence is low, keep existing memory instead of guessing.\n- If memory quality is already strong, make no changes and explicitly say so.\n\nReturn a short summary of what you updated, merged, or removed.";
5
+ export declare const AUTODREAM_USER_MESSAGE = "Run the consolidation pass over the current memory directory now.";
@@ -0,0 +1,78 @@
1
+ // The only copies of the extraction and auto-dream prompts.
2
+ export const EXTRACT_PROMPT = `You are now acting as the memory extraction subagent. The conversation below is reviewed for anything worth remembering for future sessions.
3
+
4
+ ## What to save
5
+
6
+ Use the \`memory_save\` tool to persist memories. There are four types:
7
+
8
+ 1. **user** — Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.
9
+ 2. **feedback** — Guidance on how to work: corrections ("don't do X"), confirmations ("yes, keep doing that"), approach preferences. Include *why* so edge cases can be judged.
10
+ 3. **project** — Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.
11
+ 4. **reference** — Pointers to external resources: URLs, tool names, where to find information outside the codebase.
12
+
13
+ ## What NOT to save
14
+
15
+ - Code patterns, architecture, file structure — derivable from the codebase
16
+ - Git history, recent changes — use \`git log\`/\`git blame\`
17
+ - Debugging solutions — the fix is in the code
18
+ - Anything already in AGENTS.md / project config files
19
+ - Ephemeral task details or current conversation context
20
+ - Information that is already covered by an existing memory (see the list below) unless you are updating it with something new
21
+
22
+ ## How to save
23
+
24
+ For each memory worth saving, call \`memory_save\` with:
25
+ - \`file_name\`: descriptive slug (e.g., \`user_role\`, \`feedback_testing_approach\`)
26
+ - \`name\`: short title
27
+ - \`description\`: one-line description (used for relevance matching in future sessions)
28
+ - \`type\`: one of user, feedback, project, reference
29
+ - \`content\`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.
30
+
31
+ ## Instructions
32
+
33
+ 1. Analyze the conversation for memorable information
34
+ 2. The existing memories are listed below — do not duplicate them. To update one, call \`memory_read\` first and re-save it under the same \`file_name\` with the merged content
35
+ 3. Save each distinct memory as a separate entry
36
+ 4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that's fine
37
+ 5. Be selective: 0-3 memories per session is typical. Quality over quantity.
38
+ 6. Do NOT save a memory about the extraction process itself.
39
+ 7. Each \`memory_save\` call persists immediately and its result lists everything saved so far in this run. Never save the same \`file_name\` twice in one run. When nothing else is worth saving, stop calling tools and reply with a one-line summary.`;
40
+ export const EXTRACT_EXISTING_MEMORIES_HEADING = "## Existing memories";
41
+ export function buildExtractionSystemPrompt(manifest) {
42
+ const inventory = manifest.trim() ? manifest.trim() : "(none yet)";
43
+ return `${EXTRACT_PROMPT}\n\n${EXTRACT_EXISTING_MEMORIES_HEADING}\n\n${inventory}`;
44
+ }
45
+ export const AUTODREAM_PROMPT = `You are performing an auto-dream memory consolidation pass.
46
+
47
+ Goal: tighten and de-duplicate memory files so future sessions can orient faster.
48
+
49
+ ## Available tools
50
+ - memory_list
51
+ - memory_search
52
+ - memory_read
53
+ - memory_save
54
+ - memory_delete
55
+
56
+ ## Phase 1 — Orient
57
+ 1. Use memory_list to inspect current memory inventory.
58
+ 2. Identify overlapping or stale entries that can be merged/updated/deleted.
59
+
60
+ ## Phase 2 — Consolidate
61
+ 1. Merge duplicates into a single stronger memory using memory_save.
62
+ 2. Rewrite vague descriptions so retrieval is easier and more precise.
63
+ 3. For feedback/project entries, ensure content is structured as:
64
+ - main rule/fact
65
+ - **Why:**
66
+ - **How to apply:**
67
+
68
+ ## Phase 3 — Prune
69
+ 1. Delete memories that are clearly obsolete, contradictory, or low-value.
70
+ 2. Keep total memory set concise and high signal.
71
+
72
+ ## Guardrails
73
+ - Do NOT invent facts.
74
+ - If confidence is low, keep existing memory instead of guessing.
75
+ - If memory quality is already strong, make no changes and explicitly say so.
76
+
77
+ Return a short summary of what you updated, merged, or removed.`;
78
+ export const AUTODREAM_USER_MESSAGE = "Run the consolidation pass over the current memory directory now.";
@@ -0,0 +1,38 @@
1
+ export declare const EXTRACTION_STATE_VERSION = 1;
2
+ export declare const EXTRACTION_STATE_FILE = "extraction-state.json";
3
+ export declare const MAINTENANCE_LOCK_FILE = "maintenance.lock";
4
+ export declare const STATE_LOCK_FILE = "extraction-state.lock";
5
+ export declare const SESSION_STATE_TTL_MS: number;
6
+ export type SessionExtractionState = {
7
+ lastExtractedMessageID?: string;
8
+ lastMessageAt?: number;
9
+ updatedAt: number;
10
+ failures: number;
11
+ attemptedAt?: number;
12
+ };
13
+ export type AutodreamState = {
14
+ lastConsolidatedAt: number;
15
+ sessionsSince: string[];
16
+ };
17
+ export type ExtractionStateData = {
18
+ version: typeof EXTRACTION_STATE_VERSION;
19
+ sessions: Record<string, SessionExtractionState>;
20
+ autodream: AutodreamState;
21
+ };
22
+ export declare function emptyExtractionState(): ExtractionStateData;
23
+ export declare function parseExtractionState(raw: string): ExtractionStateData;
24
+ export declare class ExtractionStateStore {
25
+ readonly stateDir: string;
26
+ private readonly now;
27
+ readonly filePath: string;
28
+ readonly lockPath: string;
29
+ readonly stateLockPath: string;
30
+ constructor(stateDir: string, now?: () => number);
31
+ read(): ExtractionStateData;
32
+ getSession(sessionID: string): SessionExtractionState | undefined;
33
+ update(mutate: (data: ExtractionStateData) => void): ExtractionStateData;
34
+ private prune;
35
+ private write;
36
+ }
37
+ export declare function posixCksum(input: string): number;
38
+ export declare function migrateLegacyAutodreamState(store: ExtractionStateStore, legacyDir: string, projectRoots: readonly string[]): boolean;
@@ -0,0 +1,159 @@
1
+ // Persistent extraction state: per-session watermarks and the auto-dream gate.
2
+ // <CLAUDE_CONFIG_DIR>/opencode-memory/<sanitizePath(canonicalRoot)>/extraction-state.json
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { withFileLock } from "../util/exclusiveFile.js";
6
+ export const EXTRACTION_STATE_VERSION = 1;
7
+ export const EXTRACTION_STATE_FILE = "extraction-state.json";
8
+ export const MAINTENANCE_LOCK_FILE = "maintenance.lock";
9
+ export const STATE_LOCK_FILE = "extraction-state.lock";
10
+ export const SESSION_STATE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
11
+ export function emptyExtractionState() {
12
+ return { version: EXTRACTION_STATE_VERSION, sessions: {}, autodream: { lastConsolidatedAt: 0, sessionsSince: [] } };
13
+ }
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function normaliseSession(value) {
18
+ if (!isRecord(value))
19
+ return undefined;
20
+ const updatedAt = typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) ? value.updatedAt : 0;
21
+ const failures = typeof value.failures === "number" && Number.isFinite(value.failures) ? value.failures : 0;
22
+ const state = { updatedAt, failures };
23
+ if (typeof value.lastExtractedMessageID === "string")
24
+ state.lastExtractedMessageID = value.lastExtractedMessageID;
25
+ if (typeof value.lastMessageAt === "number" && Number.isFinite(value.lastMessageAt))
26
+ state.lastMessageAt = value.lastMessageAt;
27
+ if (typeof value.attemptedAt === "number" && Number.isFinite(value.attemptedAt))
28
+ state.attemptedAt = value.attemptedAt;
29
+ return state;
30
+ }
31
+ // Tolerant parser: a missing or corrupt file yields empty state instead of disabling extraction.
32
+ export function parseExtractionState(raw) {
33
+ const data = emptyExtractionState();
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return data;
40
+ }
41
+ if (!isRecord(parsed))
42
+ return data;
43
+ if (isRecord(parsed.sessions)) {
44
+ for (const [id, value] of Object.entries(parsed.sessions)) {
45
+ const session = normaliseSession(value);
46
+ if (session)
47
+ data.sessions[id] = session;
48
+ }
49
+ }
50
+ if (isRecord(parsed.autodream)) {
51
+ const last = parsed.autodream.lastConsolidatedAt;
52
+ if (typeof last === "number" && Number.isFinite(last))
53
+ data.autodream.lastConsolidatedAt = last;
54
+ if (Array.isArray(parsed.autodream.sessionsSince)) {
55
+ data.autodream.sessionsSince = parsed.autodream.sessionsSince.filter((s) => typeof s === "string");
56
+ }
57
+ }
58
+ return data;
59
+ }
60
+ // Never cached: several OpenCode processes may maintain the same project (#30). Every `update()` is a
61
+ // read-modify-write under a short cross-process file lock, so two processes committing different
62
+ // sessions at the same time cannot overwrite each other. The file is tiny and writes are rare.
63
+ export class ExtractionStateStore {
64
+ stateDir;
65
+ now;
66
+ filePath;
67
+ lockPath;
68
+ stateLockPath;
69
+ constructor(stateDir, now = Date.now) {
70
+ this.stateDir = stateDir;
71
+ this.now = now;
72
+ this.filePath = join(stateDir, EXTRACTION_STATE_FILE);
73
+ this.lockPath = join(stateDir, MAINTENANCE_LOCK_FILE);
74
+ this.stateLockPath = join(stateDir, STATE_LOCK_FILE);
75
+ }
76
+ read() {
77
+ let raw = "";
78
+ try {
79
+ raw = readFileSync(this.filePath, "utf-8");
80
+ }
81
+ catch {
82
+ // first run
83
+ }
84
+ return parseExtractionState(raw);
85
+ }
86
+ getSession(sessionID) {
87
+ return this.read().sessions[sessionID];
88
+ }
89
+ // `mutate` sees the on-disk state as of this moment (under the lock), so it can make decisions
90
+ // against what other processes committed, not against a snapshot taken earlier.
91
+ update(mutate) {
92
+ mkdirSync(this.stateDir, { recursive: true });
93
+ return withFileLock(this.stateLockPath, () => {
94
+ const data = this.read();
95
+ mutate(data);
96
+ this.prune(data);
97
+ this.write(data);
98
+ return data;
99
+ });
100
+ }
101
+ prune(data) {
102
+ const cutoff = this.now() - SESSION_STATE_TTL_MS;
103
+ for (const [id, session] of Object.entries(data.sessions)) {
104
+ if (Math.max(session.updatedAt, session.attemptedAt ?? 0) < cutoff)
105
+ delete data.sessions[id];
106
+ }
107
+ }
108
+ write(data) {
109
+ mkdirSync(this.stateDir, { recursive: true });
110
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
111
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
112
+ renameSync(tmp, this.filePath);
113
+ }
114
+ }
115
+ // POSIX `cksum` (CRC-32/CKSUM: polynomial 0x04C11DB7, non-reflected, length appended, inverted).
116
+ // v1's bash wrapper keyed its auto-dream lock file on `cksum` of the git top-level path.
117
+ export function posixCksum(input) {
118
+ const bytes = Buffer.from(input, "utf-8");
119
+ let crc = 0;
120
+ const feed = (byte) => {
121
+ crc ^= byte << 24;
122
+ for (let i = 0; i < 8; i++) {
123
+ crc = crc & 0x80000000 ? ((crc << 1) ^ 0x04c11db7) >>> 0 : (crc << 1) >>> 0;
124
+ }
125
+ };
126
+ for (const byte of bytes)
127
+ feed(byte);
128
+ let length = bytes.length;
129
+ while (length > 0) {
130
+ feed(length & 0xff);
131
+ length >>>= 8;
132
+ }
133
+ return (~crc >>> 0) >>> 0;
134
+ }
135
+ // v1 stored the last successful consolidation time as the mtime of
136
+ // <CLAUDE_CONFIG_DIR>/opencode-memory/<cksum(project root)>.consolidate-lock. Carry it over once so
137
+ // upgrading does not immediately trigger a consolidation pass, then remove the old file.
138
+ export function migrateLegacyAutodreamState(store, legacyDir, projectRoots) {
139
+ if (store.read().autodream.lastConsolidatedAt > 0)
140
+ return false;
141
+ let migrated = false;
142
+ for (const root of new Set(projectRoots)) {
143
+ const legacyPath = join(legacyDir, `${posixCksum(root)}.consolidate-lock`);
144
+ if (!existsSync(legacyPath))
145
+ continue;
146
+ try {
147
+ const mtimeMs = statSync(legacyPath).mtimeMs;
148
+ store.update((data) => {
149
+ data.autodream.lastConsolidatedAt = Math.max(data.autodream.lastConsolidatedAt, Math.floor(mtimeMs));
150
+ });
151
+ unlinkSync(legacyPath);
152
+ migrated = true;
153
+ }
154
+ catch {
155
+ // best-effort
156
+ }
157
+ }
158
+ return migrated;
159
+ }
@@ -0,0 +1,6 @@
1
+ import type { ChatMessage, MessagePart } from "../sdk.js";
2
+ export declare function detectIgnoreMemory(query: string | undefined): boolean;
3
+ export declare function detectResumeMemory(query: string | undefined): boolean;
4
+ export declare function deriveIgnoredFromHistory(messages: readonly ChatMessage[]): boolean;
5
+ export declare function isAutoMemoryPart(part: MessagePart): boolean;
6
+ export declare function stripAutoMemoryParts(messages: readonly ChatMessage[]): ChatMessage[];