opencode-codex-memory 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/index.js CHANGED
@@ -7,13 +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
- // Configured MCP server names, fetched lazily; null until first successful fetch.
16
- let mcpServerNames = null;
15
+ // Single-flight guard for mcp.status(); see mcpToolPrefixes below.
16
+ let mcpStatusInFlight = null;
17
+ const MCP_STATUS_TIMEOUT_MS = 1_000;
17
18
  // Deliberately uncached: openDb() is already a singleton, and caching a store
18
19
  // here would hold a stale handle across closeDb() (e.g. after memory_reset).
19
20
  function getStore() {
@@ -25,17 +26,25 @@ function getStore() {
25
26
  // recorded per part to count each citation exactly once across both paths.
26
27
  const recordedCitations = new Map();
27
28
  const MAX_TRACKED_PARTS = 500;
28
- export function takeNewCitations(partKey, ids) {
29
- let seen = recordedCitations.get(partKey);
30
- if (!seen) {
31
- seen = new Set();
32
- recordedCitations.set(partKey, seen);
33
- if (recordedCitations.size > MAX_TRACKED_PARTS) {
34
- const oldest = recordedCitations.keys().next().value;
35
- if (oldest !== undefined)
36
- recordedCitations.delete(oldest);
37
- }
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);
38
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);
39
48
  const fresh = ids.filter((id) => !seen.has(id));
40
49
  for (const id of fresh)
41
50
  seen.add(id);
@@ -43,19 +52,13 @@ export function takeNewCitations(partKey, ids) {
43
52
  }
44
53
  // One stamp+pump per session per process from the chat.message hook; later
45
54
  // messages in the same session add nothing (stamp is idempotent, the pump
46
- // re-fires on idle anyway).
47
- const seenTurnSessions = new Set();
55
+ // re-fires on idle anyway). Value = first-seen timestamp (debugging only).
56
+ const seenTurnSessions = new Map();
48
57
  const MAX_TRACKED_TURN_SESSIONS = 1000;
49
58
  export function markTurnSeen(sessionId) {
50
- if (seenTurnSessions.has(sessionId))
51
- return false;
52
- seenTurnSessions.add(sessionId);
53
- if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
54
- const oldest = seenTurnSessions.keys().next().value;
55
- if (oldest !== undefined)
56
- seenTurnSessions.delete(oldest);
57
- }
58
- return true;
59
+ const first = seenTurnSessions.get(sessionId);
60
+ lruSet(seenTurnSessions, sessionId, first ?? Date.now(), MAX_TRACKED_TURN_SESSIONS);
61
+ return first === undefined;
59
62
  }
60
63
  // opencode 1.17 publishes BOTH session.status {type:"idle"} and the
61
64
  // deprecated session.idle for the same transition, back to back. Handle
@@ -65,15 +68,11 @@ const IDLE_DEDUP_MS = 5000;
65
68
  const MAX_TRACKED_IDLE = 500;
66
69
  export function shouldHandleIdle(sessionId, now = Date.now()) {
67
70
  const last = recentIdle.get(sessionId);
68
- if (last !== undefined && now - last < IDLE_DEDUP_MS)
69
- return false;
70
- recentIdle.set(sessionId, now);
71
- if (recentIdle.size > MAX_TRACKED_IDLE) {
72
- const oldest = recentIdle.keys().next().value;
73
- if (oldest !== undefined)
74
- recentIdle.delete(oldest);
75
- }
76
- 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;
77
76
  }
78
77
  export function handleSessionDeleted(sessionId, store = getStore(),
79
78
  // With generation off the memorize agent is not injected, so a consolidation
@@ -90,9 +89,16 @@ export default {
90
89
  async server(input, opts) {
91
90
  setPluginInput(input);
92
91
  pluginClient = input.client;
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();
93
97
  if (opts)
94
98
  applyPluginOptions(opts);
95
- void cleanupOldSubSessions().catch(() => { });
99
+ // Finish bounded reseeding before hooks can see a surviving memory
100
+ // sub-session after a plugin reload.
101
+ await cleanupOldSubSessions();
96
102
  return buildHooks();
97
103
  },
98
104
  };
@@ -119,6 +125,9 @@ function clampInt(value, min, max, fallback) {
119
125
  return Math.min(max, Math.max(min, Math.floor(value)));
120
126
  }
121
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();
122
131
  for (const key of Object.keys(opts)) {
123
132
  if (!KNOWN_OPTION_KEYS.has(key)) {
124
133
  // codex uses deny_unknown_fields; a plugin can only warn (recorded for
@@ -168,27 +177,68 @@ export function applyPluginOptions(opts) {
168
177
  * codex marks every MCP server as memory-polluting unconditionally
169
178
  * (codex-mcp server.rs pollutes_memory: true). opencode registers MCP tools
170
179
  * as "<server>_<tool>", so match tool names against the configured server
171
- * list. Fails closed to the web-tools-only check when the list is unavailable.
180
+ * list. Query live status so runtime MCP changes cannot escape pollution
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.
172
190
  */
173
- async function isExternalContextTool(toolName) {
174
- if (toolName === "websearch" || toolName === "webfetch")
175
- return true;
176
- if (!mcpServerNames && pluginClient) {
177
- try {
178
- const res = await pluginClient.mcp.status();
179
- const servers = res?.data ?? res;
180
- if (servers && typeof servers === "object") {
181
- mcpServerNames = new Set(Object.keys(servers));
191
+ async function mcpToolPrefixes() {
192
+ if (!pluginClient)
193
+ return null;
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;
182
221
  }
183
- }
184
- catch {
185
- // MCP status unavailable (older opencode); keep web-tools-only checks.
186
- }
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
+ })();
187
231
  }
188
- if (!mcpServerNames)
189
- return false;
190
- for (const server of mcpServerNames) {
191
- if (toolName.startsWith(`${server}_`))
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)
239
+ return null;
240
+ for (const prefix of prefixes) {
241
+ if (toolName.startsWith(`${prefix}_`))
192
242
  return true;
193
243
  }
194
244
  return false;
@@ -247,7 +297,9 @@ function buildHooks() {
247
297
  try {
248
298
  if (!pluginOptions.use_memories)
249
299
  return;
250
- if (input.sessionID && isMemorySubSession(input.sessionID))
300
+ // OpenCode also invokes this hook while generating agent definitions,
301
+ // without a session. Memory belongs only in real conversation prompts.
302
+ if (!input.sessionID || isMemorySubSession(input.sessionID))
251
303
  return;
252
304
  ensureMemoryLayout();
253
305
  const memoryPrompt = buildMemorySystemPrompt(pluginOptions.dedicated_tools);
@@ -343,20 +395,34 @@ function buildHooks() {
343
395
  console.error("[opencode-codex-memory] chat.message error:", err);
344
396
  }
345
397
  },
346
- // Dedicated plugin hook (NOT an event-bus type): fires after every tool
347
- // call. Mirrors codex: external context (web search or any MCP tool) only
348
- // pollutes the session's memory when disable_on_external_context is
349
- // enabled. Off by default.
350
- async "tool.execute.after"(input) {
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
+ */
414
+ async "tool.execute.before"(input) {
351
415
  try {
352
- if (!pluginOptions.disable_on_external_context)
416
+ if (!pluginOptions.disable_on_external_context || !input.sessionID)
353
417
  return;
354
- if (input.sessionID && (await isExternalContextTool(input.tool))) {
355
- getStore().markPolluted(input.sessionID);
356
- }
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)
421
+ return;
422
+ getStore().markPolluted(input.sessionID);
357
423
  }
358
424
  catch (err) {
359
- console.error("[opencode-codex-memory] tool.execute.after error:", err);
425
+ console.error("[opencode-codex-memory] tool.execute.before error:", err);
360
426
  }
361
427
  },
362
428
  async event(input) {
package/dist/src/llm.d.ts CHANGED
@@ -7,14 +7,24 @@ 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
+ }
10
18
  export interface ExtractOptions {
11
19
  cwd?: string;
12
20
  model?: string;
21
+ /** Override the default 1h extract timeout (tests / advanced). */
22
+ timeoutMs?: number;
13
23
  }
14
24
  /** Returns null when the extractor reported a no-op (nothing worth remembering). */
15
25
  export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
16
26
  export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
17
- export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
27
+ export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
18
28
  export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
19
29
  export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
20
30
  /**
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,42 @@ 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
+ async function abortSession(sessionId) {
85
+ const input = getPluginInput();
86
+ const session = input?.client?.session;
87
+ if (typeof session?.abort !== "function")
88
+ return;
89
+ const controller = new AbortController();
90
+ let timer;
91
+ try {
92
+ await Promise.race([
93
+ session.abort({ path: { id: sessionId }, signal: controller.signal }),
94
+ new Promise((_, reject) => {
95
+ timer = setTimeout(() => {
96
+ controller.abort();
97
+ reject(new Error(`session.abort timed out after ${SUBSESSION_ABORT_TIMEOUT_MS}ms`));
98
+ }, SUBSESSION_ABORT_TIMEOUT_MS);
99
+ }),
100
+ ]);
101
+ }
102
+ catch {
103
+ // Best-effort: deleteSession is the backup cancel path.
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ }
108
+ }
66
109
  /** Runs a sub-agent prompt and returns the raw response data (`{ info, parts }`). */
67
110
  async function runPrompt(sessionId, prompt, agent, opts = {}) {
68
111
  const timeoutMs = opts.timeoutMs ?? 300_000;
@@ -87,13 +130,27 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
87
130
  const res = await Promise.race([
88
131
  promptPromise,
89
132
  new Promise((_, reject) => {
90
- timer = setTimeout(() => reject(new Error(`sub-agent prompt timed out after ${timeoutMs}ms`)), timeoutMs);
133
+ timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
91
134
  }),
92
135
  ]);
93
136
  if (!res.data)
94
137
  throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
138
+ const promptError = res.data.info?.error;
139
+ if (promptError) {
140
+ const detail = promptError.data?.message;
141
+ throw new Error(`sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}${detail ? `: ${detail}` : ""}`);
142
+ }
95
143
  return res.data;
96
144
  }
145
+ catch (err) {
146
+ // Only a timeout leaves the turn running server-side; every other failure
147
+ // here means the request already settled. Stop the run so tokens stop
148
+ // burning — deleteSession in the caller finally is the backup.
149
+ if (err instanceof SubagentTimeoutError) {
150
+ await abortSession(sessionId);
151
+ }
152
+ throw err;
153
+ }
97
154
  finally {
98
155
  clearTimeout(timer);
99
156
  }
@@ -148,7 +205,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
148
205
  // Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
149
206
  // and a near-600k-char transcript on a slow model can easily exceed a
150
207
  // short one — repeated timeouts would exhaust the job's retries.
151
- timeoutMs: 3600_000,
208
+ timeoutMs: opts.timeoutMs ?? 3600_000,
152
209
  system: readTemplate("stage_one_system.md"),
153
210
  model,
154
211
  // opencode enforces json_schema output via a forced StructuredOutput tool
@@ -190,31 +247,54 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
190
247
  // Must exceed the longest legitimate sub-session lifetime (consolidation may
191
248
  // run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
192
249
  // plugin reload would delete a working sub-session mid-run.
193
- export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
250
+ export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBSESSION_LIST_TIMEOUT_MS) {
194
251
  const input = getPluginInput();
195
252
  if (!input)
196
253
  return;
254
+ let timer;
197
255
  try {
198
- const res = await input.client.session.list();
256
+ if (typeof input.client?.session?.list !== "function")
257
+ return;
258
+ const res = await Promise.race([
259
+ input.client.session.list(),
260
+ new Promise((_, reject) => {
261
+ timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
262
+ }),
263
+ ]);
199
264
  if (!res.data)
200
265
  return;
201
266
  const list = res.data;
202
267
  const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
203
268
  for (const s of list) {
204
- if (s.title && s.title.startsWith("codex-memory-")) {
205
- const created = s.time?.created ?? 0;
206
- if (created && created < cutoff) {
207
- await deleteSession(s.id);
208
- }
269
+ if (!s.id)
270
+ continue;
271
+ const pluginTitle = isPluginSubSessionTitle(s.title);
272
+ const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
273
+ const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
274
+ if (!owned && !legacy)
275
+ continue;
276
+ // Durable ownership requires marker + generated title; a legacy title
277
+ // alone can reseed the skip set but never authorizes deletion.
278
+ activeSubSessions.add(s.id);
279
+ if (!owned)
280
+ continue;
281
+ const created = s.time?.created ?? 0;
282
+ if (created && created < cutoff) {
283
+ void deleteSession(s.id);
209
284
  }
210
285
  }
211
286
  }
212
287
  catch {
213
288
  // best effort only
214
289
  }
290
+ finally {
291
+ clearTimeout(timer);
292
+ }
293
+ }
294
+ function isPluginSubSessionTitle(title) {
295
+ return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
215
296
  }
216
297
  async function deleteSession(id) {
217
- activeSubSessions.delete(id);
218
298
  const input = getPluginInput();
219
299
  if (!input)
220
300
  return;
@@ -222,12 +302,44 @@ async function deleteSession(id) {
222
302
  const res = await input.client.session.delete({ path: { id } });
223
303
  if (res.error) {
224
304
  console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
305
+ return;
306
+ }
307
+ // OpenCode's Session.remove logs and swallows some internal failures while
308
+ // the HTTP route still returns success. Only a confirmed 404 proves the
309
+ // session is gone; otherwise retain ownership so hooks keep skipping it.
310
+ if (await sessionDeletionConfirmed(input.client, id)) {
311
+ activeSubSessions.delete(id);
225
312
  }
226
313
  }
227
314
  catch (err) {
228
315
  console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
229
316
  }
230
317
  }
318
+ async function sessionDeletionConfirmed(client, id) {
319
+ const session = client.session;
320
+ if (typeof session?.get !== "function")
321
+ return false;
322
+ const controller = new AbortController();
323
+ let timer;
324
+ try {
325
+ const res = await Promise.race([
326
+ session.get({ path: { id }, signal: controller.signal }),
327
+ new Promise((_, reject) => {
328
+ timer = setTimeout(() => {
329
+ controller.abort();
330
+ reject(new Error(`session.get timed out after ${SUBSESSION_CONFIRM_TIMEOUT_MS}ms`));
331
+ }, SUBSESSION_CONFIRM_TIMEOUT_MS);
332
+ }),
333
+ ]);
334
+ return res?.response?.status === 404;
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ finally {
340
+ clearTimeout(timer);
341
+ }
342
+ }
231
343
  // Substitute with a function so `$&`/`$'` sequences in the value are not
232
344
  // expanded as String.replace replacement patterns.
233
345
  export function fillTemplate(tmpl, vars) {
@@ -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
  }
@@ -17,3 +17,5 @@
17
17
  */
18
18
  export declare function assertMemoryRootSafe(): string;
19
19
  export declare function safeResolveMemoryPath(rel: string): string;
20
+ /** Resolve a relative path under an arbitrary trusted root without following symlinks. */
21
+ export declare function safeResolveUnderRoot(root: string, rel: string): string;
@@ -34,9 +34,26 @@ export function assertMemoryRootSafe() {
34
34
  }
35
35
  export function safeResolveMemoryPath(rel) {
36
36
  const root = assertMemoryRootSafe();
37
+ return safeResolveUnderRoot(root, rel);
38
+ }
39
+ /** Resolve a relative path under an arbitrary trusted root without following symlinks. */
40
+ export function safeResolveUnderRoot(root, rel) {
37
41
  if (path.isAbsolute(rel)) {
38
42
  throw new Error(`path escapes memory root: ${rel}`);
39
43
  }
44
+ try {
45
+ const rootStat = fs.lstatSync(root);
46
+ if (rootStat.isSymbolicLink()) {
47
+ throw new Error(`root is a symlink; refusing write: ${root}`);
48
+ }
49
+ if (!rootStat.isDirectory()) {
50
+ throw new Error(`root is not a directory: ${root}`);
51
+ }
52
+ }
53
+ catch (err) {
54
+ if (err.code !== "ENOENT")
55
+ throw err;
56
+ }
40
57
  const parts = rel.split(/[\\/]+/).filter((p) => p.length > 0 && p !== ".");
41
58
  let current = root;
42
59
  for (const part of parts) {
@@ -64,7 +64,7 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
64
64
  });
65
65
  }
66
66
  catch (err) {
67
- store.markStage1Failed(sid, claim.ownershipToken, err.message);
67
+ store.markStage1Failed(sid, claim.ownershipToken, err);
68
68
  }
69
69
  });
70
70
  }
@@ -1,4 +1,5 @@
1
1
  import { MemoryStore } from "./store.js";
2
+ import { checkRateLimit } from "./ratelimit.js";
2
3
  import { type CodexInteropOptions } from "./codex-interop.js";
3
4
  export interface Phase2Options {
4
5
  maxRaw: number;
@@ -10,6 +11,6 @@ export interface Phase2Options {
10
11
  export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
11
12
  /** True while THIS process runs a consolidation (memory_reset refuses then). */
12
13
  export declare function isPhase2InFlight(): boolean;
13
- export declare function runPhase2(store: MemoryStore, opts?: Phase2Options): Promise<{
14
+ export declare function runPhase2(store: MemoryStore, opts?: Phase2Options, rateLimitCheck?: typeof checkRateLimit): Promise<{
14
15
  status: string;
15
16
  }>;
@@ -27,21 +27,23 @@ let phase2InFlight = false;
27
27
  export function isPhase2InFlight() {
28
28
  return phase2InFlight;
29
29
  }
30
- export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
30
+ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitCheck = checkRateLimit) {
31
31
  if (phase2InFlight)
32
32
  return { status: "already_running" };
33
33
  phase2InFlight = true;
34
34
  try {
35
- const rl = await checkRateLimit("phase2");
35
+ const rl = await rateLimitCheck("phase2");
36
36
  if (!rl.ok)
37
37
  return { status: "skipped_rate_limit" };
38
38
  const claim = store.claimGlobalPhase2Job();
39
39
  if (claim.type !== "claimed")
40
40
  return { status: claim.type };
41
- // Resolved once per claimed job (not per attempt): resolution warns on
42
- // misconfiguration, and warning on every skipped attempt would be noise.
43
- const interop = opts.codexInterop ? resolveCodexInterop(opts.codexInterop) : null;
44
41
  try {
42
+ // Resolved once per claimed job (not per attempt): resolution warns on
43
+ // misconfiguration, and warning on every skipped attempt would be noise.
44
+ // Keep this inside the claimed-job try so resolution failures release
45
+ // the lease instead of leaving the row running until it expires.
46
+ const interop = opts.codexInterop ? resolveCodexInterop(opts.codexInterop) : null;
45
47
  ensureLayout();
46
48
  // Preserves an existing baseline (only initializes a missing one): the
47
49
  // diff below must span last-successful-run -> now so user edits and
@@ -100,10 +102,8 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
100
102
  console.warn("[opencode-codex-memory] phase2 heartbeat error:", err);
101
103
  }
102
104
  }, 90_000);
103
- let agentCompleted = false;
104
105
  try {
105
106
  await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
106
- agentCompleted = true;
107
107
  }
108
108
  finally {
109
109
  clearInterval(heartbeat);
@@ -118,10 +118,6 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
118
118
  store.markPhase2Failed(claim.ownershipToken, "ownership lost");
119
119
  return { status: "heartbeat_lost" };
120
120
  }
121
- if (!agentCompleted) {
122
- store.markPhase2Failed(claim.ownershipToken, "failed_agent");
123
- return { status: "failed_agent" };
124
- }
125
121
  // codex failed_invalid_artifacts: do not reset baseline on bad output so
126
122
  // the next run still sees a diff / can re-INIT.
127
123
  const artifacts = validateConsolidationArtifacts();
@@ -139,7 +135,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
139
135
  return { status: "succeeded" };
140
136
  }
141
137
  catch (err) {
142
- store.markPhase2Failed(claim.ownershipToken, err.message);
138
+ store.markPhase2Failed(claim.ownershipToken, err);
143
139
  return { status: "failed" };
144
140
  }
145
141
  }
@@ -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;