opencode-codex-memory 0.4.1 → 0.4.2
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 +12 -3
- 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 +11 -1
- package/dist/src/llm.js +118 -11
- package/dist/src/options.d.ts +2 -0
- package/dist/src/options.js +5 -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.
|
|
@@ -41,7 +50,7 @@ If you want the mental model before the details, jump to
|
|
|
41
50
|
|
|
42
51
|
```json
|
|
43
52
|
{
|
|
44
|
-
|
|
53
|
+
"plugin": ["opencode-codex-memory@0.4.2"]
|
|
45
54
|
}
|
|
46
55
|
```
|
|
47
56
|
|
|
@@ -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.2", { "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.2", { "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,24 @@ 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
|
+
}
|
|
10
18
|
export interface ExtractOptions {
|
|
11
19
|
cwd?: string;
|
|
12
20
|
model?: string;
|
|
21
|
+
/** Override the default 1h extract timeout (tests / advanced). */
|
|
22
|
+
timeoutMs?: number;
|
|
13
23
|
}
|
|
14
24
|
/** Returns null when the extractor reported a no-op (nothing worth remembering). */
|
|
15
25
|
export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
|
|
16
26
|
export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
|
|
17
|
-
export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
|
|
27
|
+
export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
|
|
18
28
|
export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
|
|
19
29
|
export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
|
|
20
30
|
/**
|
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,42 @@ 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
|
+
async function abortSession(sessionId) {
|
|
85
|
+
const input = getPluginInput();
|
|
86
|
+
const session = input?.client?.session;
|
|
87
|
+
if (typeof session?.abort !== "function")
|
|
88
|
+
return;
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
let timer;
|
|
91
|
+
try {
|
|
92
|
+
await Promise.race([
|
|
93
|
+
session.abort({ path: { id: sessionId }, signal: controller.signal }),
|
|
94
|
+
new Promise((_, reject) => {
|
|
95
|
+
timer = setTimeout(() => {
|
|
96
|
+
controller.abort();
|
|
97
|
+
reject(new Error(`session.abort timed out after ${SUBSESSION_ABORT_TIMEOUT_MS}ms`));
|
|
98
|
+
}, SUBSESSION_ABORT_TIMEOUT_MS);
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Best-effort: deleteSession is the backup cancel path.
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
66
109
|
/** Runs a sub-agent prompt and returns the raw response data (`{ info, parts }`). */
|
|
67
110
|
async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
68
111
|
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
@@ -87,7 +130,7 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
87
130
|
const res = await Promise.race([
|
|
88
131
|
promptPromise,
|
|
89
132
|
new Promise((_, reject) => {
|
|
90
|
-
timer = setTimeout(() => reject(new
|
|
133
|
+
timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
|
|
91
134
|
}),
|
|
92
135
|
]);
|
|
93
136
|
if (!res.data)
|
|
@@ -99,6 +142,15 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
99
142
|
}
|
|
100
143
|
return res.data;
|
|
101
144
|
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
// Only a timeout leaves the turn running server-side; every other failure
|
|
147
|
+
// here means the request already settled. Stop the run so tokens stop
|
|
148
|
+
// burning — deleteSession in the caller finally is the backup.
|
|
149
|
+
if (err instanceof SubagentTimeoutError) {
|
|
150
|
+
await abortSession(sessionId);
|
|
151
|
+
}
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
102
154
|
finally {
|
|
103
155
|
clearTimeout(timer);
|
|
104
156
|
}
|
|
@@ -153,7 +205,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
153
205
|
// Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
|
|
154
206
|
// and a near-600k-char transcript on a slow model can easily exceed a
|
|
155
207
|
// short one — repeated timeouts would exhaust the job's retries.
|
|
156
|
-
timeoutMs: 3600_000,
|
|
208
|
+
timeoutMs: opts.timeoutMs ?? 3600_000,
|
|
157
209
|
system: readTemplate("stage_one_system.md"),
|
|
158
210
|
model,
|
|
159
211
|
// opencode enforces json_schema output via a forced StructuredOutput tool
|
|
@@ -195,31 +247,54 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
|
|
|
195
247
|
// Must exceed the longest legitimate sub-session lifetime (consolidation may
|
|
196
248
|
// run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
|
|
197
249
|
// plugin reload would delete a working sub-session mid-run.
|
|
198
|
-
export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
|
|
250
|
+
export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBSESSION_LIST_TIMEOUT_MS) {
|
|
199
251
|
const input = getPluginInput();
|
|
200
252
|
if (!input)
|
|
201
253
|
return;
|
|
254
|
+
let timer;
|
|
202
255
|
try {
|
|
203
|
-
|
|
256
|
+
if (typeof input.client?.session?.list !== "function")
|
|
257
|
+
return;
|
|
258
|
+
const res = await Promise.race([
|
|
259
|
+
input.client.session.list(),
|
|
260
|
+
new Promise((_, reject) => {
|
|
261
|
+
timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
262
|
+
}),
|
|
263
|
+
]);
|
|
204
264
|
if (!res.data)
|
|
205
265
|
return;
|
|
206
266
|
const list = res.data;
|
|
207
267
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
208
268
|
for (const s of list) {
|
|
209
|
-
if (s.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
269
|
+
if (!s.id)
|
|
270
|
+
continue;
|
|
271
|
+
const pluginTitle = isPluginSubSessionTitle(s.title);
|
|
272
|
+
const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
|
|
273
|
+
const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
|
|
274
|
+
if (!owned && !legacy)
|
|
275
|
+
continue;
|
|
276
|
+
// Durable ownership requires marker + generated title; a legacy title
|
|
277
|
+
// alone can reseed the skip set but never authorizes deletion.
|
|
278
|
+
activeSubSessions.add(s.id);
|
|
279
|
+
if (!owned)
|
|
280
|
+
continue;
|
|
281
|
+
const created = s.time?.created ?? 0;
|
|
282
|
+
if (created && created < cutoff) {
|
|
283
|
+
void deleteSession(s.id);
|
|
214
284
|
}
|
|
215
285
|
}
|
|
216
286
|
}
|
|
217
287
|
catch {
|
|
218
288
|
// best effort only
|
|
219
289
|
}
|
|
290
|
+
finally {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function isPluginSubSessionTitle(title) {
|
|
295
|
+
return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
|
|
220
296
|
}
|
|
221
297
|
async function deleteSession(id) {
|
|
222
|
-
activeSubSessions.delete(id);
|
|
223
298
|
const input = getPluginInput();
|
|
224
299
|
if (!input)
|
|
225
300
|
return;
|
|
@@ -227,12 +302,44 @@ async function deleteSession(id) {
|
|
|
227
302
|
const res = await input.client.session.delete({ path: { id } });
|
|
228
303
|
if (res.error) {
|
|
229
304
|
console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// OpenCode's Session.remove logs and swallows some internal failures while
|
|
308
|
+
// the HTTP route still returns success. Only a confirmed 404 proves the
|
|
309
|
+
// session is gone; otherwise retain ownership so hooks keep skipping it.
|
|
310
|
+
if (await sessionDeletionConfirmed(input.client, id)) {
|
|
311
|
+
activeSubSessions.delete(id);
|
|
230
312
|
}
|
|
231
313
|
}
|
|
232
314
|
catch (err) {
|
|
233
315
|
console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
|
|
234
316
|
}
|
|
235
317
|
}
|
|
318
|
+
async function sessionDeletionConfirmed(client, id) {
|
|
319
|
+
const session = client.session;
|
|
320
|
+
if (typeof session?.get !== "function")
|
|
321
|
+
return false;
|
|
322
|
+
const controller = new AbortController();
|
|
323
|
+
let timer;
|
|
324
|
+
try {
|
|
325
|
+
const res = await Promise.race([
|
|
326
|
+
session.get({ path: { id }, signal: controller.signal }),
|
|
327
|
+
new Promise((_, reject) => {
|
|
328
|
+
timer = setTimeout(() => {
|
|
329
|
+
controller.abort();
|
|
330
|
+
reject(new Error(`session.get timed out after ${SUBSESSION_CONFIRM_TIMEOUT_MS}ms`));
|
|
331
|
+
}, SUBSESSION_CONFIRM_TIMEOUT_MS);
|
|
332
|
+
}),
|
|
333
|
+
]);
|
|
334
|
+
return res?.response?.status === 404;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
236
343
|
// Substitute with a function so `$&`/`$'` sequences in the value are not
|
|
237
344
|
// expanded as String.replace replacement patterns.
|
|
238
345
|
export function fillTemplate(tmpl, vars) {
|
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/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.2",
|
|
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",
|