opencode-codex-memory 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/src/capture.d.ts +19 -0
  2. package/dist/src/capture.js +120 -0
  3. package/dist/src/citation.d.ts +14 -0
  4. package/dist/src/citation.js +81 -0
  5. package/dist/src/db.d.ts +3 -0
  6. package/dist/src/db.js +78 -0
  7. package/dist/src/git-baseline.d.ts +24 -0
  8. package/dist/src/git-baseline.js +150 -0
  9. package/dist/src/index.d.ts +163 -0
  10. package/dist/src/index.js +365 -0
  11. package/dist/src/llm.d.ts +19 -0
  12. package/dist/src/llm.js +251 -0
  13. package/dist/src/path-guard.d.ts +10 -0
  14. package/dist/src/path-guard.js +44 -0
  15. package/dist/src/paths.d.ts +4 -0
  16. package/dist/src/paths.js +23 -0
  17. package/dist/src/phase1.d.ts +11 -0
  18. package/dist/src/phase1.js +104 -0
  19. package/dist/src/phase2.d.ts +11 -0
  20. package/dist/src/phase2.js +83 -0
  21. package/dist/src/ratelimit.d.ts +5 -0
  22. package/dist/src/ratelimit.js +20 -0
  23. package/dist/src/redact.d.ts +8 -0
  24. package/dist/src/redact.js +37 -0
  25. package/dist/src/source.d.ts +3 -0
  26. package/dist/src/source.js +46 -0
  27. package/dist/src/store.d.ts +96 -0
  28. package/dist/src/store.js +346 -0
  29. package/dist/src/token.d.ts +8 -0
  30. package/dist/src/token.js +19 -0
  31. package/dist/src/workspace.d.ts +8 -0
  32. package/dist/src/workspace.js +194 -0
  33. package/dist/tools/control.d.ts +29 -0
  34. package/dist/tools/control.js +153 -0
  35. package/dist/tools/memory.d.ts +52 -0
  36. package/dist/tools/memory.js +322 -0
  37. package/package.json +23 -6
  38. package/src/capture.ts +0 -137
  39. package/src/citation.ts +0 -94
  40. package/src/db.ts +0 -84
  41. package/src/git-baseline.ts +0 -162
  42. package/src/index.ts +0 -366
  43. package/src/llm.ts +0 -266
  44. package/src/path-guard.ts +0 -44
  45. package/src/paths.ts +0 -29
  46. package/src/phase1.ts +0 -116
  47. package/src/phase2.ts +0 -101
  48. package/src/ratelimit.ts +0 -26
  49. package/src/redact.ts +0 -44
  50. package/src/source.ts +0 -62
  51. package/src/store.ts +0 -434
  52. package/src/templates/consolidation.md +0 -448
  53. package/src/templates/read_path.md +0 -104
  54. package/src/templates/stage_one_input.md +0 -11
  55. package/src/templates/stage_one_system.md +0 -333
  56. package/src/token.ts +0 -21
  57. package/src/workspace.ts +0 -190
  58. package/tools/control.ts +0 -145
  59. package/tools/memory.ts +0 -318
@@ -0,0 +1,365 @@
1
+ import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js";
2
+ import { stripCitations, extractCitedSessionIds } from "./citation.js";
3
+ import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js";
4
+ import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
5
+ import { MemoryStore } from "./store.js";
6
+ import { runPhase1 } from "./phase1.js";
7
+ import { runPhase2 } from "./phase2.js";
8
+ import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ let phase1InFlight = false;
12
+ let pluginClient = null;
13
+ // Configured MCP server names, fetched lazily; null until first successful fetch.
14
+ let mcpServerNames = null;
15
+ // Option names and defaults mirror codex's MemoriesToml/MemoriesConfig
16
+ // (codex-rs/config/src/types.rs). Keep them 1:1 so the drift script and manual
17
+ // syncing stay trivial; do not rename for taste.
18
+ let pluginOptions = {
19
+ generate_memories: true,
20
+ use_memories: true,
21
+ dedicated_tools: true,
22
+ disable_on_external_context: false,
23
+ max_raw_memories_for_consolidation: 256,
24
+ max_unused_days: 30,
25
+ max_rollout_age_days: 10,
26
+ max_rollouts_per_startup: 2,
27
+ min_rollout_idle_hours: 6,
28
+ };
29
+ // Deliberately uncached: openDb() is already a singleton, and caching a store
30
+ // here would hold a stale handle across closeDb() (e.g. after memory_reset).
31
+ function getStore() {
32
+ return new MemoryStore();
33
+ }
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.
37
+ const recordedCitations = new Map();
38
+ const MAX_TRACKED_PARTS = 500;
39
+ export function takeNewCitations(partKey, ids) {
40
+ let seen = recordedCitations.get(partKey);
41
+ if (!seen) {
42
+ seen = new Set();
43
+ recordedCitations.set(partKey, seen);
44
+ if (recordedCitations.size > MAX_TRACKED_PARTS) {
45
+ const oldest = recordedCitations.keys().next().value;
46
+ if (oldest !== undefined)
47
+ recordedCitations.delete(oldest);
48
+ }
49
+ }
50
+ const fresh = ids.filter((id) => !seen.has(id));
51
+ for (const id of fresh)
52
+ seen.add(id);
53
+ return fresh;
54
+ }
55
+ export default {
56
+ id: "opencode-codex-memory",
57
+ async server(input, opts) {
58
+ setPluginInput(input);
59
+ pluginClient = input.client;
60
+ if (opts)
61
+ applyPluginOptions(opts);
62
+ void cleanupOldSubSessions().catch(() => { });
63
+ return buildHooks();
64
+ },
65
+ };
66
+ const KNOWN_OPTION_KEYS = new Set([
67
+ "generate_memories",
68
+ "use_memories",
69
+ "dedicated_tools",
70
+ "disable_on_external_context",
71
+ "extract_model",
72
+ "consolidation_model",
73
+ "max_raw_memories_for_consolidation",
74
+ "max_unused_days",
75
+ "max_rollout_age_days",
76
+ "max_rollouts_per_startup",
77
+ "min_rollout_idle_hours",
78
+ ]);
79
+ // codex clamps numeric knobs in From<MemoriesToml> for MemoriesConfig
80
+ // (config/src/types.rs); mirror the exact ranges. Non-finite values fall back
81
+ // to the default.
82
+ function clampInt(value, min, max, fallback) {
83
+ if (typeof value !== "number" || !Number.isFinite(value))
84
+ return fallback;
85
+ return Math.min(max, Math.max(min, Math.floor(value)));
86
+ }
87
+ function applyPluginOptions(opts) {
88
+ for (const key of Object.keys(opts)) {
89
+ if (!KNOWN_OPTION_KEYS.has(key)) {
90
+ // codex uses deny_unknown_fields; a plugin can only warn. Covers typos
91
+ // and the deliberately unimplemented min_rate_limit_remaining_percent.
92
+ console.warn(`[opencode-codex-memory] unknown/unsupported option '${key}' ignored`);
93
+ }
94
+ }
95
+ if (typeof opts.generate_memories === "boolean")
96
+ pluginOptions.generate_memories = opts.generate_memories;
97
+ if (typeof opts.use_memories === "boolean")
98
+ pluginOptions.use_memories = opts.use_memories;
99
+ if (typeof opts.dedicated_tools === "boolean")
100
+ pluginOptions.dedicated_tools = opts.dedicated_tools;
101
+ if (typeof opts.disable_on_external_context === "boolean")
102
+ pluginOptions.disable_on_external_context = opts.disable_on_external_context;
103
+ if (typeof opts.extract_model === "string")
104
+ pluginOptions.extract_model = opts.extract_model;
105
+ if (typeof opts.consolidation_model === "string")
106
+ pluginOptions.consolidation_model = opts.consolidation_model;
107
+ if ("max_raw_memories_for_consolidation" in opts)
108
+ pluginOptions.max_raw_memories_for_consolidation = clampInt(opts.max_raw_memories_for_consolidation, 1, 4096, 256);
109
+ if ("max_unused_days" in opts)
110
+ pluginOptions.max_unused_days = clampInt(opts.max_unused_days, 0, 365, 30);
111
+ if ("max_rollout_age_days" in opts)
112
+ pluginOptions.max_rollout_age_days = clampInt(opts.max_rollout_age_days, 0, 90, 10);
113
+ if ("max_rollouts_per_startup" in opts)
114
+ pluginOptions.max_rollouts_per_startup = clampInt(opts.max_rollouts_per_startup, 1, 128, 2);
115
+ if ("min_rollout_idle_hours" in opts)
116
+ pluginOptions.min_rollout_idle_hours = clampInt(opts.min_rollout_idle_hours, 1, 48, 6);
117
+ }
118
+ /**
119
+ * codex marks every MCP server as memory-polluting unconditionally
120
+ * (codex-mcp server.rs pollutes_memory: true). opencode registers MCP tools
121
+ * as "<server>_<tool>", so match tool names against the configured server
122
+ * list. Fails closed to the web-tools-only check when the list is unavailable.
123
+ */
124
+ async function isExternalContextTool(toolName) {
125
+ if (toolName === "websearch" || toolName === "webfetch")
126
+ return true;
127
+ if (!mcpServerNames && pluginClient) {
128
+ try {
129
+ const res = await pluginClient.mcp.status();
130
+ const servers = res?.data ?? res;
131
+ if (servers && typeof servers === "object") {
132
+ mcpServerNames = new Set(Object.keys(servers));
133
+ }
134
+ }
135
+ catch {
136
+ // MCP status unavailable (older opencode); keep web-tools-only checks.
137
+ }
138
+ }
139
+ if (!mcpServerNames)
140
+ return false;
141
+ for (const server of mcpServerNames) {
142
+ if (toolName.startsWith(`${server}_`))
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ /**
148
+ * Registers the memorize / memorize-extract sub-agents through the config
149
+ * hook so installing the plugin requires no manual agent setup. Definitions
150
+ * are read from the plugin's bundled opencode.json (single source of truth
151
+ * with the dev checkout). A user-defined agent of the same name always wins —
152
+ * only missing entries are filled. opencode-specific packaging: codex ships
153
+ * its memory agents inside the binary.
154
+ */
155
+ export function injectAgentDefinitions(config) {
156
+ let defs;
157
+ try {
158
+ const raw = fs.readFileSync(path.join(import.meta.dirname, "..", "opencode.json"), "utf8");
159
+ defs = JSON.parse(raw).agent ?? {};
160
+ }
161
+ catch (err) {
162
+ console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
163
+ return;
164
+ }
165
+ config.agent ??= {};
166
+ for (const [name, def] of Object.entries(defs)) {
167
+ if (!config.agent[name])
168
+ config.agent[name] = def;
169
+ }
170
+ }
171
+ function buildHooks() {
172
+ const base = {
173
+ async config(input) {
174
+ try {
175
+ // The write pipeline is the only consumer of the sub-agents; with
176
+ // generation off they would just pollute the user's agent list.
177
+ if (!pluginOptions.generate_memories)
178
+ return;
179
+ injectAgentDefinitions(input);
180
+ }
181
+ catch (err) {
182
+ console.error("[opencode-codex-memory] config hook error:", err);
183
+ }
184
+ },
185
+ async "experimental.chat.system.transform"(input, output) {
186
+ try {
187
+ if (!pluginOptions.use_memories)
188
+ return;
189
+ if (input.sessionID && isMemorySubSession(input.sessionID))
190
+ return;
191
+ ensureMemoryLayout();
192
+ const memoryPrompt = buildMemorySystemPrompt();
193
+ if (memoryPrompt) {
194
+ output.system.push(memoryPrompt);
195
+ }
196
+ }
197
+ catch (err) {
198
+ console.error("[opencode-codex-memory] system.transform error:", err);
199
+ }
200
+ },
201
+ async "experimental.chat.messages.transform"(_input, output) {
202
+ try {
203
+ for (const msg of output.messages) {
204
+ if (msg.info?.role !== "assistant")
205
+ continue;
206
+ for (const part of msg.parts) {
207
+ if (part.type === "text" && typeof part.text === "string" && part.text.includes("<memory-citation>")) {
208
+ const before = part.text;
209
+ part.text = stripCitations(part.text);
210
+ if (part.text.includes("<memory-citation>")) {
211
+ console.warn("[opencode-codex-memory] citation marker still present after stripCitations — hook contract may have changed");
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ catch (err) {
218
+ console.error("[opencode-codex-memory] messages.transform error:", err);
219
+ }
220
+ },
221
+ async event(input) {
222
+ try {
223
+ const ev = input.event;
224
+ if (ev.type === "message.part.updated") {
225
+ const part = ev.properties.part;
226
+ if (!part || part.type !== "text" || typeof part.text !== "string")
227
+ return;
228
+ if (part.sessionID && isMemorySubSession(part.sessionID))
229
+ return;
230
+ if (!part.text.includes("<memory-citation>"))
231
+ return;
232
+ let ids = [];
233
+ try {
234
+ ids = extractCitedSessionIds(part.text);
235
+ }
236
+ catch {
237
+ return;
238
+ }
239
+ const fresh = takeNewCitations(`${part.sessionID ?? ""}:${part.id ?? ""}`, ids);
240
+ if (fresh.length > 0) {
241
+ try {
242
+ getStore().recordUsage(fresh);
243
+ }
244
+ catch (e) {
245
+ console.error("[opencode-codex-memory] recordUsage failed:", e);
246
+ }
247
+ }
248
+ return;
249
+ }
250
+ if (ev.type === "tool.execute.after") {
251
+ // Mirrors codex: external context (web search or any MCP tool) only
252
+ // pollutes the session's memory when disable_on_external_context is
253
+ // enabled. Off by default.
254
+ if (!pluginOptions.disable_on_external_context)
255
+ return;
256
+ const props = ev.properties;
257
+ const toolName = props?.tool ?? "";
258
+ if (props.sessionID && (await isExternalContextTool(toolName))) {
259
+ try {
260
+ getStore().markPolluted(props.sessionID);
261
+ }
262
+ catch (e) {
263
+ console.error("[opencode-codex-memory] markPolluted failed:", e);
264
+ }
265
+ }
266
+ return;
267
+ }
268
+ if (ev.type === "session.deleted") {
269
+ // Mirrors codex delete_thread_memory: drop the extracted memory and
270
+ // its job when the session is deleted; the file disappears at the
271
+ // next phase-2 rebuild and the diff drives forgetting.
272
+ const props = ev.properties;
273
+ const sid = props?.info?.id;
274
+ if (sid) {
275
+ try {
276
+ getStore().deleteSessionMemory(sid);
277
+ }
278
+ catch (e) {
279
+ console.error("[opencode-codex-memory] deleteSessionMemory failed:", e);
280
+ }
281
+ }
282
+ return;
283
+ }
284
+ if (ev.type === "session.idle") {
285
+ const props = ev.properties;
286
+ const sid = props?.sessionID;
287
+ if (!sid || isMemorySubSession(sid))
288
+ return;
289
+ // codex stamps memory_mode at thread creation from generate_memories:
290
+ // sessions seen while generation is off stay excluded permanently,
291
+ // even if the option is re-enabled later.
292
+ try {
293
+ getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
294
+ }
295
+ catch (e) {
296
+ console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
297
+ }
298
+ void triggerPhase1(sid);
299
+ return;
300
+ }
301
+ }
302
+ catch (err) {
303
+ console.error("[opencode-codex-memory] event error:", err);
304
+ }
305
+ },
306
+ async dispose() {
307
+ invalidateCache();
308
+ },
309
+ };
310
+ // Control tools (reset/inspect/mode) are always available. The memory
311
+ // read/search/list/add-note tools require BOTH use_memories and
312
+ // dedicated_tools, mirroring codex's MemoriesExtension: use_memories=false
313
+ // disables the whole extension including its tools (extension.rs).
314
+ const tool = pluginOptions.use_memories && pluginOptions.dedicated_tools
315
+ ? {
316
+ memory_read,
317
+ memory_search,
318
+ memory_list,
319
+ memory_add_note,
320
+ memory_reset,
321
+ memory_inspect,
322
+ memory_mode,
323
+ }
324
+ : {
325
+ memory_reset,
326
+ memory_inspect,
327
+ memory_mode,
328
+ };
329
+ return { ...base, tool };
330
+ }
331
+ async function triggerPhase1(currentSessionId) {
332
+ if (phase1InFlight || !pluginOptions.generate_memories)
333
+ return;
334
+ phase1InFlight = true;
335
+ try {
336
+ await runPhase1(getStore(), {
337
+ maxAgeDays: pluginOptions.max_rollout_age_days,
338
+ minIdleHours: pluginOptions.min_rollout_idle_hours,
339
+ maxClaimed: pluginOptions.max_rollouts_per_startup,
340
+ excludeSession: currentSessionId,
341
+ extractModel: pluginOptions.extract_model,
342
+ });
343
+ }
344
+ catch (err) {
345
+ console.error("[opencode-codex-memory] phase1 error:", err);
346
+ }
347
+ finally {
348
+ phase1InFlight = false;
349
+ }
350
+ void triggerPhase2();
351
+ }
352
+ async function triggerPhase2() {
353
+ try {
354
+ // runPhase2 has its own in-flight guard
355
+ await runPhase2(getStore(), {
356
+ maxRaw: pluginOptions.max_raw_memories_for_consolidation,
357
+ maxUnusedDays: pluginOptions.max_unused_days,
358
+ extensionRetentionDays: 7,
359
+ consolidationModel: pluginOptions.consolidation_model,
360
+ });
361
+ }
362
+ catch (err) {
363
+ console.error("[opencode-codex-memory] phase2 error:", err);
364
+ }
365
+ }
@@ -0,0 +1,19 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ export interface ExtractionResult {
3
+ raw_memory: string;
4
+ rollout_summary: string;
5
+ rollout_slug: string | null;
6
+ }
7
+ export declare function setPluginInput(input: PluginInput): void;
8
+ export declare function isMemorySubSession(sessionId: string): boolean;
9
+ export interface ExtractOptions {
10
+ cwd?: string;
11
+ model?: string;
12
+ }
13
+ /** Returns null when the extractor reported a no-op (nothing worth remembering). */
14
+ export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
15
+ export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
16
+ export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
17
+ export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
18
+ /** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
19
+ export declare function parseExtraction(raw: string): ExtractionResult | null;
@@ -0,0 +1,251 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ let inputRef = null;
4
+ export function setPluginInput(input) {
5
+ inputRef = input;
6
+ }
7
+ function getPluginInput() {
8
+ return inputRef;
9
+ }
10
+ // Sessions this plugin spawned for extraction/consolidation. The main
11
+ // hooks skip these so the plugin never injects memory into (or memorizes) its
12
+ // own sub-agents.
13
+ const activeSubSessions = new Set();
14
+ export function isMemorySubSession(sessionId) {
15
+ return activeSubSessions.has(sessionId);
16
+ }
17
+ async function createSession(agent, title) {
18
+ const input = getPluginInput();
19
+ if (!input)
20
+ throw new Error("plugin input not initialized");
21
+ const res = await input.client.session.create({
22
+ body: { title: title ?? `codex-memory-${agent}` },
23
+ });
24
+ if (!res.data)
25
+ throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
26
+ const body = res.data;
27
+ const id = body.id;
28
+ if (!id)
29
+ throw new Error(`session create returned no id: ${JSON.stringify(body)}`);
30
+ activeSubSessions.add(id);
31
+ return id;
32
+ }
33
+ /**
34
+ * opencode's config carries the same split codex expresses with provider
35
+ * model preferences: `small_model` for cheap background work (codex:
36
+ * memory_extraction_preferred_model = gpt-5.4-mini) and `model` for capable
37
+ * work (codex: memory_consolidation_preferred_model = gpt-5.4). Cached per
38
+ * plugin instance — opencode reloads plugins on config change.
39
+ */
40
+ let configModels = null;
41
+ async function getConfigModels() {
42
+ if (configModels)
43
+ return configModels;
44
+ const input = getPluginInput();
45
+ if (!input)
46
+ return {};
47
+ try {
48
+ const res = await input.client.config.get();
49
+ const cfg = res?.data;
50
+ configModels = { model: cfg?.model, smallModel: cfg?.small_model };
51
+ }
52
+ catch {
53
+ // Config endpoint unavailable: leave models unset so the sub-agent runs
54
+ // on the session default, the previous behavior.
55
+ configModels = {};
56
+ }
57
+ return configModels;
58
+ }
59
+ // extract_model / consolidation model strings are "providerID/modelID".
60
+ function parseModelRef(ref) {
61
+ const slash = ref.indexOf("/");
62
+ if (slash <= 0 || slash === ref.length - 1)
63
+ return null;
64
+ return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
65
+ }
66
+ async function promptSession(sessionId, prompt, agent, opts = {}) {
67
+ const timeoutMs = opts.timeoutMs ?? 300_000;
68
+ const input = getPluginInput();
69
+ if (!input)
70
+ throw new Error("plugin input not initialized");
71
+ const model = opts.model ? parseModelRef(opts.model) : null;
72
+ const promptPromise = input.client.session.prompt({
73
+ path: { id: sessionId },
74
+ body: {
75
+ agent,
76
+ ...(opts.system ? { system: opts.system } : {}),
77
+ ...(model ? { model } : {}),
78
+ parts: [{ type: "text", text: prompt }],
79
+ },
80
+ });
81
+ let timer;
82
+ try {
83
+ const res = await Promise.race([
84
+ promptPromise,
85
+ new Promise((_, reject) => {
86
+ timer = setTimeout(() => reject(new Error(`sub-agent prompt timed out after ${timeoutMs}ms`)), timeoutMs);
87
+ }),
88
+ ]);
89
+ if (!res.data)
90
+ throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
91
+ return extractAssistantText(res.data);
92
+ }
93
+ finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ function extractAssistantText(body) {
98
+ if (!body)
99
+ return "";
100
+ if (typeof body === "string")
101
+ return body;
102
+ if (Array.isArray(body))
103
+ return body.map(extractAssistantText).join("\n");
104
+ if (typeof body.text === "string")
105
+ return body.text;
106
+ if (body.parts && Array.isArray(body.parts))
107
+ return body.parts.map((p) => p?.text ?? "").filter(Boolean).join("\n");
108
+ if (body.messages && Array.isArray(body.messages)) {
109
+ return body.messages
110
+ .filter((m) => m?.info?.role === "assistant")
111
+ .flatMap((m) => (m.parts ?? []).map((p) => p?.text ?? ""))
112
+ .filter(Boolean)
113
+ .join("\n");
114
+ }
115
+ if (body.output && typeof body.output === "string")
116
+ return body.output;
117
+ return JSON.stringify(body);
118
+ }
119
+ /** Returns null when the extractor reported a no-op (nothing worth remembering). */
120
+ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
121
+ const agent = "memorize-extract";
122
+ const subId = await createSession(agent, `codex-memory-extract-${sessionId}`);
123
+ try {
124
+ const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
125
+ // extract_model option > opencode small_model > session default.
126
+ const model = opts.model ?? (await getConfigModels()).smallModel;
127
+ const raw = await promptSession(subId, prompt, agent, {
128
+ timeoutMs: 180_000,
129
+ system: readTemplate("stage_one_system.md"),
130
+ model,
131
+ });
132
+ return parseExtraction(raw);
133
+ }
134
+ finally {
135
+ void deleteSession(subId).catch(() => { });
136
+ }
137
+ }
138
+ // codex runs the consolidation agent under a 1h job lease with heartbeats;
139
+ // its INIT pass is explicitly allowed to run long ("do not be lazy"). A short
140
+ // timeout here would fail the job after the workspace was already synced.
141
+ const CONSOLIDATION_TIMEOUT_MS = 3600_000;
142
+ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
143
+ const agent = "memorize";
144
+ const subId = await createSession(agent, "codex-memory-consolidate");
145
+ try {
146
+ const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
147
+ // consolidation_model option > opencode model (main) > session default.
148
+ const resolved = model ?? (await getConfigModels()).model;
149
+ await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS });
150
+ }
151
+ finally {
152
+ void deleteSession(subId).catch(() => { });
153
+ }
154
+ }
155
+ // Must exceed the longest legitimate sub-session lifetime (consolidation may
156
+ // run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
157
+ // plugin reload would delete a working sub-session mid-run.
158
+ export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
159
+ const input = getPluginInput();
160
+ if (!input)
161
+ return;
162
+ try {
163
+ const res = await input.client.session.list();
164
+ if (!res.data)
165
+ return;
166
+ const list = res.data;
167
+ const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
168
+ for (const s of list) {
169
+ if (s.title && s.title.startsWith("codex-memory-")) {
170
+ const created = s.time?.created ?? 0;
171
+ if (created && created < cutoff) {
172
+ await deleteSession(s.id);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ // best effort only
179
+ }
180
+ }
181
+ async function deleteSession(id) {
182
+ activeSubSessions.delete(id);
183
+ const input = getPluginInput();
184
+ if (!input)
185
+ return;
186
+ try {
187
+ const res = await input.client.session.delete({ path: { id } });
188
+ if (res.error) {
189
+ console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
190
+ }
191
+ }
192
+ catch (err) {
193
+ console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
194
+ }
195
+ }
196
+ // Substitute with a function so `$&`/`$'` sequences in the value are not
197
+ // expanded as String.replace replacement patterns.
198
+ export function fillTemplate(tmpl, vars) {
199
+ let out = tmpl;
200
+ for (const [key, value] of Object.entries(vars)) {
201
+ out = out.replaceAll(`{{ ${key} }}`, () => value);
202
+ }
203
+ return out;
204
+ }
205
+ function buildExtractionInput(sessionId, cwd, transcript) {
206
+ return fillTemplate(readTemplate("stage_one_input.md"), {
207
+ session_id: sessionId,
208
+ session_cwd: cwd,
209
+ transcript,
210
+ });
211
+ }
212
+ function buildConsolidationPrompt(memoryRoot, diffFileName) {
213
+ return fillTemplate(readTemplate("consolidation.md"), {
214
+ memory_root: memoryRoot,
215
+ phase2_workspace_diff_file: diffFileName,
216
+ });
217
+ }
218
+ function readTemplate(name) {
219
+ return fs.readFileSync(path.join(import.meta.dirname, "templates", name), "utf8");
220
+ }
221
+ /** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
222
+ export function parseExtraction(raw) {
223
+ const cleaned = raw.replace(/^```(?:json)?/gim, "").replace(/```$/gim, "").trim();
224
+ const start = cleaned.indexOf("{");
225
+ const end = cleaned.lastIndexOf("}");
226
+ if (start === -1 || end === -1 || end <= start) {
227
+ throw new Error("extraction response contained no JSON object");
228
+ }
229
+ const json = cleaned.slice(start, end + 1);
230
+ const obj = JSON.parse(json);
231
+ if (typeof obj.raw_memory !== "string" || typeof obj.rollout_summary !== "string") {
232
+ throw new Error("extraction response missing required fields");
233
+ }
234
+ if (!obj.raw_memory.trim() && !obj.rollout_summary.trim()) {
235
+ return null;
236
+ }
237
+ // Guard against the model echoing the format skeleton from the system prompt.
238
+ const templateArtifacts = [
239
+ "<success|partial|fail|uncertain>",
240
+ "<primary task signature>",
241
+ "<short quote or near-verbatim request>",
242
+ ];
243
+ if (templateArtifacts.some((a) => obj.raw_memory.includes(a))) {
244
+ throw new Error("extraction returned template placeholder text instead of actual content");
245
+ }
246
+ return {
247
+ raw_memory: obj.raw_memory,
248
+ rollout_summary: obj.rollout_summary,
249
+ rollout_slug: typeof obj.rollout_slug === "string" && obj.rollout_slug.trim() ? obj.rollout_slug : null,
250
+ };
251
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Safe path resolution that cannot escape the memory root, mirroring codex
3
+ * ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
4
+ * - absolute paths and `..` components are rejected lexically
5
+ * - hidden (dot) components are invisible (reported as not found), so .git
6
+ * and other dotfiles are unreachable through the tools
7
+ * - every existing component is lstat-checked: symlinks are rejected, so a
8
+ * link placed inside the workspace cannot lead reads outside it
9
+ */
10
+ export declare function safeResolveMemoryPath(rel: string): string;
@@ -0,0 +1,44 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { memoryRoot } from "./paths.js";
4
+ /**
5
+ * Safe path resolution that cannot escape the memory root, mirroring codex
6
+ * ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
7
+ * - absolute paths and `..` components are rejected lexically
8
+ * - hidden (dot) components are invisible (reported as not found), so .git
9
+ * and other dotfiles are unreachable through the tools
10
+ * - every existing component is lstat-checked: symlinks are rejected, so a
11
+ * link placed inside the workspace cannot lead reads outside it
12
+ */
13
+ export function safeResolveMemoryPath(rel) {
14
+ const root = memoryRoot();
15
+ if (path.isAbsolute(rel)) {
16
+ throw new Error(`path escapes memory root: ${rel}`);
17
+ }
18
+ const parts = rel.split(/[\\/]+/).filter((p) => p.length > 0 && p !== ".");
19
+ let current = root;
20
+ for (const part of parts) {
21
+ if (part === "..") {
22
+ throw new Error(`path escapes memory root: ${rel}`);
23
+ }
24
+ if (part.startsWith(".")) {
25
+ throw new Error(`not found: ${rel}`);
26
+ }
27
+ current = path.join(current, part);
28
+ let st = null;
29
+ try {
30
+ st = fs.lstatSync(current);
31
+ }
32
+ catch {
33
+ // Component doesn't exist (yet): keep validating the rest lexically;
34
+ // the caller reports not-found / creates it under the checked prefix.
35
+ }
36
+ if (st?.isSymbolicLink()) {
37
+ throw new Error(`symlinks are not allowed in the memory workspace: ${rel}`);
38
+ }
39
+ }
40
+ if (current !== root && !current.startsWith(root + path.sep)) {
41
+ throw new Error(`path escapes memory root: ${rel}`);
42
+ }
43
+ return current;
44
+ }