opencode-codex-memory 0.1.9 → 0.2.1

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
@@ -52,8 +52,8 @@ anything.
52
52
  immediately (codex ships the same system behind a default-off feature flag
53
53
  with a consent prompt; a standalone memory plugin *is* the consent).
54
54
 
55
- Requires only opencode (official release). Git is bundled (`isomorphic-git`) —
56
- no `git` binary or any other external tool needed.
55
+ Requires opencode 1.18 or newer (official release). Git is bundled
56
+ (`isomorphic-git`) — no `git` binary or any other external tool needed.
57
57
 
58
58
  The two restricted sub-agents that do the background learning (`memorize`,
59
59
  `memorize-extract`) register themselves automatically while background learning
@@ -61,7 +61,12 @@ is enabled. To choose which models
61
61
  they use, set the `extract_model` / `consolidation_model` plugin options (see
62
62
  [Configuration](#configuration)) — don't override the agents for that. Defining
63
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.
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.
65
70
 
66
71
  ## Try it
67
72
 
@@ -90,7 +95,7 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
90
95
 
91
96
  ```
92
97
  ~/.local/share/opencode/
93
- ├── memory.db # the plugin's own database (opencode's is only ever read)
98
+ ├── memory.db # the plugin's own database (opencode's data is only accessed via its API)
94
99
  └── memories/
95
100
  ├── memory_summary.md # compact summary injected into the system prompt
96
101
  ├── MEMORY.md # searchable index of everything learned
@@ -112,8 +117,10 @@ those too.)
112
117
  session transcripts and extracted memories before anything is written or sent
113
118
  to a model. Notes you explicitly dictate ("remember that ...") are stored as
114
119
  you said them.
115
- - **The learning agents are sandboxed** — every tool except reading and editing
116
- the memory files is denied, including shell and network access.
120
+ - **The learning agents are sandboxed** — the extraction agent has no tools at
121
+ all (the transcript is handed to it inline), and the consolidation agent gets
122
+ only file tools plus access to the memory folder. Shell, network, IDE, and
123
+ MCP tools are denied for both.
117
124
  - **Reset is safe.** `memory_reset` refuses to run if the memory folder is a
118
125
  symlink, so it can't be tricked into deleting something else.
119
126
  - **Web/MCP sessions:** by default, sessions that used web search, fetch, or MCP
@@ -176,16 +183,32 @@ explicitly, so they win over an agent-level `model`.
176
183
  > This is the one intentional default difference — the tools are a core part of a
177
184
  > standalone memory plugin. Everything else matches codex's defaults.
178
185
  >
179
- > Turning `dedicated_tools` off doesn't break anything: background learning,
180
- > summary injection, and citation tracking all keep working. The injected
181
- > guidance switches to codex's file-based mode — the agent reads the memory
182
- > files with its normal file tools and writes "remember this" notes directly
183
- > into `extensions/ad_hoc/notes/`. The maintenance tools (`memory_reset`,
184
- > `memory_inspect`, `memory_mode`) stay available either way.
186
+ > Turning `dedicated_tools` off keeps background learning, summary injection,
187
+ > and citation tracking working. The injected guidance switches to codex's
188
+ > file-based mode — the agent reads the memory files with its normal file
189
+ > tools and writes "remember this" notes directly into
190
+ > `extensions/ad_hoc/notes/`. Caveat: the memory folder lives outside your
191
+ > project, so opencode raises an `external_directory` permission prompt the
192
+ > first time an agent touches it (allow-always covers later access); agents
193
+ > whose permissions deny that ask cannot use file-based mode. The dedicated
194
+ > tools have no such friction — that's why they are the default. The
195
+ > maintenance tools (`memory_reset`, `memory_inspect`, `memory_mode`) stay
196
+ > available either way.
185
197
 
186
198
  ## Under the hood
187
199
 
188
200
  opencode-codex-memory is a faithful port of the memory system from OpenAI's codex.
201
+
202
+ One design choice is worth calling out, because it shapes everything else: **memory
203
+ is global.** There's a single store for all your work, not one per project. That's
204
+ not an accident of the port — it's codex's own hard-won shape. codex *started* with
205
+ per-project memory (a separate bucket per directory, plus a user scope) and
206
+ **deliberately removed it** in early 2026, collapsing everything into one global
207
+ root for simplicity: one store, one lock, one consolidation pass. Project awareness
208
+ didn't disappear — it moved out of storage and into the prompt, as soft "this looks
209
+ like it belongs to that project" hints rather than hard partitions. This port
210
+ mirrors that exactly.
211
+
189
212
  If you want to understand the design, the trade-offs, or contribute, see
190
213
  [`ARCHITECTURE.md`](./ARCHITECTURE.md). Contributor guidance lives in
191
214
  [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`AGENTS.md`](./AGENTS.md) —
@@ -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,72 +1,123 @@
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 (accepted since
53
+ // opencode 1.14.30, well under our 1.18 floor); the pinned SDK types
54
+ // still omit them (SessionListData.query is just { directory } as of
55
+ // 1.18.1), hence the cast at the call site.
56
+ query: { directory: project.worktree, scope: "project", roots: true, limit },
57
+ }), API_TIMEOUT_MS, "session.list");
58
+ if (!res || res.error || !Array.isArray(res.data))
59
+ throw new Error(JSON.stringify(res?.error ?? {}));
60
+ for (const s of res.data) {
61
+ // Top-level sessions only: task-tool children are summarized into
62
+ // their parent, and the plugin's own sub-sessions must never be
63
+ // memorized (roots=true drops children server-side; keep both belts).
64
+ if (!s?.id || s.parentID)
65
+ continue;
66
+ if (s.title && s.title.startsWith("codex-memory-"))
67
+ continue;
68
+ all.push({ id: s.id, updated_at: s.time?.updated ?? 0, directory: s.directory ?? null });
52
69
  }
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
- });
70
+ }
71
+ catch (err) {
72
+ console.warn(`[opencode-codex-memory] session.list failed for ${project.worktree}; skipping project:`, err);
73
+ }
66
74
  }
67
- catch {
75
+ all.sort((a, b) => b.updated_at - a.updated_at);
76
+ return all.slice(0, limit);
77
+ }
78
+ /** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
79
+ async function fetchMessagesViaApi(sessionId) {
80
+ const client = getPluginInput()?.client;
81
+ if (typeof client?.session?.messages !== "function") {
82
+ throw new Error("plugin client unavailable; cannot load transcript");
83
+ }
84
+ const res = await withTimeout(client.session.messages({ path: { id: sessionId } }), API_TIMEOUT_MS, "session.messages");
85
+ if (!res || res.error || !Array.isArray(res.data)) {
86
+ throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
87
+ }
88
+ return res.data;
89
+ }
90
+ /**
91
+ * Transcript loading uses the official API — the same surface opencode's own
92
+ * UI renders history from; the session-scoped route resolves the right
93
+ * instance even for sessions from other projects.
94
+ *
95
+ * Errors PROPAGATE. An empty result must mean "session has no extractable
96
+ * content" — a swallowed error here used to surface as a successful
97
+ * no-output extraction, which deletes any previous extraction for the
98
+ * session (codex: load_rollout_items errors fail the job, which retries
99
+ * under its lease/backoff). A claimed session normally has messages, so a
100
+ * legitimately empty result is logged for observability.
101
+ */
102
+ export async function loadTranscript(sessionId) {
103
+ const rows = await fetchMessagesViaApi(sessionId);
104
+ if (rows.length === 0) {
105
+ console.warn(`[opencode-codex-memory] session.messages returned no messages for claimed session ${sessionId}`);
68
106
  return [];
69
107
  }
108
+ // One entry per part — the granularity extraction expects.
109
+ const out = [];
110
+ for (const row of rows) {
111
+ const role = row?.info?.role;
112
+ for (const part of row?.parts ?? []) {
113
+ out.push({
114
+ type: part?.type ?? "unknown",
115
+ role,
116
+ text: extractText(part),
117
+ });
118
+ }
119
+ }
120
+ return out;
70
121
  }
71
122
  function extractText(msg) {
72
123
  if (!msg)
@@ -103,11 +154,11 @@ function extractText(msg) {
103
154
  }
104
155
  return undefined;
105
156
  }
106
- export function selectEligibleSessions(store, opts) {
157
+ export async function selectEligibleSessions(store, opts) {
107
158
  const now = Date.now();
108
159
  const minUpdated = now - opts.maxAgeDays * 24 * 60 * 60 * 1000;
109
160
  const maxUpdated = now - opts.minIdleHours * 60 * 60 * 1000;
110
- const sessions = listRecentSessions();
161
+ const sessions = await listRecentSessions();
111
162
  return sessions.filter((s) => {
112
163
  if (opts.excludeSession && s.id === opts.excludeSession)
113
164
  return false;
@@ -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,19 @@ 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>;
145
192
  "tool.execute.after"(input: {
146
193
  tool: string;
147
194
  sessionID: string;
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js";
2
+ import { memoryRoot } from "./paths.js";
2
3
  import { stripCitations, extractCitedSessionIds } from "./citation.js";
3
4
  import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js";
4
5
  import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
@@ -31,9 +32,10 @@ let pluginOptions = {
31
32
  function getStore() {
32
33
  return new MemoryStore();
33
34
  }
34
- // Citation blocks arrive via message.part.updated once per streaming delta,
35
- // so the same completed block is seen many times. Track which session ids
36
- // were already recorded per part to count each citation once.
35
+ // Citation blocks are seen by both the text.complete hook (once, at
36
+ // completion) and message.part.updated (once per streaming delta), so the
37
+ // same block surfaces many times. Track which session ids were already
38
+ // recorded per part to count each citation exactly once across both paths.
37
39
  const recordedCitations = new Map();
38
40
  const MAX_TRACKED_PARTS = 500;
39
41
  export function takeNewCitations(partKey, ids) {
@@ -52,6 +54,40 @@ export function takeNewCitations(partKey, ids) {
52
54
  seen.add(id);
53
55
  return fresh;
54
56
  }
57
+ // One stamp+pump per session per process from the chat.message hook; later
58
+ // messages in the same session add nothing (stamp is idempotent, the pump
59
+ // re-fires on idle anyway).
60
+ const seenTurnSessions = new Set();
61
+ const MAX_TRACKED_TURN_SESSIONS = 1000;
62
+ export function markTurnSeen(sessionId) {
63
+ if (seenTurnSessions.has(sessionId))
64
+ return false;
65
+ seenTurnSessions.add(sessionId);
66
+ if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
67
+ const oldest = seenTurnSessions.keys().next().value;
68
+ if (oldest !== undefined)
69
+ seenTurnSessions.delete(oldest);
70
+ }
71
+ return true;
72
+ }
73
+ // opencode 1.17 publishes BOTH session.status {type:"idle"} and the
74
+ // deprecated session.idle for the same transition, back to back. Handle
75
+ // whichever arrives first and swallow the twin within a short window.
76
+ const recentIdle = new Map();
77
+ const IDLE_DEDUP_MS = 5000;
78
+ const MAX_TRACKED_IDLE = 500;
79
+ export function shouldHandleIdle(sessionId, now = Date.now()) {
80
+ const last = recentIdle.get(sessionId);
81
+ if (last !== undefined && now - last < IDLE_DEDUP_MS)
82
+ return false;
83
+ recentIdle.set(sessionId, now);
84
+ if (recentIdle.size > MAX_TRACKED_IDLE) {
85
+ const oldest = recentIdle.keys().next().value;
86
+ if (oldest !== undefined)
87
+ recentIdle.delete(oldest);
88
+ }
89
+ return true;
90
+ }
55
91
  export function handleSessionDeleted(sessionId, store = getStore(),
56
92
  // With generation off the memorize agent is not injected, so a consolidation
57
93
  // attempt could only fail; the row deletion above still happens, and the
@@ -172,6 +208,18 @@ export function injectAgentDefinitions(config) {
172
208
  console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
173
209
  return;
174
210
  }
211
+ // opencode gates file tools outside the session's project behind the
212
+ // `external_directory` permission, and the memory workspace is global —
213
+ // outside every project — so the consolidator's reads/writes there always
214
+ // trigger that ask. The bundled `"*": "deny"` matches it (permission rules
215
+ // are wildcard-on-name, last match wins), which would block consolidation
216
+ // entirely. Grant the memory root here rather than in opencode.json: the
217
+ // path is homedir/env-dependent (src/paths.ts is its single source of
218
+ // truth). Appended last so it out-ranks the wildcard deny.
219
+ const memorize = defs["memorize"];
220
+ if (memorize?.permission && !("external_directory" in memorize.permission)) {
221
+ memorize.permission["external_directory"] = { [path.join(memoryRoot(), "*")]: "allow" };
222
+ }
175
223
  config.agent ??= {};
176
224
  for (const [name, def] of Object.entries(defs)) {
177
225
  if (!config.agent[name])
@@ -208,6 +256,41 @@ function buildHooks() {
208
256
  console.error("[opencode-codex-memory] system.transform error:", err);
209
257
  }
210
258
  },
259
+ /**
260
+ * Fires at text-end, before opencode persists the final part text
261
+ * (session/processor.ts): the returned text replaces the stored one.
262
+ * Primary citation seam — records usage and strips the block so neither
263
+ * the UI nor history ever shows citation markup (matches codex, which
264
+ * strips from the displayed/persisted message). The event and
265
+ * messages.transform paths below stay as fallbacks for older opencode
266
+ * hosts and for history persisted before this hook existed.
267
+ */
268
+ async "experimental.text.complete"(input, output) {
269
+ try {
270
+ if (isMemorySubSession(input.sessionID))
271
+ return;
272
+ if (!output.text.includes("<memory-citation>"))
273
+ return;
274
+ try {
275
+ const ids = extractCitedSessionIds(output.text);
276
+ // Same part key as the event path: whichever hook sees the ids first
277
+ // records them; the other becomes a no-op.
278
+ const fresh = takeNewCitations(`${input.sessionID}:${input.partID}`, ids);
279
+ if (fresh.length > 0)
280
+ getStore().recordUsage(fresh);
281
+ }
282
+ catch (e) {
283
+ console.error("[opencode-codex-memory] citation recording failed:", e);
284
+ }
285
+ output.text = stripCitations(output.text);
286
+ }
287
+ catch (err) {
288
+ console.error("[opencode-codex-memory] text.complete error:", err);
289
+ }
290
+ },
291
+ // Fallback strip for history that still carries citation blocks (messages
292
+ // persisted by plugin versions before the text.complete seam, or hosts
293
+ // without it). Keeps citation markup out of the model-facing transcript.
211
294
  async "experimental.chat.messages.transform"(_input, output) {
212
295
  try {
213
296
  for (const msg of output.messages) {
@@ -228,6 +311,35 @@ function buildHooks() {
228
311
  console.error("[opencode-codex-memory] messages.transform error:", err);
229
312
  }
230
313
  },
314
+ /**
315
+ * Turn start. codex stamps memory_mode at thread creation (session.rs) and
316
+ * schedules memory work per startup/turn; the first user message is the
317
+ * closest plugin-visible moment. Stamping here (instead of waiting for the
318
+ * first idle) means a session created while generate_memories=false keeps
319
+ * its 'disabled' stamp even if the option flips mid-session, and the
320
+ * phase-1 pump no longer depends on idle events at all. The idle path
321
+ * below stays as a second pump trigger; both are cheap (stamp is INSERT OR
322
+ * IGNORE, the pump is gated by in-flight/rate/claim guards).
323
+ */
324
+ async "chat.message"(input) {
325
+ try {
326
+ const sid = input?.sessionID;
327
+ if (!sid || isMemorySubSession(sid))
328
+ return;
329
+ if (!markTurnSeen(sid))
330
+ return;
331
+ try {
332
+ getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
333
+ }
334
+ catch (e) {
335
+ console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
336
+ }
337
+ void triggerPhase1(sid);
338
+ }
339
+ catch (err) {
340
+ console.error("[opencode-codex-memory] chat.message error:", err);
341
+ }
342
+ },
231
343
  // Dedicated plugin hook (NOT an event-bus type): fires after every tool
232
344
  // call. Mirrors codex: external context (web search or any MCP tool) only
233
345
  // pollutes the session's memory when disable_on_external_context is
@@ -289,21 +401,19 @@ function buildHooks() {
289
401
  }
290
402
  return;
291
403
  }
404
+ // session.idle is deprecated in opencode 1.17 in favor of
405
+ // session.status {type:"idle"}; both are still emitted. Support both so
406
+ // the pipeline keeps triggering when the legacy event disappears.
407
+ if (ev.type === "session.status") {
408
+ const props = ev.properties;
409
+ if (props?.status?.type === "idle" && props.sessionID)
410
+ handleSessionIdle(props.sessionID);
411
+ return;
412
+ }
292
413
  if (ev.type === "session.idle") {
293
414
  const props = ev.properties;
294
- const sid = props?.sessionID;
295
- if (!sid || isMemorySubSession(sid))
296
- return;
297
- // codex stamps memory_mode at thread creation from generate_memories:
298
- // sessions first seen while generation is off keep that stamp when the
299
- // option is re-enabled (manual override: the memory_mode tool).
300
- try {
301
- getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
302
- }
303
- catch (e) {
304
- console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
305
- }
306
- void triggerPhase1(sid);
415
+ if (props?.sessionID)
416
+ handleSessionIdle(props.sessionID);
307
417
  return;
308
418
  }
309
419
  }
@@ -315,6 +425,22 @@ function buildHooks() {
315
425
  invalidateCache();
316
426
  },
317
427
  };
428
+ function handleSessionIdle(sid) {
429
+ if (isMemorySubSession(sid))
430
+ return;
431
+ if (!shouldHandleIdle(sid))
432
+ return;
433
+ // codex stamps memory_mode at thread creation from generate_memories:
434
+ // sessions first seen while generation is off keep that stamp when the
435
+ // option is re-enabled (manual override: the memory_mode tool).
436
+ try {
437
+ getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
438
+ }
439
+ catch (e) {
440
+ console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
441
+ }
442
+ void triggerPhase1(sid);
443
+ }
318
444
  // Control tools (reset/inspect/mode) are always available. The memory
319
445
  // read/search/list/add-note tools require BOTH use_memories and
320
446
  // dedicated_tools, mirroring codex's MemoriesExtension: use_memories=false