opencode-codex-memory 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,29 @@
1
- # opencode-codex-memory
2
-
3
- Persistent memory for [opencode](https://opencode.ai). Your agent remembers what
1
+ # OpenCode Codex Memory
2
+
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/opencode-codex-memory">
5
+ <img src="https://img.shields.io/npm/v/opencode-codex-memory?logo=npm&amp;label=latest" alt="Latest npm version" />
6
+ </a>
7
+ <a href="https://www.npmjs.com/package/opencode-codex-memory">
8
+ <img src="https://img.shields.io/npm/dt/opencode-codex-memory?logo=npm&amp;label=downloads" alt="npm downloads" />
9
+ </a>
10
+ </p>
11
+
12
+ Persistent memory for [OpenCode](https://opencode.ai): your agent remembers what
4
13
  it learned in past sessions — your conventions, your projects, the decisions you
5
14
  made — and brings that context into new conversations automatically.
6
15
 
7
- It's a single plugin. No core changes, no MCP server, no separate process, no
8
- cloud service. Everything stays on your machine under
9
- `~/.local/share/opencode/`.
10
-
11
- Despite the name: **no codex subscription or OpenAI account is needed.** This
12
- project ports the memory *design* from OpenAI's codex to opencode. It works out
16
+ Despite the name: **no Codex subscription or OpenAI account is needed.** This
17
+ project is a faithful port of the memory system in OpenAI's Codex. It works out
13
18
  of the box with zero extra configuration and uses whatever models you already
14
- have set up in opencode.
19
+ have set up in OpenCode.
20
+
21
+ **Local-first by design.** Memory is plain markdown files plus a small SQLite
22
+ database on your own machine — no memory service to sign up for, no MCP server,
23
+ no separate process, no sync. Installing it is one line in your `opencode.json`;
24
+ from there everything lives under `~/.local/share/opencode/`, so you can read it,
25
+ grep it, edit it, or delete it like anything else you own. Nothing leaves your
26
+ machine beyond the model calls OpenCode already makes.
15
27
 
16
28
  If you *do* also use the Codex CLI: the plugin can share memory with Codex in
17
29
  both directions — what one assistant learns on your machine, the other picks
@@ -20,24 +32,17 @@ up. Off by default, one config flag per direction; see
20
32
 
21
33
  ## Why
22
34
 
23
- By default every opencode session starts from zero. You re-explain your build
35
+ By default every OpenCode session starts from zero. You re-explain your build
24
36
  commands, your code style, and the quirks of each repo over and over.
25
37
 
26
- opencode-codex-memory closes that loop:
38
+ This plugin closes that loop. It reviews finished sessions in the
39
+ background, keeps what's durable — your preferences, how a repo is built, what
40
+ worked and what didn't — and puts that context back in front of the agent in
41
+ later conversations. You don't manage any of it; OpenCode just gets more useful
42
+ the more you use it.
27
43
 
28
- - **It learns in the background.** Once a session has been idle for a while
29
- (default 6 h), a later background pass reviews the transcript and extracts
30
- durable facts — preferences, project structure, what worked and what didn't.
31
- - **It consolidates.** Periodically it merges those notes into a compact,
32
- searchable memory, pruning what's stale.
33
- - **It remembers at the right time.** A short summary is injected into the system
34
- prompt, and the agent can search the full memory on demand when a task looks
35
- related to past work.
36
- - **It self-corrects.** When the agent actually uses a memory it cites the source,
37
- so useful memories rank higher over time and unused ones fade.
38
-
39
- The result: opencode gets more useful the more you use it, without you managing
40
- anything.
44
+ If you want the mental model before the details, jump to
45
+ [How it works](#how-it-works).
41
46
 
42
47
  ## Install
43
48
 
@@ -45,19 +50,37 @@ anything.
45
50
 
46
51
  ```json
47
52
  {
48
- "plugin": ["opencode-codex-memory"]
53
+ "plugin": ["opencode-codex-memory@0.4.2"]
49
54
  }
50
55
  ```
51
56
 
57
+ **Pin the version** (here and for any OpenCode plugin). OpenCode installs a
58
+ plugin spec once into its package cache and never re-resolves it, so a bare
59
+ `"opencode-codex-memory"` is not "always latest" — it freezes at whatever
60
+ was latest the first time OpenCode started. With a version tag you decide
61
+ which release runs, and bumping the tag installs the new one. Check
62
+ [npm](https://www.npmjs.com/package/opencode-codex-memory) for the current
63
+ version.
64
+
65
+ Since updating is then just editing version numbers, it's worth automating
66
+ with a custom command — one that reads your `opencode.json`, compares each
67
+ pin against npm, and rewrites the ones you confirm. If you'd rather not
68
+ write your own, this repo has one you can copy:
69
+ [`docs/commands/update-plugins.md`](./docs/commands/update-plugins.md) →
70
+ `~/.config/opencode/command/update-plugins.md`, then run `/update-plugins`.
71
+ It's documentation, not part of the package: nothing about it is installed
72
+ with the plugin, and it works for every pinned plugin in your config, not
73
+ just this one.
74
+
52
75
  (While developing locally, point it at an absolute path to your checkout
53
76
  instead of the package name.)
54
77
 
55
78
  2. That's it. The memory workspace is created on first use. Installing the
56
79
  plugin is the opt-in: background learning and summary injection are active
57
- immediately (codex ships the same system behind a default-off feature flag
80
+ immediately (Codex ships the same system behind a default-off feature flag
58
81
  with a consent prompt; a standalone memory plugin *is* the consent).
59
82
 
60
- Requires opencode 1.18 or newer (official release). Git is bundled
83
+ Requires OpenCode 1.18 or newer (official release). Git is bundled
61
84
  (`isomorphic-git`) — no `git` binary or any other external tool needed.
62
85
 
63
86
  The two restricted sub-agents that do the background learning (`memorize`,
@@ -71,11 +94,11 @@ override `memorize`, keep an `external_directory` allow for
71
94
  `~/.local/share/opencode/memories/*` (e.g.
72
95
  `"external_directory": { "$HOME/.local/share/opencode/memories/*": "allow" }`
73
96
  after the wildcard deny) — the memory folder lives outside your project, and
74
- without that grant opencode blocks the consolidator's file access.
97
+ without that grant OpenCode blocks the consolidator's file access.
75
98
 
76
99
  ## Try it
77
100
 
78
- Just use opencode normally. Sessions that have been idle for a few hours get
101
+ Just use OpenCode normally. Sessions that have been idle for a few hours get
79
102
  reviewed in the background and memory starts building up — you don't have to do
80
103
  anything. Come back the next day and ask something like *"what do you know about how I
81
104
  work?"* or *"what was I doing in this repo?"* and the agent draws on what it
@@ -96,11 +119,59 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
96
119
  > ~/.local/share/opencode/memories/memory_summary.md
97
120
  ```
98
121
 
122
+ ## How it works
123
+
124
+ You don't need to know any of this to use the plugin. The design is Codex's,
125
+ ported as-is, and it does what any memory system has to do: decide what's worth
126
+ keeping, write it down so it can be found again, surface the right piece at the
127
+ right moment, and forget what stopped being useful.
128
+
129
+ Think of it as three jobs: two background writers and one reader. **Nothing here
130
+ runs while you're waiting for a reply** — an assistant that stops to take notes
131
+ mid-answer would be slower and more expensive, so the learning happens after the
132
+ fact, on transcripts of conversations that are already over.
133
+
134
+ **Phase 1 — read one finished session, write notes about it.** Once a
135
+ conversation has been idle long enough that it's clearly done (default 6 h), the
136
+ plugin fetches that transcript, strips secrets out of it, and hands it to a
137
+ cheap model with one question: *what from this is worth keeping?* The answer
138
+ comes back as structured data — a detailed note plus a short recap of the
139
+ session — and lands in a local SQLite database. One session in, one record out.
140
+ Sessions are independent, so this part is easy to parallelize and to retry when
141
+ it fails.
142
+
143
+ **Phase 2 — merge all those notes into one memory.** Every few hours at most
144
+ (and only one run at a time across all your OpenCode windows), a second pass
145
+ takes the most relevant per-session notes and rewrites the actual memory files:
146
+ `MEMORY.md` as the full index, `memory_summary.md` as the short version, and
147
+ `skills/` for procedures worth repeating. This is where the interesting work
148
+ happens — ten similar observations collapse into one rule, contradictions get
149
+ resolved, and notes nothing ever used age out. Forgetting is a feature: memory
150
+ that only grows is memory that stops being useful.
151
+
152
+ The split exists because the two halves have opposite needs. Phase 1 is
153
+ per-session and can run many at once; phase 2 touches the single shared memory,
154
+ so it has to be serialized. Keeping them apart means one slow or failing session
155
+ extraction can't corrupt or block the shared store.
156
+
157
+ **The read path — actually remembering.** Every turn, the short summary is
158
+ appended to the system prompt (capped at ~2500 tokens, so the cost is small and
159
+ predictable). That's the always-on layer. When a task looks related to past
160
+ work, the agent goes further and searches the full memory itself with the
161
+ `memory_*` tools — the equivalent of "I've seen this before, let me look it up"
162
+ rather than carrying everything around all the time.
163
+
164
+ **The feedback loop.** When the agent uses a memory, it cites it. The citation
165
+ is recorded and then stripped before it reaches your screen, and those usage
166
+ counts feed back into phase 2's ranking. Memories that keep proving useful get
167
+ kept and sharpened; memories nothing has touched in a month drop out. The system
168
+ finds out which of its own notes were worth writing.
169
+
99
170
  ## Where your data lives
100
171
 
101
172
  ```
102
173
  ~/.local/share/opencode/
103
- ├── memory.db # the plugin's own database (opencode's data is only accessed via its API)
174
+ ├── memory.db # the plugin's own database (OpenCode's data is only accessed via its API)
104
175
  └── memories/
105
176
  ├── memory_summary.md # compact summary injected into the system prompt
106
177
  ├── MEMORY.md # searchable index of everything learned
@@ -109,7 +180,7 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
109
180
  └── extensions/ad_hoc/notes/ # things you explicitly asked it to remember
110
181
  ```
111
182
 
112
- The location follows opencode's own data directory — `$XDG_DATA_HOME/opencode`
183
+ The location follows OpenCode's own data directory — `$XDG_DATA_HOME/opencode`
113
184
  when that's set, otherwise `~/.local/share/opencode` (same resolution on macOS,
114
185
  Linux, and Windows).
115
186
 
@@ -120,8 +191,12 @@ those too.)
120
191
 
121
192
  ## Privacy & safety
122
193
 
123
- - **Local only.** Nothing is sent anywhere except through your existing opencode
124
- provider, using your existing credentials. The plugin holds no keys of its own.
194
+ - **Local only.** There is no remote storage option to enable, by accident or
195
+ otherwise: Codex keeps memory storage behind a backend interface whose only
196
+ implementation today is the local filesystem, and this port implements that
197
+ path and nothing else. Nothing is sent anywhere except through your existing
198
+ OpenCode provider, using your existing credentials; the plugin holds no keys
199
+ of its own.
125
200
  - **Secrets are redacted** (API keys, tokens, private keys, passwords) from
126
201
  session transcripts and extracted memories before anything is written or sent
127
202
  to a model. Notes you explicitly dictate ("remember that ...") are stored as
@@ -133,14 +208,14 @@ those too.)
133
208
  - **Reset is safe.** `memory_reset` refuses to run if the memory folder is a
134
209
  symlink, so it can't be tricked into deleting something else.
135
210
  - **Web/MCP sessions:** by default, sessions that used web search, fetch, or MCP
136
- tools are still eligible for memory (matching codex). If you'd rather exclude
211
+ tools are still eligible for memory (matching Codex). If you'd rather exclude
137
212
  them so scraped or external content can't enter your memory, set
138
213
  `disable_on_external_context: true`.
139
214
 
140
215
  ## Configuration
141
216
 
142
217
  Optional plugin options (all have sensible defaults). Names and defaults match
143
- codex's `[memories]` config so the two stay easy to compare:
218
+ Codex's `[memories]` config so the two stay easy to compare:
144
219
 
145
220
  | Option | Default | Meaning |
146
221
  |---|---|---|
@@ -148,8 +223,8 @@ codex's `[memories]` config so the two stay easy to compare:
148
223
  | `use_memories` | `true` | Inject the memory summary into the system prompt |
149
224
  | `dedicated_tools` | `true` | Expose the `memory_read`/`memory_search`/`memory_list`/`memory_add_note` tools |
150
225
  | `disable_on_external_context` | `false` | Exclude sessions that used web/MCP tools from memory |
151
- | `extract_model` | opencode `small_model`, else see below | Model used for per-session extraction |
152
- | `consolidation_model` | opencode `model`, else see below | Model used for consolidation |
226
+ | `extract_model` | OpenCode `small_model`, else see below | Model used for per-session extraction |
227
+ | `consolidation_model` | OpenCode `model`, else see below | Model used for consolidation |
153
228
  | `max_raw_memories_for_consolidation` | `256` | How many raw memories feed each consolidation pass |
154
229
  | `max_rollout_age_days` | `10` | Ignore sessions older than this for extraction |
155
230
  | `min_rollout_idle_hours` | `6` | How long a session must be idle before it's eligible |
@@ -162,16 +237,16 @@ To set options, turn the plugin entry into a `[name, options]` pair:
162
237
  ```json
163
238
  {
164
239
  "plugin": [
165
- ["opencode-codex-memory", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
240
+ ["opencode-codex-memory@0.4.2", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
166
241
  ]
167
242
  }
168
243
  ```
169
244
 
170
- See the [opencode plugin docs](https://opencode.ai/docs/plugins/) for details.
245
+ See the [OpenCode plugin docs](https://opencode.ai/docs/plugins/) for details.
171
246
 
172
- Numeric options are clamped to codex's valid ranges; unknown option keys are
247
+ Numeric options are clamped to Codex's valid ranges; unknown option keys are
173
248
  ignored with a warning. Setting `use_memories: false` also hides the memory
174
- tools, matching codex's extension gating.
249
+ tools, matching Codex's extension gating.
175
250
 
176
251
  **Verifying your configuration:** the plugin never hard-fails on bad options.
177
252
  To check what actually took effect, ask the agent to run `memory_inspect` — it
@@ -180,32 +255,32 @@ unknown or malformed keys (typos included), and shows the resolved Codex
180
255
  interop state. A mistyped option shows up there twice: as a warning, and as
181
256
  the default value appearing where you expected your setting.
182
257
 
183
- Model selection mirrors codex's cheap-extraction / capable-consolidation
184
- split using opencode's own concepts: when `extract_model` is unset, the
185
- `small_model` from your `opencode.json` is used (codex uses `gpt-5.4-mini`);
186
- when `consolidation_model` is unset, your main `model` is used (codex uses
258
+ Model selection mirrors Codex's cheap-extraction / capable-consolidation
259
+ split using OpenCode's own concepts: when `extract_model` is unset, the
260
+ `small_model` from your `opencode.json` is used (Codex uses `gpt-5.4-mini`);
261
+ when `consolidation_model` is unset, your main `model` is used (Codex uses
187
262
  `gpt-5.4`). If neither is configured, the learning sub-agents fall back to
188
263
  their own agent-level `model` (if you defined one), else the provider default.
189
- (opencode's *automatic* small-model pick is internal to opencode and not
264
+ (OpenCode's *automatic* small-model pick is internal to OpenCode and not
190
265
  exposed to plugins — set `small_model` explicitly to get the cheap extraction
191
266
  path.)
192
267
 
193
268
  The full precedence per phase: plugin option (`extract_model` /
194
- `consolidation_model`) → opencode config (`small_model` / `model`) → a `model`
269
+ `consolidation_model`) → OpenCode config (`small_model` / `model`) → a `model`
195
270
  on your own `memorize-extract`/`memorize` agent definition, if you overrode
196
271
  one → the provider's default model. Note that the first two pass the model
197
272
  explicitly, so they win over an agent-level `model`.
198
273
 
199
- > Note: `dedicated_tools` defaults to `true` here (codex defaults it to `false`).
274
+ > Note: `dedicated_tools` defaults to `true` here (Codex defaults it to `false`).
200
275
  > This is the one intentional default difference — the tools are a core part of a
201
- > standalone memory plugin. Everything else matches codex's defaults.
276
+ > standalone memory plugin. Everything else matches Codex's defaults.
202
277
  >
203
278
  > Turning `dedicated_tools` off keeps background learning, summary injection,
204
- > and citation tracking working. The injected guidance switches to codex's
279
+ > and citation tracking working. The injected guidance switches to Codex's
205
280
  > file-based mode — the agent reads the memory files with its normal file
206
281
  > tools and writes "remember this" notes directly into
207
282
  > `extensions/ad_hoc/notes/`. Caveat: the memory folder lives outside your
208
- > project, so opencode raises an `external_directory` permission prompt the
283
+ > project, so OpenCode raises an `external_directory` permission prompt the
209
284
  > first time an agent touches it (allow-always covers later access); agents
210
285
  > whose permissions deny that ask cannot use file-based mode. The dedicated
211
286
  > tools have no such friction — that's why they are the default. The
@@ -221,7 +296,7 @@ directions:
221
296
  ```json
222
297
  {
223
298
  "plugin": [
224
- ["opencode-codex-memory", { "codex_interop": { "import": true, "export": true } }]
299
+ ["opencode-codex-memory@0.4.2", { "codex_interop": { "import": true, "export": true } }]
225
300
  ]
226
301
  }
227
302
  ```
@@ -245,28 +320,46 @@ Both sides mark imported content with a provenance tag (`[from codex]` /
245
320
  don't ping-pong between the two systems. This follows the same extension
246
321
  mechanism Codex itself uses to import Claude memories.
247
322
 
248
- ## Under the hood
323
+ ## Why one global memory?
324
+
325
+ There's a single store for everything you do, not one per project — and that's
326
+ the design choice most likely to surprise you, so it's worth explaining where it
327
+ came from.
328
+
329
+ Codex *started* with per-project memory: a separate bucket per directory, plus a
330
+ user-level scope on top. It **deliberately removed that** in early 2026 and
331
+ collapsed everything into one global root — one store, one lock, one
332
+ consolidation pass — for simplicity.
333
+
334
+ Simplicity is easy to underrate here — until you try to draw the boundaries
335
+ yourself. Scoping forces a question that often has no good answer: which project does
336
+ "prefers table-driven tests" belong to? Monorepos, worktrees, and sibling repos
337
+ of the same stack all blur the line, and the most valuable lessons — the ones
338
+ about how *you* work — belong to no project at all. Per-scope stores also each
339
+ consolidate over a thinner slice of evidence than the whole.
340
+
341
+ Project awareness didn't disappear; it moved out of storage and into the prompt.
342
+ Memories carry the project they came from, and the consolidator is told to keep
343
+ per-project detail separable — soft "this looks like it belongs to that project"
344
+ hints rather than hard partitions. You get the cross-project transfer (your
345
+ conventions follow you into a new repo on day one) while project-specific facts
346
+ stay recognizable as such.
249
347
 
250
- opencode-codex-memory is a faithful port of the memory system from OpenAI's codex.
348
+ The cost is real: with one store, an unrelated project's details can surface in
349
+ the summary. Codex judged that cheaper than the alternative, and this port
350
+ mirrors that decision rather than layering scoping back on top.
251
351
 
252
- One design choice is worth calling out, because it shapes everything else: **memory
253
- is global.** There's a single store for all your work, not one per project. That's
254
- not an accident of the port — it's codex's own hard-won shape. codex *started* with
255
- per-project memory (a separate bucket per directory, plus a user scope) and
256
- **deliberately removed it** in early 2026, collapsing everything into one global
257
- root for simplicity: one store, one lock, one consolidation pass. Project awareness
258
- didn't disappear — it moved out of storage and into the prompt, as soft "this looks
259
- like it belongs to that project" hints rather than hard partitions. This port
260
- mirrors that exactly.
352
+ ## Contributing
261
353
 
262
- If you want to understand the design, the trade-offs, or contribute, see
263
- [`ARCHITECTURE.md`](./ARCHITECTURE.md). Contributor guidance lives in
264
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`AGENTS.md`](./AGENTS.md)
265
- in short: this repo exists to port codex's memory system to opencode, and
266
- PRs that break that parity will be rejected.
354
+ The port follows Codex closely: same two-phase pipeline, same on-disk artifacts,
355
+ same prompts (adapted only where OpenCode differs). If you want the full design
356
+ and the trade-offs, see [`ARCHITECTURE.md`](./ARCHITECTURE.md); contributor
357
+ guidance lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md) and
358
+ [`AGENTS.md`](./AGENTS.md) in short: this repo exists to port Codex's memory
359
+ system to OpenCode, and PRs that break that parity will be rejected.
267
360
 
268
361
  ## License
269
362
 
270
363
  Apache 2.0 — the same license as [OpenAI Codex](https://github.com/openai/codex),
271
364
  whose memory system this project ports. See [`LICENSE`](./LICENSE) and
272
- [`NOTICE`](./NOTICE). Not affiliated with the codex project.
365
+ [`NOTICE`](./NOTICE). Not affiliated with the Codex project.
@@ -127,6 +127,11 @@ function extractText(msg) {
127
127
  // parts carry `text`, so they must be dropped before the text check.
128
128
  if (msg.type === "reasoning")
129
129
  return undefined;
130
+ // opencode itself drops `ignored` text parts when building model messages
131
+ // (session/message-v2.ts), e.g. ACP content addressed only to the user.
132
+ // The assistant never saw them, so they are not conversation.
133
+ if (msg.ignored === true)
134
+ return undefined;
130
135
  if (typeof msg.text === "string")
131
136
  return msg.text;
132
137
  if (msg.type === "tool") {
@@ -135,8 +140,16 @@ function extractText(msg) {
135
140
  // the extractor's strongest evidence — do not slice them per call.
136
141
  const tool = msg.tool ?? "unknown";
137
142
  const input = msg.state?.input ? JSON.stringify(msg.state.input) : "";
138
- const output = typeof msg.state?.output === "string" ? msg.state.output : "";
139
- return `[tool: ${tool}] ${input}${output ? "\n" + output : ""}`;
143
+ // `output` exists only on status:"completed"; a failed call carries
144
+ // `error` instead (schema v1/session.ts ToolStateError). codex persists
145
+ // failed calls too (rollout policy: FunctionCallOutput => true), and "X
146
+ // failed with Y" is often the most memorable part of a session.
147
+ const result = typeof msg.state?.output === "string"
148
+ ? msg.state.output
149
+ : typeof msg.state?.error === "string"
150
+ ? `[error] ${msg.state.error}`
151
+ : "";
152
+ return `[tool: ${tool}] ${input}${result ? "\n" + result : ""}`;
140
153
  }
141
154
  if (msg.type === "step-start" || msg.type === "step-finish")
142
155
  return undefined;
@@ -2,6 +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
6
  /**
6
7
  * Codex interop: memory exchange with an upstream Codex CLI installation on
7
8
  * the same machine, in both directions, through the generic extensions
@@ -235,8 +236,7 @@ function writeIfChanged(file, content) {
235
236
  * target workspace changed. Never creates the extension while the source has
236
237
  * nothing to offer.
237
238
  */
238
- function syncExtension(sourceRoot, extDir, subdir, instructions) {
239
- const resDir = path.join(extDir, "resources", subdir);
239
+ function syncExtension(sourceRoot, targetRoot, extension, subdir, instructions) {
240
240
  // An unreachable source ROOT is not a deletion signal: a missing/mistyped
241
241
  // codex home (or an env context without CODEX_HOME) must not trigger the
242
242
  // forgetting path. Keep existing copies untouched and do nothing.
@@ -247,6 +247,8 @@ function syncExtension(sourceRoot, extDir, subdir, instructions) {
247
247
  catch { }
248
248
  if (!rootIsDir)
249
249
  return false;
250
+ const extensionDir = safeResolveUnderRoot(targetRoot, path.join("extensions", extension));
251
+ const resDir = safeResolveUnderRoot(targetRoot, path.join("extensions", extension, "resources", subdir));
250
252
  const sourceAvailable = ARTIFACTS.some((name) => readIfFile(path.join(sourceRoot, name)) !== null);
251
253
  if (!sourceAvailable) {
252
254
  // Root exists but the artifacts are gone (e.g. codex memory cleared):
@@ -259,19 +261,18 @@ function syncExtension(sourceRoot, extDir, subdir, instructions) {
259
261
  return true;
260
262
  }
261
263
  let changed = false;
262
- if (writeIfChanged(path.join(extDir, "instructions.md"), instructions))
264
+ if (writeIfChanged(path.join(extensionDir, "instructions.md"), instructions))
263
265
  changed = true;
264
266
  for (const name of ARTIFACTS) {
265
267
  const source = readIfFile(path.join(sourceRoot, name));
266
268
  const target = path.join(resDir, name);
267
269
  if (source === null) {
268
- if (readIfFile(target) !== null) {
269
- try {
270
- fs.unlinkSync(target);
271
- changed = true;
272
- }
273
- catch { }
270
+ try {
271
+ fs.lstatSync(target);
272
+ fs.rmSync(target, { recursive: true, force: true });
273
+ changed = true;
274
274
  }
275
+ catch { }
275
276
  continue;
276
277
  }
277
278
  if (writeIfChanged(target, source))
@@ -287,8 +288,7 @@ function syncExtension(sourceRoot, extDir, subdir, instructions) {
287
288
  * Returns true when the plugin workspace changed.
288
289
  */
289
290
  export function syncCodexImport(codexMemoryRoot) {
290
- const extDir = path.join(memoryRoot(), "extensions", IMPORT_EXTENSION);
291
- return syncExtension(codexMemoryRoot, extDir, "codex", IMPORT_INSTRUCTIONS);
291
+ return syncExtension(codexMemoryRoot, memoryRoot(), IMPORT_EXTENSION, "codex", IMPORT_INSTRUCTIONS);
292
292
  }
293
293
  /**
294
294
  * Export direction: our consolidated memory -> Codex's
@@ -312,6 +312,5 @@ export function exportToCodexMemory(codexMemoryRoot) {
312
312
  const summary = readIfFile(path.join(memoryRoot(), "memory_summary.md"));
313
313
  if (summary === null || summary.toString("utf8").split(/\r?\n/, 1)[0] !== "v1")
314
314
  return false;
315
- const extDir = path.join(codexMemoryRoot, "extensions", EXPORT_EXTENSION);
316
- return syncExtension(memoryRoot(), extDir, "opencode", EXPORT_INSTRUCTIONS);
315
+ return syncExtension(memoryRoot(), codexMemoryRoot, EXPORT_EXTENSION, "opencode", EXPORT_INSTRUCTIONS);
317
316
  }
package/dist/src/db.js CHANGED
@@ -47,18 +47,24 @@ export function openDb() {
47
47
  return dbInstance;
48
48
  const dbPath = memoryDbPath();
49
49
  const db = new Database(dbPath, { create: true, readwrite: true, strict: false });
50
- // Match codex's memories-DB open options (runtime.rs): WAL, NORMAL sync,
51
- // 5s busy timeout for cross-process access, incremental auto-vacuum.
52
- db.exec("PRAGMA journal_mode=WAL");
53
- db.exec("PRAGMA synchronous=NORMAL");
54
- db.exec("PRAGMA busy_timeout=5000");
55
- db.exec("PRAGMA auto_vacuum=INCREMENTAL");
56
- runMigrations(db);
57
- dbInstance = db;
58
- return db;
50
+ try {
51
+ // Match codex's memories-DB open options (runtime.rs): WAL, NORMAL sync,
52
+ // 5s busy timeout for cross-process access, incremental auto-vacuum.
53
+ db.run("PRAGMA journal_mode=WAL");
54
+ db.run("PRAGMA synchronous=NORMAL");
55
+ db.run("PRAGMA busy_timeout=5000");
56
+ db.run("PRAGMA auto_vacuum=INCREMENTAL");
57
+ runMigrations(db);
58
+ dbInstance = db;
59
+ return db;
60
+ }
61
+ catch (err) {
62
+ db.close();
63
+ throw err;
64
+ }
59
65
  }
60
66
  function runMigrations(db) {
61
- db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
67
+ db.run(`CREATE TABLE IF NOT EXISTS schema_version (
62
68
  version INTEGER NOT NULL,
63
69
  applied_at INTEGER NOT NULL
64
70
  )`);
@@ -67,7 +73,7 @@ function runMigrations(db) {
67
73
  if (currentVersion >= 1)
68
74
  return;
69
75
  for (const stmt of SCHEMA_V1)
70
- db.exec(stmt);
76
+ db.run(stmt);
71
77
  db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(1, Date.now());
72
78
  }
73
79
  export function closeDb() {
@@ -17,10 +17,32 @@ function removeDiffArtifact(dir) {
17
17
  }
18
18
  async function ensureInit(dir) {
19
19
  const gitDir = path.join(dir, ".git");
20
+ let recreate = false;
21
+ try {
22
+ recreate = containsSymlink(gitDir) || !fs.lstatSync(gitDir).isDirectory();
23
+ }
24
+ catch (err) {
25
+ if (err.code !== "ENOENT")
26
+ throw err;
27
+ }
28
+ if (recreate)
29
+ fs.rmSync(gitDir, { recursive: true, force: true });
20
30
  if (!fs.existsSync(gitDir)) {
21
31
  await isogit.init({ fs, dir });
22
32
  }
23
33
  }
34
+ function containsSymlink(root) {
35
+ const st = fs.lstatSync(root);
36
+ if (st.isSymbolicLink())
37
+ return true;
38
+ if (!st.isDirectory())
39
+ return false;
40
+ for (const name of fs.readdirSync(root)) {
41
+ if (containsSymlink(path.join(root, name)))
42
+ return true;
43
+ }
44
+ return false;
45
+ }
24
46
  // statusMatrix rows are [filepath, head, workdir, stage]; head !== workdir
25
47
  // means the working tree differs from HEAD (added, modified, or deleted).
26
48
  async function stageAll(dir) {
@@ -189,7 +189,23 @@ declare const _default: {
189
189
  "chat.message"(input: {
190
190
  sessionID?: string;
191
191
  }): Promise<void>;
192
- "tool.execute.after"(input: {
192
+ /**
193
+ * Dedicated plugin hook (NOT an event-bus type). Marks the session polluted
194
+ * at INVOCATION, mirroring codex: mcp_tool_call.rs calls
195
+ * maybe_mark_thread_memory_mode_polluted inside handle_approved_mcp_tool_call
196
+ * BEFORE the call runs, and web search marks on the completed response item
197
+ * (stream_events_utils.rs response_item_may_include_external_context).
198
+ *
199
+ * Deliberately not tool.execute.after: opencode does not guarantee that hook
200
+ * (session/tools.ts awaits execute() with no ensuring/catchAll, and an abort
201
+ * interrupts the fiber), so a failed or cancelled websearch/webfetch/MCP call
202
+ * left the session unmarked while its output had already entered the
203
+ * transcript. Marking early over-marks a permission-denied call, which is the
204
+ * safe direction for an opt-in guard.
205
+ *
206
+ * Pollution remains gated by disable_on_external_context, off by default.
207
+ */
208
+ "tool.execute.before"(input: {
193
209
  tool: string;
194
210
  sessionID: string;
195
211
  callID: string;