opencode-codex-memory 0.4.3 → 0.4.5

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
@@ -18,6 +18,8 @@ project is a faithful port of the memory system in OpenAI's Codex. It works out
18
18
  of the box with zero extra configuration and uses whatever models you already
19
19
  have set up in OpenCode.
20
20
 
21
+ See the [changelog](./CHANGELOG.md) for release history.
22
+
21
23
  **Local-first by design.** Memory is plain markdown files plus a small SQLite
22
24
  database on your own machine — no memory service to sign up for, no MCP server,
23
25
  no separate process, no sync. Installing it is one line in your `opencode.json`;
@@ -50,7 +52,7 @@ If you want the mental model before the details, jump to
50
52
 
51
53
  ```json
52
54
  {
53
- "plugin": ["opencode-codex-memory@0.4.3"]
55
+ "plugin": ["opencode-codex-memory@0.4.5"]
54
56
  }
55
57
  ```
56
58
 
@@ -237,7 +239,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
237
239
  ```json
238
240
  {
239
241
  "plugin": [
240
- ["opencode-codex-memory@0.4.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
242
+ ["opencode-codex-memory@0.4.5", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
241
243
  ]
242
244
  }
243
245
  ```
@@ -296,7 +298,7 @@ directions:
296
298
  ```json
297
299
  {
298
300
  "plugin": [
299
- ["opencode-codex-memory@0.4.3", { "codex_interop": { "import": true, "export": true } }]
301
+ ["opencode-codex-memory@0.4.5", { "codex_interop": { "import": true, "export": true } }]
300
302
  ]
301
303
  }
302
304
  ```
@@ -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).
@@ -69,7 +70,22 @@ export declare class MemoryStore {
69
70
  * still back the consolidated artifacts.
70
71
  */
71
72
  markPhase2Succeeded(ownershipToken: string, selected?: Pick<Stage1Output, "session_id" | "source_updated_at">[]): void;
72
- /** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
73
+ /**
74
+ * Phase-2 job snapshot for memory_inspect. Always returns the global job row
75
+ * when it exists (including failed/running), so diagnostics are not limited
76
+ * to clean successes. `success_finished_at` is set only for a clean success
77
+ * (never a failure timestamp); `last_success_watermark` follows codex
78
+ * (preserved across later attempts; zero only counts while clean).
79
+ */
80
+ phase2JobSnapshot(): {
81
+ status: string;
82
+ last_error: string | null;
83
+ finished_at: number | null;
84
+ retry_at: number | null;
85
+ success_finished_at: number | null;
86
+ last_success_watermark: number | null;
87
+ } | null;
88
+ /** Last recorded phase-2 success info. Null when phase 2 never succeeded. */
73
89
  phase2LastSuccess(): {
74
90
  finished_at: number | null;
75
91
  last_success_watermark: number | null;
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).
@@ -306,15 +311,51 @@ export class MemoryStore {
306
311
  mark.run(s.source_updated_at, s.session_id, s.source_updated_at);
307
312
  }).immediate();
308
313
  }
309
- /** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
310
- phase2LastSuccess() {
314
+ /**
315
+ * Phase-2 job snapshot for memory_inspect. Always returns the global job row
316
+ * when it exists (including failed/running), so diagnostics are not limited
317
+ * to clean successes. `success_finished_at` is set only for a clean success
318
+ * (never a failure timestamp); `last_success_watermark` follows codex
319
+ * (preserved across later attempts; zero only counts while clean).
320
+ */
321
+ phase2JobSnapshot() {
311
322
  const row = this.db
312
- .prepare(`SELECT finished_at, last_success_watermark FROM memory_jobs
323
+ .prepare(`SELECT status, finished_at, last_error, retry_at, last_success_watermark FROM memory_jobs
313
324
  WHERE kind='memory_consolidate_global' AND job_key='global'`)
314
325
  .get();
315
- if (!row || !row.last_success_watermark)
326
+ if (!row)
327
+ return null;
328
+ const cleanSuccess = row.last_error === null &&
329
+ row.finished_at !== null &&
330
+ (row.status === "done" || row.status === "pending");
331
+ // Codex initializes pending global jobs with watermark 0, so zero proves a
332
+ // success only while the row itself is a clean completed attempt.
333
+ const watermark = row.last_success_watermark === null
334
+ ? null
335
+ : row.last_success_watermark === 0 && !cleanSuccess
336
+ ? null
337
+ : row.last_success_watermark;
338
+ return {
339
+ status: row.status,
340
+ last_error: row.last_error,
341
+ finished_at: row.finished_at,
342
+ retry_at: row.retry_at,
343
+ // Codex preserves last_success_watermark across later attempts, while the
344
+ // job finished_at describes only the latest attempt. Never label a failure
345
+ // timestamp as a success finish time.
346
+ success_finished_at: cleanSuccess ? row.finished_at : null,
347
+ last_success_watermark: watermark,
348
+ };
349
+ }
350
+ /** Last recorded phase-2 success info. Null when phase 2 never succeeded. */
351
+ phase2LastSuccess() {
352
+ const snap = this.phase2JobSnapshot();
353
+ if (!snap || snap.last_success_watermark === null)
316
354
  return null;
317
- return row;
355
+ return {
356
+ finished_at: snap.success_finished_at,
357
+ last_success_watermark: snap.last_success_watermark,
358
+ };
318
359
  }
319
360
  markPhase2Failed(ownershipToken, error) {
320
361
  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";
@@ -150,11 +150,22 @@ export const memory_reset = tool({
150
150
  }
151
151
  },
152
152
  });
153
+ function fmtUnixSec(sec) {
154
+ return sec ? new Date(sec * 1000).toISOString() : "none";
155
+ }
156
+ function fmtWatermarkMs(ms) {
157
+ if (ms === 0)
158
+ return "0 (no consumed inputs)";
159
+ if (ms === null || ms === undefined)
160
+ return "none";
161
+ return new Date(ms).toISOString();
162
+ }
153
163
  export const memory_inspect = tool({
154
- description: "Inspect the current memory state. Returns: stage1_outputs count, last Phase 2 success watermark, " +
155
- "memory_summary token estimate, a listing of the memories directory, the effective plugin options, " +
156
- "and any configuration warnings (unknown/malformed options). Use it to verify the plugin " +
157
- "configuration took effect. Read-only.",
164
+ description: "Inspect the current memory state. Returns: stage1_outputs count, Phase 2 job status " +
165
+ "(including last error / retry time when failed), last Phase 2 success watermark, " +
166
+ "memory_summary token estimate (on-disk; injection caps at ~2500), a listing of the " +
167
+ "memories directory, the effective plugin options, and any configuration warnings " +
168
+ "(unknown/malformed options). Use it to verify the plugin configuration took effect. Read-only.",
158
169
  args: {},
159
170
  async execute() {
160
171
  try {
@@ -166,21 +177,35 @@ export const memory_inspect = tool({
166
177
  let summaryChars = 0;
167
178
  let summaryTokens = 0;
168
179
  if (fs.existsSync(summaryPath)) {
169
- const text = fs.readFileSync(summaryPath, "utf8");
180
+ const text = readRegularFileNoFollow(summaryPath).content.toString("utf8");
170
181
  summaryChars = text.length;
171
182
  summaryTokens = estimateTokens(text);
172
183
  }
173
184
  const listing = listMemoriesDir();
174
- // The tool description promises the last Phase 2 success watermark.
175
- const phase2 = store.phase2LastSuccess();
176
- const watermark = phase2?.last_success_watermark ? new Date(phase2.last_success_watermark).toISOString() : "none";
177
- const finishedAt = phase2?.finished_at ? new Date(phase2.finished_at * 1000).toISOString() : "none";
185
+ const phase2 = store.phase2JobSnapshot();
186
+ const phase2Lines = phase2
187
+ ? [
188
+ `phase2_status: ${phase2.status}`,
189
+ `phase2_last_error: ${phase2.last_error ?? "none"}`,
190
+ `phase2_retry_at: ${fmtUnixSec(phase2.retry_at)}`,
191
+ `phase2_last_attempt_finished_at: ${fmtUnixSec(phase2.finished_at)}`,
192
+ `phase2_last_success_watermark: ${fmtWatermarkMs(phase2.last_success_watermark)}`,
193
+ // Clean-success finish only — never a failure timestamp.
194
+ `phase2_last_success_finished_at: ${fmtUnixSec(phase2.success_finished_at)}`,
195
+ ]
196
+ : [
197
+ "phase2_status: none",
198
+ "phase2_last_error: none",
199
+ "phase2_retry_at: none",
200
+ "phase2_last_attempt_finished_at: none",
201
+ "phase2_last_success_watermark: none",
202
+ "phase2_last_success_finished_at: none",
203
+ ];
178
204
  const out = [
179
205
  `stage1_outputs: ${outputs.length}`,
180
- `phase2_last_success_watermark: ${watermark}`,
181
- `phase2_last_finished_at: ${finishedAt}`,
206
+ ...phase2Lines,
182
207
  `memory_summary_chars: ${summaryChars}`,
183
- `memory_summary_tokens_est: ${summaryTokens}`,
208
+ `memory_summary_tokens_est: ${summaryTokens} (on disk; injection caps at ~2500)`,
184
209
  `memories_dir_entries: ${listing.length}`,
185
210
  "",
186
211
  ...renderEffectiveConfig(),
@@ -192,8 +217,14 @@ export const memory_inspect = tool({
192
217
  output: out,
193
218
  metadata: {
194
219
  stage1_count: outputs.length,
220
+ phase2_status: phase2?.status ?? null,
221
+ phase2_last_error: phase2?.last_error ?? null,
222
+ phase2_retry_at: phase2?.retry_at ?? null,
223
+ phase2_last_attempt_finished_at: phase2?.finished_at ?? null,
195
224
  phase2_last_success_watermark: phase2?.last_success_watermark ?? null,
196
- phase2_last_finished_at: phase2?.finished_at ?? null,
225
+ phase2_last_success_finished_at: phase2?.success_finished_at ?? null,
226
+ // Back-compat aliases used by earlier inspect consumers.
227
+ phase2_last_finished_at: phase2?.success_finished_at ?? null,
197
228
  summary_chars: summaryChars,
198
229
  summary_tokens_est: summaryTokens,
199
230
  files: listing,
@@ -208,7 +239,7 @@ export const memory_inspect = tool({
208
239
  },
209
240
  });
210
241
  export const memory_mode = tool({
211
- description: "Set the memory mode for the current session. 'enabled' allows Phase 1 extraction. " +
242
+ description: "Set the memory mode for the target session (current session by default). 'enabled' allows Phase 1 extraction. " +
212
243
  "'disabled' excludes this session from extraction. 'polluted' marks it as having external context " +
213
244
  "(websearch/webfetch) that should not be trusted for memory.",
214
245
  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.5",
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",