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.
- package/README.md +26 -28
- package/dist/opencode.json +1 -1
- package/dist/src/citation.d.ts +9 -0
- package/dist/src/citation.js +68 -11
- package/dist/src/db.js +10 -0
- package/dist/src/host-client.d.ts +1 -0
- package/dist/src/host-client.js +1 -0
- package/dist/src/index.d.ts +12 -2
- package/dist/src/index.js +32 -5
- package/dist/src/llm.d.ts +6 -0
- package/dist/src/llm.js +21 -10
- package/dist/src/phase2.d.ts +2 -0
- package/dist/src/phase2.js +1 -1
- package/dist/src/rollout-input.d.ts +6 -0
- package/dist/src/rollout-input.js +111 -0
- package/dist/src/store.d.ts +17 -1
- package/dist/src/store.js +90 -4
- package/dist/src/v2/agents.d.ts +53 -0
- package/dist/src/v2/agents.js +204 -0
- package/dist/src/v2/citation-overlay.d.ts +7 -0
- package/dist/src/v2/citation-overlay.js +52 -0
- package/dist/src/v2/index.d.ts +7 -0
- package/dist/src/v2/index.js +10 -0
- package/dist/src/v2/injection.d.ts +14 -0
- package/dist/src/v2/injection.js +19 -0
- package/dist/src/v2/plugin.d.ts +7 -0
- package/dist/src/v2/plugin.js +482 -0
- package/dist/src/v2/service.d.ts +78 -0
- package/dist/src/v2/service.js +195 -0
- package/dist/src/v2/shim.d.ts +47 -0
- package/dist/src/v2/shim.js +591 -0
- package/dist/src/v2/status-rpc.d.ts +197 -0
- package/dist/src/v2/status-rpc.js +159 -0
- package/dist/src/v2/status.d.ts +3 -0
- package/dist/src/v2/status.js +83 -0
- package/dist/src/v2/tools.d.ts +33 -0
- package/dist/src/v2/tools.js +57 -0
- package/dist/src/v2/tui.d.ts +3 -0
- package/dist/src/v2/tui.js +750 -0
- package/opencode.json +1 -1
- 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
|
|
47
|
-
[How
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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).
|
|
466
|
-
|
|
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.
|
package/dist/opencode.json
CHANGED
package/dist/src/citation.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/citation.js
CHANGED
|
@@ -1,17 +1,41 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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() {
|
package/dist/src/host-client.js
CHANGED
|
@@ -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. */
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
102
|
-
* work (codex: memory_consolidation_preferred_model = gpt-5.
|
|
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(
|
|
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(
|
|
435
|
+
const subId = await createSession("codex-memory-consolidate");
|
|
425
436
|
let promptError;
|
|
426
437
|
let promptFailed = false;
|
|
427
438
|
try {
|
package/dist/src/phase2.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/src/phase2.js
CHANGED
|
@@ -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
|
+
}
|