opencode-codex-memory 0.4.3 → 0.4.4

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
@@ -50,7 +50,7 @@ If you want the mental model before the details, jump to
50
50
 
51
51
  ```json
52
52
  {
53
- "plugin": ["opencode-codex-memory@0.4.3"]
53
+ "plugin": ["opencode-codex-memory@0.4.4"]
54
54
  }
55
55
  ```
56
56
 
@@ -237,7 +237,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
237
237
  ```json
238
238
  {
239
239
  "plugin": [
240
- ["opencode-codex-memory@0.4.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
240
+ ["opencode-codex-memory@0.4.4", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
241
241
  ]
242
242
  }
243
243
  ```
@@ -296,7 +296,7 @@ directions:
296
296
  ```json
297
297
  {
298
298
  "plugin": [
299
- ["opencode-codex-memory@0.4.3", { "codex_interop": { "import": true, "export": true } }]
299
+ ["opencode-codex-memory@0.4.4", { "codex_interop": { "import": true, "export": true } }]
300
300
  ]
301
301
  }
302
302
  ```
@@ -2,7 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import os from "os";
4
4
  import { memoryRoot } from "./paths.js";
5
- import { safeResolveUnderRoot } from "./path-guard.js";
5
+ import { readRegularFileNoFollow, safeResolveUnderRoot, writeRegularFileNoFollow } from "./path-guard.js";
6
6
  /**
7
7
  * Codex interop: memory exchange with an upstream Codex CLI installation on
8
8
  * the same machine, in both directions, through the generic extensions
@@ -202,10 +202,7 @@ export function resolveCodexInterop(opts) {
202
202
  }
203
203
  function readIfFile(file) {
204
204
  try {
205
- const st = fs.lstatSync(file);
206
- if (!st.isFile())
207
- return null;
208
- return fs.readFileSync(file);
205
+ return readRegularFileNoFollow(file).content;
209
206
  }
210
207
  catch {
211
208
  return null;
@@ -226,7 +223,7 @@ function writeIfChanged(file, content) {
226
223
  }
227
224
  catch { }
228
225
  fs.mkdirSync(path.dirname(file), { recursive: true });
229
- fs.writeFileSync(file, next, { flag: "w" });
226
+ writeRegularFileNoFollow(file, next);
230
227
  return true;
231
228
  }
232
229
  /**
package/dist/src/db.js CHANGED
@@ -68,13 +68,17 @@ function runMigrations(db) {
68
68
  version INTEGER NOT NULL,
69
69
  applied_at INTEGER NOT NULL
70
70
  )`);
71
- const current = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get();
72
- const currentVersion = current?.version ?? 0;
73
- if (currentVersion >= 1)
74
- return;
75
- for (const stmt of SCHEMA_V1)
76
- db.run(stmt);
77
- db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(1, Date.now());
71
+ db.transaction(() => {
72
+ // Read the version only after taking the write lock so concurrent plugin
73
+ // instances cannot both apply the same ALTER TABLE.
74
+ const current = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get();
75
+ const currentVersion = current?.version ?? 0;
76
+ if (currentVersion < 1) {
77
+ for (const stmt of SCHEMA_V1)
78
+ db.run(stmt);
79
+ db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(1, Date.now());
80
+ }
81
+ }).immediate();
78
82
  }
79
83
  export function closeDb() {
80
84
  if (dbInstance) {
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import { memoryRoot } from "./paths.js";
4
4
  import * as isogit from "isomorphic-git";
5
5
  import { createPatch } from "diff";
6
+ import { readRegularFileNoFollow, safeResolveUnderRoot } from "./path-guard.js";
6
7
  const AUTHOR = { name: "opencode-codex-memory", email: "memory@opencode.local" };
7
8
  // Generated prompt artifact; removed before diffing and before baseline
8
9
  // commits (mirrors codex's remove_workspace_diff) so it never enters the
@@ -120,12 +121,8 @@ async function readBaselineText(dir, headOid, filepath) {
120
121
  }
121
122
  }
122
123
  function readWorkdirText(dir, filepath) {
123
- try {
124
- return fs.readFileSync(path.join(dir, filepath), "utf8");
125
- }
126
- catch {
127
- return "";
128
- }
124
+ const file = safeResolveUnderRoot(dir, filepath);
125
+ return readRegularFileNoFollow(file).content.toString("utf8");
129
126
  }
130
127
  // Throws on failure: codex fails the phase-2 job on workspace-status errors
131
128
  // (failed_workspace_status). Swallowing the error here would make an errored
package/dist/src/index.js CHANGED
@@ -7,7 +7,7 @@ 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, clearConfigWarnings } from "./options.js";
10
+ import { pluginOptions, recordConfigWarning, clearConfigWarnings, resetPluginOptions } from "./options.js";
11
11
  import fs from "fs";
12
12
  import path from "path";
13
13
  let phase1InFlight = false;
@@ -96,6 +96,8 @@ export default {
96
96
  clearConfigWarnings();
97
97
  if (opts)
98
98
  applyPluginOptions(opts);
99
+ else
100
+ resetPluginOptions();
99
101
  // Finish bounded reseeding before hooks can see a surviving memory
100
102
  // sub-session after a plugin reload.
101
103
  await cleanupOldSubSessions();
@@ -116,18 +118,23 @@ const KNOWN_OPTION_KEYS = new Set([
116
118
  "min_rollout_idle_hours",
117
119
  "codex_interop",
118
120
  ]);
121
+ const KNOWN_CODEX_INTEROP_KEYS = new Set(["import", "export", "codex_home"]);
119
122
  // codex clamps numeric knobs in From<MemoriesToml> for MemoriesConfig
120
123
  // (config/src/types.rs); mirror the exact ranges. Non-finite values fall back
121
124
  // to the default.
122
- function clampInt(value, min, max, fallback) {
123
- if (typeof value !== "number" || !Number.isFinite(value))
125
+ function clampInt(key, value, min, max, fallback) {
126
+ if (typeof value !== "number" || !Number.isFinite(value)) {
127
+ recordConfigWarning(`${key} must be a finite number; using default ${fallback}`);
124
128
  return fallback;
129
+ }
125
130
  return Math.min(max, Math.max(min, Math.floor(value)));
126
131
  }
127
132
  export function applyPluginOptions(opts) {
128
133
  // Fresh pass per apply so memory_inspect never shows warnings for keys the
129
134
  // caller has since fixed. server() clears too, for boots without options.
130
135
  clearConfigWarnings();
136
+ resetPluginOptions();
137
+ const raw = opts;
131
138
  for (const key of Object.keys(opts)) {
132
139
  if (!KNOWN_OPTION_KEYS.has(key)) {
133
140
  // codex uses deny_unknown_fields; a plugin can only warn (recorded for
@@ -136,32 +143,50 @@ export function applyPluginOptions(opts) {
136
143
  recordConfigWarning(`unknown/unsupported option '${key}' ignored`);
137
144
  }
138
145
  }
139
- if (typeof opts.generate_memories === "boolean")
140
- pluginOptions.generate_memories = opts.generate_memories;
141
- if (typeof opts.use_memories === "boolean")
142
- pluginOptions.use_memories = opts.use_memories;
143
- if (typeof opts.dedicated_tools === "boolean")
144
- pluginOptions.dedicated_tools = opts.dedicated_tools;
145
- if (typeof opts.disable_on_external_context === "boolean")
146
- pluginOptions.disable_on_external_context = opts.disable_on_external_context;
147
- if (typeof opts.extract_model === "string")
148
- pluginOptions.extract_model = opts.extract_model;
149
- if (typeof opts.consolidation_model === "string")
150
- pluginOptions.consolidation_model = opts.consolidation_model;
146
+ for (const key of ["generate_memories", "use_memories", "dedicated_tools", "disable_on_external_context"]) {
147
+ if (!(key in raw))
148
+ continue;
149
+ if (typeof raw[key] === "boolean")
150
+ pluginOptions[key] = raw[key];
151
+ else
152
+ recordConfigWarning(`${key} must be a boolean; using default ${pluginOptions[key]}`);
153
+ }
154
+ for (const key of ["extract_model", "consolidation_model"]) {
155
+ if (!(key in raw))
156
+ continue;
157
+ if (typeof raw[key] === "string")
158
+ pluginOptions[key] = raw[key];
159
+ else
160
+ recordConfigWarning(`${key} must be a string; using the opencode model default`);
161
+ }
151
162
  if ("max_raw_memories_for_consolidation" in opts)
152
- pluginOptions.max_raw_memories_for_consolidation = clampInt(opts.max_raw_memories_for_consolidation, 1, 4096, 256);
163
+ pluginOptions.max_raw_memories_for_consolidation = clampInt("max_raw_memories_for_consolidation", opts.max_raw_memories_for_consolidation, 1, 4096, 256);
153
164
  if ("max_unused_days" in opts)
154
- pluginOptions.max_unused_days = clampInt(opts.max_unused_days, 0, 365, 30);
165
+ pluginOptions.max_unused_days = clampInt("max_unused_days", opts.max_unused_days, 0, 365, 30);
155
166
  if ("max_rollout_age_days" in opts)
156
- pluginOptions.max_rollout_age_days = clampInt(opts.max_rollout_age_days, 0, 90, 10);
167
+ pluginOptions.max_rollout_age_days = clampInt("max_rollout_age_days", opts.max_rollout_age_days, 0, 90, 10);
157
168
  if ("max_rollouts_per_startup" in opts)
158
- pluginOptions.max_rollouts_per_startup = clampInt(opts.max_rollouts_per_startup, 1, 128, 2);
169
+ pluginOptions.max_rollouts_per_startup = clampInt("max_rollouts_per_startup", opts.max_rollouts_per_startup, 1, 128, 2);
159
170
  if ("min_rollout_idle_hours" in opts)
160
- pluginOptions.min_rollout_idle_hours = clampInt(opts.min_rollout_idle_hours, 1, 48, 6);
171
+ pluginOptions.min_rollout_idle_hours = clampInt("min_rollout_idle_hours", opts.min_rollout_idle_hours, 1, 48, 6);
161
172
  if ("codex_interop" in opts) {
162
173
  const raw = opts.codex_interop;
163
174
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
164
175
  const o = raw;
176
+ for (const key of Object.keys(o)) {
177
+ if (!KNOWN_CODEX_INTEROP_KEYS.has(key)) {
178
+ recordConfigWarning(`unknown codex_interop option '${key}' ignored`);
179
+ }
180
+ }
181
+ if ("import" in o && typeof o.import !== "boolean") {
182
+ recordConfigWarning("codex_interop.import must be a boolean; using false");
183
+ }
184
+ if ("export" in o && typeof o.export !== "boolean") {
185
+ recordConfigWarning("codex_interop.export must be a boolean; using false");
186
+ }
187
+ if ("codex_home" in o && (typeof o.codex_home !== "string" || o.codex_home.length === 0)) {
188
+ recordConfigWarning("codex_interop.codex_home must be a non-empty string; using the default Codex home");
189
+ }
165
190
  pluginOptions.codex_interop = {
166
191
  import: o.import === true,
167
192
  export: o.export === true,
package/dist/src/llm.d.ts CHANGED
@@ -15,6 +15,10 @@ export declare function isMemorySubSession(sessionId: string): boolean;
15
15
  export declare class SubagentTimeoutError extends Error {
16
16
  constructor(timeoutMs: number);
17
17
  }
18
+ /** Thrown after an external owner cancels a running sub-agent prompt. */
19
+ export declare class SubagentCancelledError extends Error {
20
+ constructor();
21
+ }
18
22
  /**
19
23
  * Thrown when a sub-agent session could not be closed. Codex treats a failed
20
24
  * consolidation-agent shutdown as "the agent may still be alive", so the caller
@@ -31,7 +35,7 @@ export interface ExtractOptions {
31
35
  }
32
36
  /** Returns null when the extractor reported a no-op (nothing worth remembering). */
33
37
  export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
34
- export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
38
+ export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string, signal?: AbortSignal): Promise<void>;
35
39
  export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
36
40
  export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
37
41
  export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
package/dist/src/llm.js CHANGED
@@ -3,6 +3,7 @@ import path from "path";
3
3
  let inputRef = null;
4
4
  export function setPluginInput(input) {
5
5
  inputRef = input;
6
+ configModels = null;
6
7
  }
7
8
  export function getPluginInput() {
8
9
  return inputRef;
@@ -15,6 +16,7 @@ const SUBSESSION_METADATA_KEY = "opencode-codex-memory";
15
16
  const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
16
17
  const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
17
18
  const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
19
+ const SUBSESSION_DELETE_TIMEOUT_MS = 10_000;
18
20
  export function isMemorySubSession(sessionId) {
19
21
  return activeSubSessions.has(sessionId);
20
22
  }
@@ -81,6 +83,13 @@ export class SubagentTimeoutError extends Error {
81
83
  this.name = "SubagentTimeoutError";
82
84
  }
83
85
  }
86
+ /** Thrown after an external owner cancels a running sub-agent prompt. */
87
+ export class SubagentCancelledError extends Error {
88
+ constructor() {
89
+ super("sub-agent prompt cancelled");
90
+ this.name = "SubagentCancelledError";
91
+ }
92
+ }
84
93
  /**
85
94
  * Thrown when a sub-agent session could not be closed. Codex treats a failed
86
95
  * consolidation-agent shutdown as "the agent may still be alive", so the caller
@@ -123,6 +132,10 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
123
132
  const input = getPluginInput();
124
133
  if (!input)
125
134
  throw new Error("plugin input not initialized");
135
+ if (opts.signal?.aborted) {
136
+ await abortSession(sessionId);
137
+ throw new SubagentCancelledError();
138
+ }
126
139
  const model = opts.model ? parseModelRef(opts.model) : null;
127
140
  const promptPromise = input.client.session.prompt({
128
141
  path: { id: sessionId },
@@ -137,9 +150,17 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
137
150
  },
138
151
  });
139
152
  let timer;
153
+ let onAbort;
140
154
  try {
155
+ const cancellation = new Promise((_, reject) => {
156
+ if (!opts.signal)
157
+ return;
158
+ onAbort = () => reject(new SubagentCancelledError());
159
+ opts.signal.addEventListener("abort", onAbort, { once: true });
160
+ });
141
161
  const res = await Promise.race([
142
162
  promptPromise,
163
+ cancellation,
143
164
  new Promise((_, reject) => {
144
165
  timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
145
166
  }),
@@ -154,16 +175,18 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
154
175
  return res.data;
155
176
  }
156
177
  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) {
178
+ // A timeout or owner cancellation can leave the turn running server-side;
179
+ // other failures mean the request already settled. Stop the live run so
180
+ // tokens stop burning — deleteSession in the caller is the backup.
181
+ if (err instanceof SubagentTimeoutError || err instanceof SubagentCancelledError) {
161
182
  await abortSession(sessionId);
162
183
  }
163
184
  throw err;
164
185
  }
165
186
  finally {
166
187
  clearTimeout(timer);
188
+ if (onAbort)
189
+ opts.signal?.removeEventListener("abort", onAbort);
167
190
  }
168
191
  }
169
192
  async function promptSession(sessionId, prompt, agent, opts = {}) {
@@ -245,7 +268,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
245
268
  // its INIT pass is explicitly allowed to run long ("do not be lazy"). A short
246
269
  // timeout here would fail the job after the workspace was already synced.
247
270
  const CONSOLIDATION_TIMEOUT_MS = 3600_000;
248
- export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
271
+ export async function consolidateViaSubagent(memoryRoot, diffFileName, model, signal) {
249
272
  const agent = "memorize";
250
273
  const subId = await createSession(agent, "codex-memory-consolidate");
251
274
  let promptError;
@@ -254,7 +277,7 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
254
277
  const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
255
278
  // consolidation_model option > opencode model (main) > session default.
256
279
  const resolved = model ?? (await getConfigModels()).model;
257
- await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS });
280
+ await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS, signal });
258
281
  }
259
282
  catch (err) {
260
283
  promptError = err;
@@ -334,8 +357,19 @@ async function deleteSession(id) {
334
357
  const input = getPluginInput();
335
358
  if (!input)
336
359
  return false;
360
+ const controller = new AbortController();
361
+ let timer;
337
362
  try {
338
- const res = await input.client.session.delete({ path: { id } });
363
+ const res = await Promise.race([
364
+ input.client.session.delete({ path: { id }, signal: controller.signal }),
365
+ new Promise((_, reject) => {
366
+ timer = setTimeout(() => {
367
+ controller.abort();
368
+ reject(new Error(`session.delete timed out after ${SUBSESSION_DELETE_TIMEOUT_MS}ms`));
369
+ }, SUBSESSION_DELETE_TIMEOUT_MS);
370
+ timer.unref?.();
371
+ }),
372
+ ]);
339
373
  if (res.error) {
340
374
  console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
341
375
  return false;
@@ -354,6 +388,9 @@ async function deleteSession(id) {
354
388
  console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
355
389
  return false;
356
390
  }
391
+ finally {
392
+ clearTimeout(timer);
393
+ }
357
394
  }
358
395
  async function sessionDeletionConfirmed(client, id) {
359
396
  const session = client.session;
@@ -24,6 +24,7 @@ export interface PluginOptionsState {
24
24
  codex_interop: CodexInteropOptions;
25
25
  }
26
26
  export declare const pluginOptions: PluginOptionsState;
27
+ export declare function resetPluginOptions(): void;
27
28
  export declare function recordConfigWarning(message: string): void;
28
29
  export declare function getConfigWarnings(): readonly string[];
29
30
  /** Drop warnings from a previous apply pass (server boot / option re-apply). */
@@ -1,4 +1,4 @@
1
- export const pluginOptions = {
1
+ const DEFAULT_PLUGIN_OPTIONS = {
2
2
  generate_memories: true,
3
3
  use_memories: true,
4
4
  dedicated_tools: true,
@@ -10,6 +10,17 @@ export const pluginOptions = {
10
10
  min_rollout_idle_hours: 6,
11
11
  codex_interop: { import: false, export: false },
12
12
  };
13
+ export const pluginOptions = {
14
+ ...DEFAULT_PLUGIN_OPTIONS,
15
+ codex_interop: { ...DEFAULT_PLUGIN_OPTIONS.codex_interop },
16
+ };
17
+ export function resetPluginOptions() {
18
+ delete pluginOptions.extract_model;
19
+ delete pluginOptions.consolidation_model;
20
+ Object.assign(pluginOptions, DEFAULT_PLUGIN_OPTIONS, {
21
+ codex_interop: { ...DEFAULT_PLUGIN_OPTIONS.codex_interop },
22
+ });
23
+ }
13
24
  /**
14
25
  * Config problems noticed while applying plugin options (unknown keys,
15
26
  * malformed values). The plugin never hard-fails on bad options — codex uses
@@ -1,3 +1,4 @@
1
+ import fs from "fs";
1
2
  /**
2
3
  * Safe path resolution that cannot escape the memory root, mirroring codex
3
4
  * ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
@@ -19,3 +20,17 @@ export declare function assertMemoryRootSafe(): string;
19
20
  export declare function safeResolveMemoryPath(rel: string): string;
20
21
  /** Resolve a relative path under an arbitrary trusted root without following symlinks. */
21
22
  export declare function safeResolveUnderRoot(root: string, rel: string): string;
23
+ /**
24
+ * Opens a regular file without following its final path component. The lstat
25
+ * after open is a fallback for platforms without O_NOFOLLOW and also verifies
26
+ * that a path-swap race did not give us a different inode.
27
+ */
28
+ export declare function withRegularFileNoFollow<T>(file: string, flags: number, fn: (fd: number, stat: fs.Stats) => T): T;
29
+ export declare function readRegularFileNoFollow(file: string): {
30
+ content: Buffer;
31
+ stat: fs.Stats;
32
+ };
33
+ /** Overwrite or exclusively create a regular file without following symlinks. */
34
+ export declare function writeRegularFileNoFollow(file: string, content: string | Uint8Array, options?: {
35
+ exclusive?: boolean;
36
+ }): void;
@@ -81,3 +81,83 @@ export function safeResolveUnderRoot(root, rel) {
81
81
  }
82
82
  return current;
83
83
  }
84
+ function sameFile(a, b) {
85
+ return a.dev === b.dev && a.ino === b.ino;
86
+ }
87
+ /**
88
+ * Opens a regular file without following its final path component. The lstat
89
+ * after open is a fallback for platforms without O_NOFOLLOW and also verifies
90
+ * that a path-swap race did not give us a different inode.
91
+ */
92
+ export function withRegularFileNoFollow(file, flags, fn) {
93
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
94
+ const nonBlock = fs.constants.O_NONBLOCK ?? 0;
95
+ let fd;
96
+ try {
97
+ fd = fs.openSync(file, flags | noFollow | nonBlock);
98
+ }
99
+ catch (err) {
100
+ if (err.code === "ELOOP") {
101
+ throw new Error(`symlinks are not allowed in the memory workspace: ${file}`);
102
+ }
103
+ throw err;
104
+ }
105
+ try {
106
+ const opened = fs.fstatSync(fd);
107
+ const current = fs.lstatSync(file);
108
+ if (current.isSymbolicLink() || !current.isFile() || !opened.isFile() || !sameFile(opened, current)) {
109
+ throw new Error(`refusing non-regular or replaced file: ${file}`);
110
+ }
111
+ return fn(fd, opened);
112
+ }
113
+ finally {
114
+ fs.closeSync(fd);
115
+ }
116
+ }
117
+ export function readRegularFileNoFollow(file) {
118
+ return withRegularFileNoFollow(file, fs.constants.O_RDONLY, (fd, stat) => ({
119
+ content: fs.readFileSync(fd),
120
+ stat,
121
+ }));
122
+ }
123
+ /** Overwrite or exclusively create a regular file without following symlinks. */
124
+ export function writeRegularFileNoFollow(file, content, options = {}) {
125
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
126
+ const create = () => {
127
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, 0o666);
128
+ try {
129
+ const opened = fs.fstatSync(fd);
130
+ const current = fs.lstatSync(file);
131
+ if (!opened.isFile() || !current.isFile() || !sameFile(opened, current)) {
132
+ throw new Error(`refusing non-regular or replaced file: ${file}`);
133
+ }
134
+ fs.writeFileSync(fd, content);
135
+ }
136
+ finally {
137
+ fs.closeSync(fd);
138
+ }
139
+ };
140
+ if (options.exclusive) {
141
+ create();
142
+ return;
143
+ }
144
+ let current;
145
+ try {
146
+ current = fs.lstatSync(file);
147
+ }
148
+ catch (err) {
149
+ if (err.code === "ENOENT") {
150
+ create();
151
+ return;
152
+ }
153
+ throw err;
154
+ }
155
+ if (current.isSymbolicLink() || !current.isFile()) {
156
+ throw new Error(`refusing to overwrite non-regular file: ${file}`);
157
+ }
158
+ withRegularFileNoFollow(file, fs.constants.O_WRONLY, (fd) => {
159
+ // Do not truncate until the descriptor and current path are verified.
160
+ fs.ftruncateSync(fd, 0);
161
+ fs.writeFileSync(fd, content);
162
+ });
163
+ }
@@ -1,4 +1,5 @@
1
1
  import { MemoryStore } from "./store.js";
2
+ import { checkRateLimit } from "./ratelimit.js";
2
3
  export interface Phase1Options {
3
4
  maxAgeDays: number;
4
5
  minIdleHours: number;
@@ -8,5 +9,5 @@ export interface Phase1Options {
8
9
  extractModel?: string;
9
10
  }
10
11
  export declare const DEFAULT_PHASE1_OPTIONS: Phase1Options;
11
- export declare function runPhase1(store: MemoryStore, opts?: Phase1Options): Promise<void>;
12
+ export declare function runPhase1(store: MemoryStore, opts?: Phase1Options, rateLimitCheck?: typeof checkRateLimit): Promise<void>;
12
13
  export declare function buildTranscript(sessionId: string): Promise<string>;
@@ -19,9 +19,9 @@ const TRANSCRIPT_MAX_CHARS = 600_000;
19
19
  // budget 50/50 between head and tail (truncate.rs split_budget).
20
20
  const TRANSCRIPT_HEAD_CHARS = 300_000;
21
21
  const TRANSCRIPT_TAIL_CHARS = 300_000;
22
- export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
22
+ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitCheck = checkRateLimit) {
23
23
  store.pruneStage1Outputs(opts.maxUnusedDays ?? 30);
24
- const rl = await checkRateLimit("phase1");
24
+ const rl = await rateLimitCheck("phase1");
25
25
  if (!rl.ok) {
26
26
  console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason);
27
27
  return;
@@ -40,6 +40,12 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
40
40
  const sourceUpdatedAt = session?.updated_at ?? Date.now();
41
41
  const transcript = await buildTranscript(sid);
42
42
  if (!transcript.trim()) {
43
+ // A newly empty chat is a legitimate no-output result. An existing
44
+ // extraction plus an empty API success is anomalous: retry instead of
45
+ // permanently forgetting memory because of a transient host glitch.
46
+ if (store.hasStage1Output(sid)) {
47
+ throw new Error(`empty transcript for previously extracted session ${sid}`);
48
+ }
43
49
  store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt);
44
50
  return;
45
51
  }
@@ -7,6 +7,8 @@ export interface Phase2Options {
7
7
  extensionRetentionDays: number;
8
8
  consolidationModel?: string;
9
9
  codexInterop?: CodexInteropOptions;
10
+ /** Override the 90s heartbeat interval (tests / advanced). */
11
+ heartbeatIntervalMs?: number;
10
12
  }
11
13
  export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
12
14
  /** True while THIS process runs a consolidation (memory_reset refuses then). */
@@ -89,21 +89,40 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
89
89
  }
90
90
  writeWorkspaceDiff(diff);
91
91
  let heartbeatLost = false;
92
- const heartbeat = setInterval(() => {
92
+ let heartbeatFailure = "ownership lost";
93
+ const consolidationAbort = new AbortController();
94
+ const heartbeatOnce = () => {
95
+ if (heartbeatLost)
96
+ return false;
93
97
  try {
94
98
  if (!store.heartbeatPhase2Job(claim.ownershipToken)) {
95
99
  heartbeatLost = true;
100
+ consolidationAbort.abort();
101
+ return false;
96
102
  }
97
103
  }
98
104
  catch (err) {
99
- // Transient DB error (e.g. SQLITE_BUSY): don't treat as ownership
100
- // loss — the token+status-guarded final confirmation below stays
101
- // authoritative. Uncaught, this would kill the interval silently.
102
105
  console.warn("[opencode-codex-memory] phase2 heartbeat error:", err);
106
+ // Codex stops the consolidation agent on heartbeat Ok(false) OR Err.
107
+ // Fail closed: without a refreshed lease, another process may reclaim
108
+ // the job while this helper still has live write access.
109
+ heartbeatLost = true;
110
+ heartbeatFailure = err;
111
+ consolidationAbort.abort();
112
+ return false;
103
113
  }
104
- }, 90_000);
114
+ return true;
115
+ };
116
+ // Workspace preparation can itself be slow. Confirm ownership before
117
+ // granting a new helper write access, then keep the lease alive while it
118
+ // runs. This also mirrors tokio::time::interval's immediate first tick.
119
+ if (!heartbeatOnce()) {
120
+ store.markPhase2Failed(claim.ownershipToken, heartbeatFailure);
121
+ return { status: "heartbeat_lost" };
122
+ }
123
+ const heartbeat = setInterval(heartbeatOnce, opts.heartbeatIntervalMs ?? 90_000);
105
124
  try {
106
- await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
125
+ await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel, consolidationAbort.signal);
107
126
  }
108
127
  catch (err) {
109
128
  // codex phase2.rs: when the consolidation agent's shutdown fails, keep
@@ -114,6 +133,10 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
114
133
  console.warn(`[opencode-codex-memory] ${err.message}; holding the phase2 lease until it expires`);
115
134
  return { status: "shutdown_failed" };
116
135
  }
136
+ if (heartbeatLost) {
137
+ store.markPhase2Failed(claim.ownershipToken, heartbeatFailure);
138
+ return { status: "heartbeat_lost" };
139
+ }
117
140
  throw err;
118
141
  }
119
142
  finally {
@@ -126,7 +149,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
126
149
  // heartbeat is token+status guarded, so it fails once ownership is lost;
127
150
  // markPhase2Failed is equally guarded and becomes a no-op then.
128
151
  if (heartbeatLost || !store.heartbeatPhase2Job(claim.ownershipToken)) {
129
- store.markPhase2Failed(claim.ownershipToken, "ownership lost");
152
+ store.markPhase2Failed(claim.ownershipToken, heartbeatFailure);
130
153
  return { status: "heartbeat_lost" };
131
154
  }
132
155
  // codex failed_invalid_artifacts: do not reset baseline on bad output so
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { memoryRoot } from "./paths.js";
4
- import { assertMemoryRootSafe, safeResolveMemoryPath } from "./path-guard.js";
4
+ import { assertMemoryRootSafe, safeResolveMemoryPath, withRegularFileNoFollow } from "./path-guard.js";
5
5
  import { truncateToTokens } from "./token.js";
6
6
  import { fillTemplate } from "./llm.js";
7
7
  const MEMORY_SUMMARY_TOKEN_LIMIT = 2500;
@@ -31,36 +31,28 @@ function readTemplate() {
31
31
  return fs.readFileSync(templatePath, "utf8");
32
32
  }
33
33
  function readMemorySummary() {
34
- let summaryPath;
35
- let fd;
36
34
  try {
37
35
  // Use the same component-by-component symlink refusal as the memory tools:
38
36
  // 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;
37
+ const summaryPath = safeResolveMemoryPath("memory_summary.md");
38
+ return withRegularFileNoFollow(summaryPath, fs.constants.O_RDONLY, (fd, stat) => {
39
+ if (cached && cached.mtime === stat.mtimeMs) {
40
+ return cached.content;
41
+ }
42
+ const raw = fs.readFileSync(fd, "utf8").trim();
43
+ if (!raw)
44
+ return null;
45
+ const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT);
46
+ cached = {
47
+ content: truncated,
48
+ mtime: stat.mtimeMs,
49
+ };
50
+ return truncated;
51
+ });
56
52
  }
57
53
  catch {
58
54
  return null;
59
55
  }
60
- finally {
61
- if (fd !== undefined)
62
- fs.closeSync(fd);
63
- }
64
56
  }
65
57
  export function invalidateCache() {
66
58
  cached = null;
@@ -44,6 +44,7 @@ export declare class MemoryStore {
44
44
  private db;
45
45
  constructor(db?: Database);
46
46
  stage1Outputs(): Stage1Output[];
47
+ hasStage1Output(sessionId: string): boolean;
47
48
  /**
48
49
  * Deletes stale rows; snapshots consumed by the last successful Phase 2 are
49
50
  * protected. Stalest-first, capped per run (codex PRUNE_BATCH_SIZE).
package/dist/src/store.js CHANGED
@@ -37,6 +37,11 @@ export class MemoryStore {
37
37
  .prepare("SELECT * FROM memory_stage1_outputs ORDER BY source_updated_at DESC")
38
38
  .all();
39
39
  }
40
+ hasStage1Output(sessionId) {
41
+ return this.db
42
+ .prepare("SELECT 1 FROM memory_stage1_outputs WHERE session_id = ?")
43
+ .get(sessionId) !== null;
44
+ }
40
45
  /**
41
46
  * Deletes stale rows; snapshots consumed by the last successful Phase 2 are
42
47
  * protected. Stalest-first, capped per run (codex PRUNE_BATCH_SIZE).
@@ -309,12 +314,25 @@ export class MemoryStore {
309
314
  /** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
310
315
  phase2LastSuccess() {
311
316
  const row = this.db
312
- .prepare(`SELECT finished_at, last_success_watermark FROM memory_jobs
317
+ .prepare(`SELECT status, finished_at, last_error, last_success_watermark FROM memory_jobs
313
318
  WHERE kind='memory_consolidate_global' AND job_key='global'`)
314
319
  .get();
315
- if (!row || !row.last_success_watermark)
320
+ if (!row || row.last_success_watermark === null)
321
+ return null;
322
+ const cleanSuccess = row.last_error === null &&
323
+ row.finished_at !== null &&
324
+ (row.status === "done" || row.status === "pending");
325
+ // Codex initializes pending global jobs with watermark 0, so zero proves a
326
+ // success only while the row itself is a clean completed attempt.
327
+ if (row.last_success_watermark === 0 && !cleanSuccess)
316
328
  return null;
317
- return row;
329
+ return {
330
+ // Codex preserves last_success_watermark across later attempts, while
331
+ // finished_at describes only the latest attempt. Expose them separately
332
+ // so a failure timestamp is never labeled as a success timestamp.
333
+ finished_at: cleanSuccess ? row.finished_at : null,
334
+ last_success_watermark: row.last_success_watermark,
335
+ };
318
336
  }
319
337
  markPhase2Failed(ownershipToken, error) {
320
338
  const message = failureMessage(error);
@@ -2,7 +2,7 @@ import { createHash } from "crypto";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { memoryRoot } from "./paths.js";
5
- import { assertMemoryRootSafe, safeResolveMemoryPath } from "./path-guard.js";
5
+ import { assertMemoryRootSafe, readRegularFileNoFollow, safeResolveMemoryPath, writeRegularFileNoFollow, } from "./path-guard.js";
6
6
  import { DIFF_ARTIFACT } from "./git-baseline.js";
7
7
  const RAW_MEMORIES_FILE = "raw_memories.md";
8
8
  const ROLLOUT_DIR = "rollout_summaries";
@@ -38,13 +38,13 @@ export function ensureLayout() {
38
38
  }
39
39
  const memoryMd = safeResolveMemoryPath("MEMORY.md");
40
40
  if (!fs.existsSync(memoryMd))
41
- fs.writeFileSync(memoryMd, "# MEMORY.md\n\n_Searchable index of memories._\n", { flag: "w" });
41
+ writeRegularFileNoFollow(memoryMd, "# MEMORY.md\n\n_Searchable index of memories._\n");
42
42
  const summary = safeResolveMemoryPath("memory_summary.md");
43
43
  if (!fs.existsSync(summary))
44
- fs.writeFileSync(summary, "", { flag: "w" });
44
+ writeRegularFileNoFollow(summary, "");
45
45
  const adhocInstructions = safeResolveMemoryPath(path.join(EXTENSIONS_DIR, "ad_hoc", "instructions.md"));
46
46
  if (!fs.existsSync(adhocInstructions))
47
- fs.writeFileSync(adhocInstructions, ADHOC_INSTRUCTIONS, { flag: "w" });
47
+ writeRegularFileNoFollow(adhocInstructions, ADHOC_INSTRUCTIONS);
48
48
  }
49
49
  /**
50
50
  * Mirrors codex `validate_consolidation_artifacts` (workspace.rs): after
@@ -68,7 +68,7 @@ export function validateConsolidationArtifacts(root = memoryRoot()) {
68
68
  if (!fs.lstatSync(summaryPath).isFile()) {
69
69
  return { ok: false, reason: `memory summary artifact is not a file: ${summaryPath}` };
70
70
  }
71
- summary = fs.readFileSync(summaryPath, "utf8");
71
+ summary = readRegularFileNoFollow(summaryPath).content.toString("utf8");
72
72
  }
73
73
  catch {
74
74
  return { ok: false, reason: `missing memory summary artifact: ${summaryPath}` };
@@ -119,7 +119,7 @@ export function rebuildRawMemories(outputs) {
119
119
  content += "\n\n";
120
120
  }
121
121
  }
122
- fs.writeFileSync(safeResolveMemoryPath(RAW_MEMORIES_FILE), content, { flag: "w" });
122
+ writeRegularFileNoFollow(safeResolveMemoryPath(RAW_MEMORIES_FILE), content);
123
123
  return content;
124
124
  }
125
125
  export function writeRolloutSummaries(outputs) {
@@ -142,7 +142,7 @@ export function writeRolloutSummaries(outputs) {
142
142
  `usage_count: ${o.usage_count}\n\n` +
143
143
  o.rollout_summary +
144
144
  "\n";
145
- fs.writeFileSync(file, body, { flag: "w" });
145
+ writeRegularFileNoFollow(file, body);
146
146
  }
147
147
  }
148
148
  // Resource filenames start with an ISO-like timestamp: 2026-07-03T05-11-22_slug.md
@@ -229,6 +229,6 @@ export function writeWorkspaceDiff(diff) {
229
229
  rendered += "\n## Diff\n\n```diff\n" + body + (body.endsWith("\n") ? "" : "\n") + "```\n";
230
230
  }
231
231
  const file = safeResolveMemoryPath(DIFF_ARTIFACT);
232
- fs.writeFileSync(file, rendered, { flag: "w" });
232
+ writeRegularFileNoFollow(file, rendered);
233
233
  return file;
234
234
  }
@@ -5,7 +5,7 @@ import { memoryRoot, memorySummaryPath } from "../src/paths.js";
5
5
  import { MemoryStore } from "../src/store.js";
6
6
  import { invalidateCache } from "../src/source.js";
7
7
  import { estimateTokens } from "../src/token.js";
8
- import { assertMemoryRootSafe } from "../src/path-guard.js";
8
+ import { assertMemoryRootSafe, readRegularFileNoFollow } from "../src/path-guard.js";
9
9
  import { isPhase2InFlight } from "../src/phase2.js";
10
10
  import { pluginOptions, getConfigWarnings } from "../src/options.js";
11
11
  import { resolveCodexInterop } from "../src/codex-interop.js";
@@ -166,14 +166,18 @@ export const memory_inspect = tool({
166
166
  let summaryChars = 0;
167
167
  let summaryTokens = 0;
168
168
  if (fs.existsSync(summaryPath)) {
169
- const text = fs.readFileSync(summaryPath, "utf8");
169
+ const text = readRegularFileNoFollow(summaryPath).content.toString("utf8");
170
170
  summaryChars = text.length;
171
171
  summaryTokens = estimateTokens(text);
172
172
  }
173
173
  const listing = listMemoriesDir();
174
174
  // The tool description promises the last Phase 2 success watermark.
175
175
  const phase2 = store.phase2LastSuccess();
176
- const watermark = phase2?.last_success_watermark ? new Date(phase2.last_success_watermark).toISOString() : "none";
176
+ const watermark = phase2?.last_success_watermark === 0
177
+ ? "0 (no consumed inputs)"
178
+ : phase2?.last_success_watermark !== null && phase2?.last_success_watermark !== undefined
179
+ ? new Date(phase2.last_success_watermark).toISOString()
180
+ : "none";
177
181
  const finishedAt = phase2?.finished_at ? new Date(phase2.finished_at * 1000).toISOString() : "none";
178
182
  const out = [
179
183
  `stage1_outputs: ${outputs.length}`,
@@ -208,7 +212,7 @@ export const memory_inspect = tool({
208
212
  },
209
213
  });
210
214
  export const memory_mode = tool({
211
- description: "Set the memory mode for the current session. 'enabled' allows Phase 1 extraction. " +
215
+ description: "Set the memory mode for the target session (current session by default). 'enabled' allows Phase 1 extraction. " +
212
216
  "'disabled' excludes this session from extraction. 'polluted' marks it as having external context " +
213
217
  "(websearch/webfetch) that should not be trusted for memory.",
214
218
  args: {
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { safeResolveMemoryPath, assertMemoryRootSafe } from "../src/path-guard.js";
3
+ import { safeResolveMemoryPath, assertMemoryRootSafe, readRegularFileNoFollow, writeRegularFileNoFollow, } from "../src/path-guard.js";
4
4
  import { tool } from "@opencode-ai/plugin";
5
5
  const MAX_READ_BYTES = 256 * 1024;
6
6
  export const memory_read = tool({
@@ -18,7 +18,7 @@ export const memory_read = tool({
18
18
  if (!fs.existsSync(fullPath)) {
19
19
  return { output: `Not found: ${args.path}` };
20
20
  }
21
- const stat = fs.statSync(fullPath);
21
+ const stat = fs.lstatSync(fullPath);
22
22
  if (stat.isDirectory()) {
23
23
  const entries = fs.readdirSync(fullPath);
24
24
  return {
@@ -29,7 +29,7 @@ export const memory_read = tool({
29
29
  // Read the whole file and apply the line window FIRST; the byte cap
30
30
  // applies to the WINDOWED output. Capping the raw read used to make
31
31
  // lines beyond the first 256 KiB unreachable regardless of line_offset.
32
- const text = fs.readFileSync(fullPath, "utf8");
32
+ const text = readRegularFileNoFollow(fullPath).content.toString("utf8");
33
33
  // Line windowing mirrors codex memories/read: 1-indexed offset, bounded
34
34
  // line count, and the start line reported so file:line citations work.
35
35
  const startLine = args.line_offset ?? 1;
@@ -303,7 +303,7 @@ export const memory_search = tool({
303
303
  const start = safeResolveMemoryPath(args.path);
304
304
  let st;
305
305
  try {
306
- st = fs.statSync(start);
306
+ st = fs.lstatSync(start);
307
307
  }
308
308
  catch {
309
309
  return { output: `Not found: ${args.path}` };
@@ -326,7 +326,7 @@ export const memory_search = tool({
326
326
  const listing = files.slice(0, args.max_results).map((f) => {
327
327
  let content = "";
328
328
  try {
329
- content = fs.readFileSync(f.abs, "utf8");
329
+ content = readRegularFileNoFollow(f.abs).content.toString("utf8");
330
330
  }
331
331
  catch {
332
332
  }
@@ -350,7 +350,7 @@ export const memory_search = tool({
350
350
  for (const f of files) {
351
351
  let content;
352
352
  try {
353
- content = fs.readFileSync(f.abs, "utf8");
353
+ content = readRegularFileNoFollow(f.abs).content.toString("utf8");
354
354
  }
355
355
  catch {
356
356
  continue;
@@ -425,7 +425,7 @@ export const memory_add_note = tool({
425
425
  let file = safeResolveMemoryPath(path.join(NOTES_DIR, `${stem}.md`));
426
426
  for (let i = 2;; i++) {
427
427
  try {
428
- fs.writeFileSync(file, header + args.note + "\n", { flag: "wx" });
428
+ writeRegularFileNoFollow(file, header + args.note + "\n", { exclusive: true });
429
429
  break;
430
430
  }
431
431
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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",