opencode-codex-memory 0.4.1 → 0.4.3
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 +16 -7
- package/dist/src/capture.js +15 -2
- package/dist/src/index.d.ts +16 -5
- package/dist/src/index.js +121 -94
- package/dist/src/llm.d.ts +19 -1
- package/dist/src/llm.js +161 -14
- package/dist/src/options.d.ts +2 -0
- package/dist/src/options.js +5 -1
- package/dist/src/phase2.js +12 -1
- package/dist/src/redact.d.ts +8 -0
- package/dist/src/redact.js +210 -6
- package/dist/src/source.js +32 -16
- package/dist/src/store.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# OpenCode Codex Memory
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/opencode-codex-memory">
|
|
5
|
+
<img src="https://img.shields.io/npm/v/opencode-codex-memory?logo=npm&label=latest" alt="Latest npm version" />
|
|
6
|
+
</a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/opencode-codex-memory">
|
|
8
|
+
<img src="https://img.shields.io/npm/dt/opencode-codex-memory?logo=npm&label=downloads" alt="npm downloads" />
|
|
9
|
+
</a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
3
12
|
Persistent memory for [OpenCode](https://opencode.ai): your agent remembers what
|
|
4
13
|
it learned in past sessions — your conventions, your projects, the decisions you
|
|
5
14
|
made — and brings that context into new conversations automatically.
|
|
@@ -39,11 +48,11 @@ If you want the mental model before the details, jump to
|
|
|
39
48
|
|
|
40
49
|
1. Add the plugin to your `~/.config/opencode/opencode.json`:
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"plugin": ["opencode-codex-memory@0.4.3"]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
47
56
|
|
|
48
57
|
**Pin the version** (here and for any OpenCode plugin). OpenCode installs a
|
|
49
58
|
plugin spec once into its package cache and never re-resolves it, so a bare
|
|
@@ -228,7 +237,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
|
|
|
228
237
|
```json
|
|
229
238
|
{
|
|
230
239
|
"plugin": [
|
|
231
|
-
["opencode-codex-memory@0.4.
|
|
240
|
+
["opencode-codex-memory@0.4.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
|
|
232
241
|
]
|
|
233
242
|
}
|
|
234
243
|
```
|
|
@@ -287,7 +296,7 @@ directions:
|
|
|
287
296
|
```json
|
|
288
297
|
{
|
|
289
298
|
"plugin": [
|
|
290
|
-
["opencode-codex-memory@0.4.
|
|
299
|
+
["opencode-codex-memory@0.4.3", { "codex_interop": { "import": true, "export": true } }]
|
|
291
300
|
]
|
|
292
301
|
}
|
|
293
302
|
```
|
package/dist/src/capture.js
CHANGED
|
@@ -127,6 +127,11 @@ function extractText(msg) {
|
|
|
127
127
|
// parts carry `text`, so they must be dropped before the text check.
|
|
128
128
|
if (msg.type === "reasoning")
|
|
129
129
|
return undefined;
|
|
130
|
+
// opencode itself drops `ignored` text parts when building model messages
|
|
131
|
+
// (session/message-v2.ts), e.g. ACP content addressed only to the user.
|
|
132
|
+
// The assistant never saw them, so they are not conversation.
|
|
133
|
+
if (msg.ignored === true)
|
|
134
|
+
return undefined;
|
|
130
135
|
if (typeof msg.text === "string")
|
|
131
136
|
return msg.text;
|
|
132
137
|
if (msg.type === "tool") {
|
|
@@ -135,8 +140,16 @@ function extractText(msg) {
|
|
|
135
140
|
// the extractor's strongest evidence — do not slice them per call.
|
|
136
141
|
const tool = msg.tool ?? "unknown";
|
|
137
142
|
const input = msg.state?.input ? JSON.stringify(msg.state.input) : "";
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
// `output` exists only on status:"completed"; a failed call carries
|
|
144
|
+
// `error` instead (schema v1/session.ts ToolStateError). codex persists
|
|
145
|
+
// failed calls too (rollout policy: FunctionCallOutput => true), and "X
|
|
146
|
+
// failed with Y" is often the most memorable part of a session.
|
|
147
|
+
const result = typeof msg.state?.output === "string"
|
|
148
|
+
? msg.state.output
|
|
149
|
+
: typeof msg.state?.error === "string"
|
|
150
|
+
? `[error] ${msg.state.error}`
|
|
151
|
+
: "";
|
|
152
|
+
return `[tool: ${tool}] ${input}${result ? "\n" + result : ""}`;
|
|
140
153
|
}
|
|
141
154
|
if (msg.type === "step-start" || msg.type === "step-finish")
|
|
142
155
|
return undefined;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -189,16 +189,27 @@ declare const _default: {
|
|
|
189
189
|
"chat.message"(input: {
|
|
190
190
|
sessionID?: string;
|
|
191
191
|
}): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* Dedicated plugin hook (NOT an event-bus type). Marks the session polluted
|
|
194
|
+
* at INVOCATION, mirroring codex: mcp_tool_call.rs calls
|
|
195
|
+
* maybe_mark_thread_memory_mode_polluted inside handle_approved_mcp_tool_call
|
|
196
|
+
* BEFORE the call runs, and web search marks on the completed response item
|
|
197
|
+
* (stream_events_utils.rs response_item_may_include_external_context).
|
|
198
|
+
*
|
|
199
|
+
* Deliberately not tool.execute.after: opencode does not guarantee that hook
|
|
200
|
+
* (session/tools.ts awaits execute() with no ensuring/catchAll, and an abort
|
|
201
|
+
* interrupts the fiber), so a failed or cancelled websearch/webfetch/MCP call
|
|
202
|
+
* left the session unmarked while its output had already entered the
|
|
203
|
+
* transcript. Marking early over-marks a permission-denied call, which is the
|
|
204
|
+
* safe direction for an opt-in guard.
|
|
205
|
+
*
|
|
206
|
+
* Pollution remains gated by disable_on_external_context, off by default.
|
|
207
|
+
*/
|
|
192
208
|
"tool.execute.before"(input: {
|
|
193
209
|
tool: string;
|
|
194
210
|
sessionID: string;
|
|
195
211
|
callID: string;
|
|
196
212
|
}): Promise<void>;
|
|
197
|
-
"tool.execute.after"(input: {
|
|
198
|
-
tool: string;
|
|
199
|
-
sessionID: string;
|
|
200
|
-
callID: string;
|
|
201
|
-
}): Promise<void>;
|
|
202
213
|
event(input: {
|
|
203
214
|
event: {
|
|
204
215
|
type: string;
|
package/dist/src/index.js
CHANGED
|
@@ -7,16 +7,14 @@ import { MemoryStore } from "./store.js";
|
|
|
7
7
|
import { runPhase1 } from "./phase1.js";
|
|
8
8
|
import { runPhase2 } from "./phase2.js";
|
|
9
9
|
import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js";
|
|
10
|
-
import { pluginOptions, recordConfigWarning } from "./options.js";
|
|
10
|
+
import { pluginOptions, recordConfigWarning, clearConfigWarnings } from "./options.js";
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
13
|
let phase1InFlight = false;
|
|
14
14
|
let pluginClient = null;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return `${sessionID}\0${callID}`;
|
|
19
|
-
}
|
|
15
|
+
// Single-flight guard for mcp.status(); see mcpToolPrefixes below.
|
|
16
|
+
let mcpStatusInFlight = null;
|
|
17
|
+
const MCP_STATUS_TIMEOUT_MS = 1_000;
|
|
20
18
|
// Deliberately uncached: openDb() is already a singleton, and caching a store
|
|
21
19
|
// here would hold a stale handle across closeDb() (e.g. after memory_reset).
|
|
22
20
|
function getStore() {
|
|
@@ -28,17 +26,25 @@ function getStore() {
|
|
|
28
26
|
// recorded per part to count each citation exactly once across both paths.
|
|
29
27
|
const recordedCitations = new Map();
|
|
30
28
|
const MAX_TRACKED_PARTS = 500;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Inserts `key` at the most-recently-used end and evicts the oldest entry
|
|
31
|
+
* beyond `max`. Map.set alone does NOT reorder an existing key, so the
|
|
32
|
+
* delete is what makes eviction least-recently-*used* rather than
|
|
33
|
+
* first-inserted — long-lived sessions must not age out mid-use.
|
|
34
|
+
*/
|
|
35
|
+
function lruSet(map, key, value, max) {
|
|
36
|
+
map.delete(key);
|
|
37
|
+
map.set(key, value);
|
|
38
|
+
if (map.size > max) {
|
|
39
|
+
const oldest = map.keys().next().value;
|
|
40
|
+
if (oldest !== undefined)
|
|
41
|
+
map.delete(oldest);
|
|
41
42
|
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
export function takeNewCitations(partKey, ids) {
|
|
46
|
+
const seen = recordedCitations.get(partKey) ?? new Set();
|
|
47
|
+
lruSet(recordedCitations, partKey, seen, MAX_TRACKED_PARTS);
|
|
42
48
|
const fresh = ids.filter((id) => !seen.has(id));
|
|
43
49
|
for (const id of fresh)
|
|
44
50
|
seen.add(id);
|
|
@@ -46,19 +52,13 @@ export function takeNewCitations(partKey, ids) {
|
|
|
46
52
|
}
|
|
47
53
|
// One stamp+pump per session per process from the chat.message hook; later
|
|
48
54
|
// messages in the same session add nothing (stamp is idempotent, the pump
|
|
49
|
-
// re-fires on idle anyway).
|
|
50
|
-
const seenTurnSessions = new
|
|
55
|
+
// re-fires on idle anyway). Value = first-seen timestamp (debugging only).
|
|
56
|
+
const seenTurnSessions = new Map();
|
|
51
57
|
const MAX_TRACKED_TURN_SESSIONS = 1000;
|
|
52
58
|
export function markTurnSeen(sessionId) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
|
|
57
|
-
const oldest = seenTurnSessions.keys().next().value;
|
|
58
|
-
if (oldest !== undefined)
|
|
59
|
-
seenTurnSessions.delete(oldest);
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
59
|
+
const first = seenTurnSessions.get(sessionId);
|
|
60
|
+
lruSet(seenTurnSessions, sessionId, first ?? Date.now(), MAX_TRACKED_TURN_SESSIONS);
|
|
61
|
+
return first === undefined;
|
|
62
62
|
}
|
|
63
63
|
// opencode 1.17 publishes BOTH session.status {type:"idle"} and the
|
|
64
64
|
// deprecated session.idle for the same transition, back to back. Handle
|
|
@@ -68,15 +68,11 @@ const IDLE_DEDUP_MS = 5000;
|
|
|
68
68
|
const MAX_TRACKED_IDLE = 500;
|
|
69
69
|
export function shouldHandleIdle(sessionId, now = Date.now()) {
|
|
70
70
|
const last = recentIdle.get(sessionId);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (oldest !== undefined)
|
|
77
|
-
recentIdle.delete(oldest);
|
|
78
|
-
}
|
|
79
|
-
return true;
|
|
71
|
+
const deduped = last !== undefined && now - last < IDLE_DEDUP_MS;
|
|
72
|
+
// Keep the original stamp while deduping so the window cannot be extended
|
|
73
|
+
// indefinitely by a stream of twins; refresh LRU order either way.
|
|
74
|
+
lruSet(recentIdle, sessionId, deduped ? last : now, MAX_TRACKED_IDLE);
|
|
75
|
+
return !deduped;
|
|
80
76
|
}
|
|
81
77
|
export function handleSessionDeleted(sessionId, store = getStore(),
|
|
82
78
|
// With generation off the memorize agent is not injected, so a consolidation
|
|
@@ -93,10 +89,16 @@ export default {
|
|
|
93
89
|
async server(input, opts) {
|
|
94
90
|
setPluginInput(input);
|
|
95
91
|
pluginClient = input.client;
|
|
96
|
-
|
|
92
|
+
mcpStatusInFlight = null;
|
|
93
|
+
// Unconditional, like the caches above: a boot WITHOUT options must not
|
|
94
|
+
// inherit the previous boot's warnings (opencode can host several
|
|
95
|
+
// instances in one process — see the ARCHITECTURE known-gaps table).
|
|
96
|
+
clearConfigWarnings();
|
|
97
97
|
if (opts)
|
|
98
98
|
applyPluginOptions(opts);
|
|
99
|
-
|
|
99
|
+
// Finish bounded reseeding before hooks can see a surviving memory
|
|
100
|
+
// sub-session after a plugin reload.
|
|
101
|
+
await cleanupOldSubSessions();
|
|
100
102
|
return buildHooks();
|
|
101
103
|
},
|
|
102
104
|
};
|
|
@@ -123,6 +125,9 @@ function clampInt(value, min, max, fallback) {
|
|
|
123
125
|
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
124
126
|
}
|
|
125
127
|
export function applyPluginOptions(opts) {
|
|
128
|
+
// Fresh pass per apply so memory_inspect never shows warnings for keys the
|
|
129
|
+
// caller has since fixed. server() clears too, for boots without options.
|
|
130
|
+
clearConfigWarnings();
|
|
126
131
|
for (const key of Object.keys(opts)) {
|
|
127
132
|
if (!KNOWN_OPTION_KEYS.has(key)) {
|
|
128
133
|
// codex uses deny_unknown_fields; a plugin can only warn (recorded for
|
|
@@ -174,33 +179,69 @@ export function applyPluginOptions(opts) {
|
|
|
174
179
|
* as "<server>_<tool>", so match tool names against the configured server
|
|
175
180
|
* list. Query live status so runtime MCP changes cannot escape pollution
|
|
176
181
|
* marking. Falls back to the web-tools-only check when status is unavailable.
|
|
182
|
+
*
|
|
183
|
+
* Concurrent tool calls coalesce on one in-flight status fetch. Deliberately
|
|
184
|
+
* NOT cached with a TTL: a stale list would miss servers connected
|
|
185
|
+
* mid-session and silently stop marking their calls as polluting, which is
|
|
186
|
+
* the failure this classification exists to prevent. The accepted cost is one
|
|
187
|
+
* status round trip per tool call — bounded to sessions that opt in with
|
|
188
|
+
* disable_on_external_context (off by default), and paid only in
|
|
189
|
+
* tool.execute.before.
|
|
177
190
|
*/
|
|
178
|
-
async function
|
|
179
|
-
if (toolName === "websearch" || toolName === "webfetch")
|
|
180
|
-
return true;
|
|
191
|
+
async function mcpToolPrefixes() {
|
|
181
192
|
if (!pluginClient)
|
|
182
193
|
return null;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
if (!mcpStatusInFlight) {
|
|
195
|
+
mcpStatusInFlight = (async () => {
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
let timer;
|
|
198
|
+
try {
|
|
199
|
+
const res = await Promise.race([
|
|
200
|
+
pluginClient.mcp.status({ signal: controller.signal }),
|
|
201
|
+
new Promise((_, reject) => {
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
controller.abort();
|
|
204
|
+
reject(new Error(`mcp.status timed out after ${MCP_STATUS_TIMEOUT_MS}ms`));
|
|
205
|
+
}, MCP_STATUS_TIMEOUT_MS);
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
if (res?.error)
|
|
209
|
+
return null;
|
|
210
|
+
const servers = res?.data;
|
|
211
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
212
|
+
return null;
|
|
213
|
+
const prefixes = [];
|
|
214
|
+
for (const [server, status] of Object.entries(servers)) {
|
|
215
|
+
if (!status || typeof status !== "object" || typeof status.status !== "string")
|
|
216
|
+
continue;
|
|
217
|
+
// Mirrors OpenCode's McpCatalog.sanitize when constructing tool names.
|
|
218
|
+
prefixes.push(server.replace(/[^a-zA-Z0-9_-]/g, "_"));
|
|
219
|
+
}
|
|
220
|
+
return prefixes;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// MCP status unavailable (older OpenCode); keep web-tools-only checks.
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
mcpStatusInFlight = null;
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
199
231
|
}
|
|
200
|
-
|
|
201
|
-
|
|
232
|
+
return mcpStatusInFlight;
|
|
233
|
+
}
|
|
234
|
+
async function classifyExternalContextTool(toolName) {
|
|
235
|
+
if (toolName === "websearch" || toolName === "webfetch")
|
|
236
|
+
return true;
|
|
237
|
+
const prefixes = await mcpToolPrefixes();
|
|
238
|
+
if (prefixes === null)
|
|
202
239
|
return null;
|
|
240
|
+
for (const prefix of prefixes) {
|
|
241
|
+
if (toolName.startsWith(`${prefix}_`))
|
|
242
|
+
return true;
|
|
203
243
|
}
|
|
244
|
+
return false;
|
|
204
245
|
}
|
|
205
246
|
/**
|
|
206
247
|
* Registers the memorize / memorize-extract sub-agents through the config
|
|
@@ -354,50 +395,36 @@ function buildHooks() {
|
|
|
354
395
|
console.error("[opencode-codex-memory] chat.message error:", err);
|
|
355
396
|
}
|
|
356
397
|
},
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Dedicated plugin hook (NOT an event-bus type). Marks the session polluted
|
|
400
|
+
* at INVOCATION, mirroring codex: mcp_tool_call.rs calls
|
|
401
|
+
* maybe_mark_thread_memory_mode_polluted inside handle_approved_mcp_tool_call
|
|
402
|
+
* BEFORE the call runs, and web search marks on the completed response item
|
|
403
|
+
* (stream_events_utils.rs response_item_may_include_external_context).
|
|
404
|
+
*
|
|
405
|
+
* Deliberately not tool.execute.after: opencode does not guarantee that hook
|
|
406
|
+
* (session/tools.ts awaits execute() with no ensuring/catchAll, and an abort
|
|
407
|
+
* interrupts the fiber), so a failed or cancelled websearch/webfetch/MCP call
|
|
408
|
+
* left the session unmarked while its output had already entered the
|
|
409
|
+
* transcript. Marking early over-marks a permission-denied call, which is the
|
|
410
|
+
* safe direction for an opt-in guard.
|
|
411
|
+
*
|
|
412
|
+
* Pollution remains gated by disable_on_external_context, off by default.
|
|
413
|
+
*/
|
|
361
414
|
async "tool.execute.before"(input) {
|
|
362
415
|
try {
|
|
363
|
-
if (!pluginOptions.disable_on_external_context || !input.
|
|
416
|
+
if (!pluginOptions.disable_on_external_context || !input.sessionID)
|
|
364
417
|
return;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (classification === null)
|
|
418
|
+
// null = MCP status unavailable; websearch/webfetch still classify true
|
|
419
|
+
// without it, so only MCP-prefixed tools go unmarked.
|
|
420
|
+
if ((await classifyExternalContextTool(input.tool)) !== true)
|
|
369
421
|
return;
|
|
370
|
-
|
|
371
|
-
if (externalContextCalls.size > MAX_TRACKED_TOOL_CALLS) {
|
|
372
|
-
const oldest = externalContextCalls.keys().next().value;
|
|
373
|
-
if (oldest !== undefined)
|
|
374
|
-
externalContextCalls.delete(oldest);
|
|
375
|
-
}
|
|
422
|
+
getStore().markPolluted(input.sessionID);
|
|
376
423
|
}
|
|
377
424
|
catch (err) {
|
|
378
425
|
console.error("[opencode-codex-memory] tool.execute.before error:", err);
|
|
379
426
|
}
|
|
380
427
|
},
|
|
381
|
-
async "tool.execute.after"(input) {
|
|
382
|
-
try {
|
|
383
|
-
const key = externalContextCallKey(input.sessionID, input.callID);
|
|
384
|
-
const hasCapturedClassification = Boolean(input.callID) && externalContextCalls.has(key);
|
|
385
|
-
const capturedClassification = input.callID ? externalContextCalls.get(key) : undefined;
|
|
386
|
-
if (input.callID)
|
|
387
|
-
externalContextCalls.delete(key);
|
|
388
|
-
if (!pluginOptions.disable_on_external_context)
|
|
389
|
-
return;
|
|
390
|
-
const isExternal = hasCapturedClassification
|
|
391
|
-
? capturedClassification === true
|
|
392
|
-
: (await classifyExternalContextTool(input.tool)) === true;
|
|
393
|
-
if (input.sessionID && isExternal) {
|
|
394
|
-
getStore().markPolluted(input.sessionID);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
catch (err) {
|
|
398
|
-
console.error("[opencode-codex-memory] tool.execute.after error:", err);
|
|
399
|
-
}
|
|
400
|
-
},
|
|
401
428
|
async event(input) {
|
|
402
429
|
try {
|
|
403
430
|
const ev = input.event;
|
package/dist/src/llm.d.ts
CHANGED
|
@@ -7,14 +7,32 @@ export interface ExtractionResult {
|
|
|
7
7
|
export declare function setPluginInput(input: PluginInput): void;
|
|
8
8
|
export declare function getPluginInput(): PluginInput | null;
|
|
9
9
|
export declare function isMemorySubSession(sessionId: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
|
|
12
|
+
* than matching on the message text) is what tells the catch below that the
|
|
13
|
+
* run is still executing server-side and must be aborted.
|
|
14
|
+
*/
|
|
15
|
+
export declare class SubagentTimeoutError extends Error {
|
|
16
|
+
constructor(timeoutMs: number);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when a sub-agent session could not be closed. Codex treats a failed
|
|
20
|
+
* consolidation-agent shutdown as "the agent may still be alive", so the caller
|
|
21
|
+
* must keep its job lease instead of completing the job (phase2.rs).
|
|
22
|
+
*/
|
|
23
|
+
export declare class SubagentShutdownError extends Error {
|
|
24
|
+
constructor(sessionId: string);
|
|
25
|
+
}
|
|
10
26
|
export interface ExtractOptions {
|
|
11
27
|
cwd?: string;
|
|
12
28
|
model?: string;
|
|
29
|
+
/** Override the default 1h extract timeout (tests / advanced). */
|
|
30
|
+
timeoutMs?: number;
|
|
13
31
|
}
|
|
14
32
|
/** Returns null when the extractor reported a no-op (nothing worth remembering). */
|
|
15
33
|
export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
|
|
16
34
|
export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
|
|
17
|
-
export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
|
|
35
|
+
export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
|
|
18
36
|
export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
|
|
19
37
|
export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
|
|
20
38
|
/**
|
package/dist/src/llm.js
CHANGED
|
@@ -11,6 +11,10 @@ export function getPluginInput() {
|
|
|
11
11
|
// hooks skip these so the plugin never injects memory into (or memorizes) its
|
|
12
12
|
// own sub-agents.
|
|
13
13
|
const activeSubSessions = new Set();
|
|
14
|
+
const SUBSESSION_METADATA_KEY = "opencode-codex-memory";
|
|
15
|
+
const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
|
|
16
|
+
const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
|
|
17
|
+
const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
|
|
14
18
|
export function isMemorySubSession(sessionId) {
|
|
15
19
|
return activeSubSessions.has(sessionId);
|
|
16
20
|
}
|
|
@@ -19,7 +23,10 @@ async function createSession(agent, title) {
|
|
|
19
23
|
if (!input)
|
|
20
24
|
throw new Error("plugin input not initialized");
|
|
21
25
|
const res = await input.client.session.create({
|
|
22
|
-
body: {
|
|
26
|
+
body: {
|
|
27
|
+
title: title ?? `codex-memory-${agent}`,
|
|
28
|
+
metadata: { [SUBSESSION_METADATA_KEY]: true },
|
|
29
|
+
},
|
|
23
30
|
});
|
|
24
31
|
if (!res.data)
|
|
25
32
|
throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
|
|
@@ -63,6 +70,53 @@ function parseModelRef(ref) {
|
|
|
63
70
|
return null;
|
|
64
71
|
return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
|
|
65
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
|
|
75
|
+
* than matching on the message text) is what tells the catch below that the
|
|
76
|
+
* run is still executing server-side and must be aborted.
|
|
77
|
+
*/
|
|
78
|
+
export class SubagentTimeoutError extends Error {
|
|
79
|
+
constructor(timeoutMs) {
|
|
80
|
+
super(`sub-agent prompt timed out after ${timeoutMs}ms`);
|
|
81
|
+
this.name = "SubagentTimeoutError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Thrown when a sub-agent session could not be closed. Codex treats a failed
|
|
86
|
+
* consolidation-agent shutdown as "the agent may still be alive", so the caller
|
|
87
|
+
* must keep its job lease instead of completing the job (phase2.rs).
|
|
88
|
+
*/
|
|
89
|
+
export class SubagentShutdownError extends Error {
|
|
90
|
+
constructor(sessionId) {
|
|
91
|
+
super(`failed to close memory sub-session ${sessionId}`);
|
|
92
|
+
this.name = "SubagentShutdownError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function abortSession(sessionId) {
|
|
96
|
+
const input = getPluginInput();
|
|
97
|
+
const session = input?.client?.session;
|
|
98
|
+
if (typeof session?.abort !== "function")
|
|
99
|
+
return;
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
let timer;
|
|
102
|
+
try {
|
|
103
|
+
await Promise.race([
|
|
104
|
+
session.abort({ path: { id: sessionId }, signal: controller.signal }),
|
|
105
|
+
new Promise((_, reject) => {
|
|
106
|
+
timer = setTimeout(() => {
|
|
107
|
+
controller.abort();
|
|
108
|
+
reject(new Error(`session.abort timed out after ${SUBSESSION_ABORT_TIMEOUT_MS}ms`));
|
|
109
|
+
}, SUBSESSION_ABORT_TIMEOUT_MS);
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Best-effort: deleteSession is the backup cancel path.
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
66
120
|
/** Runs a sub-agent prompt and returns the raw response data (`{ info, parts }`). */
|
|
67
121
|
async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
68
122
|
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
@@ -87,7 +141,7 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
87
141
|
const res = await Promise.race([
|
|
88
142
|
promptPromise,
|
|
89
143
|
new Promise((_, reject) => {
|
|
90
|
-
timer = setTimeout(() => reject(new
|
|
144
|
+
timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
|
|
91
145
|
}),
|
|
92
146
|
]);
|
|
93
147
|
if (!res.data)
|
|
@@ -99,6 +153,15 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
99
153
|
}
|
|
100
154
|
return res.data;
|
|
101
155
|
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// Only a timeout leaves the turn running server-side; every other failure
|
|
158
|
+
// here means the request already settled. Stop the run so tokens stop
|
|
159
|
+
// burning — deleteSession in the caller finally is the backup.
|
|
160
|
+
if (err instanceof SubagentTimeoutError) {
|
|
161
|
+
await abortSession(sessionId);
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
102
165
|
finally {
|
|
103
166
|
clearTimeout(timer);
|
|
104
167
|
}
|
|
@@ -153,7 +216,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
153
216
|
// Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
|
|
154
217
|
// and a near-600k-char transcript on a slow model can easily exceed a
|
|
155
218
|
// short one — repeated timeouts would exhaust the job's retries.
|
|
156
|
-
timeoutMs: 3600_000,
|
|
219
|
+
timeoutMs: opts.timeoutMs ?? 3600_000,
|
|
157
220
|
system: readTemplate("stage_one_system.md"),
|
|
158
221
|
model,
|
|
159
222
|
// opencode enforces json_schema output via a forced StructuredOutput tool
|
|
@@ -172,6 +235,9 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
172
235
|
return parseExtraction(extractAssistantText(data));
|
|
173
236
|
}
|
|
174
237
|
finally {
|
|
238
|
+
// Fire-and-forget on purpose (unlike consolidation): stage 1 has no codex
|
|
239
|
+
// agent-shutdown step, and memorize-extract has no write tools, so a
|
|
240
|
+
// lingering extract session cannot touch the memory root.
|
|
175
241
|
void deleteSession(subId).catch(() => { });
|
|
176
242
|
}
|
|
177
243
|
}
|
|
@@ -182,55 +248,136 @@ const CONSOLIDATION_TIMEOUT_MS = 3600_000;
|
|
|
182
248
|
export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
|
|
183
249
|
const agent = "memorize";
|
|
184
250
|
const subId = await createSession(agent, "codex-memory-consolidate");
|
|
251
|
+
let promptError;
|
|
252
|
+
let promptFailed = false;
|
|
185
253
|
try {
|
|
186
254
|
const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
|
|
187
255
|
// consolidation_model option > opencode model (main) > session default.
|
|
188
256
|
const resolved = model ?? (await getConfigModels()).model;
|
|
189
257
|
await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS });
|
|
190
258
|
}
|
|
191
|
-
|
|
192
|
-
|
|
259
|
+
catch (err) {
|
|
260
|
+
promptError = err;
|
|
261
|
+
promptFailed = true;
|
|
193
262
|
}
|
|
263
|
+
// codex phase2.rs awaits the consolidation agent's shutdown BEFORE artifacts
|
|
264
|
+
// are validated and the job is finished, and a failed shutdown outranks the
|
|
265
|
+
// run result: the caller must keep its lease rather than release it to a
|
|
266
|
+
// worker that could race a consolidator which is still alive. The
|
|
267
|
+
// consolidation agent holds write access to the memory root, so this is the
|
|
268
|
+
// difference between one writer and two.
|
|
269
|
+
if (!(await deleteSession(subId)))
|
|
270
|
+
throw new SubagentShutdownError(subId);
|
|
271
|
+
if (promptFailed)
|
|
272
|
+
throw promptError;
|
|
194
273
|
}
|
|
195
274
|
// Must exceed the longest legitimate sub-session lifetime (consolidation may
|
|
196
275
|
// run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
|
|
197
276
|
// plugin reload would delete a working sub-session mid-run.
|
|
198
|
-
export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
|
|
277
|
+
export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBSESSION_LIST_TIMEOUT_MS) {
|
|
199
278
|
const input = getPluginInput();
|
|
200
279
|
if (!input)
|
|
201
280
|
return;
|
|
281
|
+
let timer;
|
|
202
282
|
try {
|
|
203
|
-
|
|
283
|
+
if (typeof input.client?.session?.list !== "function")
|
|
284
|
+
return;
|
|
285
|
+
const res = await Promise.race([
|
|
286
|
+
input.client.session.list(),
|
|
287
|
+
new Promise((_, reject) => {
|
|
288
|
+
timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
289
|
+
}),
|
|
290
|
+
]);
|
|
204
291
|
if (!res.data)
|
|
205
292
|
return;
|
|
206
293
|
const list = res.data;
|
|
207
294
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
208
295
|
for (const s of list) {
|
|
209
|
-
if (s.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
296
|
+
if (!s.id)
|
|
297
|
+
continue;
|
|
298
|
+
const pluginTitle = isPluginSubSessionTitle(s.title);
|
|
299
|
+
const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
|
|
300
|
+
const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
|
|
301
|
+
if (!owned && !legacy)
|
|
302
|
+
continue;
|
|
303
|
+
// Durable ownership requires marker + generated title; a legacy title
|
|
304
|
+
// alone can reseed the skip set but never authorizes deletion.
|
|
305
|
+
activeSubSessions.add(s.id);
|
|
306
|
+
if (!owned)
|
|
307
|
+
continue;
|
|
308
|
+
const created = s.time?.created ?? 0;
|
|
309
|
+
if (created && created < cutoff) {
|
|
310
|
+
void deleteSession(s.id);
|
|
214
311
|
}
|
|
215
312
|
}
|
|
216
313
|
}
|
|
217
314
|
catch {
|
|
218
315
|
// best effort only
|
|
219
316
|
}
|
|
317
|
+
finally {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function isPluginSubSessionTitle(title) {
|
|
322
|
+
return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
|
|
220
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Closes a sub-session. Returns true when the delete call itself succeeded —
|
|
326
|
+
* the port's equivalent of codex's `shutdown_consolidation_agent` returning Ok
|
|
327
|
+
* (runtime.rs). A false return means the sub-agent may still be running.
|
|
328
|
+
*
|
|
329
|
+
* The 404 confirmation below is a separate, stricter question (is the session
|
|
330
|
+
* really gone?) and only governs ownership tracking, never the shutdown result:
|
|
331
|
+
* hosts without `session.get` would otherwise never report a clean shutdown.
|
|
332
|
+
*/
|
|
221
333
|
async function deleteSession(id) {
|
|
222
|
-
activeSubSessions.delete(id);
|
|
223
334
|
const input = getPluginInput();
|
|
224
335
|
if (!input)
|
|
225
|
-
return;
|
|
336
|
+
return false;
|
|
226
337
|
try {
|
|
227
338
|
const res = await input.client.session.delete({ path: { id } });
|
|
228
339
|
if (res.error) {
|
|
229
340
|
console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
|
|
341
|
+
return false;
|
|
230
342
|
}
|
|
343
|
+
// OpenCode's Session.remove logs and swallows some internal failures while
|
|
344
|
+
// the HTTP route still returns success. Only a confirmed 404 proves the
|
|
345
|
+
// session is gone; otherwise retain ownership so hooks keep skipping it.
|
|
346
|
+
// codex runtime.rs drops the thread from its manager the same way: only
|
|
347
|
+
// after shutdown succeeded.
|
|
348
|
+
if (await sessionDeletionConfirmed(input.client, id)) {
|
|
349
|
+
activeSubSessions.delete(id);
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
231
352
|
}
|
|
232
353
|
catch (err) {
|
|
233
354
|
console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function sessionDeletionConfirmed(client, id) {
|
|
359
|
+
const session = client.session;
|
|
360
|
+
if (typeof session?.get !== "function")
|
|
361
|
+
return false;
|
|
362
|
+
const controller = new AbortController();
|
|
363
|
+
let timer;
|
|
364
|
+
try {
|
|
365
|
+
const res = await Promise.race([
|
|
366
|
+
session.get({ path: { id }, signal: controller.signal }),
|
|
367
|
+
new Promise((_, reject) => {
|
|
368
|
+
timer = setTimeout(() => {
|
|
369
|
+
controller.abort();
|
|
370
|
+
reject(new Error(`session.get timed out after ${SUBSESSION_CONFIRM_TIMEOUT_MS}ms`));
|
|
371
|
+
}, SUBSESSION_CONFIRM_TIMEOUT_MS);
|
|
372
|
+
}),
|
|
373
|
+
]);
|
|
374
|
+
return res?.response?.status === 404;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
clearTimeout(timer);
|
|
234
381
|
}
|
|
235
382
|
}
|
|
236
383
|
// Substitute with a function so `$&`/`$'` sequences in the value are not
|
package/dist/src/options.d.ts
CHANGED
|
@@ -26,5 +26,7 @@ export interface PluginOptionsState {
|
|
|
26
26
|
export declare const pluginOptions: PluginOptionsState;
|
|
27
27
|
export declare function recordConfigWarning(message: string): void;
|
|
28
28
|
export declare function getConfigWarnings(): readonly string[];
|
|
29
|
+
/** Drop warnings from a previous apply pass (server boot / option re-apply). */
|
|
30
|
+
export declare function clearConfigWarnings(): void;
|
|
29
31
|
/** Test seam: options/warnings are module state, tests need a clean slate. */
|
|
30
32
|
export declare function resetConfigWarningsForTest(): void;
|
package/dist/src/options.js
CHANGED
|
@@ -25,7 +25,11 @@ export function recordConfigWarning(message) {
|
|
|
25
25
|
export function getConfigWarnings() {
|
|
26
26
|
return configWarnings;
|
|
27
27
|
}
|
|
28
|
+
/** Drop warnings from a previous apply pass (server boot / option re-apply). */
|
|
29
|
+
export function clearConfigWarnings() {
|
|
30
|
+
configWarnings.length = 0;
|
|
31
|
+
}
|
|
28
32
|
/** Test seam: options/warnings are module state, tests need a clean slate. */
|
|
29
33
|
export function resetConfigWarningsForTest() {
|
|
30
|
-
|
|
34
|
+
clearConfigWarnings();
|
|
31
35
|
}
|
package/dist/src/phase2.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff, validateConsolidationArtifacts, } from "./workspace.js";
|
|
2
2
|
import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js";
|
|
3
|
-
import { consolidateViaSubagent } from "./llm.js";
|
|
3
|
+
import { consolidateViaSubagent, SubagentShutdownError } from "./llm.js";
|
|
4
4
|
import { invalidateCache } from "./source.js";
|
|
5
5
|
import { memoryRoot } from "./paths.js";
|
|
6
6
|
import { checkRateLimit } from "./ratelimit.js";
|
|
@@ -105,6 +105,17 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
|
|
|
105
105
|
try {
|
|
106
106
|
await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
|
|
107
107
|
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// codex phase2.rs: when the consolidation agent's shutdown fails, keep
|
|
110
|
+
// the existing lease until it expires so another worker cannot race a
|
|
111
|
+
// consolidator whose shutdown has not completed. Neither succeed nor
|
|
112
|
+
// fail the job — marking it failed would release the lease immediately.
|
|
113
|
+
if (err instanceof SubagentShutdownError) {
|
|
114
|
+
console.warn(`[opencode-codex-memory] ${err.message}; holding the phase2 lease until it expires`);
|
|
115
|
+
return { status: "shutdown_failed" };
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
108
119
|
finally {
|
|
109
120
|
clearInterval(heartbeat);
|
|
110
121
|
}
|
package/dist/src/redact.d.ts
CHANGED
|
@@ -4,5 +4,13 @@ export declare function redact(text: string): string;
|
|
|
4
4
|
* injected AGENTS.md instruction blocks and <skill> payloads inside user
|
|
5
5
|
* content are contextual boilerplate, not conversation — they must not be
|
|
6
6
|
* mined for memories.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: inert on opencode today, kept for codex parity and future-proofing.
|
|
9
|
+
* opencode delivers both of these through the SYSTEM prompt, never as a user
|
|
10
|
+
* text part: AGENTS.md is joined into `system[0]` and skills are a
|
|
11
|
+
* `<available_skills>` catalog (skill/index.ts `fmt`), so neither shape ever
|
|
12
|
+
* reaches this check. Do not treat it as an active safeguard — the structural
|
|
13
|
+
* filters in capture.ts (`ignored` parts) are what actually exclude
|
|
14
|
+
* non-conversation content on this platform.
|
|
7
15
|
*/
|
|
8
16
|
export declare function isMemoryExcludedFragment(text: string): boolean;
|
package/dist/src/redact.js
CHANGED
|
@@ -10,18 +10,214 @@ const REDACTIONS = [
|
|
|
10
10
|
re: /-----BEGIN [A-Z]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z]+ PRIVATE KEY-----/g,
|
|
11
11
|
replacement: "[REDACTED:private-key]",
|
|
12
12
|
},
|
|
13
|
-
// Optional quotes around the KEY cover JSON/YAML forms like
|
|
14
|
-
// "password": "value" — codex's SECRET_ASSIGNMENT_REGEX misses those (it
|
|
15
|
-
// allows a quote only before the value); this is a deliberate superset.
|
|
16
|
-
{ re: /["']?(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token)["']?\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
|
|
17
|
-
{ re: /["']?(aws_secret_access_key|aws_access_key_id)["']?\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
|
|
18
13
|
];
|
|
14
|
+
// Optional quotes around the key cover JSON/YAML forms that codex's bare-key
|
|
15
|
+
// assignment regex misses. Value boundaries are scanned instead of guessed by
|
|
16
|
+
// one regex so escaped strings and nested JSON remain intact.
|
|
17
|
+
const SECRET_ASSIGNMENT_START = /(["']?)(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token|aws_secret_access_key|aws_access_key_id)\1([ \t]*[:=][ \t]*)/gi;
|
|
18
|
+
const JSON_PRIMITIVE = /^(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)(?=\s*[,}\]])/i;
|
|
19
19
|
export function redact(text) {
|
|
20
20
|
let out = text;
|
|
21
21
|
for (const { re, replacement } of REDACTIONS) {
|
|
22
22
|
out = out.replace(re, replacement);
|
|
23
23
|
}
|
|
24
|
-
return out;
|
|
24
|
+
return redactAssignments(out);
|
|
25
|
+
}
|
|
26
|
+
function redactAssignments(text) {
|
|
27
|
+
let cursor = 0;
|
|
28
|
+
let out = "";
|
|
29
|
+
let flowCursor = 0;
|
|
30
|
+
const matchedOpeners = findMatchedFlowOpeners(text);
|
|
31
|
+
const flowState = { closers: [], quote: null, escaped: false };
|
|
32
|
+
SECRET_ASSIGNMENT_START.lastIndex = 0;
|
|
33
|
+
for (let match = SECRET_ASSIGNMENT_START.exec(text); match; match = SECRET_ASSIGNMENT_START.exec(text)) {
|
|
34
|
+
advanceFlowState(text, flowCursor, match.index, flowState, matchedOpeners);
|
|
35
|
+
flowCursor = match.index;
|
|
36
|
+
const valueStart = SECRET_ASSIGNMENT_START.lastIndex;
|
|
37
|
+
const value = scanAssignmentValue(text, valueStart, match[1], match[3], flowState.closers.length > 0, flowState.quote);
|
|
38
|
+
if (!value)
|
|
39
|
+
continue;
|
|
40
|
+
out += text.slice(cursor, value.start) + value.replacement;
|
|
41
|
+
cursor = value.end;
|
|
42
|
+
SECRET_ASSIGNMENT_START.lastIndex = value.end;
|
|
43
|
+
}
|
|
44
|
+
return out + text.slice(cursor);
|
|
45
|
+
}
|
|
46
|
+
function scanAssignmentValue(text, start, keyQuote, separator, flowCollection, enclosingQuote) {
|
|
47
|
+
let valueStart = start;
|
|
48
|
+
if (enclosingQuote) {
|
|
49
|
+
const end = scanEnclosingQuote(text, valueStart, enclosingQuote);
|
|
50
|
+
return end > valueStart ? { start: valueStart, end, replacement: "[REDACTED]" } : null;
|
|
51
|
+
}
|
|
52
|
+
if (flowCollection && separator.includes(":")) {
|
|
53
|
+
while (/\s/.test(text[valueStart] ?? ""))
|
|
54
|
+
valueStart++;
|
|
55
|
+
}
|
|
56
|
+
const first = text[valueStart];
|
|
57
|
+
if (!first || first === "\r" || first === "\n")
|
|
58
|
+
return null;
|
|
59
|
+
if (first === '"' || first === "'") {
|
|
60
|
+
const end = scanQuoted(text, valueStart, first) ?? plainValueEnd(text, valueStart, flowCollection);
|
|
61
|
+
return end > valueStart ? { start: valueStart, end, replacement: `${first}[REDACTED]${first}` } : null;
|
|
62
|
+
}
|
|
63
|
+
if (first === "{" || first === "[") {
|
|
64
|
+
const end = scanStructuredJson(text, valueStart) ?? plainValueEnd(text, valueStart, flowCollection);
|
|
65
|
+
return end > valueStart ? { start: valueStart, end, replacement: '"[REDACTED]"' } : null;
|
|
66
|
+
}
|
|
67
|
+
if (keyQuote === '"' && separator.includes(":")) {
|
|
68
|
+
const primitive = JSON_PRIMITIVE.exec(text.slice(valueStart));
|
|
69
|
+
if (primitive)
|
|
70
|
+
return { start: valueStart, end: valueStart + primitive[0].length, replacement: '"[REDACTED]"' };
|
|
71
|
+
}
|
|
72
|
+
const end = plainValueEnd(text, valueStart, flowCollection);
|
|
73
|
+
return end > valueStart ? { start: valueStart, end, replacement: '"[REDACTED]"' } : null;
|
|
74
|
+
}
|
|
75
|
+
function scanEnclosingQuote(text, start, quote) {
|
|
76
|
+
for (let i = start; i < text.length; i++) {
|
|
77
|
+
if (quote === '"' && text[i] === "\\") {
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (text[i] !== quote)
|
|
82
|
+
continue;
|
|
83
|
+
if (quote === "'" && text[i + 1] === "'") {
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
return i;
|
|
88
|
+
}
|
|
89
|
+
return plainValueEnd(text, start, false);
|
|
90
|
+
}
|
|
91
|
+
function scanQuoted(text, start, quote) {
|
|
92
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
93
|
+
if (quote === '"' && text[i] === "\\") {
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (text[i] !== quote)
|
|
98
|
+
continue;
|
|
99
|
+
if (quote === "'" && text[i + 1] === "'") {
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
return i + 1;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function scanStructuredJson(text, start) {
|
|
108
|
+
const closers = [text[start] === "{" ? "}" : "]"];
|
|
109
|
+
let quote = null;
|
|
110
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
111
|
+
const char = text[i];
|
|
112
|
+
if (quote) {
|
|
113
|
+
if (quote === '"' && char === "\\")
|
|
114
|
+
i++;
|
|
115
|
+
else if (char === quote) {
|
|
116
|
+
if (quote === "'" && text[i + 1] === "'")
|
|
117
|
+
i++;
|
|
118
|
+
else
|
|
119
|
+
quote = null;
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (char === '"' || char === "'") {
|
|
124
|
+
quote = char;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (char === "{")
|
|
128
|
+
closers.push("}");
|
|
129
|
+
else if (char === "[")
|
|
130
|
+
closers.push("]");
|
|
131
|
+
else if (char === closers[closers.length - 1]) {
|
|
132
|
+
closers.pop();
|
|
133
|
+
if (closers.length === 0)
|
|
134
|
+
return i + 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
function findMatchedFlowOpeners(text) {
|
|
140
|
+
const matched = new Set();
|
|
141
|
+
const stack = [];
|
|
142
|
+
let quote = null;
|
|
143
|
+
let escaped = false;
|
|
144
|
+
for (let i = 0; i < text.length; i++) {
|
|
145
|
+
const char = text[i];
|
|
146
|
+
if (quote) {
|
|
147
|
+
if (escaped)
|
|
148
|
+
escaped = false;
|
|
149
|
+
else if (quote === '"' && char === "\\")
|
|
150
|
+
escaped = true;
|
|
151
|
+
else if (char === quote) {
|
|
152
|
+
if (quote === "'" && text[i + 1] === "'")
|
|
153
|
+
i++;
|
|
154
|
+
else
|
|
155
|
+
quote = null;
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (char === '"' || char === "'")
|
|
160
|
+
quote = char;
|
|
161
|
+
else if (char === "{")
|
|
162
|
+
stack.push({ index: i, closer: "}" });
|
|
163
|
+
else if (char === "[")
|
|
164
|
+
stack.push({ index: i, closer: "]" });
|
|
165
|
+
else if (char === stack[stack.length - 1]?.closer) {
|
|
166
|
+
const opener = stack.pop();
|
|
167
|
+
if (opener)
|
|
168
|
+
matched.add(opener.index);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return matched;
|
|
172
|
+
}
|
|
173
|
+
function advanceFlowState(text, start, end, state, matchedOpeners) {
|
|
174
|
+
for (let i = start; i < end; i++) {
|
|
175
|
+
const char = text[i];
|
|
176
|
+
if (state.quote) {
|
|
177
|
+
if (state.escaped) {
|
|
178
|
+
state.escaped = false;
|
|
179
|
+
}
|
|
180
|
+
else if (state.quote === '"' && char === "\\") {
|
|
181
|
+
state.escaped = true;
|
|
182
|
+
}
|
|
183
|
+
else if (char === state.quote) {
|
|
184
|
+
if (state.quote === "'" && text[i + 1] === "'")
|
|
185
|
+
i++;
|
|
186
|
+
else
|
|
187
|
+
state.quote = null;
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (char === '"' || char === "'")
|
|
192
|
+
state.quote = char;
|
|
193
|
+
else if (char === "{" && matchedOpeners.has(i))
|
|
194
|
+
state.closers.push("}");
|
|
195
|
+
else if (char === "[" && matchedOpeners.has(i))
|
|
196
|
+
state.closers.push("]");
|
|
197
|
+
else if (char === state.closers[state.closers.length - 1])
|
|
198
|
+
state.closers.pop();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function plainValueEnd(text, start, flowCollection) {
|
|
202
|
+
let end = text.length;
|
|
203
|
+
for (let i = start; i < text.length; i++) {
|
|
204
|
+
const char = text[i];
|
|
205
|
+
if (char === "\n") {
|
|
206
|
+
end = i;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
if (flowCollection && (char === "," || char === "}" || char === "]")) {
|
|
210
|
+
end = i;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (char === "#" && i > start && /\s/.test(text[i - 1])) {
|
|
214
|
+
end = i;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
while (end > start && /\s/.test(text[end - 1]))
|
|
219
|
+
end--;
|
|
220
|
+
return end;
|
|
25
221
|
}
|
|
26
222
|
function matchesMarkedFragment(text, startMarker, endMarker) {
|
|
27
223
|
const trimmed = text.trim();
|
|
@@ -33,6 +229,14 @@ function matchesMarkedFragment(text, startMarker, endMarker) {
|
|
|
33
229
|
* injected AGENTS.md instruction blocks and <skill> payloads inside user
|
|
34
230
|
* content are contextual boilerplate, not conversation — they must not be
|
|
35
231
|
* mined for memories.
|
|
232
|
+
*
|
|
233
|
+
* NOTE: inert on opencode today, kept for codex parity and future-proofing.
|
|
234
|
+
* opencode delivers both of these through the SYSTEM prompt, never as a user
|
|
235
|
+
* text part: AGENTS.md is joined into `system[0]` and skills are a
|
|
236
|
+
* `<available_skills>` catalog (skill/index.ts `fmt`), so neither shape ever
|
|
237
|
+
* reaches this check. Do not treat it as an active safeguard — the structural
|
|
238
|
+
* filters in capture.ts (`ignored` parts) are what actually exclude
|
|
239
|
+
* non-conversation content on this platform.
|
|
36
240
|
*/
|
|
37
241
|
export function isMemoryExcludedFragment(text) {
|
|
38
242
|
return (matchesMarkedFragment(text, "# AGENTS.md instructions", "</INSTRUCTIONS>") ||
|
package/dist/src/source.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { memoryRoot } from "./paths.js";
|
|
4
|
+
import { assertMemoryRootSafe, safeResolveMemoryPath } from "./path-guard.js";
|
|
4
5
|
import { truncateToTokens } from "./token.js";
|
|
5
6
|
import { fillTemplate } from "./llm.js";
|
|
6
7
|
const MEMORY_SUMMARY_TOKEN_LIMIT = 2500;
|
|
@@ -30,22 +31,36 @@ function readTemplate() {
|
|
|
30
31
|
return fs.readFileSync(templatePath, "utf8");
|
|
31
32
|
}
|
|
32
33
|
function readMemorySummary() {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
let summaryPath;
|
|
35
|
+
let fd;
|
|
36
|
+
try {
|
|
37
|
+
// Use the same component-by-component symlink refusal as the memory tools:
|
|
38
|
+
// neither the root nor memory_summary.md may redirect outside the workspace.
|
|
39
|
+
summaryPath = safeResolveMemoryPath("memory_summary.md");
|
|
40
|
+
fd = fs.openSync(summaryPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
41
|
+
const stat = fs.fstatSync(fd);
|
|
42
|
+
if (!stat.isFile())
|
|
43
|
+
return null;
|
|
44
|
+
if (cached && cached.mtime === stat.mtimeMs) {
|
|
45
|
+
return cached.content;
|
|
46
|
+
}
|
|
47
|
+
const raw = fs.readFileSync(fd, "utf8").trim();
|
|
48
|
+
if (!raw)
|
|
49
|
+
return null;
|
|
50
|
+
const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT);
|
|
51
|
+
cached = {
|
|
52
|
+
content: truncated,
|
|
53
|
+
mtime: stat.mtimeMs,
|
|
54
|
+
};
|
|
55
|
+
return truncated;
|
|
39
56
|
}
|
|
40
|
-
|
|
41
|
-
if (!raw)
|
|
57
|
+
catch {
|
|
42
58
|
return null;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
return truncated;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (fd !== undefined)
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
}
|
|
49
64
|
}
|
|
50
65
|
export function invalidateCache() {
|
|
51
66
|
cached = null;
|
|
@@ -64,5 +79,6 @@ export function buildMemorySystemPrompt(dedicatedTools) {
|
|
|
64
79
|
});
|
|
65
80
|
}
|
|
66
81
|
export function ensureMemoryLayout() {
|
|
67
|
-
|
|
82
|
+
const root = assertMemoryRootSafe();
|
|
83
|
+
fs.mkdirSync(root, { recursive: true });
|
|
68
84
|
}
|
package/dist/src/store.js
CHANGED
|
@@ -81,10 +81,16 @@ export class MemoryStore {
|
|
|
81
81
|
recordUsage(sessionIds) {
|
|
82
82
|
if (sessionIds.length === 0)
|
|
83
83
|
return;
|
|
84
|
+
// One transaction for the whole batch (codex record_stage1_output_usage).
|
|
85
|
+
// .immediate() like every other write transaction here: take the write
|
|
86
|
+
// lock up front so busy_timeout applies instead of risking a mid-txn
|
|
87
|
+
// upgrade failure under cross-process access.
|
|
84
88
|
const ts = now();
|
|
85
89
|
const stmt = this.db.prepare("UPDATE memory_stage1_outputs SET usage_count = usage_count + 1, last_usage = ? WHERE session_id = ?");
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
this.db.transaction(() => {
|
|
91
|
+
for (const id of sessionIds)
|
|
92
|
+
stmt.run(ts, id);
|
|
93
|
+
}).immediate();
|
|
88
94
|
}
|
|
89
95
|
claimStage1Jobs(sessions, excludeSession, maxClaimed) {
|
|
90
96
|
const workerId = newId();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-codex-memory",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|