opencode-codex-memory 0.4.0 → 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 +163 -70
- package/dist/src/capture.js +15 -2
- package/dist/src/codex-interop.js +12 -13
- package/dist/src/db.js +17 -11
- package/dist/src/git-baseline.js +22 -0
- package/dist/src/index.d.ts +17 -1
- package/dist/src/index.js +129 -63
- package/dist/src/llm.d.ts +11 -1
- package/dist/src/llm.js +123 -11
- package/dist/src/options.d.ts +2 -0
- package/dist/src/options.js +5 -1
- package/dist/src/path-guard.d.ts +2 -0
- package/dist/src/path-guard.js +17 -0
- package/dist/src/phase1.js +1 -1
- package/dist/src/phase2.d.ts +2 -1
- package/dist/src/phase2.js +8 -12
- 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.d.ts +2 -2
- package/dist/src/store.js +26 -8
- package/dist/src/workspace.js +25 -20
- package/dist/tools/memory.js +3 -4
- package/package.json +1 -1
package/dist/src/index.js
CHANGED
|
@@ -7,13 +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
|
-
let
|
|
15
|
+
// Single-flight guard for mcp.status(); see mcpToolPrefixes below.
|
|
16
|
+
let mcpStatusInFlight = null;
|
|
17
|
+
const MCP_STATUS_TIMEOUT_MS = 1_000;
|
|
17
18
|
// Deliberately uncached: openDb() is already a singleton, and caching a store
|
|
18
19
|
// here would hold a stale handle across closeDb() (e.g. after memory_reset).
|
|
19
20
|
function getStore() {
|
|
@@ -25,17 +26,25 @@ function getStore() {
|
|
|
25
26
|
// recorded per part to count each citation exactly once across both paths.
|
|
26
27
|
const recordedCitations = new Map();
|
|
27
28
|
const MAX_TRACKED_PARTS = 500;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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);
|
|
38
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);
|
|
39
48
|
const fresh = ids.filter((id) => !seen.has(id));
|
|
40
49
|
for (const id of fresh)
|
|
41
50
|
seen.add(id);
|
|
@@ -43,19 +52,13 @@ export function takeNewCitations(partKey, ids) {
|
|
|
43
52
|
}
|
|
44
53
|
// One stamp+pump per session per process from the chat.message hook; later
|
|
45
54
|
// messages in the same session add nothing (stamp is idempotent, the pump
|
|
46
|
-
// re-fires on idle anyway).
|
|
47
|
-
const seenTurnSessions = new
|
|
55
|
+
// re-fires on idle anyway). Value = first-seen timestamp (debugging only).
|
|
56
|
+
const seenTurnSessions = new Map();
|
|
48
57
|
const MAX_TRACKED_TURN_SESSIONS = 1000;
|
|
49
58
|
export function markTurnSeen(sessionId) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
|
|
54
|
-
const oldest = seenTurnSessions.keys().next().value;
|
|
55
|
-
if (oldest !== undefined)
|
|
56
|
-
seenTurnSessions.delete(oldest);
|
|
57
|
-
}
|
|
58
|
-
return true;
|
|
59
|
+
const first = seenTurnSessions.get(sessionId);
|
|
60
|
+
lruSet(seenTurnSessions, sessionId, first ?? Date.now(), MAX_TRACKED_TURN_SESSIONS);
|
|
61
|
+
return first === undefined;
|
|
59
62
|
}
|
|
60
63
|
// opencode 1.17 publishes BOTH session.status {type:"idle"} and the
|
|
61
64
|
// deprecated session.idle for the same transition, back to back. Handle
|
|
@@ -65,15 +68,11 @@ const IDLE_DEDUP_MS = 5000;
|
|
|
65
68
|
const MAX_TRACKED_IDLE = 500;
|
|
66
69
|
export function shouldHandleIdle(sessionId, now = Date.now()) {
|
|
67
70
|
const last = recentIdle.get(sessionId);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (oldest !== undefined)
|
|
74
|
-
recentIdle.delete(oldest);
|
|
75
|
-
}
|
|
76
|
-
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;
|
|
77
76
|
}
|
|
78
77
|
export function handleSessionDeleted(sessionId, store = getStore(),
|
|
79
78
|
// With generation off the memorize agent is not injected, so a consolidation
|
|
@@ -90,9 +89,16 @@ export default {
|
|
|
90
89
|
async server(input, opts) {
|
|
91
90
|
setPluginInput(input);
|
|
92
91
|
pluginClient = input.client;
|
|
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();
|
|
93
97
|
if (opts)
|
|
94
98
|
applyPluginOptions(opts);
|
|
95
|
-
|
|
99
|
+
// Finish bounded reseeding before hooks can see a surviving memory
|
|
100
|
+
// sub-session after a plugin reload.
|
|
101
|
+
await cleanupOldSubSessions();
|
|
96
102
|
return buildHooks();
|
|
97
103
|
},
|
|
98
104
|
};
|
|
@@ -119,6 +125,9 @@ function clampInt(value, min, max, fallback) {
|
|
|
119
125
|
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
120
126
|
}
|
|
121
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();
|
|
122
131
|
for (const key of Object.keys(opts)) {
|
|
123
132
|
if (!KNOWN_OPTION_KEYS.has(key)) {
|
|
124
133
|
// codex uses deny_unknown_fields; a plugin can only warn (recorded for
|
|
@@ -168,27 +177,68 @@ export function applyPluginOptions(opts) {
|
|
|
168
177
|
* codex marks every MCP server as memory-polluting unconditionally
|
|
169
178
|
* (codex-mcp server.rs pollutes_memory: true). opencode registers MCP tools
|
|
170
179
|
* as "<server>_<tool>", so match tool names against the configured server
|
|
171
|
-
* list.
|
|
180
|
+
* list. Query live status so runtime MCP changes cannot escape pollution
|
|
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.
|
|
172
190
|
*/
|
|
173
|
-
async function
|
|
174
|
-
if (
|
|
175
|
-
return
|
|
176
|
-
if (!
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
async function mcpToolPrefixes() {
|
|
192
|
+
if (!pluginClient)
|
|
193
|
+
return null;
|
|
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;
|
|
182
221
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
})();
|
|
187
231
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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)
|
|
239
|
+
return null;
|
|
240
|
+
for (const prefix of prefixes) {
|
|
241
|
+
if (toolName.startsWith(`${prefix}_`))
|
|
192
242
|
return true;
|
|
193
243
|
}
|
|
194
244
|
return false;
|
|
@@ -247,7 +297,9 @@ function buildHooks() {
|
|
|
247
297
|
try {
|
|
248
298
|
if (!pluginOptions.use_memories)
|
|
249
299
|
return;
|
|
250
|
-
|
|
300
|
+
// OpenCode also invokes this hook while generating agent definitions,
|
|
301
|
+
// without a session. Memory belongs only in real conversation prompts.
|
|
302
|
+
if (!input.sessionID || isMemorySubSession(input.sessionID))
|
|
251
303
|
return;
|
|
252
304
|
ensureMemoryLayout();
|
|
253
305
|
const memoryPrompt = buildMemorySystemPrompt(pluginOptions.dedicated_tools);
|
|
@@ -343,20 +395,34 @@ function buildHooks() {
|
|
|
343
395
|
console.error("[opencode-codex-memory] chat.message error:", err);
|
|
344
396
|
}
|
|
345
397
|
},
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
*/
|
|
414
|
+
async "tool.execute.before"(input) {
|
|
351
415
|
try {
|
|
352
|
-
if (!pluginOptions.disable_on_external_context)
|
|
416
|
+
if (!pluginOptions.disable_on_external_context || !input.sessionID)
|
|
353
417
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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)
|
|
421
|
+
return;
|
|
422
|
+
getStore().markPolluted(input.sessionID);
|
|
357
423
|
}
|
|
358
424
|
catch (err) {
|
|
359
|
-
console.error("[opencode-codex-memory] tool.execute.
|
|
425
|
+
console.error("[opencode-codex-memory] tool.execute.before error:", err);
|
|
360
426
|
}
|
|
361
427
|
},
|
|
362
428
|
async event(input) {
|
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,13 +130,27 @@ 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)
|
|
94
137
|
throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
|
|
138
|
+
const promptError = res.data.info?.error;
|
|
139
|
+
if (promptError) {
|
|
140
|
+
const detail = promptError.data?.message;
|
|
141
|
+
throw new Error(`sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}${detail ? `: ${detail}` : ""}`);
|
|
142
|
+
}
|
|
95
143
|
return res.data;
|
|
96
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
|
+
}
|
|
97
154
|
finally {
|
|
98
155
|
clearTimeout(timer);
|
|
99
156
|
}
|
|
@@ -148,7 +205,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
148
205
|
// Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
|
|
149
206
|
// and a near-600k-char transcript on a slow model can easily exceed a
|
|
150
207
|
// short one — repeated timeouts would exhaust the job's retries.
|
|
151
|
-
timeoutMs: 3600_000,
|
|
208
|
+
timeoutMs: opts.timeoutMs ?? 3600_000,
|
|
152
209
|
system: readTemplate("stage_one_system.md"),
|
|
153
210
|
model,
|
|
154
211
|
// opencode enforces json_schema output via a forced StructuredOutput tool
|
|
@@ -190,31 +247,54 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
|
|
|
190
247
|
// Must exceed the longest legitimate sub-session lifetime (consolidation may
|
|
191
248
|
// run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
|
|
192
249
|
// plugin reload would delete a working sub-session mid-run.
|
|
193
|
-
export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
|
|
250
|
+
export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBSESSION_LIST_TIMEOUT_MS) {
|
|
194
251
|
const input = getPluginInput();
|
|
195
252
|
if (!input)
|
|
196
253
|
return;
|
|
254
|
+
let timer;
|
|
197
255
|
try {
|
|
198
|
-
|
|
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
|
+
]);
|
|
199
264
|
if (!res.data)
|
|
200
265
|
return;
|
|
201
266
|
const list = res.data;
|
|
202
267
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
203
268
|
for (const s of list) {
|
|
204
|
-
if (s.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
|
209
284
|
}
|
|
210
285
|
}
|
|
211
286
|
}
|
|
212
287
|
catch {
|
|
213
288
|
// best effort only
|
|
214
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 ?? "");
|
|
215
296
|
}
|
|
216
297
|
async function deleteSession(id) {
|
|
217
|
-
activeSubSessions.delete(id);
|
|
218
298
|
const input = getPluginInput();
|
|
219
299
|
if (!input)
|
|
220
300
|
return;
|
|
@@ -222,12 +302,44 @@ async function deleteSession(id) {
|
|
|
222
302
|
const res = await input.client.session.delete({ path: { id } });
|
|
223
303
|
if (res.error) {
|
|
224
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);
|
|
225
312
|
}
|
|
226
313
|
}
|
|
227
314
|
catch (err) {
|
|
228
315
|
console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
|
|
229
316
|
}
|
|
230
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
|
+
}
|
|
231
343
|
// Substitute with a function so `$&`/`$'` sequences in the value are not
|
|
232
344
|
// expanded as String.replace replacement patterns.
|
|
233
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/path-guard.d.ts
CHANGED
|
@@ -17,3 +17,5 @@
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function assertMemoryRootSafe(): string;
|
|
19
19
|
export declare function safeResolveMemoryPath(rel: string): string;
|
|
20
|
+
/** Resolve a relative path under an arbitrary trusted root without following symlinks. */
|
|
21
|
+
export declare function safeResolveUnderRoot(root: string, rel: string): string;
|
package/dist/src/path-guard.js
CHANGED
|
@@ -34,9 +34,26 @@ export function assertMemoryRootSafe() {
|
|
|
34
34
|
}
|
|
35
35
|
export function safeResolveMemoryPath(rel) {
|
|
36
36
|
const root = assertMemoryRootSafe();
|
|
37
|
+
return safeResolveUnderRoot(root, rel);
|
|
38
|
+
}
|
|
39
|
+
/** Resolve a relative path under an arbitrary trusted root without following symlinks. */
|
|
40
|
+
export function safeResolveUnderRoot(root, rel) {
|
|
37
41
|
if (path.isAbsolute(rel)) {
|
|
38
42
|
throw new Error(`path escapes memory root: ${rel}`);
|
|
39
43
|
}
|
|
44
|
+
try {
|
|
45
|
+
const rootStat = fs.lstatSync(root);
|
|
46
|
+
if (rootStat.isSymbolicLink()) {
|
|
47
|
+
throw new Error(`root is a symlink; refusing write: ${root}`);
|
|
48
|
+
}
|
|
49
|
+
if (!rootStat.isDirectory()) {
|
|
50
|
+
throw new Error(`root is not a directory: ${root}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err.code !== "ENOENT")
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
40
57
|
const parts = rel.split(/[\\/]+/).filter((p) => p.length > 0 && p !== ".");
|
|
41
58
|
let current = root;
|
|
42
59
|
for (const part of parts) {
|
package/dist/src/phase1.js
CHANGED
package/dist/src/phase2.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MemoryStore } from "./store.js";
|
|
2
|
+
import { checkRateLimit } from "./ratelimit.js";
|
|
2
3
|
import { type CodexInteropOptions } from "./codex-interop.js";
|
|
3
4
|
export interface Phase2Options {
|
|
4
5
|
maxRaw: number;
|
|
@@ -10,6 +11,6 @@ export interface Phase2Options {
|
|
|
10
11
|
export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
|
|
11
12
|
/** True while THIS process runs a consolidation (memory_reset refuses then). */
|
|
12
13
|
export declare function isPhase2InFlight(): boolean;
|
|
13
|
-
export declare function runPhase2(store: MemoryStore, opts?: Phase2Options): Promise<{
|
|
14
|
+
export declare function runPhase2(store: MemoryStore, opts?: Phase2Options, rateLimitCheck?: typeof checkRateLimit): Promise<{
|
|
14
15
|
status: string;
|
|
15
16
|
}>;
|
package/dist/src/phase2.js
CHANGED
|
@@ -27,21 +27,23 @@ let phase2InFlight = false;
|
|
|
27
27
|
export function isPhase2InFlight() {
|
|
28
28
|
return phase2InFlight;
|
|
29
29
|
}
|
|
30
|
-
export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
30
|
+
export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitCheck = checkRateLimit) {
|
|
31
31
|
if (phase2InFlight)
|
|
32
32
|
return { status: "already_running" };
|
|
33
33
|
phase2InFlight = true;
|
|
34
34
|
try {
|
|
35
|
-
const rl = await
|
|
35
|
+
const rl = await rateLimitCheck("phase2");
|
|
36
36
|
if (!rl.ok)
|
|
37
37
|
return { status: "skipped_rate_limit" };
|
|
38
38
|
const claim = store.claimGlobalPhase2Job();
|
|
39
39
|
if (claim.type !== "claimed")
|
|
40
40
|
return { status: claim.type };
|
|
41
|
-
// Resolved once per claimed job (not per attempt): resolution warns on
|
|
42
|
-
// misconfiguration, and warning on every skipped attempt would be noise.
|
|
43
|
-
const interop = opts.codexInterop ? resolveCodexInterop(opts.codexInterop) : null;
|
|
44
41
|
try {
|
|
42
|
+
// Resolved once per claimed job (not per attempt): resolution warns on
|
|
43
|
+
// misconfiguration, and warning on every skipped attempt would be noise.
|
|
44
|
+
// Keep this inside the claimed-job try so resolution failures release
|
|
45
|
+
// the lease instead of leaving the row running until it expires.
|
|
46
|
+
const interop = opts.codexInterop ? resolveCodexInterop(opts.codexInterop) : null;
|
|
45
47
|
ensureLayout();
|
|
46
48
|
// Preserves an existing baseline (only initializes a missing one): the
|
|
47
49
|
// diff below must span last-successful-run -> now so user edits and
|
|
@@ -100,10 +102,8 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
100
102
|
console.warn("[opencode-codex-memory] phase2 heartbeat error:", err);
|
|
101
103
|
}
|
|
102
104
|
}, 90_000);
|
|
103
|
-
let agentCompleted = false;
|
|
104
105
|
try {
|
|
105
106
|
await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
|
|
106
|
-
agentCompleted = true;
|
|
107
107
|
}
|
|
108
108
|
finally {
|
|
109
109
|
clearInterval(heartbeat);
|
|
@@ -118,10 +118,6 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
118
118
|
store.markPhase2Failed(claim.ownershipToken, "ownership lost");
|
|
119
119
|
return { status: "heartbeat_lost" };
|
|
120
120
|
}
|
|
121
|
-
if (!agentCompleted) {
|
|
122
|
-
store.markPhase2Failed(claim.ownershipToken, "failed_agent");
|
|
123
|
-
return { status: "failed_agent" };
|
|
124
|
-
}
|
|
125
121
|
// codex failed_invalid_artifacts: do not reset baseline on bad output so
|
|
126
122
|
// the next run still sees a diff / can re-INIT.
|
|
127
123
|
const artifacts = validateConsolidationArtifacts();
|
|
@@ -139,7 +135,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
139
135
|
return { status: "succeeded" };
|
|
140
136
|
}
|
|
141
137
|
catch (err) {
|
|
142
|
-
store.markPhase2Failed(claim.ownershipToken, err
|
|
138
|
+
store.markPhase2Failed(claim.ownershipToken, err);
|
|
143
139
|
return { status: "failed" };
|
|
144
140
|
}
|
|
145
141
|
}
|
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;
|