opencode-codex-memory 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # OpenCode Codex Memory
2
2
 
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/opencode-codex-memory">
5
+ <img src="https://img.shields.io/npm/v/opencode-codex-memory?logo=npm&amp;label=latest" alt="Latest npm version" />
6
+ </a>
7
+ <a href="https://www.npmjs.com/package/opencode-codex-memory">
8
+ <img src="https://img.shields.io/npm/dt/opencode-codex-memory?logo=npm&amp;label=downloads" alt="npm downloads" />
9
+ </a>
10
+ </p>
11
+
3
12
  Persistent memory for [OpenCode](https://opencode.ai): your agent remembers what
4
13
  it learned in past sessions — your conventions, your projects, the decisions you
5
14
  made — and brings that context into new conversations automatically.
@@ -39,11 +48,11 @@ If you want the mental model before the details, jump to
39
48
 
40
49
  1. Add the plugin to your `~/.config/opencode/opencode.json`:
41
50
 
42
- ```json
43
- {
44
- "plugin": ["opencode-codex-memory@0.4.1"]
45
- }
46
- ```
51
+ ```json
52
+ {
53
+ "plugin": ["opencode-codex-memory@0.4.3"]
54
+ }
55
+ ```
47
56
 
48
57
  **Pin the version** (here and for any OpenCode plugin). OpenCode installs a
49
58
  plugin spec once into its package cache and never re-resolves it, so a bare
@@ -228,7 +237,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
228
237
  ```json
229
238
  {
230
239
  "plugin": [
231
- ["opencode-codex-memory@0.4.1", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
240
+ ["opencode-codex-memory@0.4.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
232
241
  ]
233
242
  }
234
243
  ```
@@ -287,7 +296,7 @@ directions:
287
296
  ```json
288
297
  {
289
298
  "plugin": [
290
- ["opencode-codex-memory@0.4.1", { "codex_interop": { "import": true, "export": true } }]
299
+ ["opencode-codex-memory@0.4.3", { "codex_interop": { "import": true, "export": true } }]
291
300
  ]
292
301
  }
293
302
  ```
@@ -127,6 +127,11 @@ function extractText(msg) {
127
127
  // parts carry `text`, so they must be dropped before the text check.
128
128
  if (msg.type === "reasoning")
129
129
  return undefined;
130
+ // opencode itself drops `ignored` text parts when building model messages
131
+ // (session/message-v2.ts), e.g. ACP content addressed only to the user.
132
+ // The assistant never saw them, so they are not conversation.
133
+ if (msg.ignored === true)
134
+ return undefined;
130
135
  if (typeof msg.text === "string")
131
136
  return msg.text;
132
137
  if (msg.type === "tool") {
@@ -135,8 +140,16 @@ function extractText(msg) {
135
140
  // the extractor's strongest evidence — do not slice them per call.
136
141
  const tool = msg.tool ?? "unknown";
137
142
  const input = msg.state?.input ? JSON.stringify(msg.state.input) : "";
138
- const output = typeof msg.state?.output === "string" ? msg.state.output : "";
139
- return `[tool: ${tool}] ${input}${output ? "\n" + output : ""}`;
143
+ // `output` exists only on status:"completed"; a failed call carries
144
+ // `error` instead (schema v1/session.ts ToolStateError). codex persists
145
+ // failed calls too (rollout policy: FunctionCallOutput => true), and "X
146
+ // failed with Y" is often the most memorable part of a session.
147
+ const result = typeof msg.state?.output === "string"
148
+ ? msg.state.output
149
+ : typeof msg.state?.error === "string"
150
+ ? `[error] ${msg.state.error}`
151
+ : "";
152
+ return `[tool: ${tool}] ${input}${result ? "\n" + result : ""}`;
140
153
  }
141
154
  if (msg.type === "step-start" || msg.type === "step-finish")
142
155
  return undefined;
@@ -189,16 +189,27 @@ declare const _default: {
189
189
  "chat.message"(input: {
190
190
  sessionID?: string;
191
191
  }): Promise<void>;
192
+ /**
193
+ * Dedicated plugin hook (NOT an event-bus type). Marks the session polluted
194
+ * at INVOCATION, mirroring codex: mcp_tool_call.rs calls
195
+ * maybe_mark_thread_memory_mode_polluted inside handle_approved_mcp_tool_call
196
+ * BEFORE the call runs, and web search marks on the completed response item
197
+ * (stream_events_utils.rs response_item_may_include_external_context).
198
+ *
199
+ * Deliberately not tool.execute.after: opencode does not guarantee that hook
200
+ * (session/tools.ts awaits execute() with no ensuring/catchAll, and an abort
201
+ * interrupts the fiber), so a failed or cancelled websearch/webfetch/MCP call
202
+ * left the session unmarked while its output had already entered the
203
+ * transcript. Marking early over-marks a permission-denied call, which is the
204
+ * safe direction for an opt-in guard.
205
+ *
206
+ * Pollution remains gated by disable_on_external_context, off by default.
207
+ */
192
208
  "tool.execute.before"(input: {
193
209
  tool: string;
194
210
  sessionID: string;
195
211
  callID: string;
196
212
  }): Promise<void>;
197
- "tool.execute.after"(input: {
198
- tool: string;
199
- sessionID: string;
200
- callID: string;
201
- }): Promise<void>;
202
213
  event(input: {
203
214
  event: {
204
215
  type: string;
package/dist/src/index.js CHANGED
@@ -7,16 +7,14 @@ import { MemoryStore } from "./store.js";
7
7
  import { runPhase1 } from "./phase1.js";
8
8
  import { runPhase2 } from "./phase2.js";
9
9
  import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js";
10
- import { pluginOptions, recordConfigWarning } from "./options.js";
10
+ import { pluginOptions, recordConfigWarning, clearConfigWarnings } from "./options.js";
11
11
  import fs from "fs";
12
12
  import path from "path";
13
13
  let phase1InFlight = false;
14
14
  let pluginClient = null;
15
- const externalContextCalls = new Map();
16
- const MAX_TRACKED_TOOL_CALLS = 500;
17
- function externalContextCallKey(sessionID, callID) {
18
- return `${sessionID}\0${callID}`;
19
- }
15
+ // Single-flight guard for mcp.status(); see mcpToolPrefixes below.
16
+ let mcpStatusInFlight = null;
17
+ const MCP_STATUS_TIMEOUT_MS = 1_000;
20
18
  // Deliberately uncached: openDb() is already a singleton, and caching a store
21
19
  // here would hold a stale handle across closeDb() (e.g. after memory_reset).
22
20
  function getStore() {
@@ -28,17 +26,25 @@ function getStore() {
28
26
  // recorded per part to count each citation exactly once across both paths.
29
27
  const recordedCitations = new Map();
30
28
  const MAX_TRACKED_PARTS = 500;
31
- export function takeNewCitations(partKey, ids) {
32
- let seen = recordedCitations.get(partKey);
33
- if (!seen) {
34
- seen = new Set();
35
- recordedCitations.set(partKey, seen);
36
- if (recordedCitations.size > MAX_TRACKED_PARTS) {
37
- const oldest = recordedCitations.keys().next().value;
38
- if (oldest !== undefined)
39
- recordedCitations.delete(oldest);
40
- }
29
+ /**
30
+ * Inserts `key` at the most-recently-used end and evicts the oldest entry
31
+ * beyond `max`. Map.set alone does NOT reorder an existing key, so the
32
+ * delete is what makes eviction least-recently-*used* rather than
33
+ * first-inserted — long-lived sessions must not age out mid-use.
34
+ */
35
+ function lruSet(map, key, value, max) {
36
+ map.delete(key);
37
+ map.set(key, value);
38
+ if (map.size > max) {
39
+ const oldest = map.keys().next().value;
40
+ if (oldest !== undefined)
41
+ map.delete(oldest);
41
42
  }
43
+ return value;
44
+ }
45
+ export function takeNewCitations(partKey, ids) {
46
+ const seen = recordedCitations.get(partKey) ?? new Set();
47
+ lruSet(recordedCitations, partKey, seen, MAX_TRACKED_PARTS);
42
48
  const fresh = ids.filter((id) => !seen.has(id));
43
49
  for (const id of fresh)
44
50
  seen.add(id);
@@ -46,19 +52,13 @@ export function takeNewCitations(partKey, ids) {
46
52
  }
47
53
  // One stamp+pump per session per process from the chat.message hook; later
48
54
  // messages in the same session add nothing (stamp is idempotent, the pump
49
- // re-fires on idle anyway).
50
- const seenTurnSessions = new Set();
55
+ // re-fires on idle anyway). Value = first-seen timestamp (debugging only).
56
+ const seenTurnSessions = new Map();
51
57
  const MAX_TRACKED_TURN_SESSIONS = 1000;
52
58
  export function markTurnSeen(sessionId) {
53
- if (seenTurnSessions.has(sessionId))
54
- return false;
55
- seenTurnSessions.add(sessionId);
56
- if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
57
- const oldest = seenTurnSessions.keys().next().value;
58
- if (oldest !== undefined)
59
- seenTurnSessions.delete(oldest);
60
- }
61
- return true;
59
+ const first = seenTurnSessions.get(sessionId);
60
+ lruSet(seenTurnSessions, sessionId, first ?? Date.now(), MAX_TRACKED_TURN_SESSIONS);
61
+ return first === undefined;
62
62
  }
63
63
  // opencode 1.17 publishes BOTH session.status {type:"idle"} and the
64
64
  // deprecated session.idle for the same transition, back to back. Handle
@@ -68,15 +68,11 @@ const IDLE_DEDUP_MS = 5000;
68
68
  const MAX_TRACKED_IDLE = 500;
69
69
  export function shouldHandleIdle(sessionId, now = Date.now()) {
70
70
  const last = recentIdle.get(sessionId);
71
- if (last !== undefined && now - last < IDLE_DEDUP_MS)
72
- return false;
73
- recentIdle.set(sessionId, now);
74
- if (recentIdle.size > MAX_TRACKED_IDLE) {
75
- const oldest = recentIdle.keys().next().value;
76
- if (oldest !== undefined)
77
- recentIdle.delete(oldest);
78
- }
79
- return true;
71
+ const deduped = last !== undefined && now - last < IDLE_DEDUP_MS;
72
+ // Keep the original stamp while deduping so the window cannot be extended
73
+ // indefinitely by a stream of twins; refresh LRU order either way.
74
+ lruSet(recentIdle, sessionId, deduped ? last : now, MAX_TRACKED_IDLE);
75
+ return !deduped;
80
76
  }
81
77
  export function handleSessionDeleted(sessionId, store = getStore(),
82
78
  // With generation off the memorize agent is not injected, so a consolidation
@@ -93,10 +89,16 @@ export default {
93
89
  async server(input, opts) {
94
90
  setPluginInput(input);
95
91
  pluginClient = input.client;
96
- externalContextCalls.clear();
92
+ mcpStatusInFlight = null;
93
+ // Unconditional, like the caches above: a boot WITHOUT options must not
94
+ // inherit the previous boot's warnings (opencode can host several
95
+ // instances in one process — see the ARCHITECTURE known-gaps table).
96
+ clearConfigWarnings();
97
97
  if (opts)
98
98
  applyPluginOptions(opts);
99
- void cleanupOldSubSessions().catch(() => { });
99
+ // Finish bounded reseeding before hooks can see a surviving memory
100
+ // sub-session after a plugin reload.
101
+ await cleanupOldSubSessions();
100
102
  return buildHooks();
101
103
  },
102
104
  };
@@ -123,6 +125,9 @@ function clampInt(value, min, max, fallback) {
123
125
  return Math.min(max, Math.max(min, Math.floor(value)));
124
126
  }
125
127
  export function applyPluginOptions(opts) {
128
+ // Fresh pass per apply so memory_inspect never shows warnings for keys the
129
+ // caller has since fixed. server() clears too, for boots without options.
130
+ clearConfigWarnings();
126
131
  for (const key of Object.keys(opts)) {
127
132
  if (!KNOWN_OPTION_KEYS.has(key)) {
128
133
  // codex uses deny_unknown_fields; a plugin can only warn (recorded for
@@ -174,33 +179,69 @@ export function applyPluginOptions(opts) {
174
179
  * as "<server>_<tool>", so match tool names against the configured server
175
180
  * list. Query live status so runtime MCP changes cannot escape pollution
176
181
  * marking. Falls back to the web-tools-only check when status is unavailable.
182
+ *
183
+ * Concurrent tool calls coalesce on one in-flight status fetch. Deliberately
184
+ * NOT cached with a TTL: a stale list would miss servers connected
185
+ * mid-session and silently stop marking their calls as polluting, which is
186
+ * the failure this classification exists to prevent. The accepted cost is one
187
+ * status round trip per tool call — bounded to sessions that opt in with
188
+ * disable_on_external_context (off by default), and paid only in
189
+ * tool.execute.before.
177
190
  */
178
- async function classifyExternalContextTool(toolName) {
179
- if (toolName === "websearch" || toolName === "webfetch")
180
- return true;
191
+ async function mcpToolPrefixes() {
181
192
  if (!pluginClient)
182
193
  return null;
183
- try {
184
- const res = await pluginClient.mcp.status();
185
- if (res?.error)
186
- return null;
187
- const servers = res?.data;
188
- if (!servers || typeof servers !== "object" || Array.isArray(servers))
189
- return null;
190
- for (const [server, status] of Object.entries(servers)) {
191
- if (!status || typeof status !== "object" || typeof status.status !== "string")
192
- continue;
193
- // Mirrors OpenCode's McpCatalog.sanitize when constructing tool names.
194
- const toolPrefix = server.replace(/[^a-zA-Z0-9_-]/g, "_");
195
- if (toolName.startsWith(`${toolPrefix}_`))
196
- return true;
197
- }
198
- return false;
194
+ if (!mcpStatusInFlight) {
195
+ mcpStatusInFlight = (async () => {
196
+ const controller = new AbortController();
197
+ let timer;
198
+ try {
199
+ const res = await Promise.race([
200
+ pluginClient.mcp.status({ signal: controller.signal }),
201
+ new Promise((_, reject) => {
202
+ timer = setTimeout(() => {
203
+ controller.abort();
204
+ reject(new Error(`mcp.status timed out after ${MCP_STATUS_TIMEOUT_MS}ms`));
205
+ }, MCP_STATUS_TIMEOUT_MS);
206
+ }),
207
+ ]);
208
+ if (res?.error)
209
+ return null;
210
+ const servers = res?.data;
211
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
212
+ return null;
213
+ const prefixes = [];
214
+ for (const [server, status] of Object.entries(servers)) {
215
+ if (!status || typeof status !== "object" || typeof status.status !== "string")
216
+ continue;
217
+ // Mirrors OpenCode's McpCatalog.sanitize when constructing tool names.
218
+ prefixes.push(server.replace(/[^a-zA-Z0-9_-]/g, "_"));
219
+ }
220
+ return prefixes;
221
+ }
222
+ catch {
223
+ // MCP status unavailable (older OpenCode); keep web-tools-only checks.
224
+ return null;
225
+ }
226
+ finally {
227
+ clearTimeout(timer);
228
+ mcpStatusInFlight = null;
229
+ }
230
+ })();
199
231
  }
200
- catch {
201
- // MCP status unavailable (older OpenCode); keep web-tools-only checks.
232
+ return mcpStatusInFlight;
233
+ }
234
+ async function classifyExternalContextTool(toolName) {
235
+ if (toolName === "websearch" || toolName === "webfetch")
236
+ return true;
237
+ const prefixes = await mcpToolPrefixes();
238
+ if (prefixes === null)
202
239
  return null;
240
+ for (const prefix of prefixes) {
241
+ if (toolName.startsWith(`${prefix}_`))
242
+ return true;
203
243
  }
244
+ return false;
204
245
  }
205
246
  /**
206
247
  * Registers the memorize / memorize-extract sub-agents through the config
@@ -354,50 +395,36 @@ function buildHooks() {
354
395
  console.error("[opencode-codex-memory] chat.message error:", err);
355
396
  }
356
397
  },
357
- // Dedicated plugin hooks (NOT event-bus types): capture external-context
358
- // classification before each call and mark memory after successful calls.
359
- // Pollution remains gated by disable_on_external_context, which is off by
360
- // default.
398
+ /**
399
+ * Dedicated plugin hook (NOT an event-bus type). Marks the session polluted
400
+ * at INVOCATION, mirroring codex: mcp_tool_call.rs calls
401
+ * maybe_mark_thread_memory_mode_polluted inside handle_approved_mcp_tool_call
402
+ * BEFORE the call runs, and web search marks on the completed response item
403
+ * (stream_events_utils.rs response_item_may_include_external_context).
404
+ *
405
+ * Deliberately not tool.execute.after: opencode does not guarantee that hook
406
+ * (session/tools.ts awaits execute() with no ensuring/catchAll, and an abort
407
+ * interrupts the fiber), so a failed or cancelled websearch/webfetch/MCP call
408
+ * left the session unmarked while its output had already entered the
409
+ * transcript. Marking early over-marks a permission-denied call, which is the
410
+ * safe direction for an opt-in guard.
411
+ *
412
+ * Pollution remains gated by disable_on_external_context, off by default.
413
+ */
361
414
  async "tool.execute.before"(input) {
362
415
  try {
363
- if (!pluginOptions.disable_on_external_context || !input.callID)
416
+ if (!pluginOptions.disable_on_external_context || !input.sessionID)
364
417
  return;
365
- const key = externalContextCallKey(input.sessionID, input.callID);
366
- externalContextCalls.delete(key);
367
- const classification = await classifyExternalContextTool(input.tool);
368
- if (classification === null)
418
+ // null = MCP status unavailable; websearch/webfetch still classify true
419
+ // without it, so only MCP-prefixed tools go unmarked.
420
+ if ((await classifyExternalContextTool(input.tool)) !== true)
369
421
  return;
370
- externalContextCalls.set(key, classification);
371
- if (externalContextCalls.size > MAX_TRACKED_TOOL_CALLS) {
372
- const oldest = externalContextCalls.keys().next().value;
373
- if (oldest !== undefined)
374
- externalContextCalls.delete(oldest);
375
- }
422
+ getStore().markPolluted(input.sessionID);
376
423
  }
377
424
  catch (err) {
378
425
  console.error("[opencode-codex-memory] tool.execute.before error:", err);
379
426
  }
380
427
  },
381
- async "tool.execute.after"(input) {
382
- try {
383
- const key = externalContextCallKey(input.sessionID, input.callID);
384
- const hasCapturedClassification = Boolean(input.callID) && externalContextCalls.has(key);
385
- const capturedClassification = input.callID ? externalContextCalls.get(key) : undefined;
386
- if (input.callID)
387
- externalContextCalls.delete(key);
388
- if (!pluginOptions.disable_on_external_context)
389
- return;
390
- const isExternal = hasCapturedClassification
391
- ? capturedClassification === true
392
- : (await classifyExternalContextTool(input.tool)) === true;
393
- if (input.sessionID && isExternal) {
394
- getStore().markPolluted(input.sessionID);
395
- }
396
- }
397
- catch (err) {
398
- console.error("[opencode-codex-memory] tool.execute.after error:", err);
399
- }
400
- },
401
428
  async event(input) {
402
429
  try {
403
430
  const ev = input.event;
package/dist/src/llm.d.ts CHANGED
@@ -7,14 +7,32 @@ export interface ExtractionResult {
7
7
  export declare function setPluginInput(input: PluginInput): void;
8
8
  export declare function getPluginInput(): PluginInput | null;
9
9
  export declare function isMemorySubSession(sessionId: string): boolean;
10
+ /**
11
+ * Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
12
+ * than matching on the message text) is what tells the catch below that the
13
+ * run is still executing server-side and must be aborted.
14
+ */
15
+ export declare class SubagentTimeoutError extends Error {
16
+ constructor(timeoutMs: number);
17
+ }
18
+ /**
19
+ * Thrown when a sub-agent session could not be closed. Codex treats a failed
20
+ * consolidation-agent shutdown as "the agent may still be alive", so the caller
21
+ * must keep its job lease instead of completing the job (phase2.rs).
22
+ */
23
+ export declare class SubagentShutdownError extends Error {
24
+ constructor(sessionId: string);
25
+ }
10
26
  export interface ExtractOptions {
11
27
  cwd?: string;
12
28
  model?: string;
29
+ /** Override the default 1h extract timeout (tests / advanced). */
30
+ timeoutMs?: number;
13
31
  }
14
32
  /** Returns null when the extractor reported a no-op (nothing worth remembering). */
15
33
  export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
16
34
  export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
17
- export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
35
+ export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
18
36
  export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
19
37
  export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
20
38
  /**
package/dist/src/llm.js CHANGED
@@ -11,6 +11,10 @@ export function getPluginInput() {
11
11
  // hooks skip these so the plugin never injects memory into (or memorizes) its
12
12
  // own sub-agents.
13
13
  const activeSubSessions = new Set();
14
+ const SUBSESSION_METADATA_KEY = "opencode-codex-memory";
15
+ const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
16
+ const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
17
+ const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
14
18
  export function isMemorySubSession(sessionId) {
15
19
  return activeSubSessions.has(sessionId);
16
20
  }
@@ -19,7 +23,10 @@ async function createSession(agent, title) {
19
23
  if (!input)
20
24
  throw new Error("plugin input not initialized");
21
25
  const res = await input.client.session.create({
22
- body: { title: title ?? `codex-memory-${agent}` },
26
+ body: {
27
+ title: title ?? `codex-memory-${agent}`,
28
+ metadata: { [SUBSESSION_METADATA_KEY]: true },
29
+ },
23
30
  });
24
31
  if (!res.data)
25
32
  throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
@@ -63,6 +70,53 @@ function parseModelRef(ref) {
63
70
  return null;
64
71
  return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
65
72
  }
73
+ /**
74
+ * Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
75
+ * than matching on the message text) is what tells the catch below that the
76
+ * run is still executing server-side and must be aborted.
77
+ */
78
+ export class SubagentTimeoutError extends Error {
79
+ constructor(timeoutMs) {
80
+ super(`sub-agent prompt timed out after ${timeoutMs}ms`);
81
+ this.name = "SubagentTimeoutError";
82
+ }
83
+ }
84
+ /**
85
+ * Thrown when a sub-agent session could not be closed. Codex treats a failed
86
+ * consolidation-agent shutdown as "the agent may still be alive", so the caller
87
+ * must keep its job lease instead of completing the job (phase2.rs).
88
+ */
89
+ export class SubagentShutdownError extends Error {
90
+ constructor(sessionId) {
91
+ super(`failed to close memory sub-session ${sessionId}`);
92
+ this.name = "SubagentShutdownError";
93
+ }
94
+ }
95
+ async function abortSession(sessionId) {
96
+ const input = getPluginInput();
97
+ const session = input?.client?.session;
98
+ if (typeof session?.abort !== "function")
99
+ return;
100
+ const controller = new AbortController();
101
+ let timer;
102
+ try {
103
+ await Promise.race([
104
+ session.abort({ path: { id: sessionId }, signal: controller.signal }),
105
+ new Promise((_, reject) => {
106
+ timer = setTimeout(() => {
107
+ controller.abort();
108
+ reject(new Error(`session.abort timed out after ${SUBSESSION_ABORT_TIMEOUT_MS}ms`));
109
+ }, SUBSESSION_ABORT_TIMEOUT_MS);
110
+ }),
111
+ ]);
112
+ }
113
+ catch {
114
+ // Best-effort: deleteSession is the backup cancel path.
115
+ }
116
+ finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
66
120
  /** Runs a sub-agent prompt and returns the raw response data (`{ info, parts }`). */
67
121
  async function runPrompt(sessionId, prompt, agent, opts = {}) {
68
122
  const timeoutMs = opts.timeoutMs ?? 300_000;
@@ -87,7 +141,7 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
87
141
  const res = await Promise.race([
88
142
  promptPromise,
89
143
  new Promise((_, reject) => {
90
- timer = setTimeout(() => reject(new Error(`sub-agent prompt timed out after ${timeoutMs}ms`)), timeoutMs);
144
+ timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
91
145
  }),
92
146
  ]);
93
147
  if (!res.data)
@@ -99,6 +153,15 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
99
153
  }
100
154
  return res.data;
101
155
  }
156
+ catch (err) {
157
+ // Only a timeout leaves the turn running server-side; every other failure
158
+ // here means the request already settled. Stop the run so tokens stop
159
+ // burning — deleteSession in the caller finally is the backup.
160
+ if (err instanceof SubagentTimeoutError) {
161
+ await abortSession(sessionId);
162
+ }
163
+ throw err;
164
+ }
102
165
  finally {
103
166
  clearTimeout(timer);
104
167
  }
@@ -153,7 +216,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
153
216
  // Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
154
217
  // and a near-600k-char transcript on a slow model can easily exceed a
155
218
  // short one — repeated timeouts would exhaust the job's retries.
156
- timeoutMs: 3600_000,
219
+ timeoutMs: opts.timeoutMs ?? 3600_000,
157
220
  system: readTemplate("stage_one_system.md"),
158
221
  model,
159
222
  // opencode enforces json_schema output via a forced StructuredOutput tool
@@ -172,6 +235,9 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
172
235
  return parseExtraction(extractAssistantText(data));
173
236
  }
174
237
  finally {
238
+ // Fire-and-forget on purpose (unlike consolidation): stage 1 has no codex
239
+ // agent-shutdown step, and memorize-extract has no write tools, so a
240
+ // lingering extract session cannot touch the memory root.
175
241
  void deleteSession(subId).catch(() => { });
176
242
  }
177
243
  }
@@ -182,55 +248,136 @@ const CONSOLIDATION_TIMEOUT_MS = 3600_000;
182
248
  export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
183
249
  const agent = "memorize";
184
250
  const subId = await createSession(agent, "codex-memory-consolidate");
251
+ let promptError;
252
+ let promptFailed = false;
185
253
  try {
186
254
  const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
187
255
  // consolidation_model option > opencode model (main) > session default.
188
256
  const resolved = model ?? (await getConfigModels()).model;
189
257
  await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS });
190
258
  }
191
- finally {
192
- void deleteSession(subId).catch(() => { });
259
+ catch (err) {
260
+ promptError = err;
261
+ promptFailed = true;
193
262
  }
263
+ // codex phase2.rs awaits the consolidation agent's shutdown BEFORE artifacts
264
+ // are validated and the job is finished, and a failed shutdown outranks the
265
+ // run result: the caller must keep its lease rather than release it to a
266
+ // worker that could race a consolidator which is still alive. The
267
+ // consolidation agent holds write access to the memory root, so this is the
268
+ // difference between one writer and two.
269
+ if (!(await deleteSession(subId)))
270
+ throw new SubagentShutdownError(subId);
271
+ if (promptFailed)
272
+ throw promptError;
194
273
  }
195
274
  // Must exceed the longest legitimate sub-session lifetime (consolidation may
196
275
  // run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
197
276
  // plugin reload would delete a working sub-session mid-run.
198
- export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
277
+ export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBSESSION_LIST_TIMEOUT_MS) {
199
278
  const input = getPluginInput();
200
279
  if (!input)
201
280
  return;
281
+ let timer;
202
282
  try {
203
- const res = await input.client.session.list();
283
+ if (typeof input.client?.session?.list !== "function")
284
+ return;
285
+ const res = await Promise.race([
286
+ input.client.session.list(),
287
+ new Promise((_, reject) => {
288
+ timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
289
+ }),
290
+ ]);
204
291
  if (!res.data)
205
292
  return;
206
293
  const list = res.data;
207
294
  const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
208
295
  for (const s of list) {
209
- if (s.title && s.title.startsWith("codex-memory-")) {
210
- const created = s.time?.created ?? 0;
211
- if (created && created < cutoff) {
212
- await deleteSession(s.id);
213
- }
296
+ if (!s.id)
297
+ continue;
298
+ const pluginTitle = isPluginSubSessionTitle(s.title);
299
+ const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
300
+ const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
301
+ if (!owned && !legacy)
302
+ continue;
303
+ // Durable ownership requires marker + generated title; a legacy title
304
+ // alone can reseed the skip set but never authorizes deletion.
305
+ activeSubSessions.add(s.id);
306
+ if (!owned)
307
+ continue;
308
+ const created = s.time?.created ?? 0;
309
+ if (created && created < cutoff) {
310
+ void deleteSession(s.id);
214
311
  }
215
312
  }
216
313
  }
217
314
  catch {
218
315
  // best effort only
219
316
  }
317
+ finally {
318
+ clearTimeout(timer);
319
+ }
320
+ }
321
+ function isPluginSubSessionTitle(title) {
322
+ return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
220
323
  }
324
+ /**
325
+ * Closes a sub-session. Returns true when the delete call itself succeeded —
326
+ * the port's equivalent of codex's `shutdown_consolidation_agent` returning Ok
327
+ * (runtime.rs). A false return means the sub-agent may still be running.
328
+ *
329
+ * The 404 confirmation below is a separate, stricter question (is the session
330
+ * really gone?) and only governs ownership tracking, never the shutdown result:
331
+ * hosts without `session.get` would otherwise never report a clean shutdown.
332
+ */
221
333
  async function deleteSession(id) {
222
- activeSubSessions.delete(id);
223
334
  const input = getPluginInput();
224
335
  if (!input)
225
- return;
336
+ return false;
226
337
  try {
227
338
  const res = await input.client.session.delete({ path: { id } });
228
339
  if (res.error) {
229
340
  console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
341
+ return false;
230
342
  }
343
+ // OpenCode's Session.remove logs and swallows some internal failures while
344
+ // the HTTP route still returns success. Only a confirmed 404 proves the
345
+ // session is gone; otherwise retain ownership so hooks keep skipping it.
346
+ // codex runtime.rs drops the thread from its manager the same way: only
347
+ // after shutdown succeeded.
348
+ if (await sessionDeletionConfirmed(input.client, id)) {
349
+ activeSubSessions.delete(id);
350
+ }
351
+ return true;
231
352
  }
232
353
  catch (err) {
233
354
  console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
355
+ return false;
356
+ }
357
+ }
358
+ async function sessionDeletionConfirmed(client, id) {
359
+ const session = client.session;
360
+ if (typeof session?.get !== "function")
361
+ return false;
362
+ const controller = new AbortController();
363
+ let timer;
364
+ try {
365
+ const res = await Promise.race([
366
+ session.get({ path: { id }, signal: controller.signal }),
367
+ new Promise((_, reject) => {
368
+ timer = setTimeout(() => {
369
+ controller.abort();
370
+ reject(new Error(`session.get timed out after ${SUBSESSION_CONFIRM_TIMEOUT_MS}ms`));
371
+ }, SUBSESSION_CONFIRM_TIMEOUT_MS);
372
+ }),
373
+ ]);
374
+ return res?.response?.status === 404;
375
+ }
376
+ catch {
377
+ return false;
378
+ }
379
+ finally {
380
+ clearTimeout(timer);
234
381
  }
235
382
  }
236
383
  // Substitute with a function so `$&`/`$'` sequences in the value are not
@@ -26,5 +26,7 @@ export interface PluginOptionsState {
26
26
  export declare const pluginOptions: PluginOptionsState;
27
27
  export declare function recordConfigWarning(message: string): void;
28
28
  export declare function getConfigWarnings(): readonly string[];
29
+ /** Drop warnings from a previous apply pass (server boot / option re-apply). */
30
+ export declare function clearConfigWarnings(): void;
29
31
  /** Test seam: options/warnings are module state, tests need a clean slate. */
30
32
  export declare function resetConfigWarningsForTest(): void;
@@ -25,7 +25,11 @@ export function recordConfigWarning(message) {
25
25
  export function getConfigWarnings() {
26
26
  return configWarnings;
27
27
  }
28
+ /** Drop warnings from a previous apply pass (server boot / option re-apply). */
29
+ export function clearConfigWarnings() {
30
+ configWarnings.length = 0;
31
+ }
28
32
  /** Test seam: options/warnings are module state, tests need a clean slate. */
29
33
  export function resetConfigWarningsForTest() {
30
- configWarnings.length = 0;
34
+ clearConfigWarnings();
31
35
  }
@@ -1,6 +1,6 @@
1
1
  import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff, validateConsolidationArtifacts, } from "./workspace.js";
2
2
  import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js";
3
- import { consolidateViaSubagent } from "./llm.js";
3
+ import { consolidateViaSubagent, SubagentShutdownError } from "./llm.js";
4
4
  import { invalidateCache } from "./source.js";
5
5
  import { memoryRoot } from "./paths.js";
6
6
  import { checkRateLimit } from "./ratelimit.js";
@@ -105,6 +105,17 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
105
105
  try {
106
106
  await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
107
107
  }
108
+ catch (err) {
109
+ // codex phase2.rs: when the consolidation agent's shutdown fails, keep
110
+ // the existing lease until it expires so another worker cannot race a
111
+ // consolidator whose shutdown has not completed. Neither succeed nor
112
+ // fail the job — marking it failed would release the lease immediately.
113
+ if (err instanceof SubagentShutdownError) {
114
+ console.warn(`[opencode-codex-memory] ${err.message}; holding the phase2 lease until it expires`);
115
+ return { status: "shutdown_failed" };
116
+ }
117
+ throw err;
118
+ }
108
119
  finally {
109
120
  clearInterval(heartbeat);
110
121
  }
@@ -4,5 +4,13 @@ export declare function redact(text: string): string;
4
4
  * injected AGENTS.md instruction blocks and <skill> payloads inside user
5
5
  * content are contextual boilerplate, not conversation — they must not be
6
6
  * mined for memories.
7
+ *
8
+ * NOTE: inert on opencode today, kept for codex parity and future-proofing.
9
+ * opencode delivers both of these through the SYSTEM prompt, never as a user
10
+ * text part: AGENTS.md is joined into `system[0]` and skills are a
11
+ * `<available_skills>` catalog (skill/index.ts `fmt`), so neither shape ever
12
+ * reaches this check. Do not treat it as an active safeguard — the structural
13
+ * filters in capture.ts (`ignored` parts) are what actually exclude
14
+ * non-conversation content on this platform.
7
15
  */
8
16
  export declare function isMemoryExcludedFragment(text: string): boolean;
@@ -10,18 +10,214 @@ const REDACTIONS = [
10
10
  re: /-----BEGIN [A-Z]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z]+ PRIVATE KEY-----/g,
11
11
  replacement: "[REDACTED:private-key]",
12
12
  },
13
- // Optional quotes around the KEY cover JSON/YAML forms like
14
- // "password": "value" — codex's SECRET_ASSIGNMENT_REGEX misses those (it
15
- // allows a quote only before the value); this is a deliberate superset.
16
- { re: /["']?(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token)["']?\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
17
- { re: /["']?(aws_secret_access_key|aws_access_key_id)["']?\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
18
13
  ];
14
+ // Optional quotes around the key cover JSON/YAML forms that codex's bare-key
15
+ // assignment regex misses. Value boundaries are scanned instead of guessed by
16
+ // one regex so escaped strings and nested JSON remain intact.
17
+ const SECRET_ASSIGNMENT_START = /(["']?)(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token|aws_secret_access_key|aws_access_key_id)\1([ \t]*[:=][ \t]*)/gi;
18
+ const JSON_PRIMITIVE = /^(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)(?=\s*[,}\]])/i;
19
19
  export function redact(text) {
20
20
  let out = text;
21
21
  for (const { re, replacement } of REDACTIONS) {
22
22
  out = out.replace(re, replacement);
23
23
  }
24
- return out;
24
+ return redactAssignments(out);
25
+ }
26
+ function redactAssignments(text) {
27
+ let cursor = 0;
28
+ let out = "";
29
+ let flowCursor = 0;
30
+ const matchedOpeners = findMatchedFlowOpeners(text);
31
+ const flowState = { closers: [], quote: null, escaped: false };
32
+ SECRET_ASSIGNMENT_START.lastIndex = 0;
33
+ for (let match = SECRET_ASSIGNMENT_START.exec(text); match; match = SECRET_ASSIGNMENT_START.exec(text)) {
34
+ advanceFlowState(text, flowCursor, match.index, flowState, matchedOpeners);
35
+ flowCursor = match.index;
36
+ const valueStart = SECRET_ASSIGNMENT_START.lastIndex;
37
+ const value = scanAssignmentValue(text, valueStart, match[1], match[3], flowState.closers.length > 0, flowState.quote);
38
+ if (!value)
39
+ continue;
40
+ out += text.slice(cursor, value.start) + value.replacement;
41
+ cursor = value.end;
42
+ SECRET_ASSIGNMENT_START.lastIndex = value.end;
43
+ }
44
+ return out + text.slice(cursor);
45
+ }
46
+ function scanAssignmentValue(text, start, keyQuote, separator, flowCollection, enclosingQuote) {
47
+ let valueStart = start;
48
+ if (enclosingQuote) {
49
+ const end = scanEnclosingQuote(text, valueStart, enclosingQuote);
50
+ return end > valueStart ? { start: valueStart, end, replacement: "[REDACTED]" } : null;
51
+ }
52
+ if (flowCollection && separator.includes(":")) {
53
+ while (/\s/.test(text[valueStart] ?? ""))
54
+ valueStart++;
55
+ }
56
+ const first = text[valueStart];
57
+ if (!first || first === "\r" || first === "\n")
58
+ return null;
59
+ if (first === '"' || first === "'") {
60
+ const end = scanQuoted(text, valueStart, first) ?? plainValueEnd(text, valueStart, flowCollection);
61
+ return end > valueStart ? { start: valueStart, end, replacement: `${first}[REDACTED]${first}` } : null;
62
+ }
63
+ if (first === "{" || first === "[") {
64
+ const end = scanStructuredJson(text, valueStart) ?? plainValueEnd(text, valueStart, flowCollection);
65
+ return end > valueStart ? { start: valueStart, end, replacement: '"[REDACTED]"' } : null;
66
+ }
67
+ if (keyQuote === '"' && separator.includes(":")) {
68
+ const primitive = JSON_PRIMITIVE.exec(text.slice(valueStart));
69
+ if (primitive)
70
+ return { start: valueStart, end: valueStart + primitive[0].length, replacement: '"[REDACTED]"' };
71
+ }
72
+ const end = plainValueEnd(text, valueStart, flowCollection);
73
+ return end > valueStart ? { start: valueStart, end, replacement: '"[REDACTED]"' } : null;
74
+ }
75
+ function scanEnclosingQuote(text, start, quote) {
76
+ for (let i = start; i < text.length; i++) {
77
+ if (quote === '"' && text[i] === "\\") {
78
+ i++;
79
+ continue;
80
+ }
81
+ if (text[i] !== quote)
82
+ continue;
83
+ if (quote === "'" && text[i + 1] === "'") {
84
+ i++;
85
+ continue;
86
+ }
87
+ return i;
88
+ }
89
+ return plainValueEnd(text, start, false);
90
+ }
91
+ function scanQuoted(text, start, quote) {
92
+ for (let i = start + 1; i < text.length; i++) {
93
+ if (quote === '"' && text[i] === "\\") {
94
+ i++;
95
+ continue;
96
+ }
97
+ if (text[i] !== quote)
98
+ continue;
99
+ if (quote === "'" && text[i + 1] === "'") {
100
+ i++;
101
+ continue;
102
+ }
103
+ return i + 1;
104
+ }
105
+ return null;
106
+ }
107
+ function scanStructuredJson(text, start) {
108
+ const closers = [text[start] === "{" ? "}" : "]"];
109
+ let quote = null;
110
+ for (let i = start + 1; i < text.length; i++) {
111
+ const char = text[i];
112
+ if (quote) {
113
+ if (quote === '"' && char === "\\")
114
+ i++;
115
+ else if (char === quote) {
116
+ if (quote === "'" && text[i + 1] === "'")
117
+ i++;
118
+ else
119
+ quote = null;
120
+ }
121
+ continue;
122
+ }
123
+ if (char === '"' || char === "'") {
124
+ quote = char;
125
+ continue;
126
+ }
127
+ if (char === "{")
128
+ closers.push("}");
129
+ else if (char === "[")
130
+ closers.push("]");
131
+ else if (char === closers[closers.length - 1]) {
132
+ closers.pop();
133
+ if (closers.length === 0)
134
+ return i + 1;
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+ function findMatchedFlowOpeners(text) {
140
+ const matched = new Set();
141
+ const stack = [];
142
+ let quote = null;
143
+ let escaped = false;
144
+ for (let i = 0; i < text.length; i++) {
145
+ const char = text[i];
146
+ if (quote) {
147
+ if (escaped)
148
+ escaped = false;
149
+ else if (quote === '"' && char === "\\")
150
+ escaped = true;
151
+ else if (char === quote) {
152
+ if (quote === "'" && text[i + 1] === "'")
153
+ i++;
154
+ else
155
+ quote = null;
156
+ }
157
+ continue;
158
+ }
159
+ if (char === '"' || char === "'")
160
+ quote = char;
161
+ else if (char === "{")
162
+ stack.push({ index: i, closer: "}" });
163
+ else if (char === "[")
164
+ stack.push({ index: i, closer: "]" });
165
+ else if (char === stack[stack.length - 1]?.closer) {
166
+ const opener = stack.pop();
167
+ if (opener)
168
+ matched.add(opener.index);
169
+ }
170
+ }
171
+ return matched;
172
+ }
173
+ function advanceFlowState(text, start, end, state, matchedOpeners) {
174
+ for (let i = start; i < end; i++) {
175
+ const char = text[i];
176
+ if (state.quote) {
177
+ if (state.escaped) {
178
+ state.escaped = false;
179
+ }
180
+ else if (state.quote === '"' && char === "\\") {
181
+ state.escaped = true;
182
+ }
183
+ else if (char === state.quote) {
184
+ if (state.quote === "'" && text[i + 1] === "'")
185
+ i++;
186
+ else
187
+ state.quote = null;
188
+ }
189
+ continue;
190
+ }
191
+ if (char === '"' || char === "'")
192
+ state.quote = char;
193
+ else if (char === "{" && matchedOpeners.has(i))
194
+ state.closers.push("}");
195
+ else if (char === "[" && matchedOpeners.has(i))
196
+ state.closers.push("]");
197
+ else if (char === state.closers[state.closers.length - 1])
198
+ state.closers.pop();
199
+ }
200
+ }
201
+ function plainValueEnd(text, start, flowCollection) {
202
+ let end = text.length;
203
+ for (let i = start; i < text.length; i++) {
204
+ const char = text[i];
205
+ if (char === "\n") {
206
+ end = i;
207
+ break;
208
+ }
209
+ if (flowCollection && (char === "," || char === "}" || char === "]")) {
210
+ end = i;
211
+ break;
212
+ }
213
+ if (char === "#" && i > start && /\s/.test(text[i - 1])) {
214
+ end = i;
215
+ break;
216
+ }
217
+ }
218
+ while (end > start && /\s/.test(text[end - 1]))
219
+ end--;
220
+ return end;
25
221
  }
26
222
  function matchesMarkedFragment(text, startMarker, endMarker) {
27
223
  const trimmed = text.trim();
@@ -33,6 +229,14 @@ function matchesMarkedFragment(text, startMarker, endMarker) {
33
229
  * injected AGENTS.md instruction blocks and <skill> payloads inside user
34
230
  * content are contextual boilerplate, not conversation — they must not be
35
231
  * mined for memories.
232
+ *
233
+ * NOTE: inert on opencode today, kept for codex parity and future-proofing.
234
+ * opencode delivers both of these through the SYSTEM prompt, never as a user
235
+ * text part: AGENTS.md is joined into `system[0]` and skills are a
236
+ * `<available_skills>` catalog (skill/index.ts `fmt`), so neither shape ever
237
+ * reaches this check. Do not treat it as an active safeguard — the structural
238
+ * filters in capture.ts (`ignored` parts) are what actually exclude
239
+ * non-conversation content on this platform.
36
240
  */
37
241
  export function isMemoryExcludedFragment(text) {
38
242
  return (matchesMarkedFragment(text, "# AGENTS.md instructions", "</INSTRUCTIONS>") ||
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { memorySummaryPath, memoryRoot } from "./paths.js";
3
+ import { memoryRoot } from "./paths.js";
4
+ import { assertMemoryRootSafe, safeResolveMemoryPath } from "./path-guard.js";
4
5
  import { truncateToTokens } from "./token.js";
5
6
  import { fillTemplate } from "./llm.js";
6
7
  const MEMORY_SUMMARY_TOKEN_LIMIT = 2500;
@@ -30,22 +31,36 @@ function readTemplate() {
30
31
  return fs.readFileSync(templatePath, "utf8");
31
32
  }
32
33
  function readMemorySummary() {
33
- const summaryPath = memorySummaryPath();
34
- if (!fs.existsSync(summaryPath))
35
- return null;
36
- const stat = fs.statSync(summaryPath);
37
- if (cached && cached.mtime === stat.mtimeMs) {
38
- return cached.content;
34
+ let summaryPath;
35
+ let fd;
36
+ try {
37
+ // Use the same component-by-component symlink refusal as the memory tools:
38
+ // neither the root nor memory_summary.md may redirect outside the workspace.
39
+ summaryPath = safeResolveMemoryPath("memory_summary.md");
40
+ fd = fs.openSync(summaryPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
41
+ const stat = fs.fstatSync(fd);
42
+ if (!stat.isFile())
43
+ return null;
44
+ if (cached && cached.mtime === stat.mtimeMs) {
45
+ return cached.content;
46
+ }
47
+ const raw = fs.readFileSync(fd, "utf8").trim();
48
+ if (!raw)
49
+ return null;
50
+ const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT);
51
+ cached = {
52
+ content: truncated,
53
+ mtime: stat.mtimeMs,
54
+ };
55
+ return truncated;
39
56
  }
40
- const raw = fs.readFileSync(summaryPath, "utf8").trim();
41
- if (!raw)
57
+ catch {
42
58
  return null;
43
- const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT);
44
- cached = {
45
- content: truncated,
46
- mtime: stat.mtimeMs,
47
- };
48
- return truncated;
59
+ }
60
+ finally {
61
+ if (fd !== undefined)
62
+ fs.closeSync(fd);
63
+ }
49
64
  }
50
65
  export function invalidateCache() {
51
66
  cached = null;
@@ -64,5 +79,6 @@ export function buildMemorySystemPrompt(dedicatedTools) {
64
79
  });
65
80
  }
66
81
  export function ensureMemoryLayout() {
67
- fs.mkdirSync(memoryRoot(), { recursive: true });
82
+ const root = assertMemoryRootSafe();
83
+ fs.mkdirSync(root, { recursive: true });
68
84
  }
package/dist/src/store.js CHANGED
@@ -81,10 +81,16 @@ export class MemoryStore {
81
81
  recordUsage(sessionIds) {
82
82
  if (sessionIds.length === 0)
83
83
  return;
84
+ // One transaction for the whole batch (codex record_stage1_output_usage).
85
+ // .immediate() like every other write transaction here: take the write
86
+ // lock up front so busy_timeout applies instead of risking a mid-txn
87
+ // upgrade failure under cross-process access.
84
88
  const ts = now();
85
89
  const stmt = this.db.prepare("UPDATE memory_stage1_outputs SET usage_count = usage_count + 1, last_usage = ? WHERE session_id = ?");
86
- for (const id of sessionIds)
87
- stmt.run(ts, id);
90
+ this.db.transaction(() => {
91
+ for (const id of sessionIds)
92
+ stmt.run(ts, id);
93
+ }).immediate();
88
94
  }
89
95
  claimStage1Jobs(sessions, excludeSession, maxClaimed) {
90
96
  const workerId = newId();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",