opencode-codex-memory 0.6.5 → 0.7.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.
Files changed (41) hide show
  1. package/README.md +26 -28
  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 +32 -5
  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 +78 -0
  29. package/dist/src/v2/service.js +195 -0
  30. package/dist/src/v2/shim.d.ts +47 -0
  31. package/dist/src/v2/shim.js +591 -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.5"]
55
+ "plugin": ["opencode-codex-memory@0.7.1"]
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.1" }],
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,23 +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. In short: two background
105
- writers and one reader. Once a session has been idle long enough (default 6 h),
106
- a cheap model reviews its transcript and writes a structured note (**phase 1 —
107
- extraction**). Every few hours, a stronger model merges those notes into the
108
- actual memory files — the index, the short summary, reusable skills — resolving
109
- duplicates and dropping what went stale (**phase 2 — consolidation**). On every
110
- turn, the short summary goes into the system prompt, and the agent searches
111
- deeper layers itself when a task looks familiar (**read path**). When the agent
112
- uses a memory it cites it; those citations feed consolidation's ranking, so
113
- useful memories survive and unused ones age out.
114
-
115
- For the full architecture — learning, remembering, forgetting, and the
116
- trade-offs behind each — see
117
- [How OpenCode Codex Memory works](./docs/how-ai-memory-works.md).
118
-
119
116
  ## Where your data lives
120
117
 
121
118
  ```
@@ -201,7 +198,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
201
198
  ```json
202
199
  {
203
200
  "plugin": [
204
- ["opencode-codex-memory@0.6.5", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
201
+ ["opencode-codex-memory@0.7.1", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
205
202
  ]
206
203
  }
207
204
  ```
@@ -221,9 +218,9 @@ the default value appearing where you expected your setting.
221
218
 
222
219
  Model selection mirrors Codex's cheap-extraction / capable-consolidation
223
220
  split using OpenCode's own concepts: when `extract_model` is unset, the
224
- `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`);
225
222
  when `consolidation_model` is unset, your main `model` is used (Codex uses
226
- `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
227
224
  their own agent-level `model` (if you defined one), else the provider default.
228
225
  (OpenCode's *automatic* small-model pick is internal to OpenCode and not
229
226
  exposed to plugins — set `small_model` explicitly to get the cheap extraction
@@ -268,7 +265,7 @@ Off by default; no changes to Codex's own config are required.
268
265
  {
269
266
  "plugin": [
270
267
  [
271
- "opencode-codex-memory@0.6.5",
268
+ "opencode-codex-memory@0.7.1",
272
269
  { "codex_interop": { "import": true, "export": true } }
273
270
  ]
274
271
  ]
@@ -325,7 +322,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
325
322
  ```json
326
323
  {
327
324
  "plugin": [
328
- ["opencode-codex-memory@0.6.5", { "claude_import": { "enabled": true } }]
325
+ ["opencode-codex-memory@0.7.1", { "claude_import": { "enabled": true } }]
329
326
  ]
330
327
  }
331
328
  ```
@@ -352,7 +349,7 @@ Claude names each project with an opaque id (a folder under
352
349
  {
353
350
  "plugin": [
354
351
  [
355
- "opencode-codex-memory@0.6.5",
352
+ "opencode-codex-memory@0.7.1",
356
353
  {
357
354
  "claude_import": {
358
355
  "enabled": true,
@@ -462,8 +459,9 @@ package cache.
462
459
  ## Contributing
463
460
 
464
461
  The port follows Codex closely: same two-phase pipeline, same on-disk artifacts,
465
- same prompts (adapted only where OpenCode differs). If you want the full design
466
- 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
467
465
  guidance lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md) and
468
466
  [`AGENTS.md`](./AGENTS.md) — in short: this repo exists to port Codex's memory
469
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
  };
@@ -417,7 +444,7 @@ function buildHooks() {
417
444
  try {
418
445
  if (isMemorySubSession(input.sessionID))
419
446
  return;
420
- if (!output.text.includes("<memory-citation>"))
447
+ if (!hasCitationMarkup(output.text))
421
448
  return;
422
449
  try {
423
450
  const ids = extractCitedSessionIds(output.text);
@@ -445,10 +472,10 @@ function buildHooks() {
445
472
  if (msg.info?.role !== "assistant")
446
473
  continue;
447
474
  for (const part of msg.parts) {
448
- 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)) {
449
476
  const before = part.text;
450
477
  part.text = stripCitations(part.text);
451
- if (part.text.includes("<memory-citation>")) {
478
+ if (hasCitationMarkup(part.text)) {
452
479
  console.warn("[opencode-codex-memory] citation marker still present after stripCitations — hook contract may have changed");
453
480
  }
454
481
  }
@@ -527,7 +554,7 @@ function buildHooks() {
527
554
  return;
528
555
  if (part.sessionID && isMemorySubSession(part.sessionID))
529
556
  return;
530
- if (!part.text.includes("<memory-citation>"))
557
+ if (!hasCitationMarkup(part.text))
531
558
  return;
532
559
  let ids = [];
533
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;
@@ -0,0 +1,111 @@
1
+ import { isMemoryExcludedFragment } from "./redact.js";
2
+ const OMITTED = "[... response items omitted ...]\n";
3
+ const TOOL_ROW_CHARS = 8_000;
4
+ const MAX_ROW_CHARS = 10_000;
5
+ const DEFAULT_CHAR_LIMIT = 600_000;
6
+ const TIER_ORDER = ["human", "final", "other_agent", "commentary", "context", "tool"];
7
+ const LABELS = {
8
+ human: "human user",
9
+ final: "assistant final",
10
+ other_agent: "other agent",
11
+ commentary: "assistant commentary",
12
+ context: "harness context",
13
+ tool: "tool",
14
+ };
15
+ function isEnvContext(text) {
16
+ const trimmed = text.trim();
17
+ return trimmed.startsWith("<environment_context>") && trimmed.endsWith("</environment_context>");
18
+ }
19
+ function isOtherAgent(text) {
20
+ const trimmed = text.trimStart();
21
+ return (trimmed.startsWith("<subagent_notification>")
22
+ || (trimmed.startsWith("Message Type:")
23
+ && trimmed.includes("\nTask name:")
24
+ && trimmed.includes("\nSender:")));
25
+ }
26
+ function classify(msg) {
27
+ if (msg.type === "reasoning" || msg.type === "step-start" || msg.type === "step-finish")
28
+ return null;
29
+ if (msg.role === "developer" || msg.role === "system")
30
+ return null;
31
+ if (msg.type === "tool")
32
+ return "tool";
33
+ if (msg.type === "file" || msg.type === "image")
34
+ return msg.role === "user" ? "human" : "final";
35
+ const text = msg.text ?? "";
36
+ if (!text.trim() && msg.type !== "file" && msg.type !== "image")
37
+ return null;
38
+ if (msg.type === "agent" || msg.type === "task")
39
+ return "other_agent";
40
+ if (msg.role === "user") {
41
+ if (isMemoryExcludedFragment(text))
42
+ return null;
43
+ if (isEnvContext(text))
44
+ return "context";
45
+ return "human";
46
+ }
47
+ if (msg.role === "assistant") {
48
+ if (isOtherAgent(text))
49
+ return "other_agent";
50
+ return "final";
51
+ }
52
+ return null;
53
+ }
54
+ function rowText(msg, tier) {
55
+ if (msg.type === "image")
56
+ return "[image omitted]";
57
+ if (msg.type === "file" && !msg.text?.trim())
58
+ return "[file omitted]";
59
+ let text = msg.text ?? "";
60
+ if (tier === "tool" && text.length > TOOL_ROW_CHARS)
61
+ text = text.slice(0, TOOL_ROW_CHARS);
62
+ if (text.length > MAX_ROW_CHARS)
63
+ text = text.slice(0, MAX_ROW_CHARS);
64
+ return text;
65
+ }
66
+ /**
67
+ * Human-first extract input. OpenCode parts, not Codex RolloutItem:
68
+ * no commentary phase, no request_user_input pairing.
69
+ */
70
+ export function serializeTieredInput(items, charLimit = DEFAULT_CHAR_LIMIT) {
71
+ const rows = [];
72
+ for (const msg of items) {
73
+ const tier = classify(msg);
74
+ if (!tier)
75
+ continue;
76
+ const body = rowText(msg, tier);
77
+ if (!body.trim())
78
+ continue;
79
+ rows.push({ tier, text: `[${LABELS[tier]}]\n${body}\n` });
80
+ }
81
+ let remaining = Math.max(0, charLimit - OMITTED.length);
82
+ const selected = rows.map(() => null);
83
+ for (const tier of TIER_ORDER) {
84
+ for (let i = rows.length - 1; i >= 0; i--) {
85
+ if (rows[i].tier !== tier || remaining <= OMITTED.length)
86
+ continue;
87
+ let text = rows[i].text;
88
+ if (text.length > remaining)
89
+ text = text.slice(0, Math.max(0, remaining - OMITTED.length));
90
+ if (!text)
91
+ continue;
92
+ remaining -= text.length + OMITTED.length;
93
+ selected[i] = text;
94
+ }
95
+ }
96
+ let rendered = "";
97
+ let gap = false;
98
+ for (const row of selected) {
99
+ if (row) {
100
+ rendered += row;
101
+ gap = false;
102
+ }
103
+ else if (!gap) {
104
+ rendered += OMITTED;
105
+ gap = true;
106
+ }
107
+ }
108
+ if (rendered.length > charLimit)
109
+ return "";
110
+ return rendered;
111
+ }