opencode-codex-memory 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/opencode.json +4 -4
- package/dist/src/capture.d.ts +29 -4
- package/dist/src/capture.js +106 -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 +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -176,12 +181,17 @@ explicitly, so they win over an agent-level `model`.
|
|
|
176
181
|
> This is the one intentional default difference — the tools are a core part of a
|
|
177
182
|
> standalone memory plugin. Everything else matches codex's defaults.
|
|
178
183
|
>
|
|
179
|
-
> Turning `dedicated_tools` off
|
|
180
|
-
>
|
|
181
|
-
>
|
|
182
|
-
>
|
|
183
|
-
>
|
|
184
|
-
>
|
|
184
|
+
> Turning `dedicated_tools` off keeps background learning, summary injection,
|
|
185
|
+
> and citation tracking working. The injected guidance switches to codex's
|
|
186
|
+
> file-based mode — the agent reads the memory files with its normal file
|
|
187
|
+
> tools and writes "remember this" notes directly into
|
|
188
|
+
> `extensions/ad_hoc/notes/`. Caveat: the memory folder lives outside your
|
|
189
|
+
> project, so opencode raises an `external_directory` permission prompt the
|
|
190
|
+
> first time an agent touches it (allow-always covers later access); agents
|
|
191
|
+
> whose permissions deny that ask cannot use file-based mode. The dedicated
|
|
192
|
+
> tools have no such friction — that's why they are the default. The
|
|
193
|
+
> maintenance tools (`memory_reset`, `memory_inspect`, `memory_mode`) stay
|
|
194
|
+
> available either way.
|
|
185
195
|
|
|
186
196
|
## Under the hood
|
|
187
197
|
|
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,121 @@
|
|
|
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 since 1.17; the
|
|
53
|
+
// pinned SDK types lag behind, hence the cast at the call site.
|
|
54
|
+
query: { directory: project.worktree, scope: "project", roots: true, limit },
|
|
55
|
+
}), API_TIMEOUT_MS, "session.list");
|
|
56
|
+
if (!res || res.error || !Array.isArray(res.data))
|
|
57
|
+
throw new Error(JSON.stringify(res?.error ?? {}));
|
|
58
|
+
for (const s of res.data) {
|
|
59
|
+
// Top-level sessions only: task-tool children are summarized into
|
|
60
|
+
// their parent, and the plugin's own sub-sessions must never be
|
|
61
|
+
// memorized (roots=true drops children server-side; keep both belts).
|
|
62
|
+
if (!s?.id || s.parentID)
|
|
63
|
+
continue;
|
|
64
|
+
if (s.title && s.title.startsWith("codex-memory-"))
|
|
65
|
+
continue;
|
|
66
|
+
all.push({ id: s.id, updated_at: s.time?.updated ?? 0, directory: s.directory ?? null });
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
type: parsed.type ?? "unknown",
|
|
62
|
-
role,
|
|
63
|
-
text: extractText(parsed),
|
|
64
|
-
};
|
|
65
|
-
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.warn(`[opencode-codex-memory] session.list failed for ${project.worktree}; skipping project:`, err);
|
|
71
|
+
}
|
|
66
72
|
}
|
|
67
|
-
|
|
73
|
+
all.sort((a, b) => b.updated_at - a.updated_at);
|
|
74
|
+
return all.slice(0, limit);
|
|
75
|
+
}
|
|
76
|
+
/** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
|
|
77
|
+
async function fetchMessagesViaApi(sessionId) {
|
|
78
|
+
const client = getPluginInput()?.client;
|
|
79
|
+
if (typeof client?.session?.messages !== "function") {
|
|
80
|
+
throw new Error("plugin client unavailable; cannot load transcript");
|
|
81
|
+
}
|
|
82
|
+
const res = await withTimeout(client.session.messages({ path: { id: sessionId } }), API_TIMEOUT_MS, "session.messages");
|
|
83
|
+
if (!res || res.error || !Array.isArray(res.data)) {
|
|
84
|
+
throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
|
|
85
|
+
}
|
|
86
|
+
return res.data;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Transcript loading uses the official API — the same surface opencode's own
|
|
90
|
+
* UI renders history from; the session-scoped route resolves the right
|
|
91
|
+
* instance even for sessions from other projects.
|
|
92
|
+
*
|
|
93
|
+
* Errors PROPAGATE. An empty result must mean "session has no extractable
|
|
94
|
+
* content" — a swallowed error here used to surface as a successful
|
|
95
|
+
* no-output extraction, which deletes any previous extraction for the
|
|
96
|
+
* session (codex: load_rollout_items errors fail the job, which retries
|
|
97
|
+
* under its lease/backoff). A claimed session normally has messages, so a
|
|
98
|
+
* legitimately empty result is logged for observability.
|
|
99
|
+
*/
|
|
100
|
+
export async function loadTranscript(sessionId) {
|
|
101
|
+
const rows = await fetchMessagesViaApi(sessionId);
|
|
102
|
+
if (rows.length === 0) {
|
|
103
|
+
console.warn(`[opencode-codex-memory] session.messages returned no messages for claimed session ${sessionId}`);
|
|
68
104
|
return [];
|
|
69
105
|
}
|
|
106
|
+
// One entry per part — the granularity extraction expects.
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
const role = row?.info?.role;
|
|
110
|
+
for (const part of row?.parts ?? []) {
|
|
111
|
+
out.push({
|
|
112
|
+
type: part?.type ?? "unknown",
|
|
113
|
+
role,
|
|
114
|
+
text: extractText(part),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
70
119
|
}
|
|
71
120
|
function extractText(msg) {
|
|
72
121
|
if (!msg)
|
|
@@ -103,11 +152,11 @@ function extractText(msg) {
|
|
|
103
152
|
}
|
|
104
153
|
return undefined;
|
|
105
154
|
}
|
|
106
|
-
export function selectEligibleSessions(store, opts) {
|
|
155
|
+
export async function selectEligibleSessions(store, opts) {
|
|
107
156
|
const now = Date.now();
|
|
108
157
|
const minUpdated = now - opts.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
109
158
|
const maxUpdated = now - opts.minIdleHours * 60 * 60 * 1000;
|
|
110
|
-
const sessions = listRecentSessions();
|
|
159
|
+
const sessions = await listRecentSessions();
|
|
111
160
|
return sessions.filter((s) => {
|
|
112
161
|
if (opts.excludeSession && s.id === opts.excludeSession)
|
|
113
162
|
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
|
package/dist/src/llm.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface ExtractionResult {
|
|
|
5
5
|
rollout_slug: string | null;
|
|
6
6
|
}
|
|
7
7
|
export declare function setPluginInput(input: PluginInput): void;
|
|
8
|
+
export declare function getPluginInput(): PluginInput | null;
|
|
8
9
|
export declare function isMemorySubSession(sessionId: string): boolean;
|
|
9
10
|
export interface ExtractOptions {
|
|
10
11
|
cwd?: string;
|
package/dist/src/llm.js
CHANGED
package/dist/src/path-guard.d.ts
CHANGED
|
@@ -7,4 +7,13 @@
|
|
|
7
7
|
* - every existing component is lstat-checked: symlinks are rejected, so a
|
|
8
8
|
* link placed inside the workspace cannot lead reads outside it
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The memory root itself must not be a symlink: every scoped resolution and
|
|
12
|
+
* every workspace walk starts there, so a symlinked root would redirect ALL
|
|
13
|
+
* memory reads/writes elsewhere on disk. codex rejects a symlinked root when
|
|
14
|
+
* clearing (control.rs clear_memory_root_contents); the model-facing tools
|
|
15
|
+
* here extend that check to every memory operation. Returns the root path.
|
|
16
|
+
* A missing root is fine — callers create it as a real directory.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assertMemoryRootSafe(): string;
|
|
10
19
|
export declare function safeResolveMemoryPath(rel: string): string;
|