opencode-codex-memory 0.6.4 → 0.7.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.
Files changed (41) hide show
  1. package/README.md +26 -59
  2. package/dist/opencode.json +1 -1
  3. package/dist/src/citation.d.ts +9 -0
  4. package/dist/src/citation.js +68 -11
  5. package/dist/src/db.js +10 -0
  6. package/dist/src/host-client.d.ts +1 -0
  7. package/dist/src/host-client.js +1 -0
  8. package/dist/src/index.d.ts +12 -2
  9. package/dist/src/index.js +41 -6
  10. package/dist/src/llm.d.ts +6 -0
  11. package/dist/src/llm.js +21 -10
  12. package/dist/src/phase2.d.ts +2 -0
  13. package/dist/src/phase2.js +1 -1
  14. package/dist/src/rollout-input.d.ts +6 -0
  15. package/dist/src/rollout-input.js +111 -0
  16. package/dist/src/store.d.ts +17 -1
  17. package/dist/src/store.js +90 -4
  18. package/dist/src/v2/agents.d.ts +53 -0
  19. package/dist/src/v2/agents.js +204 -0
  20. package/dist/src/v2/citation-overlay.d.ts +7 -0
  21. package/dist/src/v2/citation-overlay.js +52 -0
  22. package/dist/src/v2/index.d.ts +7 -0
  23. package/dist/src/v2/index.js +10 -0
  24. package/dist/src/v2/injection.d.ts +14 -0
  25. package/dist/src/v2/injection.js +19 -0
  26. package/dist/src/v2/plugin.d.ts +7 -0
  27. package/dist/src/v2/plugin.js +482 -0
  28. package/dist/src/v2/service.d.ts +74 -0
  29. package/dist/src/v2/service.js +173 -0
  30. package/dist/src/v2/shim.d.ts +47 -0
  31. package/dist/src/v2/shim.js +581 -0
  32. package/dist/src/v2/status-rpc.d.ts +197 -0
  33. package/dist/src/v2/status-rpc.js +159 -0
  34. package/dist/src/v2/status.d.ts +3 -0
  35. package/dist/src/v2/status.js +83 -0
  36. package/dist/src/v2/tools.d.ts +33 -0
  37. package/dist/src/v2/tools.js +57 -0
  38. package/dist/src/v2/tui.d.ts +3 -0
  39. package/dist/src/v2/tui.js +750 -0
  40. package/opencode.json +1 -1
  41. package/package.json +38 -2
package/README.md CHANGED
@@ -43,8 +43,8 @@ worked and what didn't — and puts that context back in front of the agent in
43
43
  later conversations. You don't manage any of it; OpenCode just gets more useful
44
44
  the more you use it.
45
45
 
46
- If you want the mental model before the details, jump to
47
- [How it works](#how-it-works).
46
+ If you want the mental model learning, remembering, forgetting — see
47
+ [How OpenCode Codex Memory works](./docs/how-ai-memory-works.md).
48
48
 
49
49
  ## Install
50
50
 
@@ -52,7 +52,7 @@ If you want the mental model before the details, jump to
52
52
 
53
53
  ```json
54
54
  {
55
- "plugin": ["opencode-codex-memory@0.6.4"]
55
+ "plugin": ["opencode-codex-memory@0.7.0"]
56
56
  }
57
57
  ```
58
58
 
@@ -67,6 +67,20 @@ learning starts immediately.
67
67
  Requires OpenCode 1.18 or newer. Models and other options: see
68
68
  [Configuration](#configuration).
69
69
 
70
+ **OpenCode 2:** the same package works on opencode2 — install it with the
71
+ V2 plugin syntax:
72
+
73
+ ```jsonc
74
+ {
75
+ "plugins": [{ "package": "opencode-codex-memory@0.7.0" }],
76
+ }
77
+ ```
78
+
79
+ Same pipeline, not the same host APIs — see [docs/opencode2.md](./docs/opencode2.md)
80
+ for the limits (registered service, retained citation markup, no `small_model`).
81
+ On opencode2 you also get a **Memory** section in the session sidebar plus a
82
+ `/memory-status` command, served live from the same state as `memory_inspect`.
83
+
70
84
  ### Installation hints
71
85
 
72
86
  To bump pins, copy
@@ -99,54 +113,6 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
99
113
  > ~/.local/share/opencode/memories/memory_summary.md
100
114
  ```
101
115
 
102
- ## How it works
103
-
104
- You don't need to know any of this to use the plugin. The design is Codex's,
105
- ported as-is, and it does what any memory system has to do: decide what's worth
106
- keeping, write it down so it can be found again, surface the right piece at the
107
- right moment, and forget what stopped being useful.
108
-
109
- Think of it as three jobs: two background writers and one reader. **Nothing here
110
- runs while you're waiting for a reply** — an assistant that stops to take notes
111
- mid-answer would be slower and more expensive, so the learning happens after the
112
- fact, on transcripts of conversations that are already over.
113
-
114
- **Phase 1 — read one finished session, write notes about it.** Once a
115
- conversation has been idle long enough that it's clearly done (default 6 h), the
116
- plugin fetches that transcript, strips secrets out of it, and hands it to a
117
- cheap model with one question: *what from this is worth keeping?* The answer
118
- comes back as structured data — a detailed note plus a short recap of the
119
- session — and lands in a local SQLite database. One session in, one record out.
120
- Sessions are independent, so this part is easy to parallelize and to retry when
121
- it fails.
122
-
123
- **Phase 2 — merge all those notes into one memory.** Every few hours at most
124
- (and only one run at a time across all your OpenCode windows), a second pass
125
- takes the most relevant per-session notes and rewrites the actual memory files:
126
- `MEMORY.md` as the full index, `memory_summary.md` as the short version, and
127
- `skills/` for procedures worth repeating. This is where the interesting work
128
- happens — ten similar observations collapse into one rule, contradictions get
129
- resolved, and notes nothing ever used age out. Forgetting is a feature: memory
130
- that only grows is memory that stops being useful.
131
-
132
- The split exists because the two halves have opposite needs. Phase 1 is
133
- per-session and can run many at once; phase 2 touches the single shared memory,
134
- so it has to be serialized. Keeping them apart means one slow or failing session
135
- extraction can't corrupt or block the shared store.
136
-
137
- **The read path — actually remembering.** Every turn, the short summary is
138
- appended to the system prompt (capped at ~2500 tokens, so the cost is small and
139
- predictable). That's the always-on layer. When a task looks related to past
140
- work, the agent goes further and searches the full memory itself with the
141
- `memory_*` tools — the equivalent of "I've seen this before, let me look it up"
142
- rather than carrying everything around all the time.
143
-
144
- **The feedback loop.** When the agent uses a memory, it cites it. The citation
145
- is recorded and then stripped before it reaches your screen, and those usage
146
- counts feed back into phase 2's ranking. Memories that keep proving useful get
147
- kept and sharpened; memories nothing has touched in a month drop out. The system
148
- finds out which of its own notes were worth writing.
149
-
150
116
  ## Where your data lives
151
117
 
152
118
  ```
@@ -232,7 +198,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
232
198
  ```json
233
199
  {
234
200
  "plugin": [
235
- ["opencode-codex-memory@0.6.4", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
201
+ ["opencode-codex-memory@0.7.0", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
236
202
  ]
237
203
  }
238
204
  ```
@@ -252,9 +218,9 @@ the default value appearing where you expected your setting.
252
218
 
253
219
  Model selection mirrors Codex's cheap-extraction / capable-consolidation
254
220
  split using OpenCode's own concepts: when `extract_model` is unset, the
255
- `small_model` from your `opencode.json` is used (Codex uses `gpt-5.4-mini`);
221
+ `small_model` from your `opencode.json` is used (Codex uses `gpt-5.6-luna`);
256
222
  when `consolidation_model` is unset, your main `model` is used (Codex uses
257
- `gpt-5.4`). If neither is configured, the learning sub-agents fall back to
223
+ `gpt-5.6-terra`). If neither is configured, the learning sub-agents fall back to
258
224
  their own agent-level `model` (if you defined one), else the provider default.
259
225
  (OpenCode's *automatic* small-model pick is internal to OpenCode and not
260
226
  exposed to plugins — set `small_model` explicitly to get the cheap extraction
@@ -299,7 +265,7 @@ Off by default; no changes to Codex's own config are required.
299
265
  {
300
266
  "plugin": [
301
267
  [
302
- "opencode-codex-memory@0.6.4",
268
+ "opencode-codex-memory@0.7.0",
303
269
  { "codex_interop": { "import": true, "export": true } }
304
270
  ]
305
271
  ]
@@ -356,7 +322,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
356
322
  ```json
357
323
  {
358
324
  "plugin": [
359
- ["opencode-codex-memory@0.6.4", { "claude_import": { "enabled": true } }]
325
+ ["opencode-codex-memory@0.7.0", { "claude_import": { "enabled": true } }]
360
326
  ]
361
327
  }
362
328
  ```
@@ -383,7 +349,7 @@ Claude names each project with an opaque id (a folder under
383
349
  {
384
350
  "plugin": [
385
351
  [
386
- "opencode-codex-memory@0.6.4",
352
+ "opencode-codex-memory@0.7.0",
387
353
  {
388
354
  "claude_import": {
389
355
  "enabled": true,
@@ -493,8 +459,9 @@ package cache.
493
459
  ## Contributing
494
460
 
495
461
  The port follows Codex closely: same two-phase pipeline, same on-disk artifacts,
496
- same prompts (adapted only where OpenCode differs). If you want the full design
497
- and the trade-offs, see [`ARCHITECTURE.md`](./ARCHITECTURE.md); contributor
462
+ same prompts (adapted only where OpenCode differs). Conceptual design:
463
+ [How OpenCode Codex Memory works](./docs/how-ai-memory-works.md). Implementation
464
+ map and workarounds: [`ARCHITECTURE.md`](./ARCHITECTURE.md). Contributor
498
465
  guidance lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md) and
499
466
  [`AGENTS.md`](./AGENTS.md) — in short: this repo exists to port Codex's memory
500
467
  system to OpenCode, and PRs that break that parity will be rejected.
@@ -37,4 +37,4 @@
37
37
  }
38
38
  }
39
39
  }
40
- }
40
+ }
@@ -9,6 +9,15 @@ export interface ParsedCitation {
9
9
  entries: MemoryCitationEntry[];
10
10
  raw: string;
11
11
  }
12
+ /** Fence language the TUI code-block renderer is registered for. */
13
+ export declare const CITATION_FENCE_LANG = "memory-citation";
14
+ /** Parse the fenced body: entry lines plus an optional `sessions:` line. */
15
+ export declare function parseCitationBody(body: string): {
16
+ entries: MemoryCitationEntry[];
17
+ sessionIds: string[];
18
+ };
12
19
  export declare function parseCitations(text: string): ParsedCitation[];
20
+ /** Cheap pre-check before running the full parser (either format). */
21
+ export declare function hasCitationMarkup(text: string): boolean;
13
22
  export declare function extractCitedSessionIds(text: string): string[];
14
23
  export declare function stripCitations(text: string): string;
@@ -1,17 +1,41 @@
1
- const CITATION_BLOCK_RE = /<memory-citation>[\s\S]*?<\/memory-citation>/gi;
1
+ /** Fence language the TUI code-block renderer is registered for. */
2
+ export const CITATION_FENCE_LANG = "memory-citation";
3
+ // Legacy bare-XML block (still accepted for persisted history) or the current
4
+ // fenced form ```memory-citation ... ``` which markdown clients render as a
5
+ // code block and the V2 TUI renders natively.
6
+ const CITATION_BLOCK_RE = /<memory-citation>[\s\S]*?<\/memory-citation>|^[ \t]{0,3}(`{3,})memory-citation[ \t]*\r?\n[\s\S]*?\r?\n[ \t]*\1[ \t]*$/gim;
7
+ const FENCE_RE = /^[ \t]{0,3}(`{3,})memory-citation[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1[ \t]*$/i;
8
+ const SESSIONS_LINE_RE = /^sessions?\s*:\s*(.*)$/i;
2
9
  function extractSection(block, name) {
3
10
  const m = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "i"));
4
11
  return m ? m[1] : null;
5
12
  }
6
- function parseEntry(line) {
7
- const trimmed = line.trim();
8
- if (!trimmed)
9
- return null;
10
- const noteSplit = trimmed.lastIndexOf("|note=[");
11
- if (noteSplit === -1 || !trimmed.endsWith("]"))
12
- return null;
13
- const location = trimmed.slice(0, noteSplit);
14
- const note = trimmed.slice(noteSplit + "|note=[".length, -1).trim();
13
+ /** Parse the fenced body: entry lines plus an optional `sessions:` line. */
14
+ export function parseCitationBody(body) {
15
+ const entries = [];
16
+ const sessionIds = [];
17
+ const seen = new Set();
18
+ for (const line of body.split(/\r?\n/)) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed)
21
+ continue;
22
+ const sessions = trimmed.match(SESSIONS_LINE_RE);
23
+ if (sessions) {
24
+ for (const id of sessions[1].split(/[\s,]+/).map((s) => s.trim()).filter(Boolean)) {
25
+ if (!seen.has(id)) {
26
+ seen.add(id);
27
+ sessionIds.push(id);
28
+ }
29
+ }
30
+ continue;
31
+ }
32
+ const entry = parseFenceEntry(trimmed);
33
+ if (entry)
34
+ entries.push(entry);
35
+ }
36
+ return { entries, sessionIds };
37
+ }
38
+ function parseLocation(location, note) {
15
39
  const colon = location.lastIndexOf(":");
16
40
  if (colon === -1)
17
41
  return null;
@@ -26,19 +50,48 @@ function parseEntry(line) {
26
50
  return null;
27
51
  return { path, lineStart, lineEnd, note };
28
52
  }
53
+ function parseXmlEntry(line) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed)
56
+ return null;
57
+ const noteSplit = trimmed.lastIndexOf("|note=[");
58
+ if (noteSplit === -1 || !trimmed.endsWith("]"))
59
+ return null;
60
+ const note = trimmed.slice(noteSplit + "|note=[".length, -1).trim();
61
+ return parseLocation(trimmed.slice(0, noteSplit), note);
62
+ }
63
+ function parseFenceEntry(line) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed)
66
+ return null;
67
+ const noteSplit = trimmed.lastIndexOf("|note=");
68
+ if (noteSplit === -1)
69
+ return null;
70
+ let note = trimmed.slice(noteSplit + "|note=".length).trim();
71
+ if (note.startsWith("[") && note.endsWith("]"))
72
+ note = note.slice(1, -1).trim();
73
+ return parseLocation(trimmed.slice(0, noteSplit), note);
74
+ }
29
75
  export function parseCitations(text) {
30
76
  const results = [];
31
77
  const re = new RegExp(CITATION_BLOCK_RE);
32
78
  let m;
33
79
  while ((m = re.exec(text)) !== null) {
34
80
  const raw = m[0];
81
+ const fenced = raw.match(FENCE_RE);
82
+ if (fenced) {
83
+ const parsed = parseCitationBody(fenced[2]);
84
+ if (parsed.entries.length > 0 || parsed.sessionIds.length > 0)
85
+ results.push({ ...parsed, raw });
86
+ continue;
87
+ }
35
88
  const entries = [];
36
89
  const sessionIds = [];
37
90
  const seen = new Set();
38
91
  const entriesBlock = extractSection(raw, "citation_entries");
39
92
  if (entriesBlock) {
40
93
  for (const line of entriesBlock.split(/\r?\n/)) {
41
- const entry = parseEntry(line);
94
+ const entry = parseXmlEntry(line);
42
95
  if (entry)
43
96
  entries.push(entry);
44
97
  }
@@ -68,6 +121,10 @@ export function parseCitations(text) {
68
121
  }
69
122
  return results;
70
123
  }
124
+ /** Cheap pre-check before running the full parser (either format). */
125
+ export function hasCitationMarkup(text) {
126
+ return /<memory-citation>/i.test(text) || /^[ \t]{0,3}`{3,}memory-citation[ \t]*$/im.test(text);
127
+ }
71
128
  export function extractCitedSessionIds(text) {
72
129
  const seen = new Set();
73
130
  for (const c of parseCitations(text)) {
package/dist/src/db.js CHANGED
@@ -78,6 +78,16 @@ function runMigrations(db) {
78
78
  db.run(stmt);
79
79
  db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(1, Date.now());
80
80
  }
81
+ if (currentVersion < 2) {
82
+ db.run(`CREATE TABLE IF NOT EXISTS memory_citation_usage (
83
+ session_id TEXT NOT NULL,
84
+ assistant_message_id TEXT NOT NULL,
85
+ cited_session_id TEXT NOT NULL,
86
+ recorded_at INTEGER NOT NULL,
87
+ PRIMARY KEY (session_id, assistant_message_id, cited_session_id)
88
+ )`);
89
+ db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(2, Date.now());
90
+ }
81
91
  }).immediate();
82
92
  }
83
93
  export function closeDb() {
@@ -82,6 +82,7 @@ export interface HostPromptBody {
82
82
  export declare function hostSessionPrompt(client: PluginInput["client"], opts: {
83
83
  sessionId: string;
84
84
  body: HostPromptBody;
85
+ signal?: AbortSignal;
85
86
  }): Promise<{
86
87
  error?: unknown;
87
88
  data?: unknown;
@@ -96,6 +96,7 @@ export async function hostSessionPrompt(client, opts) {
96
96
  ...(opts.body.variant ? { variant: opts.body.variant } : {}),
97
97
  parts: opts.body.parts,
98
98
  },
99
+ ...(opts.signal ? { signal: opts.signal } : {}),
99
100
  });
100
101
  }
101
102
  /** Read AssistantMessage.structured when the host captured json_schema output. */
@@ -1,13 +1,23 @@
1
- import { MemoryStore } from "./store.js";
2
1
  import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
3
2
  /** Test seam: wait for all hook-launched work, including follow-up phase 2. */
4
3
  export declare function waitForBackgroundTasks(): Promise<void>;
4
+ /**
5
+ * Load the OpenCode 2 implementation only when a V2 host actually invokes
6
+ * setup(). OpenCode 1 imports this module to obtain server(); it must not need
7
+ * the V2 SDK or execute any V2 module initialization just to start.
8
+ */
9
+ type PluginSetup = (() => void | Promise<void>) | void;
10
+ declare function setupV2(ctx: unknown): Promise<PluginSetup>;
5
11
  export declare function takeNewCitations(partKey: string, ids: string[]): string[];
6
12
  export declare function markTurnSeen(sessionId: string): boolean;
7
13
  export declare function shouldHandleIdle(sessionId: string, now?: number): boolean;
8
- export declare function handleSessionDeleted(sessionId: string, store?: Pick<MemoryStore, "deleteSessionMemory">, schedulePhase2?: () => void): void;
14
+ interface SessionMemoryStore {
15
+ deleteSessionMemory(sessionId: string): boolean;
16
+ }
17
+ export declare function handleSessionDeleted(sessionId: string, store?: SessionMemoryStore, schedulePhase2?: () => void): void;
9
18
  declare const _default: {
10
19
  id: string;
20
+ setup: typeof setupV2;
11
21
  server(input: PluginInput, opts?: PluginOptions): Promise<{
12
22
  tool: {
13
23
  memory_read: {
package/dist/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js";
2
2
  import { memoryRoot } from "./paths.js";
3
- import { stripCitations, extractCitedSessionIds } from "./citation.js";
3
+ import { stripCitations, extractCitedSessionIds, hasCitationMarkup } from "./citation.js";
4
4
  import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js";
5
5
  import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
6
6
  import { MemoryStore } from "./store.js";
@@ -39,6 +39,22 @@ const MCP_STATUS_TIMEOUT_MS = 1_000;
39
39
  function getStore() {
40
40
  return new MemoryStore();
41
41
  }
42
+ function isV2PluginContext(ctx) {
43
+ if (!ctx || typeof ctx !== "object")
44
+ return false;
45
+ const session = ctx.session;
46
+ const directory = ctx.location?.directory;
47
+ return typeof session?.hook === "function" && typeof directory === "string";
48
+ }
49
+ async function setupV2(ctx) {
50
+ // OpenCode 1.x may still invoke a `setup` export if present. Running the
51
+ // V2 adapter there replaces the V1 client with the shim and breaks
52
+ // discovery/consolidation. Only a V2 Plugin.Context has session.hook.
53
+ if (!isV2PluginContext(ctx))
54
+ return;
55
+ const { setup } = await import("./v2/plugin.js");
56
+ return setup(ctx);
57
+ }
42
58
  // Citation blocks are seen by both the text.complete hook (once, at
43
59
  // completion) and message.part.updated (once per streaming delta), so the
44
60
  // same block surfaces many times. Track which session ids were already
@@ -105,6 +121,9 @@ schedulePhase2 = () => { if (pluginOptions.generate_memories)
105
121
  }
106
122
  export default {
107
123
  id: "opencode-codex-memory",
124
+ // opencode2 entry point (V2 reads id + setup(), ignoring server()).
125
+ // Added additively: V1 hosts keep calling server() exactly as before.
126
+ setup: setupV2,
108
127
  async server(input, opts) {
109
128
  // A reload after dispose must be able to run the pipeline again.
110
129
  resetPluginLifecycle();
@@ -123,6 +142,14 @@ export default {
123
142
  // Finish bounded reseeding before hooks can see a surviving memory
124
143
  // sub-session after a plugin reload.
125
144
  await cleanupOldSubSessions();
145
+ try {
146
+ if (getStore().releaseOrphanedPhase2Job()) {
147
+ console.warn("[opencode-codex-memory] released a consolidation lease orphaned by a dead process");
148
+ }
149
+ }
150
+ catch (err) {
151
+ console.warn("[opencode-codex-memory] orphaned phase2 sweep failed:", err);
152
+ }
126
153
  return buildHooks();
127
154
  },
128
155
  };
@@ -326,6 +353,10 @@ async function classifyExternalContextTool(toolName) {
326
353
  }
327
354
  return false;
328
355
  }
356
+ const TITLE_GENERATOR_MARKER = "You are a title generator. You output ONLY a thread title.";
357
+ function isTitleGenerationPrompt(system) {
358
+ return system.some((block) => block.includes(TITLE_GENERATOR_MARKER));
359
+ }
329
360
  /**
330
361
  * Registers the memorize / memorize-extract sub-agents through the config
331
362
  * hook so installing the plugin requires no manual agent setup. Definitions
@@ -383,9 +414,13 @@ function buildHooks() {
383
414
  if (!pluginOptions.use_memories)
384
415
  return;
385
416
  // OpenCode also invokes this hook while generating agent definitions,
386
- // without a session. Memory belongs only in real conversation prompts.
417
+ // without a session, and while naming a session (hidden `title` agent,
418
+ // same sessionID; hook has no agent field). Memory belongs only in
419
+ // real conversation prompts.
387
420
  if (!input.sessionID || isMemorySubSession(input.sessionID))
388
421
  return;
422
+ if (isTitleGenerationPrompt(output.system))
423
+ return;
389
424
  ensureMemoryLayout();
390
425
  const memoryPrompt = buildMemorySystemPrompt(pluginOptions.dedicated_tools);
391
426
  if (memoryPrompt) {
@@ -409,7 +444,7 @@ function buildHooks() {
409
444
  try {
410
445
  if (isMemorySubSession(input.sessionID))
411
446
  return;
412
- if (!output.text.includes("<memory-citation>"))
447
+ if (!hasCitationMarkup(output.text))
413
448
  return;
414
449
  try {
415
450
  const ids = extractCitedSessionIds(output.text);
@@ -437,10 +472,10 @@ function buildHooks() {
437
472
  if (msg.info?.role !== "assistant")
438
473
  continue;
439
474
  for (const part of msg.parts) {
440
- if (part.type === "text" && typeof part.text === "string" && part.text.includes("<memory-citation>")) {
475
+ if (part.type === "text" && typeof part.text === "string" && hasCitationMarkup(part.text)) {
441
476
  const before = part.text;
442
477
  part.text = stripCitations(part.text);
443
- if (part.text.includes("<memory-citation>")) {
478
+ if (hasCitationMarkup(part.text)) {
444
479
  console.warn("[opencode-codex-memory] citation marker still present after stripCitations — hook contract may have changed");
445
480
  }
446
481
  }
@@ -519,7 +554,7 @@ function buildHooks() {
519
554
  return;
520
555
  if (part.sessionID && isMemorySubSession(part.sessionID))
521
556
  return;
522
- if (!part.text.includes("<memory-citation>"))
557
+ if (!hasCitationMarkup(part.text))
523
558
  return;
524
559
  let ids = [];
525
560
  try {
package/dist/src/llm.d.ts CHANGED
@@ -6,6 +6,12 @@ export interface ExtractionResult {
6
6
  }
7
7
  export declare function setPluginInput(input: PluginInput): void;
8
8
  export declare function getPluginInput(): PluginInput | null;
9
+ /**
10
+ * V2 agent definitions are location-scoped. The V2 setup supplies the active
11
+ * location so helper sessions resolve the same agent; V1 leaves this unset
12
+ * and continues using the dedicated memory workspace as its session root.
13
+ */
14
+ export declare function setSubSessionDirectory(directory?: string): void;
9
15
  /** Test seam. */
10
16
  export declare function setSubSessionCreateTimeoutForTest(ms?: number): void;
11
17
  /** Test seam. */
package/dist/src/llm.js CHANGED
@@ -8,6 +8,7 @@ import { SCAN_LIMIT } from "./store.js";
8
8
  import { isProviderCapacityError, ProviderCapacityError } from "./ratelimit.js";
9
9
  let inputRef = null;
10
10
  let inputGeneration = 0;
11
+ let subSessionDirectoryOverride = null;
11
12
  export function setPluginInput(input) {
12
13
  inputRef = input;
13
14
  inputGeneration++;
@@ -19,6 +20,14 @@ export function setPluginInput(input) {
19
20
  export function getPluginInput() {
20
21
  return inputRef;
21
22
  }
23
+ /**
24
+ * V2 agent definitions are location-scoped. The V2 setup supplies the active
25
+ * location so helper sessions resolve the same agent; V1 leaves this unset
26
+ * and continues using the dedicated memory workspace as its session root.
27
+ */
28
+ export function setSubSessionDirectory(directory) {
29
+ subSessionDirectoryOverride = directory ?? null;
30
+ }
22
31
  // Sessions this plugin spawned for extraction/consolidation. The main
23
32
  // hooks skip these so the plugin never injects memory into (or memorizes) its
24
33
  // own sub-agents.
@@ -59,16 +68,17 @@ export function isMemorySubSession(sessionId) {
59
68
  /**
60
69
  * Host directory for memory sub-sessions. Must exist: OpenCode resolves it in
61
70
  * SystemPrompt.environment and fails the turn with UnknownError/ENOENT when
62
- * missing. Prefer the memory workspace itself global, always ours, already
63
- * granted to `memorize` via external_directory, and independent of whatever
64
- * (possibly deleted) project PluginInput.directory points at.
71
+ * missing. V1 uses the memory workspace; V2 supplies the active project
72
+ * location so its location-scoped `memorize` agent is available, with the
73
+ * memory workspace as the safe fallback.
65
74
  */
66
75
  function resolveSubSessionDirectory() {
67
- const root = memoryRoot();
76
+ const configured = subSessionDirectoryOverride;
77
+ const root = configured && fs.existsSync(configured) ? configured : memoryRoot();
68
78
  fs.mkdirSync(root, { recursive: true });
69
79
  return root;
70
80
  }
71
- async function createSession(agent, title) {
81
+ async function createSession(title) {
72
82
  const input = getPluginInput();
73
83
  if (!input)
74
84
  throw new Error("plugin input not initialized");
@@ -81,7 +91,7 @@ async function createSession(agent, title) {
81
91
  const res = await withHostTimeout(hostSessionCreate(input.client, {
82
92
  directory,
83
93
  body: {
84
- title: title ?? `codex-memory-${agent}`,
94
+ title,
85
95
  metadata: { [SUBSESSION_METADATA_KEY]: true },
86
96
  },
87
97
  signal: controller.signal,
@@ -98,8 +108,8 @@ async function createSession(agent, title) {
98
108
  /**
99
109
  * opencode's config carries the same split codex expresses with provider
100
110
  * model preferences: `small_model` for cheap background work (codex:
101
- * memory_extraction_preferred_model = gpt-5.4-mini) and `model` for capable
102
- * work (codex: memory_consolidation_preferred_model = gpt-5.4). Cached per
111
+ * memory_extraction_preferred_model = gpt-5.6-luna) and `model` for capable
112
+ * work (codex: memory_consolidation_preferred_model = gpt-5.6-terra). Cached per
103
113
  * plugin instance — opencode reloads plugins on config change.
104
114
  */
105
115
  let configModels = null;
@@ -265,6 +275,7 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
265
275
  const model = opts.model ? parseModelRef(opts.model) : null;
266
276
  const promptPromise = hostSessionPrompt(input.client, {
267
277
  sessionId,
278
+ signal: opts.signal,
268
279
  body: {
269
280
  agent,
270
281
  ...(opts.system ? { system: opts.system } : {}),
@@ -377,7 +388,7 @@ const EXTRACTION_SCHEMA = {
377
388
  */
378
389
  export async function extractViaSubagent(sessionId, transcript, opts = {}) {
379
390
  const agent = "memorize-extract";
380
- const subId = await createSession(agent, `codex-memory-extract-${sessionId}`);
391
+ const subId = await createSession(`codex-memory-extract-${sessionId}`);
381
392
  try {
382
393
  const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
383
394
  // extract_model option > opencode small_model > session default.
@@ -421,7 +432,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
421
432
  const CONSOLIDATION_TIMEOUT_MS = 3600_000;
422
433
  export async function consolidateViaSubagent(memoryRoot, diffFileName, model, signal) {
423
434
  const agent = "memorize";
424
- const subId = await createSession(agent, "codex-memory-consolidate");
435
+ const subId = await createSession("codex-memory-consolidate");
425
436
  let promptError;
426
437
  let promptFailed = false;
427
438
  try {
@@ -10,6 +10,8 @@ export interface Phase2Options {
10
10
  claudeImport?: ClaudeImportOptions;
11
11
  /** Override the 90s heartbeat interval (tests / advanced). */
12
12
  heartbeatIntervalMs?: number;
13
+ /** User-requested run: skip the 6h success cooldown (lease/retry gates still apply). */
14
+ bypassCooldown?: boolean;
13
15
  }
14
16
  export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
15
17
  /**
@@ -111,7 +111,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
111
111
  const rl = await checkRateLimit("phase2", consolidationModel);
112
112
  if (!rl.ok)
113
113
  return { status: "skipped_rate_limit" };
114
- const claim = store.claimGlobalPhase2Job();
114
+ const claim = store.claimGlobalPhase2Job({ bypassCooldown: opts.bypassCooldown });
115
115
  if (claim.type !== "claimed")
116
116
  return { status: claim.type };
117
117
  // Abort scope covers prep + consolidator so dispose during baseline/diff
@@ -0,0 +1,6 @@
1
+ import type { TranscriptMessage } from "./capture.js";
2
+ /**
3
+ * Human-first extract input. OpenCode parts, not Codex RolloutItem:
4
+ * no commentary phase, no request_user_input pairing.
5
+ */
6
+ export declare function serializeTieredInput(items: TranscriptMessage[], charLimit?: number): string;