opencode-codex-memory 0.7.3 → 0.7.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
@@ -23,9 +23,10 @@ See the [changelog](./CHANGELOG.md) for release history.
23
23
  **Local-first by design.** Memory is plain markdown files plus a small SQLite
24
24
  database on your own machine — no memory service to sign up for, no MCP server,
25
25
  no separate process, no sync. Installing it is one line in your `opencode.json`;
26
- from there everything lives under `~/.local/share/opencode/`, so you can read it,
27
- grep it, edit it, or delete it like anything else you own. Nothing leaves your
28
- machine beyond the model calls OpenCode already makes.
26
+ from there everything lives under `~/.local/share/opencode/` by default (or a
27
+ directory you set with `home`), so you can read it, grep it, edit it, or delete
28
+ it like anything else you own. Nothing leaves your machine beyond the model
29
+ calls OpenCode already makes.
29
30
 
30
31
  If you *do* also use the Codex CLI or Claude Code on the same machine: the
31
32
  plugin can bring their memories in (and, for Codex, push ours back). Off by
@@ -52,7 +53,7 @@ If you want the mental model — learning, remembering, forgetting — see
52
53
 
53
54
  ```json
54
55
  {
55
- "plugin": ["opencode-codex-memory@0.7.3"]
56
+ "plugin": ["opencode-codex-memory@0.7.5"]
56
57
  }
57
58
  ```
58
59
 
@@ -72,7 +73,7 @@ V2 plugin syntax:
72
73
 
73
74
  ```jsonc
74
75
  {
75
- "plugins": [{ "package": "opencode-codex-memory@0.7.3" }],
76
+ "plugins": [{ "package": "opencode-codex-memory@0.7.5" }],
76
77
  }
77
78
  ```
78
79
 
@@ -126,9 +127,15 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
126
127
  └── extensions/ad_hoc/notes/ # things you explicitly asked it to remember
127
128
  ```
128
129
 
129
- The location follows OpenCode's own data directory — `$XDG_DATA_HOME/opencode`
130
+ By default this is OpenCode's own data directory — `$XDG_DATA_HOME/opencode`
130
131
  when that's set, otherwise `~/.local/share/opencode` (same resolution on macOS,
131
- Linux, and Windows).
132
+ Linux, and Windows). Relocating OpenCode's local dir (for example by setting
133
+ `XDG_DATA_HOME`) therefore moves memory with it. To pin **only** `memory.db`
134
+ and `memories/` somewhere else — a sandbox mount, without the rest of
135
+ OpenCode's database — set `home`. That pin is always enforced; OpenCode's data
136
+ dir and `XDG_DATA_HOME` no longer apply. Precedence:
137
+ [Relocating memory](#relocating-memory-sandbox-mounts). Do not symlink
138
+ `memories/` — the plugin refuses a symlinked memory root.
132
139
 
133
140
  It's all plain files and a local SQLite database. Read them, edit them, delete
134
141
  them — it's yours. (The `memories/` folder also holds a few working files and
@@ -143,11 +150,13 @@ leave job state, Git baseline, and memory files out of sync. Include hidden
143
150
  files, especially `memories/.git/`, and SQLite sidecars such as `memory.db-wal`
144
151
  or `memory.db-shm` when present.
145
152
 
146
- The directory is `$XDG_DATA_HOME/opencode` when `XDG_DATA_HOME` is set,
147
- otherwise `~/.local/share/opencode`. Copy that whole directory to a dated
148
- backup location. To restore, stop OpenCode, replace the current `opencode/`
149
- data directory with the backup copy, then start OpenCode again. Do not restore
150
- while OpenCode is running or copy only `memory.db` or only `memories/`.
153
+ The default directory is `$XDG_DATA_HOME/opencode` when `XDG_DATA_HOME` is set,
154
+ otherwise `~/.local/share/opencode`. If you set `home` (or
155
+ `OPENCODE_CODEX_MEMORY_HOME`), back up that directory instead it is the pair
156
+ `memory.db` + `memories/`. Copy it to a dated backup location. To restore, stop
157
+ OpenCode, replace the current home with the backup copy, then start OpenCode
158
+ again. Do not restore while OpenCode is running or copy only `memory.db` or
159
+ only `memories/`.
151
160
 
152
161
  ## Privacy & safety
153
162
 
@@ -192,13 +201,14 @@ Codex's `[memories]` config so the two stay easy to compare:
192
201
  | `max_unused_days` | `30` | Prune memories unused for this long |
193
202
  | `codex_interop` | `{ "import": false, "export": false }` | Two-way memory exchange with a local Codex CLI (see below) |
194
203
  | `claude_import` | `{ "enabled": false }` | One-way import of Claude Code project memories (see below) |
204
+ | `home` | unset (follows OpenCode data dir) | Pin `memory.db` + `memories/` here. When set, OpenCode's data dir and `XDG_DATA_HOME` are ignored. See [Relocating memory](#relocating-memory-sandbox-mounts) |
195
205
 
196
206
  To set options, turn the plugin entry into a `[name, options]` pair:
197
207
 
198
208
  ```json
199
209
  {
200
210
  "plugin": [
201
- ["opencode-codex-memory@0.7.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
211
+ ["opencode-codex-memory@0.7.5", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
202
212
  ]
203
213
  }
204
214
  ```
@@ -253,6 +263,56 @@ nearest OpenCode variant on `none < minimal < low < medium < high < xhigh < max`
253
263
  > maintenance tools (`memory_reset`, `memory_inspect`, `memory_mode`) stay
254
264
  > available either way.
255
265
 
266
+ ### Relocating memory (sandbox mounts)
267
+
268
+ First match wins:
269
+
270
+ 1. **`home` plugin option** — pinned. Always enforced. OpenCode's data
271
+ directory and `XDG_DATA_HOME` do not apply.
272
+ 2. **`OPENCODE_CODEX_MEMORY_HOME`** — same pin, only if `home` is unset.
273
+ 3. **OpenCode data dir** (default) — `$XDG_DATA_HOME/opencode` when that env
274
+ is set, otherwise `~/.local/share/opencode`. Memory moves with OpenCode's
275
+ local directory.
276
+
277
+ To mount **only** memory into a sandbox, use (1) or (2) and point at a
278
+ dedicated directory:
279
+
280
+ ```json
281
+ {
282
+ "plugin": [
283
+ ["opencode-codex-memory@0.7.5", { "home": "/path/to/opencode-memory" }]
284
+ ]
285
+ }
286
+ ```
287
+
288
+ OpenCode 2:
289
+
290
+ ```jsonc
291
+ {
292
+ "plugins": [
293
+ { "package": "opencode-codex-memory@0.7.5", "options": { "home": "/path/to/opencode-memory" } }
294
+ ]
295
+ }
296
+ ```
297
+
298
+ `~` is expanded. The path must be absolute — a project-relative path would
299
+ make memory follow the cwd, and memory is global. Same layout as the default,
300
+ just elsewhere:
301
+
302
+ ```
303
+ /path/to/opencode-memory/
304
+ ├── memory.db
305
+ └── memories/
306
+ ```
307
+
308
+ Mount that directory into the sandbox at the **same absolute path**.
309
+
310
+ This does not move existing files. Stop OpenCode, copy `memory.db` (and
311
+ `memory.db-wal` / `memory.db-shm` if present) plus `memories/` into the new
312
+ home, then start it again. Do not symlink `memories/` in place of `home` —
313
+ a symlinked memory root is refused. Ask the agent to run `memory_inspect` to
314
+ confirm the resolved path.
315
+
256
316
  ### Sharing memory with the Codex CLI
257
317
 
258
318
  If you use OpenCode and the Codex CLI on the same machine, turn this on so each
@@ -265,7 +325,7 @@ Off by default; no changes to Codex's own config are required.
265
325
  {
266
326
  "plugin": [
267
327
  [
268
- "opencode-codex-memory@0.7.3",
328
+ "opencode-codex-memory@0.7.5",
269
329
  { "codex_interop": { "import": true, "export": true } }
270
330
  ]
271
331
  ]
@@ -322,7 +382,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
322
382
  ```json
323
383
  {
324
384
  "plugin": [
325
- ["opencode-codex-memory@0.7.3", { "claude_import": { "enabled": true } }]
385
+ ["opencode-codex-memory@0.7.5", { "claude_import": { "enabled": true } }]
326
386
  ]
327
387
  }
328
388
  ```
@@ -349,7 +409,7 @@ Claude names each project with an opaque id (a folder under
349
409
  {
350
410
  "plugin": [
351
411
  [
352
- "opencode-codex-memory@0.7.3",
412
+ "opencode-codex-memory@0.7.5",
353
413
  {
354
414
  "claude_import": {
355
415
  "enabled": true,
package/dist/src/db.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import fs from "fs";
3
+ import path from "path";
2
4
  import { memoryDbPath } from "./paths.js";
3
5
  const SCHEMA_V1 = [
4
6
  `CREATE TABLE IF NOT EXISTS memory_stage1_outputs (
@@ -46,6 +48,7 @@ export function openDb() {
46
48
  if (dbInstance)
47
49
  return dbInstance;
48
50
  const dbPath = memoryDbPath();
51
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
49
52
  const db = new Database(dbPath, { create: true, readwrite: true, strict: false });
50
53
  try {
51
54
  // Match codex's memories-DB open options (runtime.rs): WAL, NORMAL sync,
package/dist/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js";
2
- import { memoryRoot } from "./paths.js";
2
+ import { memoryRoot, memoryDbPath, setConfiguredHome, resolveHomePath } from "./paths.js";
3
+ import { closeDb } from "./db.js";
3
4
  import { stripCitations, extractCitedSessionIds, hasCitationMarkup } from "./citation.js";
4
5
  import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js";
5
6
  import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
@@ -137,8 +138,12 @@ export default {
137
138
  clearConfigWarnings();
138
139
  if (opts)
139
140
  applyPluginOptions(opts);
140
- else
141
+ else {
142
+ const previousDb = memoryDbPath();
141
143
  resetPluginOptions();
144
+ if (memoryDbPath() !== previousDb)
145
+ closeDb();
146
+ }
142
147
  // Finish bounded reseeding before hooks can see a surviving memory
143
148
  // sub-session after a plugin reload.
144
149
  await cleanupOldSubSessions();
@@ -165,6 +170,7 @@ const KNOWN_OPTION_KEYS = new Set([
165
170
  "max_rollout_age_days",
166
171
  "max_rollouts_per_startup",
167
172
  "min_rollout_idle_hours",
173
+ "home",
168
174
  "codex_interop",
169
175
  "claude_import",
170
176
  ]);
@@ -183,6 +189,7 @@ function clampInt(key, value, min, max, fallback) {
183
189
  export function applyPluginOptions(opts) {
184
190
  // Fresh pass per apply so memory_inspect never shows warnings for keys the
185
191
  // caller has since fixed. server() clears too, for boots without options.
192
+ const previousDb = memoryDbPath();
186
193
  clearConfigWarnings();
187
194
  resetPluginOptions();
188
195
  const raw = opts;
@@ -220,6 +227,28 @@ export function applyPluginOptions(opts) {
220
227
  pluginOptions.max_rollouts_per_startup = clampInt("max_rollouts_per_startup", opts.max_rollouts_per_startup, 1, 128, 2);
221
228
  if ("min_rollout_idle_hours" in opts)
222
229
  pluginOptions.min_rollout_idle_hours = clampInt("min_rollout_idle_hours", opts.min_rollout_idle_hours, 1, 48, 6);
230
+ if ("home" in raw) {
231
+ if (typeof raw.home !== "string") {
232
+ recordConfigWarning("home must be a string; using the OpenCode data dir");
233
+ }
234
+ else {
235
+ const resolved = resolveHomePath(raw.home);
236
+ if (!resolved) {
237
+ recordConfigWarning("home must be an absolute path (tilde ~ is ok); using the OpenCode data dir");
238
+ }
239
+ else {
240
+ pluginOptions.home = resolved;
241
+ setConfiguredHome(resolved);
242
+ const envHome = process.env.OPENCODE_CODEX_MEMORY_HOME;
243
+ if (envHome) {
244
+ const envResolved = resolveHomePath(envHome);
245
+ if (envResolved && envResolved !== resolved) {
246
+ recordConfigWarning("OPENCODE_CODEX_MEMORY_HOME ignored because home option is set");
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
223
252
  if ("codex_interop" in opts) {
224
253
  const raw = opts.codex_interop;
225
254
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
@@ -282,6 +311,8 @@ export function applyPluginOptions(opts) {
282
311
  recordConfigWarning("claude_import must be an object like { enabled, claude_home, projects }; ignored");
283
312
  }
284
313
  }
314
+ if (memoryDbPath() !== previousDb)
315
+ closeDb();
285
316
  }
286
317
  /**
287
318
  * codex marks every MCP server as memory-polluting unconditionally
@@ -376,7 +407,7 @@ export function injectAgentDefinitions(config) {
376
407
  }
377
408
  // Sub-sessions use directory=memoryRoot (llm.ts), so memory paths are usually
378
409
  // in-bounds. Keep an explicit external_directory allow for the memory root as
379
- // belt-and-suspenders (path is homedir/env-dependent; out-ranks `"*": deny`).
410
+ // belt-and-suspenders (path is home/env-dependent; out-ranks `"*": deny`).
380
411
  const memorize = defs["memorize"];
381
412
  if (memorize?.permission && !("external_directory" in memorize.permission)) {
382
413
  memorize.permission["external_directory"] = { [path.join(memoryRoot(), "*")]: "allow" };
@@ -7,9 +7,9 @@ import type { ClaudeImportOptions } from "./claude-import.js";
7
7
  *
8
8
  * Option names and defaults mirror codex's MemoriesToml/MemoriesConfig
9
9
  * (codex-rs/config/src/types.rs). Keep them 1:1 so the drift script and
10
- * manual syncing stay trivial; do not rename for taste. codex_interop and
11
- * claude_import are opencode-facing knobs for external-agent exchange
12
- * (codex's Claude importer is migration-UI gated / default-off).
10
+ * manual syncing stay trivial; do not rename for taste. codex_interop,
11
+ * claude_import, and home are opencode-facing knobs (Codex has no equivalent
12
+ * of relocating just the memory files away from the host data dir).
13
13
  */
14
14
  export interface PluginOptionsState {
15
15
  generate_memories: boolean;
@@ -18,6 +18,8 @@ export interface PluginOptionsState {
18
18
  disable_on_external_context: boolean;
19
19
  extract_model?: string;
20
20
  consolidation_model?: string;
21
+ /** Resolved absolute plugin home (parent of memory.db + memories/). Unset = default. */
22
+ home?: string;
21
23
  max_raw_memories_for_consolidation: number;
22
24
  max_unused_days: number;
23
25
  max_rollout_age_days: number;
@@ -1,3 +1,4 @@
1
+ import { setConfiguredHome } from "./paths.js";
1
2
  const DEFAULT_PLUGIN_OPTIONS = {
2
3
  generate_memories: true,
3
4
  use_memories: true,
@@ -19,6 +20,8 @@ export const pluginOptions = {
19
20
  export function resetPluginOptions() {
20
21
  delete pluginOptions.extract_model;
21
22
  delete pluginOptions.consolidation_model;
23
+ delete pluginOptions.home;
24
+ setConfiguredHome(undefined);
22
25
  Object.assign(pluginOptions, DEFAULT_PLUGIN_OPTIONS, {
23
26
  codex_interop: { ...DEFAULT_PLUGIN_OPTIONS.codex_interop },
24
27
  claude_import: { ...DEFAULT_PLUGIN_OPTIONS.claude_import },
@@ -1,3 +1,30 @@
1
+ export type MemoryHomeSource = "test" | "option" | "env" | "default";
2
+ /**
3
+ * Memory lives under a dedicated home, mirroring codex's
4
+ * `<codex_home>/memories` (codex-rs `find_codex_home` + `from_codex_home`) but
5
+ * with an opencode-specific default. opencode resolves its data dir as
6
+ * `path.join(xdgData, "opencode")` via the `xdg-basedir` lib
7
+ * (packages/core/src/global.ts), so we reuse the SAME lib to stay byte-identical
8
+ * across platforms and `XDG_DATA_HOME` overrides. opencode does not surface this
9
+ * directory through the plugin API (`/path` gives home/config/state/worktree/
10
+ * directory, not data), so it must be recomputed here.
11
+ *
12
+ * User-facing precedence (first match). `home` / OPENCODE_CODEX_MEMORY_HOME
13
+ * pin this plugin's files and do NOT follow OpenCode's data dir or XDG_DATA_HOME:
14
+ * 1. plugin option `home` — always enforced when set
15
+ * 2. `OPENCODE_CODEX_MEMORY_HOME` — same pin if `home` is unset
16
+ * 3. OpenCode data dir (`$XDG_DATA_HOME/opencode` or `~/.local/share/opencode`)
17
+ *
18
+ * Tests only: `OPENCODE_CODEX_MEMORY_TEST_ROOT` wins over all of the above
19
+ * (write-pipeline sandbox + unit tests). Not a user relocation knob.
20
+ *
21
+ * (1) and (2) exist so a sandbox can mount `memory.db` + `memories/` without
22
+ * the rest of OpenCode's local database. They are not Codex knobs.
23
+ */
24
+ export declare function resolveHomePath(raw: string): string | null;
25
+ export declare function setConfiguredHome(home: string | undefined): void;
26
+ export declare function dataRoot(): string;
27
+ export declare function memoryHomeSource(): MemoryHomeSource;
1
28
  export declare function memoryRoot(): string;
2
29
  export declare function memoryDbPath(): string;
3
30
  export declare function memorySummaryPath(): string;
package/dist/src/paths.js CHANGED
@@ -3,30 +3,77 @@ import os from "os";
3
3
  import { xdgData } from "xdg-basedir";
4
4
  const MEMORY_DIR_NAME = "memories";
5
5
  const MEMORY_DB_NAME = "memory.db";
6
- const OVERRIDE_ENV = "OPENCODE_CODEX_MEMORY_TEST_ROOT";
6
+ const TEST_ROOT_ENV = "OPENCODE_CODEX_MEMORY_TEST_ROOT";
7
+ const HOME_ENV = "OPENCODE_CODEX_MEMORY_HOME";
7
8
  const OPENCODE_APP_DIR = "opencode";
9
+ /** Plugin-option override of the memory home (parent of memory.db + memories/). */
10
+ let configuredHome;
8
11
  /**
9
- * Memory lives under opencode's data dir, mirroring codex's
12
+ * Memory lives under a dedicated home, mirroring codex's
10
13
  * `<codex_home>/memories` (codex-rs `find_codex_home` + `from_codex_home`) but
11
- * with the opencode-specific home. opencode resolves its data dir as
14
+ * with an opencode-specific default. opencode resolves its data dir as
12
15
  * `path.join(xdgData, "opencode")` via the `xdg-basedir` lib
13
16
  * (packages/core/src/global.ts), so we reuse the SAME lib to stay byte-identical
14
17
  * across platforms and `XDG_DATA_HOME` overrides. opencode does not surface this
15
18
  * directory through the plugin API (`/path` gives home/config/state/worktree/
16
19
  * directory, not data), so it must be recomputed here.
17
20
  *
18
- * `OPENCODE_CODEX_MEMORY_TEST_ROOT` is our `CODEX_HOME` analog: an explicit
19
- * override that wins outright (tests + the write-pipeline sandbox).
21
+ * User-facing precedence (first match). `home` / OPENCODE_CODEX_MEMORY_HOME
22
+ * pin this plugin's files and do NOT follow OpenCode's data dir or XDG_DATA_HOME:
23
+ * 1. plugin option `home` — always enforced when set
24
+ * 2. `OPENCODE_CODEX_MEMORY_HOME` — same pin if `home` is unset
25
+ * 3. OpenCode data dir (`$XDG_DATA_HOME/opencode` or `~/.local/share/opencode`)
26
+ *
27
+ * Tests only: `OPENCODE_CODEX_MEMORY_TEST_ROOT` wins over all of the above
28
+ * (write-pipeline sandbox + unit tests). Not a user relocation knob.
29
+ *
30
+ * (1) and (2) exist so a sandbox can mount `memory.db` + `memories/` without
31
+ * the rest of OpenCode's local database. They are not Codex knobs.
20
32
  */
21
- function dataRoot() {
22
- const override = process.env[OVERRIDE_ENV];
23
- if (override)
24
- return override;
33
+ export function resolveHomePath(raw) {
34
+ const trimmed = raw.trim();
35
+ if (!trimmed)
36
+ return null;
37
+ let expanded = trimmed;
38
+ if (trimmed === "~")
39
+ expanded = os.homedir();
40
+ else if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
41
+ expanded = path.join(os.homedir(), trimmed.slice(2));
42
+ }
43
+ if (!path.isAbsolute(expanded))
44
+ return null;
45
+ return path.normalize(expanded);
46
+ }
47
+ export function setConfiguredHome(home) {
48
+ configuredHome = home;
49
+ }
50
+ export function dataRoot() {
51
+ const testRoot = process.env[TEST_ROOT_ENV];
52
+ if (testRoot)
53
+ return testRoot;
54
+ if (configuredHome)
55
+ return configuredHome;
56
+ const envHome = process.env[HOME_ENV];
57
+ if (envHome) {
58
+ const resolved = resolveHomePath(envHome);
59
+ if (resolved)
60
+ return resolved;
61
+ }
25
62
  // xdgData = XDG_DATA_HOME || ~/.local/share (identical on every platform).
26
63
  // The `??` mirrors xdg-basedir's own guard for a missing home directory.
27
64
  const base = xdgData ?? path.join(os.homedir(), ".local", "share");
28
65
  return path.join(base, OPENCODE_APP_DIR);
29
66
  }
67
+ export function memoryHomeSource() {
68
+ if (process.env[TEST_ROOT_ENV])
69
+ return "test";
70
+ if (configuredHome)
71
+ return "option";
72
+ const envHome = process.env[HOME_ENV];
73
+ if (envHome && resolveHomePath(envHome))
74
+ return "env";
75
+ return "default";
76
+ }
30
77
  export function memoryRoot() {
31
78
  return path.join(dataRoot(), MEMORY_DIR_NAME);
32
79
  }
@@ -25,12 +25,14 @@ import { runPhase1 } from "../phase1.js";
25
25
  import { runPhase2 } from "../phase2.js";
26
26
  import { setPluginInput, setSubSessionDirectory, cleanupOldSubSessions, isMemorySubSession, abortActiveSubSessions, } from "../llm.js";
27
27
  import { pluginOptions, clearConfigWarnings, resetPluginOptions } from "../options.js";
28
+ import { memoryDbPath } from "../paths.js";
29
+ import { closeDb } from "../db.js";
28
30
  import { beginPluginShutdown, isPluginShuttingDown, resetPluginLifecycle } from "../lifecycle.js";
29
31
  import { hostMcpStatus } from "../host-client.js";
30
32
  import { recordDiagnostic } from "../diagnostics.js";
31
33
  import { resetAgentHealth } from "../agent-health.js";
32
34
  import { applyPluginOptions, handleSessionDeleted } from "../index.js";
33
- import { setV2Context, buildV1ClientShim, } from "./shim.js";
35
+ import { setV2Context, buildV1ClientShim, rememberV2Session, } from "./shim.js";
34
36
  import { ensureV2Agents } from "./agents.js";
35
37
  import { buildV2Tools } from "./tools.js";
36
38
  import { MemoryStatusRpc } from "./status-rpc.js";
@@ -178,7 +180,8 @@ async function classifyExternalContextTool(toolName) {
178
180
  }
179
181
  return false;
180
182
  }
181
- function stampAndPump(sid) {
183
+ function stampAndPump(sid, directory) {
184
+ rememberV2Session(sid, directory ?? null);
182
185
  try {
183
186
  getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
184
187
  }
@@ -277,8 +280,12 @@ export async function setup(ctx) {
277
280
  clearConfigWarnings();
278
281
  if (ctx.options)
279
282
  applyPluginOptions(ctx.options);
280
- else
283
+ else {
284
+ const previousDb = memoryDbPath();
281
285
  resetPluginOptions();
286
+ if (memoryDbPath() !== previousDb)
287
+ closeDb();
288
+ }
282
289
  await ensureV2Agents(ctx);
283
290
  const statusRegistration = await ctx.rpc.register(MemoryStatusRpc, {
284
291
  status: async (input) => {
@@ -336,7 +343,7 @@ export async function setup(ctx) {
336
343
  return;
337
344
  if (!markV2TurnSeen(sid))
338
345
  return;
339
- stampAndPump(sid);
346
+ stampAndPump(sid, ctx.location?.directory);
340
347
  }
341
348
  catch (err) {
342
349
  console.error("[opencode-codex-memory] v2 prompt hook error:", err);
@@ -436,6 +443,7 @@ export async function setup(ctx) {
436
443
  if (e.type === "session.execution.succeeded" || e.type === "session.execution.ended") {
437
444
  const sid = sessionIdFromV2Event(data);
438
445
  if (sid && !isMemorySubSession(sid)) {
446
+ rememberV2Session(sid, ctx.location?.directory);
439
447
  trackBackgroundTask(triggerPhase1(sid));
440
448
  }
441
449
  }
@@ -64,10 +64,14 @@ export declare function serviceHeaders(endpoint: V2ServiceEndpoint): Record<stri
64
64
  export declare function parseReadyStatus(body: unknown): V2ServiceStatus | null;
65
65
  export declare function readRegisteredEndpoint(file?: string): Promise<V2ServiceEndpoint | undefined>;
66
66
  export declare function fetchServiceStatus(endpoint: V2ServiceEndpoint, headers: Record<string, string> | undefined, signal?: AbortSignal): Promise<V2ServiceStatus>;
67
+ /** Local loopback only — IDE `serve --port 0` shares opencode.db with `--service`. */
68
+ export declare function isLoopbackEndpointUrl(url: string): boolean;
67
69
  /**
68
70
  * Find a ready, registered OpenCode service without starting or replacing one.
69
- * A missing service is a normal unavailable result; a PID mismatch is a
70
- * safety failure because it would make global memory operate on another host.
71
+ * A missing service is a normal unavailable result. A PID mismatch on a
72
+ * non-loopback URL is a safety failure (would operate on another host).
73
+ * Loopback PID mismatch is the IDE isolated-serve case: same machine, shared
74
+ * session database, so global list/get/messages still go through that service.
71
75
  */
72
76
  export declare function discoverOwnService(dependencies?: V2ServiceDependencies, timeoutMs?: number): Promise<{
73
77
  endpoint: V2ServiceEndpoint;
@@ -154,10 +154,22 @@ async function probeEndpoint(deps, endpoint, client, signal) {
154
154
  }
155
155
  return fetchServiceStatus(endpoint, deps.service.headers(endpoint), signal);
156
156
  }
157
+ /** Local loopback only — IDE `serve --port 0` shares opencode.db with `--service`. */
158
+ export function isLoopbackEndpointUrl(url) {
159
+ try {
160
+ const hostname = new URL(url).hostname;
161
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
157
167
  /**
158
168
  * Find a ready, registered OpenCode service without starting or replacing one.
159
- * A missing service is a normal unavailable result; a PID mismatch is a
160
- * safety failure because it would make global memory operate on another host.
169
+ * A missing service is a normal unavailable result. A PID mismatch on a
170
+ * non-loopback URL is a safety failure (would operate on another host).
171
+ * Loopback PID mismatch is the IDE isolated-serve case: same machine, shared
172
+ * session database, so global list/get/messages still go through that service.
161
173
  */
162
174
  export async function discoverOwnService(dependencies, timeoutMs = SERVICE_REQUEST_TIMEOUT_MS) {
163
175
  const deps = dependencies ?? testDependencies ?? productionDependencies();
@@ -168,7 +180,10 @@ export async function discoverOwnService(dependencies, timeoutMs = SERVICE_REQUE
168
180
  const controller = new AbortController();
169
181
  const health = await withServiceTimeout(probeEndpoint(deps, endpoint, client, controller.signal), timeoutMs, controller);
170
182
  if (health.pid !== process.pid) {
171
- throw new Error(`registered OpenCode service PID ${String(health.pid)} does not match plugin host PID ${process.pid}`);
183
+ if (!isLoopbackEndpointUrl(endpoint.url)) {
184
+ throw new Error(`registered OpenCode service PID ${String(health.pid)} does not match plugin host PID ${process.pid}`);
185
+ }
186
+ console.warn(`[opencode-codex-memory] registered OpenCode service PID ${String(health.pid)} is a different local process than plugin host PID ${process.pid}; using it for global session list`);
172
187
  }
173
188
  return { endpoint, client, health };
174
189
  }
@@ -5,8 +5,11 @@
5
5
  * run byte-identical) by presenting a V1-shaped client façade backed by the
6
6
  * V2 plugin context. Only genuinely missing V2 surfaces are adapted:
7
7
  *
8
- * - session list/discovery → the authenticated public service client
9
- * discovered through the registered local service.
8
+ * - session list/discovery → ctx.session.list when the host exposes it,
9
+ * else the authenticated public client for THIS process's registered
10
+ * service. A PID mismatch (IDE `serve --port 0` vs `serve --service`)
11
+ * does not list another host; it falls back to sessions this process
12
+ * has observed.
10
13
  * - session.prompt agent/system/model/format/variant → V2 create-time
11
14
  * agent/model (via switchAgent/switchModel) + generate.text for the
12
15
  * json_schema extraction path (V2 prompts carry text only).
@@ -27,6 +30,13 @@ export declare function setV2Context(ctx: V2Context | null): void;
27
30
  export declare function isReleasedSubSession(id: string): boolean;
28
31
  /** Stable synthetic id for extraction helpers (see create below). */
29
32
  export declare const EXTRACT_STUB_SESSION_ID = "codex-memory-extract-stub";
33
+ /**
34
+ * Isolated OpenCode 2 serves (IntelliJ/desktop `serve --port 0`) are not the
35
+ * registered `--service` process, so global session.list is unavailable.
36
+ * Remember sessions this process has actually seen so phase 1 can still
37
+ * extract them.
38
+ */
39
+ export declare function rememberV2Session(id: string, directory?: string | null, title?: string): void;
30
40
  /** Test seam. */
31
41
  export declare function resetV2ShimStateForTest(): void;
32
42
  /**
@@ -59,9 +59,58 @@ export function isReleasedSubSession(id) {
59
59
  }
60
60
  /** Stable synthetic id for extraction helpers (see create below). */
61
61
  export const EXTRACT_STUB_SESSION_ID = "codex-memory-extract-stub";
62
+ const OBSERVED_CAP = 5000;
63
+ const observedSessions = new Map();
64
+ /**
65
+ * Isolated OpenCode 2 serves (IntelliJ/desktop `serve --port 0`) are not the
66
+ * registered `--service` process, so global session.list is unavailable.
67
+ * Remember sessions this process has actually seen so phase 1 can still
68
+ * extract them.
69
+ */
70
+ export function rememberV2Session(id, directory, title) {
71
+ if (!id || id === EXTRACT_STUB_SESSION_ID)
72
+ return;
73
+ if (releasedSubSessions.has(id))
74
+ return;
75
+ observedSessions.delete(id);
76
+ observedSessions.set(id, {
77
+ updated_at: Date.now(),
78
+ directory: directory ?? null,
79
+ title: typeof title === "string" ? title : "",
80
+ });
81
+ while (observedSessions.size > OBSERVED_CAP) {
82
+ const oldest = observedSessions.keys().next().value;
83
+ if (oldest === undefined)
84
+ break;
85
+ observedSessions.delete(oldest);
86
+ }
87
+ }
88
+ function listObservedSessions(limit, cursor, search) {
89
+ const timestampCursor = typeof cursor === "number" ? cursor : undefined;
90
+ const needle = typeof search === "string" && search.length > 0 ? search.toLowerCase() : undefined;
91
+ const rows = [...observedSessions.entries()].sort((a, b) => b[1].updated_at - a[1].updated_at);
92
+ const out = [];
93
+ for (const [id, rec] of rows) {
94
+ if (timestampCursor !== undefined && rec.updated_at >= timestampCursor)
95
+ continue;
96
+ if (needle && !`${id}\n${rec.title}`.toLowerCase().includes(needle))
97
+ continue;
98
+ out.push({
99
+ id,
100
+ parentID: null,
101
+ ...(rec.title ? { title: rec.title } : {}),
102
+ directory: rec.directory,
103
+ time: { updated: rec.updated_at },
104
+ });
105
+ if (out.length >= limit)
106
+ break;
107
+ }
108
+ return out;
109
+ }
62
110
  /** Test seam. */
63
111
  export function resetV2ShimStateForTest() {
64
112
  releasedSubSessions.clear();
113
+ observedSessions.clear();
65
114
  invalidateOwnService();
66
115
  }
67
116
  // ---------------------------------------------------------------------------
@@ -367,10 +416,13 @@ export function buildV1ClientShim() {
367
416
  if (typeof localList === "function") {
368
417
  return paginateSessionList((input) => localList(input), limit, cursor, search);
369
418
  }
370
- const client = await serviceOrThrow();
371
- if (typeof client.session.list !== "function")
419
+ const client = await ownServiceClient();
420
+ if (client && typeof client.session.list === "function") {
421
+ return paginateSessionList((input) => client.session.list(input), limit, cursor, search);
422
+ }
423
+ if (client)
372
424
  throw new Error("registered service does not support session.list");
373
- return paginateSessionList((input) => client.session.list(input), limit, cursor, search);
425
+ return { data: listObservedSessions(limit, cursor, search) };
374
426
  }
375
427
  const session = {
376
428
  create: async (opts) => {
@@ -408,25 +460,30 @@ export function buildV1ClientShim() {
408
460
  try {
409
461
  if (isReleasedSubSession(opts.path.id))
410
462
  throw Object.assign(new Error("SessionNotFound"), { _tag: "SessionNotFoundError" });
411
- const client = await serviceOrThrow();
412
- if (typeof client.message?.list !== "function")
413
- throw new Error("registered service does not support message.list");
414
- const messages = [];
415
- const seenCursors = new Set();
416
- let cursor;
417
- while (true) {
418
- const response = await client.message.list(cursor ? { sessionID: opts.path.id, cursor } : { sessionID: opts.path.id, order: "asc" });
419
- const rows = responseRows(response);
420
- if (!rows)
421
- throw new Error("registered service returned an invalid message list");
422
- messages.push(...rows);
423
- const next = responseNextCursor(response);
424
- if (!next || seenCursors.has(next))
425
- break;
426
- seenCursors.add(next);
427
- cursor = next;
463
+ const client = await ownServiceClient();
464
+ if (typeof client?.message?.list === "function") {
465
+ const messages = [];
466
+ const seenCursors = new Set();
467
+ let cursor;
468
+ while (true) {
469
+ const response = await client.message.list(cursor ? { sessionID: opts.path.id, cursor } : { sessionID: opts.path.id, order: "asc" });
470
+ const rows = responseRows(response);
471
+ if (!rows)
472
+ throw new Error("registered service returned an invalid message list");
473
+ messages.push(...rows);
474
+ const next = responseNextCursor(response);
475
+ if (!next || seenCursors.has(next))
476
+ break;
477
+ seenCursors.add(next);
478
+ cursor = next;
479
+ }
480
+ return { data: adaptV2Messages(messages) };
428
481
  }
429
- return { data: adaptV2Messages(messages) };
482
+ const raw = await ctx().session.context({
483
+ sessionID: opts.path.id,
484
+ });
485
+ const rows = Array.isArray(raw) ? raw : responseRows(raw) ?? [];
486
+ return { data: adaptV2Messages(rows) };
430
487
  }
431
488
  catch (e) {
432
489
  return { error: e };
@@ -463,18 +520,23 @@ export function buildV1ClientShim() {
463
520
  }
464
521
  try {
465
522
  const client = await ownServiceClient();
466
- if (!client?.session?.get)
467
- return { error: shutdownError ?? new Error("session still exists after interrupt") };
468
- const info = await client.session.get({ sessionID: opts.path.id });
469
- if (info?.error && isNotFoundError(info.error)) {
470
- markReleased(opts.path.id);
471
- return {};
472
- }
473
- const data = und(info);
474
- if (data && typeof data === "object") {
523
+ if (client?.session?.get) {
524
+ const info = await client.session.get({ sessionID: opts.path.id });
525
+ if (info?.error && isNotFoundError(info.error)) {
526
+ markReleased(opts.path.id);
527
+ return {};
528
+ }
529
+ const data = und(info);
530
+ if (data && typeof data === "object") {
531
+ return { error: shutdownError ?? new Error("session still exists after interrupt") };
532
+ }
475
533
  return { error: shutdownError ?? new Error("session still exists after interrupt") };
476
534
  }
477
- return { error: shutdownError ?? new Error("session still exists after interrupt") };
535
+ // Isolated serve: no session.remove on plugin ctx. interrupt+wait already
536
+ // finished, so the helper is idle. Holding the phase-2 lease until it
537
+ // expires would block consolidation for an hour.
538
+ markReleased(opts.path.id);
539
+ return {};
478
540
  }
479
541
  catch (e) {
480
542
  if (isNotFoundError(e)) {
@@ -489,8 +551,19 @@ export function buildV1ClientShim() {
489
551
  if (isReleasedSubSession(opts.path.id)) {
490
552
  return { response: { status: 404 }, error: { _tag: "SessionNotFoundError" } };
491
553
  }
492
- const client = await serviceOrThrow();
493
- const info = und(await client.session.get?.({ sessionID: opts.path.id }));
554
+ const client = await ownServiceClient();
555
+ if (client?.session?.get) {
556
+ try {
557
+ const info = und(await client.session.get({ sessionID: opts.path.id }));
558
+ return { data: info };
559
+ }
560
+ catch (e) {
561
+ if (isNotFoundError(e))
562
+ return { response: { status: 404 }, error: e };
563
+ return { error: e };
564
+ }
565
+ }
566
+ const info = und(await ctx().session.get({ sessionID: opts.path.id }));
494
567
  return { data: info };
495
568
  }
496
569
  catch (e) {
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { tool } from "@opencode-ai/plugin";
4
- import { memoryRoot, memorySummaryPath } from "../src/paths.js";
4
+ import { memoryRoot, memorySummaryPath, dataRoot, memoryHomeSource } 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";
@@ -42,6 +42,18 @@ function wipeMemoriesDir() {
42
42
  fs.unlinkSync(abs);
43
43
  }
44
44
  }
45
+ function homeSourceLabel(source) {
46
+ switch (source) {
47
+ case "test":
48
+ return "test root";
49
+ case "option":
50
+ return "option";
51
+ case "env":
52
+ return "OPENCODE_CODEX_MEMORY_HOME";
53
+ case "default":
54
+ return "OpenCode data dir";
55
+ }
56
+ }
45
57
  /**
46
58
  * Renders the effective (post-parse, post-clamp) plugin options plus any
47
59
  * problems recorded while applying them. The plugin never hard-fails on bad
@@ -65,6 +77,7 @@ function renderEffectiveConfig() {
65
77
  ` max_rollout_age_days: ${o.max_rollout_age_days}`,
66
78
  ` max_rollouts_per_startup: ${o.max_rollouts_per_startup}`,
67
79
  ` min_rollout_idle_hours: ${o.min_rollout_idle_hours}`,
80
+ ` home: ${dataRoot()} (${homeSourceLabel(memoryHomeSource())})`,
68
81
  ];
69
82
  const ci = o.codex_interop;
70
83
  if (!ci.import && !ci.export) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.7.3",
3
+ "version": "0.7.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",