opencode-codex-memory 0.1.8 → 0.2.0

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
@@ -8,6 +8,11 @@ It's a single plugin. No core changes, no MCP server, no separate process, no
8
8
  cloud service. Everything stays on your machine under
9
9
  `~/.local/share/opencode/`.
10
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
13
+ of the box with zero extra configuration and uses whatever models you already
14
+ have set up in opencode.
15
+
11
16
  ## Why
12
17
 
13
18
  By default every opencode session starts from zero. You re-explain your build
@@ -15,9 +20,9 @@ commands, your code style, and the quirks of each repo over and over.
15
20
 
16
21
  opencode-codex-memory closes that loop:
17
22
 
18
- - **It learns in the background.** After a session goes idle, the plugin reviews
19
- the transcript and extracts durable facts preferences, project structure,
20
- what worked and what didn't.
23
+ - **It learns in the background.** Once a session has been idle for a while
24
+ (default 6 h), a later background pass reviews the transcript and extracts
25
+ durable facts — preferences, project structure, what worked and what didn't.
21
26
  - **It consolidates.** Periodically it merges those notes into a compact,
22
27
  searchable memory, pruning what's stale.
23
28
  - **It remembers at the right time.** A short summary is injected into the system
@@ -44,22 +49,30 @@ anything.
44
49
 
45
50
  2. That's it. The memory workspace is created on first use. Installing the
46
51
  plugin is the opt-in: background learning and summary injection are active
47
- immediately (codex ships the same system behind an experimental flag with a
48
- consent prompt; a standalone memory plugin *is* the consent).
52
+ immediately (codex ships the same system behind a default-off feature flag
53
+ with a consent prompt; a standalone memory plugin *is* the consent).
49
54
 
50
55
  Requires only opencode (official release). Git is bundled (`isomorphic-git`) —
51
56
  no `git` binary or any other external tool needed.
52
57
 
53
58
  The two restricted sub-agents that do the background learning (`memorize`,
54
- `memorize-extract`) register themselves automatically. To customize one — e.g.
55
- pin a cheaper model for extraction — define an agent with the same name in your
56
- own config; your definition wins and the plugin leaves it alone.
59
+ `memorize-extract`) register themselves automatically while background learning
60
+ is enabled. To choose which models
61
+ they use, set the `extract_model` / `consolidation_model` plugin options (see
62
+ [Configuration](#configuration)) — don't override the agents for that. Defining
63
+ an agent with the same name in your own config is only for advanced tweaks
64
+ (e.g. permissions); your definition then replaces the shipped one. If you
65
+ override `memorize`, keep an `external_directory` allow for
66
+ `~/.local/share/opencode/memories/*` (e.g.
67
+ `"external_directory": { "$HOME/.local/share/opencode/memories/*": "allow" }`
68
+ after the wildcard deny) — the memory folder lives outside your project, and
69
+ without that grant opencode blocks the consolidator's file access.
57
70
 
58
71
  ## Try it
59
72
 
60
- Just use opencode normally. After a session goes idle, the plugin reviews it in
61
- the background and starts building memory — you don't have to do anything. Come
62
- back for a later session and ask something like *"what do you know about how I
73
+ Just use opencode normally. Sessions that have been idle for a few hours get
74
+ reviewed in the background and memory starts building up — you don't have to do
75
+ anything. Come back the next day and ask something like *"what do you know about how I
63
76
  work?"* or *"what was I doing in this repo?"* and the agent draws on what it
64
77
  learned. The more you use it, the more it knows.
65
78
 
@@ -82,7 +95,7 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
82
95
 
83
96
  ```
84
97
  ~/.local/share/opencode/
85
- ├── memory.db # the plugin's own database (never touches opencode's)
98
+ ├── memory.db # the plugin's own database (opencode's is only ever read)
86
99
  └── memories/
87
100
  ├── memory_summary.md # compact summary injected into the system prompt
88
101
  ├── MEMORY.md # searchable index of everything learned
@@ -92,16 +105,20 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
92
105
  ```
93
106
 
94
107
  It's all plain files and a local SQLite database. Read them, edit them, delete
95
- them, or check them into a private repo it's yours.
108
+ them it's yours. (The `memories/` folder also holds a few working files and
109
+ an internal `.git/` the plugin uses for change tracking; `memory_reset` wipes
110
+ those too.)
96
111
 
97
112
  ## Privacy & safety
98
113
 
99
114
  - **Local only.** Nothing is sent anywhere except through your existing opencode
100
115
  provider, using your existing credentials. The plugin holds no keys of its own.
101
- - **Secrets are redacted** (API keys, tokens, private keys, passwords) before any
102
- memory is written or sent to a model.
103
- - **The learning agents are sandboxed** they cannot run shell commands or reach
104
- the network.
116
+ - **Secrets are redacted** (API keys, tokens, private keys, passwords) from
117
+ session transcripts and extracted memories before anything is written or sent
118
+ to a model. Notes you explicitly dictate ("remember that ...") are stored as
119
+ you said them.
120
+ - **The learning agents are sandboxed** — every tool except reading and editing
121
+ the memory files is denied, including shell and network access.
105
122
  - **Reset is safe.** `memory_reset` refuses to run if the memory folder is a
106
123
  symlink, so it can't be tricked into deleting something else.
107
124
  - **Web/MCP sessions:** by default, sessions that used web search, fetch, or MCP
@@ -120,8 +137,8 @@ codex's `[memories]` config so the two stay easy to compare:
120
137
  | `use_memories` | `true` | Inject the memory summary into the system prompt |
121
138
  | `dedicated_tools` | `true` | Expose the `memory_read`/`memory_search`/`memory_list`/`memory_add_note` tools |
122
139
  | `disable_on_external_context` | `false` | Exclude sessions that used web/MCP tools from memory |
123
- | `extract_model` | opencode `small_model`, else current model | Model used for per-session extraction |
124
- | `consolidation_model` | opencode `model`, else current model | Model used for consolidation |
140
+ | `extract_model` | opencode `small_model`, else see below | Model used for per-session extraction |
141
+ | `consolidation_model` | opencode `model`, else see below | Model used for consolidation |
125
142
  | `max_raw_memories_for_consolidation` | `256` | How many raw memories feed each consolidation pass |
126
143
  | `max_rollout_age_days` | `10` | Ignore sessions older than this for extraction |
127
144
  | `min_rollout_idle_hours` | `6` | How long a session must be idle before it's eligible |
@@ -148,14 +165,33 @@ Model selection mirrors codex's cheap-extraction / capable-consolidation
148
165
  split using opencode's own concepts: when `extract_model` is unset, the
149
166
  `small_model` from your `opencode.json` is used (codex uses `gpt-5.4-mini`);
150
167
  when `consolidation_model` is unset, your main `model` is used (codex uses
151
- `gpt-5.4`). If neither is configured, both fall back to the session's default
152
- model. (opencode's *automatic* small-model pick is internal to opencode and
153
- not exposed to plugins set `small_model` explicitly to get the cheap
154
- extraction path.)
168
+ `gpt-5.4`). If neither is configured, the learning sub-agents fall back to
169
+ their own agent-level `model` (if you defined one), else the provider default.
170
+ (opencode's *automatic* small-model pick is internal to opencode and not
171
+ exposed to plugins — set `small_model` explicitly to get the cheap extraction
172
+ path.)
173
+
174
+ The full precedence per phase: plugin option (`extract_model` /
175
+ `consolidation_model`) → opencode config (`small_model` / `model`) → a `model`
176
+ on your own `memorize-extract`/`memorize` agent definition, if you overrode
177
+ one → the provider's default model. Note that the first two pass the model
178
+ explicitly, so they win over an agent-level `model`.
155
179
 
156
180
  > Note: `dedicated_tools` defaults to `true` here (codex defaults it to `false`).
157
181
  > This is the one intentional default difference — the tools are a core part of a
158
182
  > standalone memory plugin. Everything else matches codex's defaults.
183
+ >
184
+ > Turning `dedicated_tools` off keeps background learning, summary injection,
185
+ > and citation tracking working. The injected guidance switches to codex's
186
+ > file-based mode — the agent reads the memory files with its normal file
187
+ > tools and writes "remember this" notes directly into
188
+ > `extensions/ad_hoc/notes/`. Caveat: the memory folder lives outside your
189
+ > project, so opencode raises an `external_directory` permission prompt the
190
+ > first time an agent touches it (allow-always covers later access); agents
191
+ > whose permissions deny that ask cannot use file-based mode. The dedicated
192
+ > tools have no such friction — that's why they are the default. The
193
+ > maintenance tools (`memory_reset`, `memory_inspect`, `memory_mode`) stay
194
+ > available either way.
159
195
 
160
196
  ## Under the hood
161
197
 
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "memorize-extract": {
22
22
  "mode": "subagent",
23
- "prompt": "You are a memory extraction agent. Read the session transcript and extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
23
+ "prompt": "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
24
24
  "permission": {
25
25
  "*": "deny",
26
26
  "bash": "deny",
@@ -28,11 +28,11 @@
28
28
  "websearch": "deny",
29
29
  "task": "deny",
30
30
  "todowrite": "deny",
31
- "read": "allow",
31
+ "read": "deny",
32
32
  "write": "deny",
33
33
  "edit": "deny",
34
- "glob": "allow",
35
- "grep": "allow"
34
+ "glob": "deny",
35
+ "grep": "deny"
36
36
  }
37
37
  }
38
38
  }
@@ -1,19 +1,44 @@
1
- import { MemoryStore } from "./store.js";
1
+ import type { MemoryStore } from "./store.js";
2
2
  export interface SessionRow {
3
3
  id: string;
4
4
  updated_at: number;
5
5
  directory: string | null;
6
6
  }
7
- export declare function listRecentSessions(limit?: number): SessionRow[];
7
+ /**
8
+ * Global session discovery through the official API: opencode's session.list
9
+ * is project-scoped, so enumerate projects (project.list) and list each one
10
+ * with scope=project (routes the request to that project's instance AND
11
+ * widens the filter from the session directory to the whole project).
12
+ * Instance contexts created this way are cached by the host for the process
13
+ * lifetime, and the whole pass is rate-limited (30s min interval).
14
+ *
15
+ * Fail-safe at two levels: a failed project.list skips the pass ([]), a
16
+ * failed per-project session.list skips that project — neither claims or
17
+ * finalizes any job. Transcript loading must NOT be fail-safe — see
18
+ * loadTranscript.
19
+ */
20
+ export declare function listRecentSessions(limit?: number): Promise<SessionRow[]>;
8
21
  export interface TranscriptMessage {
9
22
  type: string;
10
23
  role?: string;
11
24
  text?: string;
12
25
  }
13
- export declare function loadTranscript(sessionId: string): TranscriptMessage[];
26
+ /**
27
+ * Transcript loading uses the official API — the same surface opencode's own
28
+ * UI renders history from; the session-scoped route resolves the right
29
+ * instance even for sessions from other projects.
30
+ *
31
+ * Errors PROPAGATE. An empty result must mean "session has no extractable
32
+ * content" — a swallowed error here used to surface as a successful
33
+ * no-output extraction, which deletes any previous extraction for the
34
+ * session (codex: load_rollout_items errors fail the job, which retries
35
+ * under its lease/backoff). A claimed session normally has messages, so a
36
+ * legitimately empty result is logged for observability.
37
+ */
38
+ export declare function loadTranscript(sessionId: string): Promise<TranscriptMessage[]>;
14
39
  export interface EligibilityOptions {
15
40
  maxAgeDays: number;
16
41
  minIdleHours: number;
17
42
  excludeSession?: string;
18
43
  }
19
- export declare function selectEligibleSessions(store: MemoryStore, opts: EligibilityOptions): SessionRow[];
44
+ export declare function selectEligibleSessions(store: MemoryStore, opts: EligibilityOptions): Promise<SessionRow[]>;
@@ -1,76 +1,130 @@
1
- import { Database } from "bun:sqlite";
2
- import { opencodeDbPath } from "./paths.js";
3
1
  import { SCAN_LIMIT } from "./store.js";
4
- let opencodeDb = null;
5
- function openOpencodeDb() {
6
- if (opencodeDb)
7
- return opencodeDb;
8
- const p = opencodeDbPath();
2
+ import { getPluginInput } from "./llm.js";
3
+ const API_TIMEOUT_MS = 60_000;
4
+ async function withTimeout(promise, ms, label) {
5
+ let timer;
9
6
  try {
10
- opencodeDb = new Database(p, { readonly: true });
7
+ return await Promise.race([
8
+ promise,
9
+ new Promise((_, reject) => {
10
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
11
+ }),
12
+ ]);
11
13
  }
12
- catch {
13
- opencodeDb = null;
14
+ finally {
15
+ clearTimeout(timer);
14
16
  }
15
- return opencodeDb;
16
17
  }
17
- export function listRecentSessions(limit = SCAN_LIMIT) {
18
- const db = openOpencodeDb();
19
- if (!db)
18
+ /**
19
+ * Global session discovery through the official API: opencode's session.list
20
+ * is project-scoped, so enumerate projects (project.list) and list each one
21
+ * with scope=project (routes the request to that project's instance AND
22
+ * widens the filter from the session directory to the whole project).
23
+ * Instance contexts created this way are cached by the host for the process
24
+ * lifetime, and the whole pass is rate-limited (30s min interval).
25
+ *
26
+ * Fail-safe at two levels: a failed project.list skips the pass ([]), a
27
+ * failed per-project session.list skips that project — neither claims or
28
+ * finalizes any job. Transcript loading must NOT be fail-safe — see
29
+ * loadTranscript.
30
+ */
31
+ export async function listRecentSessions(limit = SCAN_LIMIT) {
32
+ const client = getPluginInput()?.client;
33
+ if (!client?.project?.list || !client?.session?.list)
20
34
  return [];
35
+ let projects;
21
36
  try {
22
- // Top-level sessions only: task-tool children are summarized into their
23
- // parent, and the plugin's own sub-sessions must never be memorized.
24
- return db
25
- .prepare(`SELECT id, time_updated AS updated_at, directory FROM session
26
- WHERE parent_id IS NULL AND title NOT LIKE 'codex-memory-%'
27
- ORDER BY time_updated DESC LIMIT ?`)
28
- .all(limit);
37
+ const res = await withTimeout(client.project.list(), API_TIMEOUT_MS, "project.list");
38
+ if (!res || res.error || !Array.isArray(res.data))
39
+ throw new Error(`project.list failed: ${JSON.stringify(res?.error ?? {})}`);
40
+ projects = res.data;
29
41
  }
30
- catch {
42
+ catch (err) {
43
+ console.warn("[opencode-codex-memory] project discovery failed; skipping pass:", err);
31
44
  return [];
32
45
  }
33
- }
34
- export function loadTranscript(sessionId) {
35
- const db = openOpencodeDb();
36
- if (!db)
37
- return [];
38
- try {
39
- const rows = db
40
- .prepare(`SELECT p.data, m.data AS msg_data
41
- FROM part p
42
- JOIN message m ON p.message_id = m.id
43
- WHERE p.session_id = ?
44
- ORDER BY p.time_created ASC`)
45
- .all(sessionId);
46
- return rows.map((r) => {
47
- let parsed = {};
48
- try {
49
- parsed = JSON.parse(r.data);
50
- }
51
- catch {
46
+ const all = [];
47
+ for (const project of projects) {
48
+ if (!project?.worktree)
49
+ continue;
50
+ try {
51
+ const res = await withTimeout(client.session.list({
52
+ // scope/roots/limit are in the server's ListQuery since 1.17; the
53
+ // pinned SDK types lag behind, hence the cast at the call site.
54
+ query: { directory: project.worktree, scope: "project", roots: true, limit },
55
+ }), API_TIMEOUT_MS, "session.list");
56
+ if (!res || res.error || !Array.isArray(res.data))
57
+ throw new Error(JSON.stringify(res?.error ?? {}));
58
+ for (const s of res.data) {
59
+ // Top-level sessions only: task-tool children are summarized into
60
+ // their parent, and the plugin's own sub-sessions must never be
61
+ // memorized (roots=true drops children server-side; keep both belts).
62
+ if (!s?.id || s.parentID)
63
+ continue;
64
+ if (s.title && s.title.startsWith("codex-memory-"))
65
+ continue;
66
+ all.push({ id: s.id, updated_at: s.time?.updated ?? 0, directory: s.directory ?? null });
52
67
  }
53
- let role;
54
- try {
55
- const msg = JSON.parse(r.msg_data);
56
- role = msg.role;
57
- }
58
- catch {
59
- }
60
- return {
61
- type: parsed.type ?? "unknown",
62
- role,
63
- text: extractText(parsed),
64
- };
65
- });
68
+ }
69
+ catch (err) {
70
+ console.warn(`[opencode-codex-memory] session.list failed for ${project.worktree}; skipping project:`, err);
71
+ }
66
72
  }
67
- catch {
73
+ all.sort((a, b) => b.updated_at - a.updated_at);
74
+ return all.slice(0, limit);
75
+ }
76
+ /** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
77
+ async function fetchMessagesViaApi(sessionId) {
78
+ const client = getPluginInput()?.client;
79
+ if (typeof client?.session?.messages !== "function") {
80
+ throw new Error("plugin client unavailable; cannot load transcript");
81
+ }
82
+ const res = await withTimeout(client.session.messages({ path: { id: sessionId } }), API_TIMEOUT_MS, "session.messages");
83
+ if (!res || res.error || !Array.isArray(res.data)) {
84
+ throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
85
+ }
86
+ return res.data;
87
+ }
88
+ /**
89
+ * Transcript loading uses the official API — the same surface opencode's own
90
+ * UI renders history from; the session-scoped route resolves the right
91
+ * instance even for sessions from other projects.
92
+ *
93
+ * Errors PROPAGATE. An empty result must mean "session has no extractable
94
+ * content" — a swallowed error here used to surface as a successful
95
+ * no-output extraction, which deletes any previous extraction for the
96
+ * session (codex: load_rollout_items errors fail the job, which retries
97
+ * under its lease/backoff). A claimed session normally has messages, so a
98
+ * legitimately empty result is logged for observability.
99
+ */
100
+ export async function loadTranscript(sessionId) {
101
+ const rows = await fetchMessagesViaApi(sessionId);
102
+ if (rows.length === 0) {
103
+ console.warn(`[opencode-codex-memory] session.messages returned no messages for claimed session ${sessionId}`);
68
104
  return [];
69
105
  }
106
+ // One entry per part — the granularity extraction expects.
107
+ const out = [];
108
+ for (const row of rows) {
109
+ const role = row?.info?.role;
110
+ for (const part of row?.parts ?? []) {
111
+ out.push({
112
+ type: part?.type ?? "unknown",
113
+ role,
114
+ text: extractText(part),
115
+ });
116
+ }
117
+ }
118
+ return out;
70
119
  }
71
120
  function extractText(msg) {
72
121
  if (!msg)
73
122
  return undefined;
123
+ // codex excludes reasoning items from extraction transcripts
124
+ // (rollout policy: ResponseItem::Reasoning => false); opencode reasoning
125
+ // parts carry `text`, so they must be dropped before the text check.
126
+ if (msg.type === "reasoning")
127
+ return undefined;
74
128
  if (typeof msg.text === "string")
75
129
  return msg.text;
76
130
  if (msg.type === "tool") {
@@ -98,11 +152,11 @@ function extractText(msg) {
98
152
  }
99
153
  return undefined;
100
154
  }
101
- export function selectEligibleSessions(store, opts) {
155
+ export async function selectEligibleSessions(store, opts) {
102
156
  const now = Date.now();
103
157
  const minUpdated = now - opts.maxAgeDays * 24 * 60 * 60 * 1000;
104
158
  const maxUpdated = now - opts.minIdleHours * 60 * 60 * 1000;
105
- const sessions = listRecentSessions();
159
+ const sessions = await listRecentSessions();
106
160
  return sessions.filter((s) => {
107
161
  if (opts.excludeSession && s.id === opts.excludeSession)
108
162
  return false;
@@ -62,8 +62,8 @@ async function commitBaseline(dir) {
62
62
  * without any commit gets a fresh baseline.
63
63
  */
64
64
  export async function ensureBaseline() {
65
+ const dir = memoryRoot();
65
66
  try {
66
- const dir = memoryRoot();
67
67
  removeDiffArtifact(dir);
68
68
  await ensureInit(dir);
69
69
  if (!(await hasHeadCommit(dir))) {
@@ -72,8 +72,20 @@ export async function ensureBaseline() {
72
72
  return true;
73
73
  }
74
74
  catch (err) {
75
- console.error("[opencode-codex-memory] ensureBaseline error:", err);
76
- return false;
75
+ // codex ensure_git_baseline_repository: unusable/corrupt git metadata is
76
+ // recovered by a destructive fresh re-init (reset_git_repository_sync)
77
+ // instead of failing the job forever.
78
+ console.error("[opencode-codex-memory] ensureBaseline error, re-initializing baseline:", err);
79
+ try {
80
+ fs.rmSync(path.join(dir, ".git"), { recursive: true, force: true });
81
+ await isogit.init({ fs, dir });
82
+ await commitBaseline(dir);
83
+ return true;
84
+ }
85
+ catch (err2) {
86
+ console.error("[opencode-codex-memory] baseline re-init failed:", err2);
87
+ return false;
88
+ }
77
89
  }
78
90
  }
79
91
  async function readBaselineText(dir, headOid, filepath) {
@@ -93,41 +105,38 @@ function readWorkdirText(dir, filepath) {
93
105
  return "";
94
106
  }
95
107
  }
108
+ // Throws on failure: codex fails the phase-2 job on workspace-status errors
109
+ // (failed_workspace_status). Swallowing the error here would make an errored
110
+ // diff indistinguishable from "no changes" and falsely mark the job succeeded.
96
111
  export async function captureWorkspaceDiff() {
112
+ const dir = memoryRoot();
113
+ await ensureInit(dir);
114
+ removeDiffArtifact(dir);
115
+ const matrix = await isogit.statusMatrix({ fs, dir });
116
+ const changedRows = matrix.filter(([filepath, head, workdir]) => head !== workdir && filepath !== DIFF_ARTIFACT);
117
+ const changes = changedRows.map(([filepath, head, workdir]) => {
118
+ if (head === 0)
119
+ return { status: "A", path: filepath };
120
+ if (workdir === 0)
121
+ return { status: "D", path: filepath };
122
+ return { status: "M", path: filepath };
123
+ });
124
+ let headOid = null;
97
125
  try {
98
- const dir = memoryRoot();
99
- await ensureInit(dir);
100
- removeDiffArtifact(dir);
101
- const matrix = await isogit.statusMatrix({ fs, dir });
102
- const changedRows = matrix.filter(([filepath, head, workdir]) => head !== workdir && filepath !== DIFF_ARTIFACT);
103
- const changes = changedRows.map(([filepath, head, workdir]) => {
104
- if (head === 0)
105
- return { status: "A", path: filepath };
106
- if (workdir === 0)
107
- return { status: "D", path: filepath };
108
- return { status: "M", path: filepath };
109
- });
110
- let headOid = null;
111
- try {
112
- headOid = await isogit.resolveRef({ fs, dir, ref: "HEAD" });
113
- }
114
- catch {
115
- // no commits yet — every file diffs against empty
116
- }
117
- const patches = [];
118
- for (const [filepath, head, workdir] of changedRows) {
119
- const oldText = head === 1 && headOid ? await readBaselineText(dir, headOid, filepath) : "";
120
- const newText = workdir === 0 ? "" : readWorkdirText(dir, filepath);
121
- // No per-file cap: codex renders every file's patch in full and relies
122
- // on the global 4 MiB truncation in writeWorkspaceDiff.
123
- patches.push(createPatch(filepath, oldText, newText));
124
- }
125
- return { changes, unifiedDiff: patches.join("\n") };
126
+ headOid = await isogit.resolveRef({ fs, dir, ref: "HEAD" });
126
127
  }
127
- catch (err) {
128
- console.error("[opencode-codex-memory] captureWorkspaceDiff error:", err);
129
- return { changes: [], unifiedDiff: "" };
128
+ catch {
129
+ // no commits yet — every file diffs against empty
130
+ }
131
+ const patches = [];
132
+ for (const [filepath, head, workdir] of changedRows) {
133
+ const oldText = head === 1 && headOid ? await readBaselineText(dir, headOid, filepath) : "";
134
+ const newText = workdir === 0 ? "" : readWorkdirText(dir, filepath);
135
+ // No per-file cap: codex renders every file's patch in full and relies
136
+ // on the global 4 MiB truncation in writeWorkspaceDiff.
137
+ patches.push(createPatch(filepath, oldText, newText));
130
138
  }
139
+ return { changes, unifiedDiff: patches.join("\n") };
131
140
  }
132
141
  /**
133
142
  * Mirrors codex reset_git_repository: delete .git and re-create a fresh
@@ -1,6 +1,8 @@
1
1
  import { MemoryStore } from "./store.js";
2
2
  import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
3
3
  export declare function takeNewCitations(partKey: string, ids: string[]): string[];
4
+ export declare function markTurnSeen(sessionId: string): boolean;
5
+ export declare function shouldHandleIdle(sessionId: string, now?: number): boolean;
4
6
  export declare function handleSessionDeleted(sessionId: string, store?: Pick<MemoryStore, "deleteSessionMemory">, schedulePhase2?: () => void): void;
5
7
  declare const _default: {
6
8
  id: string;
@@ -22,16 +24,32 @@ declare const _default: {
22
24
  memory_search: {
23
25
  description: string;
24
26
  args: {
25
- query: import("zod").ZodOptional<import("zod").ZodString>;
27
+ queries: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
28
+ match_mode: import("zod").ZodDefault<import("zod").ZodEnum<{
29
+ any: "any";
30
+ all_on_same_line: "all_on_same_line";
31
+ all_within_lines: "all_within_lines";
32
+ }>>;
33
+ line_count: import("zod").ZodOptional<import("zod").ZodNumber>;
34
+ path: import("zod").ZodOptional<import("zod").ZodString>;
35
+ cursor: import("zod").ZodOptional<import("zod").ZodString>;
36
+ context_lines: import("zod").ZodDefault<import("zod").ZodNumber>;
26
37
  case_sensitive: import("zod").ZodDefault<import("zod").ZodBoolean>;
38
+ normalized: import("zod").ZodDefault<import("zod").ZodBoolean>;
27
39
  since: import("zod").ZodOptional<import("zod").ZodString>;
28
40
  until: import("zod").ZodOptional<import("zod").ZodString>;
29
- limit: import("zod").ZodDefault<import("zod").ZodNumber>;
41
+ max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
30
42
  };
31
43
  execute(args: {
44
+ match_mode: "any" | "all_on_same_line" | "all_within_lines";
45
+ context_lines: number;
32
46
  case_sensitive: boolean;
33
- limit: number;
34
- query?: string | undefined;
47
+ normalized: boolean;
48
+ max_results: number;
49
+ queries?: string[] | undefined;
50
+ line_count?: number | undefined;
51
+ path?: string | undefined;
52
+ cursor?: string | undefined;
35
53
  since?: string | undefined;
36
54
  until?: string | undefined;
37
55
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
@@ -131,6 +149,22 @@ declare const _default: {
131
149
  }, output: {
132
150
  system: string[];
133
151
  }): Promise<void>;
152
+ /**
153
+ * Fires at text-end, before opencode persists the final part text
154
+ * (session/processor.ts): the returned text replaces the stored one.
155
+ * Primary citation seam — records usage and strips the block so neither
156
+ * the UI nor history ever shows citation markup (matches codex, which
157
+ * strips from the displayed/persisted message). The event and
158
+ * messages.transform paths below stay as fallbacks for older opencode
159
+ * hosts and for history persisted before this hook existed.
160
+ */
161
+ "experimental.text.complete"(input: {
162
+ sessionID: string;
163
+ messageID: string;
164
+ partID: string;
165
+ }, output: {
166
+ text: string;
167
+ }): Promise<void>;
134
168
  "experimental.chat.messages.transform"(_input: unknown, output: {
135
169
  messages: {
136
170
  info: {
@@ -142,6 +176,24 @@ declare const _default: {
142
176
  }[];
143
177
  }[];
144
178
  }): Promise<void>;
179
+ /**
180
+ * Turn start. codex stamps memory_mode at thread creation (session.rs) and
181
+ * schedules memory work per startup/turn; the first user message is the
182
+ * closest plugin-visible moment. Stamping here (instead of waiting for the
183
+ * first idle) means a session created while generate_memories=false keeps
184
+ * its 'disabled' stamp even if the option flips mid-session, and the
185
+ * phase-1 pump no longer depends on idle events at all. The idle path
186
+ * below stays as a second pump trigger; both are cheap (stamp is INSERT OR
187
+ * IGNORE, the pump is gated by in-flight/rate/claim guards).
188
+ */
189
+ "chat.message"(input: {
190
+ sessionID?: string;
191
+ }): Promise<void>;
192
+ "tool.execute.after"(input: {
193
+ tool: string;
194
+ sessionID: string;
195
+ callID: string;
196
+ }): Promise<void>;
145
197
  event(input: {
146
198
  event: {
147
199
  type: string;