opencode-codex-memory 0.1.9 → 0.2.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 +35 -12
- package/dist/opencode.json +4 -4
- package/dist/src/capture.d.ts +29 -4
- package/dist/src/capture.js +108 -57
- package/dist/src/index.d.ts +51 -4
- package/dist/src/index.js +142 -16
- package/dist/src/llm.d.ts +1 -0
- package/dist/src/llm.js +1 -1
- package/dist/src/path-guard.d.ts +9 -0
- package/dist/src/path-guard.js +23 -1
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -3
- package/dist/src/phase1.d.ts +1 -0
- package/dist/src/phase1.js +9 -4
- package/dist/src/phase2.d.ts +2 -0
- package/dist/src/phase2.js +4 -0
- package/dist/src/redact.js +5 -2
- package/dist/src/store.d.ts +5 -0
- package/dist/src/store.js +29 -13
- package/dist/src/workspace.js +8 -3
- package/dist/tools/control.js +40 -14
- package/dist/tools/memory.d.ts +20 -4
- package/dist/tools/memory.js +182 -60
- package/opencode.json +4 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -52,8 +52,8 @@ anything.
|
|
|
52
52
|
immediately (codex ships the same system behind a default-off feature flag
|
|
53
53
|
with a consent prompt; a standalone memory plugin *is* the consent).
|
|
54
54
|
|
|
55
|
-
Requires
|
|
56
|
-
no `git` binary or any other external tool needed.
|
|
55
|
+
Requires opencode 1.18 or newer (official release). Git is bundled
|
|
56
|
+
(`isomorphic-git`) — no `git` binary or any other external tool needed.
|
|
57
57
|
|
|
58
58
|
The two restricted sub-agents that do the background learning (`memorize`,
|
|
59
59
|
`memorize-extract`) register themselves automatically while background learning
|
|
@@ -61,7 +61,12 @@ is enabled. To choose which models
|
|
|
61
61
|
they use, set the `extract_model` / `consolidation_model` plugin options (see
|
|
62
62
|
[Configuration](#configuration)) — don't override the agents for that. Defining
|
|
63
63
|
an agent with the same name in your own config is only for advanced tweaks
|
|
64
|
-
(e.g. permissions); your definition then replaces the shipped one.
|
|
64
|
+
(e.g. permissions); your definition then replaces the shipped one. If you
|
|
65
|
+
override `memorize`, keep an `external_directory` allow for
|
|
66
|
+
`~/.local/share/opencode/memories/*` (e.g.
|
|
67
|
+
`"external_directory": { "$HOME/.local/share/opencode/memories/*": "allow" }`
|
|
68
|
+
after the wildcard deny) — the memory folder lives outside your project, and
|
|
69
|
+
without that grant opencode blocks the consolidator's file access.
|
|
65
70
|
|
|
66
71
|
## Try it
|
|
67
72
|
|
|
@@ -90,7 +95,7 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
|
|
|
90
95
|
|
|
91
96
|
```
|
|
92
97
|
~/.local/share/opencode/
|
|
93
|
-
├── memory.db # the plugin's own database (opencode's is only
|
|
98
|
+
├── memory.db # the plugin's own database (opencode's data is only accessed via its API)
|
|
94
99
|
└── memories/
|
|
95
100
|
├── memory_summary.md # compact summary injected into the system prompt
|
|
96
101
|
├── MEMORY.md # searchable index of everything learned
|
|
@@ -112,8 +117,10 @@ those too.)
|
|
|
112
117
|
session transcripts and extracted memories before anything is written or sent
|
|
113
118
|
to a model. Notes you explicitly dictate ("remember that ...") are stored as
|
|
114
119
|
you said them.
|
|
115
|
-
- **The learning agents are sandboxed** —
|
|
116
|
-
the
|
|
120
|
+
- **The learning agents are sandboxed** — the extraction agent has no tools at
|
|
121
|
+
all (the transcript is handed to it inline), and the consolidation agent gets
|
|
122
|
+
only file tools plus access to the memory folder. Shell, network, IDE, and
|
|
123
|
+
MCP tools are denied for both.
|
|
117
124
|
- **Reset is safe.** `memory_reset` refuses to run if the memory folder is a
|
|
118
125
|
symlink, so it can't be tricked into deleting something else.
|
|
119
126
|
- **Web/MCP sessions:** by default, sessions that used web search, fetch, or MCP
|
|
@@ -176,16 +183,32 @@ explicitly, so they win over an agent-level `model`.
|
|
|
176
183
|
> This is the one intentional default difference — the tools are a core part of a
|
|
177
184
|
> standalone memory plugin. Everything else matches codex's defaults.
|
|
178
185
|
>
|
|
179
|
-
> Turning `dedicated_tools` off
|
|
180
|
-
>
|
|
181
|
-
>
|
|
182
|
-
>
|
|
183
|
-
>
|
|
184
|
-
>
|
|
186
|
+
> Turning `dedicated_tools` off keeps background learning, summary injection,
|
|
187
|
+
> and citation tracking working. The injected guidance switches to codex's
|
|
188
|
+
> file-based mode — the agent reads the memory files with its normal file
|
|
189
|
+
> tools and writes "remember this" notes directly into
|
|
190
|
+
> `extensions/ad_hoc/notes/`. Caveat: the memory folder lives outside your
|
|
191
|
+
> project, so opencode raises an `external_directory` permission prompt the
|
|
192
|
+
> first time an agent touches it (allow-always covers later access); agents
|
|
193
|
+
> whose permissions deny that ask cannot use file-based mode. The dedicated
|
|
194
|
+
> tools have no such friction — that's why they are the default. The
|
|
195
|
+
> maintenance tools (`memory_reset`, `memory_inspect`, `memory_mode`) stay
|
|
196
|
+
> available either way.
|
|
185
197
|
|
|
186
198
|
## Under the hood
|
|
187
199
|
|
|
188
200
|
opencode-codex-memory is a faithful port of the memory system from OpenAI's codex.
|
|
201
|
+
|
|
202
|
+
One design choice is worth calling out, because it shapes everything else: **memory
|
|
203
|
+
is global.** There's a single store for all your work, not one per project. That's
|
|
204
|
+
not an accident of the port — it's codex's own hard-won shape. codex *started* with
|
|
205
|
+
per-project memory (a separate bucket per directory, plus a user scope) and
|
|
206
|
+
**deliberately removed it** in early 2026, collapsing everything into one global
|
|
207
|
+
root for simplicity: one store, one lock, one consolidation pass. Project awareness
|
|
208
|
+
didn't disappear — it moved out of storage and into the prompt, as soft "this looks
|
|
209
|
+
like it belongs to that project" hints rather than hard partitions. This port
|
|
210
|
+
mirrors that exactly.
|
|
211
|
+
|
|
189
212
|
If you want to understand the design, the trade-offs, or contribute, see
|
|
190
213
|
[`ARCHITECTURE.md`](./ARCHITECTURE.md). Contributor guidance lives in
|
|
191
214
|
[`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`AGENTS.md`](./AGENTS.md) —
|
package/dist/opencode.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"memorize-extract": {
|
|
22
22
|
"mode": "subagent",
|
|
23
|
-
"prompt": "You are a memory extraction agent.
|
|
23
|
+
"prompt": "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
|
|
24
24
|
"permission": {
|
|
25
25
|
"*": "deny",
|
|
26
26
|
"bash": "deny",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"websearch": "deny",
|
|
29
29
|
"task": "deny",
|
|
30
30
|
"todowrite": "deny",
|
|
31
|
-
"read": "
|
|
31
|
+
"read": "deny",
|
|
32
32
|
"write": "deny",
|
|
33
33
|
"edit": "deny",
|
|
34
|
-
"glob": "
|
|
35
|
-
"grep": "
|
|
34
|
+
"glob": "deny",
|
|
35
|
+
"grep": "deny"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
package/dist/src/capture.d.ts
CHANGED
|
@@ -1,19 +1,44 @@
|
|
|
1
|
-
import { MemoryStore } from "./store.js";
|
|
1
|
+
import type { MemoryStore } from "./store.js";
|
|
2
2
|
export interface SessionRow {
|
|
3
3
|
id: string;
|
|
4
4
|
updated_at: number;
|
|
5
5
|
directory: string | null;
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Global session discovery through the official API: opencode's session.list
|
|
9
|
+
* is project-scoped, so enumerate projects (project.list) and list each one
|
|
10
|
+
* with scope=project (routes the request to that project's instance AND
|
|
11
|
+
* widens the filter from the session directory to the whole project).
|
|
12
|
+
* Instance contexts created this way are cached by the host for the process
|
|
13
|
+
* lifetime, and the whole pass is rate-limited (30s min interval).
|
|
14
|
+
*
|
|
15
|
+
* Fail-safe at two levels: a failed project.list skips the pass ([]), a
|
|
16
|
+
* failed per-project session.list skips that project — neither claims or
|
|
17
|
+
* finalizes any job. Transcript loading must NOT be fail-safe — see
|
|
18
|
+
* loadTranscript.
|
|
19
|
+
*/
|
|
20
|
+
export declare function listRecentSessions(limit?: number): Promise<SessionRow[]>;
|
|
8
21
|
export interface TranscriptMessage {
|
|
9
22
|
type: string;
|
|
10
23
|
role?: string;
|
|
11
24
|
text?: string;
|
|
12
25
|
}
|
|
13
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Transcript loading uses the official API — the same surface opencode's own
|
|
28
|
+
* UI renders history from; the session-scoped route resolves the right
|
|
29
|
+
* instance even for sessions from other projects.
|
|
30
|
+
*
|
|
31
|
+
* Errors PROPAGATE. An empty result must mean "session has no extractable
|
|
32
|
+
* content" — a swallowed error here used to surface as a successful
|
|
33
|
+
* no-output extraction, which deletes any previous extraction for the
|
|
34
|
+
* session (codex: load_rollout_items errors fail the job, which retries
|
|
35
|
+
* under its lease/backoff). A claimed session normally has messages, so a
|
|
36
|
+
* legitimately empty result is logged for observability.
|
|
37
|
+
*/
|
|
38
|
+
export declare function loadTranscript(sessionId: string): Promise<TranscriptMessage[]>;
|
|
14
39
|
export interface EligibilityOptions {
|
|
15
40
|
maxAgeDays: number;
|
|
16
41
|
minIdleHours: number;
|
|
17
42
|
excludeSession?: string;
|
|
18
43
|
}
|
|
19
|
-
export declare function selectEligibleSessions(store: MemoryStore, opts: EligibilityOptions): SessionRow[]
|
|
44
|
+
export declare function selectEligibleSessions(store: MemoryStore, opts: EligibilityOptions): Promise<SessionRow[]>;
|
package/dist/src/capture.js
CHANGED
|
@@ -1,72 +1,123 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
2
|
-
import { opencodeDbPath } from "./paths.js";
|
|
3
1
|
import { SCAN_LIMIT } from "./store.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const p = opencodeDbPath();
|
|
2
|
+
import { getPluginInput } from "./llm.js";
|
|
3
|
+
const API_TIMEOUT_MS = 60_000;
|
|
4
|
+
async function withTimeout(promise, ms, label) {
|
|
5
|
+
let timer;
|
|
9
6
|
try {
|
|
10
|
-
|
|
7
|
+
return await Promise.race([
|
|
8
|
+
promise,
|
|
9
|
+
new Promise((_, reject) => {
|
|
10
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
11
|
+
}),
|
|
12
|
+
]);
|
|
11
13
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
finally {
|
|
15
|
+
clearTimeout(timer);
|
|
14
16
|
}
|
|
15
|
-
return opencodeDb;
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Global session discovery through the official API: opencode's session.list
|
|
20
|
+
* is project-scoped, so enumerate projects (project.list) and list each one
|
|
21
|
+
* with scope=project (routes the request to that project's instance AND
|
|
22
|
+
* widens the filter from the session directory to the whole project).
|
|
23
|
+
* Instance contexts created this way are cached by the host for the process
|
|
24
|
+
* lifetime, and the whole pass is rate-limited (30s min interval).
|
|
25
|
+
*
|
|
26
|
+
* Fail-safe at two levels: a failed project.list skips the pass ([]), a
|
|
27
|
+
* failed per-project session.list skips that project — neither claims or
|
|
28
|
+
* finalizes any job. Transcript loading must NOT be fail-safe — see
|
|
29
|
+
* loadTranscript.
|
|
30
|
+
*/
|
|
31
|
+
export async function listRecentSessions(limit = SCAN_LIMIT) {
|
|
32
|
+
const client = getPluginInput()?.client;
|
|
33
|
+
if (!client?.project?.list || !client?.session?.list)
|
|
20
34
|
return [];
|
|
35
|
+
let projects;
|
|
21
36
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
WHERE parent_id IS NULL AND title NOT LIKE 'codex-memory-%'
|
|
27
|
-
ORDER BY time_updated DESC LIMIT ?`)
|
|
28
|
-
.all(limit);
|
|
37
|
+
const res = await withTimeout(client.project.list(), API_TIMEOUT_MS, "project.list");
|
|
38
|
+
if (!res || res.error || !Array.isArray(res.data))
|
|
39
|
+
throw new Error(`project.list failed: ${JSON.stringify(res?.error ?? {})}`);
|
|
40
|
+
projects = res.data;
|
|
29
41
|
}
|
|
30
|
-
catch {
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.warn("[opencode-codex-memory] project discovery failed; skipping pass:", err);
|
|
31
44
|
return [];
|
|
32
45
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
const all = [];
|
|
47
|
+
for (const project of projects) {
|
|
48
|
+
if (!project?.worktree)
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
const res = await withTimeout(client.session.list({
|
|
52
|
+
// scope/roots/limit are in the server's ListQuery (accepted since
|
|
53
|
+
// opencode 1.14.30, well under our 1.18 floor); the pinned SDK types
|
|
54
|
+
// still omit them (SessionListData.query is just { directory } as of
|
|
55
|
+
// 1.18.1), hence the cast at the call site.
|
|
56
|
+
query: { directory: project.worktree, scope: "project", roots: true, limit },
|
|
57
|
+
}), API_TIMEOUT_MS, "session.list");
|
|
58
|
+
if (!res || res.error || !Array.isArray(res.data))
|
|
59
|
+
throw new Error(JSON.stringify(res?.error ?? {}));
|
|
60
|
+
for (const s of res.data) {
|
|
61
|
+
// Top-level sessions only: task-tool children are summarized into
|
|
62
|
+
// their parent, and the plugin's own sub-sessions must never be
|
|
63
|
+
// memorized (roots=true drops children server-side; keep both belts).
|
|
64
|
+
if (!s?.id || s.parentID)
|
|
65
|
+
continue;
|
|
66
|
+
if (s.title && s.title.startsWith("codex-memory-"))
|
|
67
|
+
continue;
|
|
68
|
+
all.push({ id: s.id, updated_at: s.time?.updated ?? 0, directory: s.directory ?? null });
|
|
52
69
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
type: parsed.type ?? "unknown",
|
|
62
|
-
role,
|
|
63
|
-
text: extractText(parsed),
|
|
64
|
-
};
|
|
65
|
-
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.warn(`[opencode-codex-memory] session.list failed for ${project.worktree}; skipping project:`, err);
|
|
73
|
+
}
|
|
66
74
|
}
|
|
67
|
-
|
|
75
|
+
all.sort((a, b) => b.updated_at - a.updated_at);
|
|
76
|
+
return all.slice(0, limit);
|
|
77
|
+
}
|
|
78
|
+
/** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
|
|
79
|
+
async function fetchMessagesViaApi(sessionId) {
|
|
80
|
+
const client = getPluginInput()?.client;
|
|
81
|
+
if (typeof client?.session?.messages !== "function") {
|
|
82
|
+
throw new Error("plugin client unavailable; cannot load transcript");
|
|
83
|
+
}
|
|
84
|
+
const res = await withTimeout(client.session.messages({ path: { id: sessionId } }), API_TIMEOUT_MS, "session.messages");
|
|
85
|
+
if (!res || res.error || !Array.isArray(res.data)) {
|
|
86
|
+
throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
|
|
87
|
+
}
|
|
88
|
+
return res.data;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Transcript loading uses the official API — the same surface opencode's own
|
|
92
|
+
* UI renders history from; the session-scoped route resolves the right
|
|
93
|
+
* instance even for sessions from other projects.
|
|
94
|
+
*
|
|
95
|
+
* Errors PROPAGATE. An empty result must mean "session has no extractable
|
|
96
|
+
* content" — a swallowed error here used to surface as a successful
|
|
97
|
+
* no-output extraction, which deletes any previous extraction for the
|
|
98
|
+
* session (codex: load_rollout_items errors fail the job, which retries
|
|
99
|
+
* under its lease/backoff). A claimed session normally has messages, so a
|
|
100
|
+
* legitimately empty result is logged for observability.
|
|
101
|
+
*/
|
|
102
|
+
export async function loadTranscript(sessionId) {
|
|
103
|
+
const rows = await fetchMessagesViaApi(sessionId);
|
|
104
|
+
if (rows.length === 0) {
|
|
105
|
+
console.warn(`[opencode-codex-memory] session.messages returned no messages for claimed session ${sessionId}`);
|
|
68
106
|
return [];
|
|
69
107
|
}
|
|
108
|
+
// One entry per part — the granularity extraction expects.
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
const role = row?.info?.role;
|
|
112
|
+
for (const part of row?.parts ?? []) {
|
|
113
|
+
out.push({
|
|
114
|
+
type: part?.type ?? "unknown",
|
|
115
|
+
role,
|
|
116
|
+
text: extractText(part),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
70
121
|
}
|
|
71
122
|
function extractText(msg) {
|
|
72
123
|
if (!msg)
|
|
@@ -103,11 +154,11 @@ function extractText(msg) {
|
|
|
103
154
|
}
|
|
104
155
|
return undefined;
|
|
105
156
|
}
|
|
106
|
-
export function selectEligibleSessions(store, opts) {
|
|
157
|
+
export async function selectEligibleSessions(store, opts) {
|
|
107
158
|
const now = Date.now();
|
|
108
159
|
const minUpdated = now - opts.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
109
160
|
const maxUpdated = now - opts.minIdleHours * 60 * 60 * 1000;
|
|
110
|
-
const sessions = listRecentSessions();
|
|
161
|
+
const sessions = await listRecentSessions();
|
|
111
162
|
return sessions.filter((s) => {
|
|
112
163
|
if (opts.excludeSession && s.id === opts.excludeSession)
|
|
113
164
|
return false;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { MemoryStore } from "./store.js";
|
|
2
2
|
import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
|
|
3
3
|
export declare function takeNewCitations(partKey: string, ids: string[]): string[];
|
|
4
|
+
export declare function markTurnSeen(sessionId: string): boolean;
|
|
5
|
+
export declare function shouldHandleIdle(sessionId: string, now?: number): boolean;
|
|
4
6
|
export declare function handleSessionDeleted(sessionId: string, store?: Pick<MemoryStore, "deleteSessionMemory">, schedulePhase2?: () => void): void;
|
|
5
7
|
declare const _default: {
|
|
6
8
|
id: string;
|
|
@@ -22,16 +24,32 @@ declare const _default: {
|
|
|
22
24
|
memory_search: {
|
|
23
25
|
description: string;
|
|
24
26
|
args: {
|
|
25
|
-
|
|
27
|
+
queries: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
28
|
+
match_mode: import("zod").ZodDefault<import("zod").ZodEnum<{
|
|
29
|
+
any: "any";
|
|
30
|
+
all_on_same_line: "all_on_same_line";
|
|
31
|
+
all_within_lines: "all_within_lines";
|
|
32
|
+
}>>;
|
|
33
|
+
line_count: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
34
|
+
path: import("zod").ZodOptional<import("zod").ZodString>;
|
|
35
|
+
cursor: import("zod").ZodOptional<import("zod").ZodString>;
|
|
36
|
+
context_lines: import("zod").ZodDefault<import("zod").ZodNumber>;
|
|
26
37
|
case_sensitive: import("zod").ZodDefault<import("zod").ZodBoolean>;
|
|
38
|
+
normalized: import("zod").ZodDefault<import("zod").ZodBoolean>;
|
|
27
39
|
since: import("zod").ZodOptional<import("zod").ZodString>;
|
|
28
40
|
until: import("zod").ZodOptional<import("zod").ZodString>;
|
|
29
|
-
|
|
41
|
+
max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
|
|
30
42
|
};
|
|
31
43
|
execute(args: {
|
|
44
|
+
match_mode: "any" | "all_on_same_line" | "all_within_lines";
|
|
45
|
+
context_lines: number;
|
|
32
46
|
case_sensitive: boolean;
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
normalized: boolean;
|
|
48
|
+
max_results: number;
|
|
49
|
+
queries?: string[] | undefined;
|
|
50
|
+
line_count?: number | undefined;
|
|
51
|
+
path?: string | undefined;
|
|
52
|
+
cursor?: string | undefined;
|
|
35
53
|
since?: string | undefined;
|
|
36
54
|
until?: string | undefined;
|
|
37
55
|
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
@@ -131,6 +149,22 @@ declare const _default: {
|
|
|
131
149
|
}, output: {
|
|
132
150
|
system: string[];
|
|
133
151
|
}): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Fires at text-end, before opencode persists the final part text
|
|
154
|
+
* (session/processor.ts): the returned text replaces the stored one.
|
|
155
|
+
* Primary citation seam — records usage and strips the block so neither
|
|
156
|
+
* the UI nor history ever shows citation markup (matches codex, which
|
|
157
|
+
* strips from the displayed/persisted message). The event and
|
|
158
|
+
* messages.transform paths below stay as fallbacks for older opencode
|
|
159
|
+
* hosts and for history persisted before this hook existed.
|
|
160
|
+
*/
|
|
161
|
+
"experimental.text.complete"(input: {
|
|
162
|
+
sessionID: string;
|
|
163
|
+
messageID: string;
|
|
164
|
+
partID: string;
|
|
165
|
+
}, output: {
|
|
166
|
+
text: string;
|
|
167
|
+
}): Promise<void>;
|
|
134
168
|
"experimental.chat.messages.transform"(_input: unknown, output: {
|
|
135
169
|
messages: {
|
|
136
170
|
info: {
|
|
@@ -142,6 +176,19 @@ declare const _default: {
|
|
|
142
176
|
}[];
|
|
143
177
|
}[];
|
|
144
178
|
}): Promise<void>;
|
|
179
|
+
/**
|
|
180
|
+
* Turn start. codex stamps memory_mode at thread creation (session.rs) and
|
|
181
|
+
* schedules memory work per startup/turn; the first user message is the
|
|
182
|
+
* closest plugin-visible moment. Stamping here (instead of waiting for the
|
|
183
|
+
* first idle) means a session created while generate_memories=false keeps
|
|
184
|
+
* its 'disabled' stamp even if the option flips mid-session, and the
|
|
185
|
+
* phase-1 pump no longer depends on idle events at all. The idle path
|
|
186
|
+
* below stays as a second pump trigger; both are cheap (stamp is INSERT OR
|
|
187
|
+
* IGNORE, the pump is gated by in-flight/rate/claim guards).
|
|
188
|
+
*/
|
|
189
|
+
"chat.message"(input: {
|
|
190
|
+
sessionID?: string;
|
|
191
|
+
}): Promise<void>;
|
|
145
192
|
"tool.execute.after"(input: {
|
|
146
193
|
tool: string;
|
|
147
194
|
sessionID: string;
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js";
|
|
2
|
+
import { memoryRoot } from "./paths.js";
|
|
2
3
|
import { stripCitations, extractCitedSessionIds } from "./citation.js";
|
|
3
4
|
import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js";
|
|
4
5
|
import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
|
|
@@ -31,9 +32,10 @@ let pluginOptions = {
|
|
|
31
32
|
function getStore() {
|
|
32
33
|
return new MemoryStore();
|
|
33
34
|
}
|
|
34
|
-
// Citation blocks
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// Citation blocks are seen by both the text.complete hook (once, at
|
|
36
|
+
// completion) and message.part.updated (once per streaming delta), so the
|
|
37
|
+
// same block surfaces many times. Track which session ids were already
|
|
38
|
+
// recorded per part to count each citation exactly once across both paths.
|
|
37
39
|
const recordedCitations = new Map();
|
|
38
40
|
const MAX_TRACKED_PARTS = 500;
|
|
39
41
|
export function takeNewCitations(partKey, ids) {
|
|
@@ -52,6 +54,40 @@ export function takeNewCitations(partKey, ids) {
|
|
|
52
54
|
seen.add(id);
|
|
53
55
|
return fresh;
|
|
54
56
|
}
|
|
57
|
+
// One stamp+pump per session per process from the chat.message hook; later
|
|
58
|
+
// messages in the same session add nothing (stamp is idempotent, the pump
|
|
59
|
+
// re-fires on idle anyway).
|
|
60
|
+
const seenTurnSessions = new Set();
|
|
61
|
+
const MAX_TRACKED_TURN_SESSIONS = 1000;
|
|
62
|
+
export function markTurnSeen(sessionId) {
|
|
63
|
+
if (seenTurnSessions.has(sessionId))
|
|
64
|
+
return false;
|
|
65
|
+
seenTurnSessions.add(sessionId);
|
|
66
|
+
if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
|
|
67
|
+
const oldest = seenTurnSessions.keys().next().value;
|
|
68
|
+
if (oldest !== undefined)
|
|
69
|
+
seenTurnSessions.delete(oldest);
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// opencode 1.17 publishes BOTH session.status {type:"idle"} and the
|
|
74
|
+
// deprecated session.idle for the same transition, back to back. Handle
|
|
75
|
+
// whichever arrives first and swallow the twin within a short window.
|
|
76
|
+
const recentIdle = new Map();
|
|
77
|
+
const IDLE_DEDUP_MS = 5000;
|
|
78
|
+
const MAX_TRACKED_IDLE = 500;
|
|
79
|
+
export function shouldHandleIdle(sessionId, now = Date.now()) {
|
|
80
|
+
const last = recentIdle.get(sessionId);
|
|
81
|
+
if (last !== undefined && now - last < IDLE_DEDUP_MS)
|
|
82
|
+
return false;
|
|
83
|
+
recentIdle.set(sessionId, now);
|
|
84
|
+
if (recentIdle.size > MAX_TRACKED_IDLE) {
|
|
85
|
+
const oldest = recentIdle.keys().next().value;
|
|
86
|
+
if (oldest !== undefined)
|
|
87
|
+
recentIdle.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
55
91
|
export function handleSessionDeleted(sessionId, store = getStore(),
|
|
56
92
|
// With generation off the memorize agent is not injected, so a consolidation
|
|
57
93
|
// attempt could only fail; the row deletion above still happens, and the
|
|
@@ -172,6 +208,18 @@ export function injectAgentDefinitions(config) {
|
|
|
172
208
|
console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
|
|
173
209
|
return;
|
|
174
210
|
}
|
|
211
|
+
// opencode gates file tools outside the session's project behind the
|
|
212
|
+
// `external_directory` permission, and the memory workspace is global —
|
|
213
|
+
// outside every project — so the consolidator's reads/writes there always
|
|
214
|
+
// trigger that ask. The bundled `"*": "deny"` matches it (permission rules
|
|
215
|
+
// are wildcard-on-name, last match wins), which would block consolidation
|
|
216
|
+
// entirely. Grant the memory root here rather than in opencode.json: the
|
|
217
|
+
// path is homedir/env-dependent (src/paths.ts is its single source of
|
|
218
|
+
// truth). Appended last so it out-ranks the wildcard deny.
|
|
219
|
+
const memorize = defs["memorize"];
|
|
220
|
+
if (memorize?.permission && !("external_directory" in memorize.permission)) {
|
|
221
|
+
memorize.permission["external_directory"] = { [path.join(memoryRoot(), "*")]: "allow" };
|
|
222
|
+
}
|
|
175
223
|
config.agent ??= {};
|
|
176
224
|
for (const [name, def] of Object.entries(defs)) {
|
|
177
225
|
if (!config.agent[name])
|
|
@@ -208,6 +256,41 @@ function buildHooks() {
|
|
|
208
256
|
console.error("[opencode-codex-memory] system.transform error:", err);
|
|
209
257
|
}
|
|
210
258
|
},
|
|
259
|
+
/**
|
|
260
|
+
* Fires at text-end, before opencode persists the final part text
|
|
261
|
+
* (session/processor.ts): the returned text replaces the stored one.
|
|
262
|
+
* Primary citation seam — records usage and strips the block so neither
|
|
263
|
+
* the UI nor history ever shows citation markup (matches codex, which
|
|
264
|
+
* strips from the displayed/persisted message). The event and
|
|
265
|
+
* messages.transform paths below stay as fallbacks for older opencode
|
|
266
|
+
* hosts and for history persisted before this hook existed.
|
|
267
|
+
*/
|
|
268
|
+
async "experimental.text.complete"(input, output) {
|
|
269
|
+
try {
|
|
270
|
+
if (isMemorySubSession(input.sessionID))
|
|
271
|
+
return;
|
|
272
|
+
if (!output.text.includes("<memory-citation>"))
|
|
273
|
+
return;
|
|
274
|
+
try {
|
|
275
|
+
const ids = extractCitedSessionIds(output.text);
|
|
276
|
+
// Same part key as the event path: whichever hook sees the ids first
|
|
277
|
+
// records them; the other becomes a no-op.
|
|
278
|
+
const fresh = takeNewCitations(`${input.sessionID}:${input.partID}`, ids);
|
|
279
|
+
if (fresh.length > 0)
|
|
280
|
+
getStore().recordUsage(fresh);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
console.error("[opencode-codex-memory] citation recording failed:", e);
|
|
284
|
+
}
|
|
285
|
+
output.text = stripCitations(output.text);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
console.error("[opencode-codex-memory] text.complete error:", err);
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
// Fallback strip for history that still carries citation blocks (messages
|
|
292
|
+
// persisted by plugin versions before the text.complete seam, or hosts
|
|
293
|
+
// without it). Keeps citation markup out of the model-facing transcript.
|
|
211
294
|
async "experimental.chat.messages.transform"(_input, output) {
|
|
212
295
|
try {
|
|
213
296
|
for (const msg of output.messages) {
|
|
@@ -228,6 +311,35 @@ function buildHooks() {
|
|
|
228
311
|
console.error("[opencode-codex-memory] messages.transform error:", err);
|
|
229
312
|
}
|
|
230
313
|
},
|
|
314
|
+
/**
|
|
315
|
+
* Turn start. codex stamps memory_mode at thread creation (session.rs) and
|
|
316
|
+
* schedules memory work per startup/turn; the first user message is the
|
|
317
|
+
* closest plugin-visible moment. Stamping here (instead of waiting for the
|
|
318
|
+
* first idle) means a session created while generate_memories=false keeps
|
|
319
|
+
* its 'disabled' stamp even if the option flips mid-session, and the
|
|
320
|
+
* phase-1 pump no longer depends on idle events at all. The idle path
|
|
321
|
+
* below stays as a second pump trigger; both are cheap (stamp is INSERT OR
|
|
322
|
+
* IGNORE, the pump is gated by in-flight/rate/claim guards).
|
|
323
|
+
*/
|
|
324
|
+
async "chat.message"(input) {
|
|
325
|
+
try {
|
|
326
|
+
const sid = input?.sessionID;
|
|
327
|
+
if (!sid || isMemorySubSession(sid))
|
|
328
|
+
return;
|
|
329
|
+
if (!markTurnSeen(sid))
|
|
330
|
+
return;
|
|
331
|
+
try {
|
|
332
|
+
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
336
|
+
}
|
|
337
|
+
void triggerPhase1(sid);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
console.error("[opencode-codex-memory] chat.message error:", err);
|
|
341
|
+
}
|
|
342
|
+
},
|
|
231
343
|
// Dedicated plugin hook (NOT an event-bus type): fires after every tool
|
|
232
344
|
// call. Mirrors codex: external context (web search or any MCP tool) only
|
|
233
345
|
// pollutes the session's memory when disable_on_external_context is
|
|
@@ -289,21 +401,19 @@ function buildHooks() {
|
|
|
289
401
|
}
|
|
290
402
|
return;
|
|
291
403
|
}
|
|
404
|
+
// session.idle is deprecated in opencode 1.17 in favor of
|
|
405
|
+
// session.status {type:"idle"}; both are still emitted. Support both so
|
|
406
|
+
// the pipeline keeps triggering when the legacy event disappears.
|
|
407
|
+
if (ev.type === "session.status") {
|
|
408
|
+
const props = ev.properties;
|
|
409
|
+
if (props?.status?.type === "idle" && props.sessionID)
|
|
410
|
+
handleSessionIdle(props.sessionID);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
292
413
|
if (ev.type === "session.idle") {
|
|
293
414
|
const props = ev.properties;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return;
|
|
297
|
-
// codex stamps memory_mode at thread creation from generate_memories:
|
|
298
|
-
// sessions first seen while generation is off keep that stamp when the
|
|
299
|
-
// option is re-enabled (manual override: the memory_mode tool).
|
|
300
|
-
try {
|
|
301
|
-
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
|
|
302
|
-
}
|
|
303
|
-
catch (e) {
|
|
304
|
-
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
305
|
-
}
|
|
306
|
-
void triggerPhase1(sid);
|
|
415
|
+
if (props?.sessionID)
|
|
416
|
+
handleSessionIdle(props.sessionID);
|
|
307
417
|
return;
|
|
308
418
|
}
|
|
309
419
|
}
|
|
@@ -315,6 +425,22 @@ function buildHooks() {
|
|
|
315
425
|
invalidateCache();
|
|
316
426
|
},
|
|
317
427
|
};
|
|
428
|
+
function handleSessionIdle(sid) {
|
|
429
|
+
if (isMemorySubSession(sid))
|
|
430
|
+
return;
|
|
431
|
+
if (!shouldHandleIdle(sid))
|
|
432
|
+
return;
|
|
433
|
+
// codex stamps memory_mode at thread creation from generate_memories:
|
|
434
|
+
// sessions first seen while generation is off keep that stamp when the
|
|
435
|
+
// option is re-enabled (manual override: the memory_mode tool).
|
|
436
|
+
try {
|
|
437
|
+
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
441
|
+
}
|
|
442
|
+
void triggerPhase1(sid);
|
|
443
|
+
}
|
|
318
444
|
// Control tools (reset/inspect/mode) are always available. The memory
|
|
319
445
|
// read/search/list/add-note tools require BOTH use_memories and
|
|
320
446
|
// dedicated_tools, mirroring codex's MemoriesExtension: use_memories=false
|