opencode-codex-memory 0.1.9 → 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
@@ -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
 
@@ -176,12 +181,17 @@ explicitly, so they win over an agent-level `model`.
176
181
  > This is the one intentional default difference — the tools are a core part of a
177
182
  > standalone memory plugin. Everything else matches codex's defaults.
178
183
  >
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.
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.
185
195
 
186
196
  ## Under the hood
187
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,72 +1,121 @@
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)
@@ -103,11 +152,11 @@ function extractText(msg) {
103
152
  }
104
153
  return undefined;
105
154
  }
106
- export function selectEligibleSessions(store, opts) {
155
+ export async function selectEligibleSessions(store, opts) {
107
156
  const now = Date.now();
108
157
  const minUpdated = now - opts.maxAgeDays * 24 * 60 * 60 * 1000;
109
158
  const maxUpdated = now - opts.minIdleHours * 60 * 60 * 1000;
110
- const sessions = listRecentSessions();
159
+ const sessions = await listRecentSessions();
111
160
  return sessions.filter((s) => {
112
161
  if (opts.excludeSession && s.id === opts.excludeSession)
113
162
  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
package/dist/src/llm.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface ExtractionResult {
5
5
  rollout_slug: string | null;
6
6
  }
7
7
  export declare function setPluginInput(input: PluginInput): void;
8
+ export declare function getPluginInput(): PluginInput | null;
8
9
  export declare function isMemorySubSession(sessionId: string): boolean;
9
10
  export interface ExtractOptions {
10
11
  cwd?: string;
package/dist/src/llm.js CHANGED
@@ -4,7 +4,7 @@ let inputRef = null;
4
4
  export function setPluginInput(input) {
5
5
  inputRef = input;
6
6
  }
7
- function getPluginInput() {
7
+ export function getPluginInput() {
8
8
  return inputRef;
9
9
  }
10
10
  // Sessions this plugin spawned for extraction/consolidation. The main
@@ -7,4 +7,13 @@
7
7
  * - every existing component is lstat-checked: symlinks are rejected, so a
8
8
  * link placed inside the workspace cannot lead reads outside it
9
9
  */
10
+ /**
11
+ * The memory root itself must not be a symlink: every scoped resolution and
12
+ * every workspace walk starts there, so a symlinked root would redirect ALL
13
+ * memory reads/writes elsewhere on disk. codex rejects a symlinked root when
14
+ * clearing (control.rs clear_memory_root_contents); the model-facing tools
15
+ * here extend that check to every memory operation. Returns the root path.
16
+ * A missing root is fine — callers create it as a real directory.
17
+ */
18
+ export declare function assertMemoryRootSafe(): string;
10
19
  export declare function safeResolveMemoryPath(rel: string): string;