opencode-codex-memory 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -23
- package/dist/opencode.json +4 -4
- package/dist/src/capture.d.ts +29 -4
- package/dist/src/capture.js +111 -57
- package/dist/src/git-baseline.js +43 -34
- package/dist/src/index.d.ts +56 -4
- package/dist/src/index.js +167 -36
- package/dist/src/llm.d.ts +1 -0
- package/dist/src/llm.js +5 -2
- package/dist/src/path-guard.d.ts +9 -0
- package/dist/src/path-guard.js +23 -1
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -3
- package/dist/src/phase1.d.ts +1 -0
- package/dist/src/phase1.js +13 -7
- package/dist/src/phase2.d.ts +2 -0
- package/dist/src/phase2.js +14 -2
- package/dist/src/redact.js +5 -2
- package/dist/src/source.d.ts +1 -1
- package/dist/src/source.js +23 -1
- package/dist/src/store.d.ts +5 -0
- package/dist/src/store.js +50 -18
- package/dist/src/templates/consolidation.md +15 -13
- package/dist/src/templates/read_path.md +3 -9
- package/dist/src/templates/stage_one_input.md +1 -1
- package/dist/src/workspace.js +8 -3
- package/dist/tools/control.js +40 -14
- package/dist/tools/memory.d.ts +20 -4
- package/dist/tools/memory.js +183 -58
- package/opencode.json +4 -4
- package/package.json +1 -1
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js";
|
|
2
|
+
import { memoryRoot } from "./paths.js";
|
|
2
3
|
import { stripCitations, extractCitedSessionIds } from "./citation.js";
|
|
3
4
|
import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js";
|
|
4
5
|
import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
|
|
@@ -31,9 +32,10 @@ let pluginOptions = {
|
|
|
31
32
|
function getStore() {
|
|
32
33
|
return new MemoryStore();
|
|
33
34
|
}
|
|
34
|
-
// Citation blocks
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// Citation blocks are seen by both the text.complete hook (once, at
|
|
36
|
+
// completion) and message.part.updated (once per streaming delta), so the
|
|
37
|
+
// same block surfaces many times. Track which session ids were already
|
|
38
|
+
// recorded per part to count each citation exactly once across both paths.
|
|
37
39
|
const recordedCitations = new Map();
|
|
38
40
|
const MAX_TRACKED_PARTS = 500;
|
|
39
41
|
export function takeNewCitations(partKey, ids) {
|
|
@@ -52,7 +54,47 @@ export function takeNewCitations(partKey, ids) {
|
|
|
52
54
|
seen.add(id);
|
|
53
55
|
return fresh;
|
|
54
56
|
}
|
|
55
|
-
|
|
57
|
+
// One stamp+pump per session per process from the chat.message hook; later
|
|
58
|
+
// messages in the same session add nothing (stamp is idempotent, the pump
|
|
59
|
+
// re-fires on idle anyway).
|
|
60
|
+
const seenTurnSessions = new Set();
|
|
61
|
+
const MAX_TRACKED_TURN_SESSIONS = 1000;
|
|
62
|
+
export function markTurnSeen(sessionId) {
|
|
63
|
+
if (seenTurnSessions.has(sessionId))
|
|
64
|
+
return false;
|
|
65
|
+
seenTurnSessions.add(sessionId);
|
|
66
|
+
if (seenTurnSessions.size > MAX_TRACKED_TURN_SESSIONS) {
|
|
67
|
+
const oldest = seenTurnSessions.keys().next().value;
|
|
68
|
+
if (oldest !== undefined)
|
|
69
|
+
seenTurnSessions.delete(oldest);
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// opencode 1.17 publishes BOTH session.status {type:"idle"} and the
|
|
74
|
+
// deprecated session.idle for the same transition, back to back. Handle
|
|
75
|
+
// whichever arrives first and swallow the twin within a short window.
|
|
76
|
+
const recentIdle = new Map();
|
|
77
|
+
const IDLE_DEDUP_MS = 5000;
|
|
78
|
+
const MAX_TRACKED_IDLE = 500;
|
|
79
|
+
export function shouldHandleIdle(sessionId, now = Date.now()) {
|
|
80
|
+
const last = recentIdle.get(sessionId);
|
|
81
|
+
if (last !== undefined && now - last < IDLE_DEDUP_MS)
|
|
82
|
+
return false;
|
|
83
|
+
recentIdle.set(sessionId, now);
|
|
84
|
+
if (recentIdle.size > MAX_TRACKED_IDLE) {
|
|
85
|
+
const oldest = recentIdle.keys().next().value;
|
|
86
|
+
if (oldest !== undefined)
|
|
87
|
+
recentIdle.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
export function handleSessionDeleted(sessionId, store = getStore(),
|
|
92
|
+
// With generation off the memorize agent is not injected, so a consolidation
|
|
93
|
+
// attempt could only fail; the row deletion above still happens, and the
|
|
94
|
+
// enqueued job runs when generation is re-enabled (codex: delete only
|
|
95
|
+
// enqueues; the pipeline itself is gated elsewhere).
|
|
96
|
+
schedulePhase2 = () => { if (pluginOptions.generate_memories)
|
|
97
|
+
void triggerPhase2(); }) {
|
|
56
98
|
if (store.deleteSessionMemory(sessionId))
|
|
57
99
|
schedulePhase2();
|
|
58
100
|
}
|
|
@@ -166,6 +208,18 @@ export function injectAgentDefinitions(config) {
|
|
|
166
208
|
console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
|
|
167
209
|
return;
|
|
168
210
|
}
|
|
211
|
+
// opencode gates file tools outside the session's project behind the
|
|
212
|
+
// `external_directory` permission, and the memory workspace is global —
|
|
213
|
+
// outside every project — so the consolidator's reads/writes there always
|
|
214
|
+
// trigger that ask. The bundled `"*": "deny"` matches it (permission rules
|
|
215
|
+
// are wildcard-on-name, last match wins), which would block consolidation
|
|
216
|
+
// entirely. Grant the memory root here rather than in opencode.json: the
|
|
217
|
+
// path is homedir/env-dependent (src/paths.ts is its single source of
|
|
218
|
+
// truth). Appended last so it out-ranks the wildcard deny.
|
|
219
|
+
const memorize = defs["memorize"];
|
|
220
|
+
if (memorize?.permission && !("external_directory" in memorize.permission)) {
|
|
221
|
+
memorize.permission["external_directory"] = { [path.join(memoryRoot(), "*")]: "allow" };
|
|
222
|
+
}
|
|
169
223
|
config.agent ??= {};
|
|
170
224
|
for (const [name, def] of Object.entries(defs)) {
|
|
171
225
|
if (!config.agent[name])
|
|
@@ -193,7 +247,7 @@ function buildHooks() {
|
|
|
193
247
|
if (input.sessionID && isMemorySubSession(input.sessionID))
|
|
194
248
|
return;
|
|
195
249
|
ensureMemoryLayout();
|
|
196
|
-
const memoryPrompt = buildMemorySystemPrompt();
|
|
250
|
+
const memoryPrompt = buildMemorySystemPrompt(pluginOptions.dedicated_tools);
|
|
197
251
|
if (memoryPrompt) {
|
|
198
252
|
output.system.push(memoryPrompt);
|
|
199
253
|
}
|
|
@@ -202,6 +256,41 @@ function buildHooks() {
|
|
|
202
256
|
console.error("[opencode-codex-memory] system.transform error:", err);
|
|
203
257
|
}
|
|
204
258
|
},
|
|
259
|
+
/**
|
|
260
|
+
* Fires at text-end, before opencode persists the final part text
|
|
261
|
+
* (session/processor.ts): the returned text replaces the stored one.
|
|
262
|
+
* Primary citation seam — records usage and strips the block so neither
|
|
263
|
+
* the UI nor history ever shows citation markup (matches codex, which
|
|
264
|
+
* strips from the displayed/persisted message). The event and
|
|
265
|
+
* messages.transform paths below stay as fallbacks for older opencode
|
|
266
|
+
* hosts and for history persisted before this hook existed.
|
|
267
|
+
*/
|
|
268
|
+
async "experimental.text.complete"(input, output) {
|
|
269
|
+
try {
|
|
270
|
+
if (isMemorySubSession(input.sessionID))
|
|
271
|
+
return;
|
|
272
|
+
if (!output.text.includes("<memory-citation>"))
|
|
273
|
+
return;
|
|
274
|
+
try {
|
|
275
|
+
const ids = extractCitedSessionIds(output.text);
|
|
276
|
+
// Same part key as the event path: whichever hook sees the ids first
|
|
277
|
+
// records them; the other becomes a no-op.
|
|
278
|
+
const fresh = takeNewCitations(`${input.sessionID}:${input.partID}`, ids);
|
|
279
|
+
if (fresh.length > 0)
|
|
280
|
+
getStore().recordUsage(fresh);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
console.error("[opencode-codex-memory] citation recording failed:", e);
|
|
284
|
+
}
|
|
285
|
+
output.text = stripCitations(output.text);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
console.error("[opencode-codex-memory] text.complete error:", err);
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
// Fallback strip for history that still carries citation blocks (messages
|
|
292
|
+
// persisted by plugin versions before the text.complete seam, or hosts
|
|
293
|
+
// without it). Keeps citation markup out of the model-facing transcript.
|
|
205
294
|
async "experimental.chat.messages.transform"(_input, output) {
|
|
206
295
|
try {
|
|
207
296
|
for (const msg of output.messages) {
|
|
@@ -222,6 +311,51 @@ function buildHooks() {
|
|
|
222
311
|
console.error("[opencode-codex-memory] messages.transform error:", err);
|
|
223
312
|
}
|
|
224
313
|
},
|
|
314
|
+
/**
|
|
315
|
+
* Turn start. codex stamps memory_mode at thread creation (session.rs) and
|
|
316
|
+
* schedules memory work per startup/turn; the first user message is the
|
|
317
|
+
* closest plugin-visible moment. Stamping here (instead of waiting for the
|
|
318
|
+
* first idle) means a session created while generate_memories=false keeps
|
|
319
|
+
* its 'disabled' stamp even if the option flips mid-session, and the
|
|
320
|
+
* phase-1 pump no longer depends on idle events at all. The idle path
|
|
321
|
+
* below stays as a second pump trigger; both are cheap (stamp is INSERT OR
|
|
322
|
+
* IGNORE, the pump is gated by in-flight/rate/claim guards).
|
|
323
|
+
*/
|
|
324
|
+
async "chat.message"(input) {
|
|
325
|
+
try {
|
|
326
|
+
const sid = input?.sessionID;
|
|
327
|
+
if (!sid || isMemorySubSession(sid))
|
|
328
|
+
return;
|
|
329
|
+
if (!markTurnSeen(sid))
|
|
330
|
+
return;
|
|
331
|
+
try {
|
|
332
|
+
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
336
|
+
}
|
|
337
|
+
void triggerPhase1(sid);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
console.error("[opencode-codex-memory] chat.message error:", err);
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
// Dedicated plugin hook (NOT an event-bus type): fires after every tool
|
|
344
|
+
// call. Mirrors codex: external context (web search or any MCP tool) only
|
|
345
|
+
// pollutes the session's memory when disable_on_external_context is
|
|
346
|
+
// enabled. Off by default.
|
|
347
|
+
async "tool.execute.after"(input) {
|
|
348
|
+
try {
|
|
349
|
+
if (!pluginOptions.disable_on_external_context)
|
|
350
|
+
return;
|
|
351
|
+
if (input.sessionID && (await isExternalContextTool(input.tool))) {
|
|
352
|
+
getStore().markPolluted(input.sessionID);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
console.error("[opencode-codex-memory] tool.execute.after error:", err);
|
|
357
|
+
}
|
|
358
|
+
},
|
|
225
359
|
async event(input) {
|
|
226
360
|
try {
|
|
227
361
|
const ev = input.event;
|
|
@@ -251,24 +385,6 @@ function buildHooks() {
|
|
|
251
385
|
}
|
|
252
386
|
return;
|
|
253
387
|
}
|
|
254
|
-
if (ev.type === "tool.execute.after") {
|
|
255
|
-
// Mirrors codex: external context (web search or any MCP tool) only
|
|
256
|
-
// pollutes the session's memory when disable_on_external_context is
|
|
257
|
-
// enabled. Off by default.
|
|
258
|
-
if (!pluginOptions.disable_on_external_context)
|
|
259
|
-
return;
|
|
260
|
-
const props = ev.properties;
|
|
261
|
-
const toolName = props?.tool ?? "";
|
|
262
|
-
if (props.sessionID && (await isExternalContextTool(toolName))) {
|
|
263
|
-
try {
|
|
264
|
-
getStore().markPolluted(props.sessionID);
|
|
265
|
-
}
|
|
266
|
-
catch (e) {
|
|
267
|
-
console.error("[opencode-codex-memory] markPolluted failed:", e);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
388
|
if (ev.type === "session.deleted") {
|
|
273
389
|
// Mirrors codex delete_thread_memory: drop the extracted memory and
|
|
274
390
|
// its job when the session is deleted. If phase 2 had consumed it,
|
|
@@ -285,21 +401,19 @@ function buildHooks() {
|
|
|
285
401
|
}
|
|
286
402
|
return;
|
|
287
403
|
}
|
|
404
|
+
// session.idle is deprecated in opencode 1.17 in favor of
|
|
405
|
+
// session.status {type:"idle"}; both are still emitted. Support both so
|
|
406
|
+
// the pipeline keeps triggering when the legacy event disappears.
|
|
407
|
+
if (ev.type === "session.status") {
|
|
408
|
+
const props = ev.properties;
|
|
409
|
+
if (props?.status?.type === "idle" && props.sessionID)
|
|
410
|
+
handleSessionIdle(props.sessionID);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
288
413
|
if (ev.type === "session.idle") {
|
|
289
414
|
const props = ev.properties;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return;
|
|
293
|
-
// codex stamps memory_mode at thread creation from generate_memories:
|
|
294
|
-
// sessions seen while generation is off stay excluded permanently,
|
|
295
|
-
// even if the option is re-enabled later.
|
|
296
|
-
try {
|
|
297
|
-
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
|
|
298
|
-
}
|
|
299
|
-
catch (e) {
|
|
300
|
-
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
301
|
-
}
|
|
302
|
-
void triggerPhase1(sid);
|
|
415
|
+
if (props?.sessionID)
|
|
416
|
+
handleSessionIdle(props.sessionID);
|
|
303
417
|
return;
|
|
304
418
|
}
|
|
305
419
|
}
|
|
@@ -311,6 +425,22 @@ function buildHooks() {
|
|
|
311
425
|
invalidateCache();
|
|
312
426
|
},
|
|
313
427
|
};
|
|
428
|
+
function handleSessionIdle(sid) {
|
|
429
|
+
if (isMemorySubSession(sid))
|
|
430
|
+
return;
|
|
431
|
+
if (!shouldHandleIdle(sid))
|
|
432
|
+
return;
|
|
433
|
+
// codex stamps memory_mode at thread creation from generate_memories:
|
|
434
|
+
// sessions first seen while generation is off keep that stamp when the
|
|
435
|
+
// option is re-enabled (manual override: the memory_mode tool).
|
|
436
|
+
try {
|
|
437
|
+
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
441
|
+
}
|
|
442
|
+
void triggerPhase1(sid);
|
|
443
|
+
}
|
|
314
444
|
// Control tools (reset/inspect/mode) are always available. The memory
|
|
315
445
|
// read/search/list/add-note tools require BOTH use_memories and
|
|
316
446
|
// dedicated_tools, mirroring codex's MemoriesExtension: use_memories=false
|
|
@@ -341,6 +471,7 @@ async function triggerPhase1(currentSessionId) {
|
|
|
341
471
|
maxAgeDays: pluginOptions.max_rollout_age_days,
|
|
342
472
|
minIdleHours: pluginOptions.min_rollout_idle_hours,
|
|
343
473
|
maxClaimed: pluginOptions.max_rollouts_per_startup,
|
|
474
|
+
maxUnusedDays: pluginOptions.max_unused_days,
|
|
344
475
|
excludeSession: currentSessionId,
|
|
345
476
|
extractModel: pluginOptions.extract_model,
|
|
346
477
|
});
|
package/dist/src/llm.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface ExtractionResult {
|
|
|
5
5
|
rollout_slug: string | null;
|
|
6
6
|
}
|
|
7
7
|
export declare function setPluginInput(input: PluginInput): void;
|
|
8
|
+
export declare function getPluginInput(): PluginInput | null;
|
|
8
9
|
export declare function isMemorySubSession(sessionId: string): boolean;
|
|
9
10
|
export interface ExtractOptions {
|
|
10
11
|
cwd?: string;
|
package/dist/src/llm.js
CHANGED
|
@@ -4,7 +4,7 @@ let inputRef = null;
|
|
|
4
4
|
export function setPluginInput(input) {
|
|
5
5
|
inputRef = input;
|
|
6
6
|
}
|
|
7
|
-
function getPluginInput() {
|
|
7
|
+
export function getPluginInput() {
|
|
8
8
|
return inputRef;
|
|
9
9
|
}
|
|
10
10
|
// Sessions this plugin spawned for extraction/consolidation. The main
|
|
@@ -125,7 +125,10 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
125
125
|
// extract_model option > opencode small_model > session default.
|
|
126
126
|
const model = opts.model ?? (await getConfigModels()).smallModel;
|
|
127
127
|
const raw = await promptSession(subId, prompt, agent, {
|
|
128
|
-
|
|
128
|
+
// Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
|
|
129
|
+
// and a near-600k-char transcript on a slow model can easily exceed a
|
|
130
|
+
// short one — repeated timeouts would exhaust the job's retries.
|
|
131
|
+
timeoutMs: 3600_000,
|
|
129
132
|
system: readTemplate("stage_one_system.md"),
|
|
130
133
|
model,
|
|
131
134
|
});
|
package/dist/src/path-guard.d.ts
CHANGED
|
@@ -7,4 +7,13 @@
|
|
|
7
7
|
* - every existing component is lstat-checked: symlinks are rejected, so a
|
|
8
8
|
* link placed inside the workspace cannot lead reads outside it
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The memory root itself must not be a symlink: every scoped resolution and
|
|
12
|
+
* every workspace walk starts there, so a symlinked root would redirect ALL
|
|
13
|
+
* memory reads/writes elsewhere on disk. codex rejects a symlinked root when
|
|
14
|
+
* clearing (control.rs clear_memory_root_contents); the model-facing tools
|
|
15
|
+
* here extend that check to every memory operation. Returns the root path.
|
|
16
|
+
* A missing root is fine — callers create it as a real directory.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assertMemoryRootSafe(): string;
|
|
10
19
|
export declare function safeResolveMemoryPath(rel: string): string;
|
package/dist/src/path-guard.js
CHANGED
|
@@ -10,8 +10,30 @@ import { memoryRoot } from "./paths.js";
|
|
|
10
10
|
* - every existing component is lstat-checked: symlinks are rejected, so a
|
|
11
11
|
* link placed inside the workspace cannot lead reads outside it
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The memory root itself must not be a symlink: every scoped resolution and
|
|
15
|
+
* every workspace walk starts there, so a symlinked root would redirect ALL
|
|
16
|
+
* memory reads/writes elsewhere on disk. codex rejects a symlinked root when
|
|
17
|
+
* clearing (control.rs clear_memory_root_contents); the model-facing tools
|
|
18
|
+
* here extend that check to every memory operation. Returns the root path.
|
|
19
|
+
* A missing root is fine — callers create it as a real directory.
|
|
20
|
+
*/
|
|
21
|
+
export function assertMemoryRootSafe() {
|
|
14
22
|
const root = memoryRoot();
|
|
23
|
+
let st = null;
|
|
24
|
+
try {
|
|
25
|
+
st = fs.lstatSync(root);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
if (st.isSymbolicLink()) {
|
|
31
|
+
throw new Error(`memory root is a symlink; refusing memory operations: ${root}`);
|
|
32
|
+
}
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
export function safeResolveMemoryPath(rel) {
|
|
36
|
+
const root = assertMemoryRootSafe();
|
|
15
37
|
if (path.isAbsolute(rel)) {
|
|
16
38
|
throw new Error(`path escapes memory root: ${rel}`);
|
|
17
39
|
}
|
package/dist/src/paths.d.ts
CHANGED
package/dist/src/paths.js
CHANGED
|
@@ -15,9 +15,6 @@ export function memoryRoot() {
|
|
|
15
15
|
export function memoryDbPath() {
|
|
16
16
|
return path.join(dataRoot(), MEMORY_DB_NAME);
|
|
17
17
|
}
|
|
18
|
-
export function opencodeDbPath() {
|
|
19
|
-
return path.join(dataRoot(), "opencode.db");
|
|
20
|
-
}
|
|
21
18
|
export function memorySummaryPath() {
|
|
22
19
|
return path.join(memoryRoot(), "memory_summary.md");
|
|
23
20
|
}
|
package/dist/src/phase1.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export interface Phase1Options {
|
|
|
9
9
|
}
|
|
10
10
|
export declare const DEFAULT_PHASE1_OPTIONS: Phase1Options;
|
|
11
11
|
export declare function runPhase1(store: MemoryStore, opts?: Phase1Options): Promise<void>;
|
|
12
|
+
export declare function buildTranscript(sessionId: string): Promise<string>;
|
package/dist/src/phase1.js
CHANGED
|
@@ -15,9 +15,10 @@ export const DEFAULT_PHASE1_OPTIONS = {
|
|
|
15
15
|
// char-estimate equivalent at 600k.
|
|
16
16
|
const TRANSCRIPT_MAX_CHARS = 600_000;
|
|
17
17
|
// When truncating, keep the head and the tail: the start carries the user's
|
|
18
|
-
// framing, the end carries the final outcome and feedback.
|
|
19
|
-
|
|
20
|
-
const
|
|
18
|
+
// framing, the end carries the final outcome and feedback. codex splits the
|
|
19
|
+
// budget 50/50 between head and tail (truncate.rs split_budget).
|
|
20
|
+
const TRANSCRIPT_HEAD_CHARS = 300_000;
|
|
21
|
+
const TRANSCRIPT_TAIL_CHARS = 300_000;
|
|
21
22
|
export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
|
|
22
23
|
store.pruneStage1Outputs(opts.maxUnusedDays ?? 30);
|
|
23
24
|
const rl = await checkRateLimit("phase1");
|
|
@@ -25,7 +26,7 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
|
|
|
25
26
|
console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason);
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
|
-
const eligible = selectEligibleSessions(store, opts);
|
|
29
|
+
const eligible = await selectEligibleSessions(store, opts);
|
|
29
30
|
if (eligible.length === 0)
|
|
30
31
|
return;
|
|
31
32
|
const claimed = store.claimStage1Jobs(eligible, opts.excludeSession, opts.maxClaimed);
|
|
@@ -37,7 +38,7 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
|
|
|
37
38
|
try {
|
|
38
39
|
const session = sessionById.get(sid);
|
|
39
40
|
const sourceUpdatedAt = session?.updated_at ?? Date.now();
|
|
40
|
-
const transcript = buildTranscript(sid);
|
|
41
|
+
const transcript = await buildTranscript(sid);
|
|
41
42
|
if (!transcript.trim()) {
|
|
42
43
|
store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt);
|
|
43
44
|
return;
|
|
@@ -67,14 +68,19 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
|
|
|
67
68
|
}
|
|
68
69
|
});
|
|
69
70
|
}
|
|
70
|
-
function buildTranscript(sessionId) {
|
|
71
|
-
const msgs = loadTranscript(sessionId);
|
|
71
|
+
export async function buildTranscript(sessionId) {
|
|
72
|
+
const msgs = await loadTranscript(sessionId);
|
|
72
73
|
if (msgs.length === 0)
|
|
73
74
|
return "";
|
|
74
75
|
const lines = [];
|
|
75
76
|
for (const m of msgs) {
|
|
76
77
|
if (m.type === "system")
|
|
77
78
|
continue;
|
|
79
|
+
// codex sanitize_response_item_for_memories drops developer-role messages
|
|
80
|
+
// entirely (injected instructions, not conversation). opencode 1.17 only
|
|
81
|
+
// stores user/assistant roles; this guards future role additions.
|
|
82
|
+
if (m.role === "developer")
|
|
83
|
+
continue;
|
|
78
84
|
const role = m.role ?? m.type;
|
|
79
85
|
const text = m.text ?? "";
|
|
80
86
|
if (!text.trim())
|
package/dist/src/phase2.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface Phase2Options {
|
|
|
6
6
|
consolidationModel?: string;
|
|
7
7
|
}
|
|
8
8
|
export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
|
|
9
|
+
/** True while THIS process runs a consolidation (memory_reset refuses then). */
|
|
10
|
+
export declare function isPhase2InFlight(): boolean;
|
|
9
11
|
export declare function runPhase2(store: MemoryStore, opts?: Phase2Options): Promise<{
|
|
10
12
|
status: string;
|
|
11
13
|
}>;
|
package/dist/src/phase2.js
CHANGED
|
@@ -10,6 +10,10 @@ export const DEFAULT_PHASE2_OPTIONS = {
|
|
|
10
10
|
extensionRetentionDays: 7,
|
|
11
11
|
};
|
|
12
12
|
let phase2InFlight = false;
|
|
13
|
+
/** True while THIS process runs a consolidation (memory_reset refuses then). */
|
|
14
|
+
export function isPhase2InFlight() {
|
|
15
|
+
return phase2InFlight;
|
|
16
|
+
}
|
|
13
17
|
export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
14
18
|
if (phase2InFlight)
|
|
15
19
|
return { status: "already_running" };
|
|
@@ -51,8 +55,16 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
51
55
|
writeWorkspaceDiff(diff);
|
|
52
56
|
let heartbeatLost = false;
|
|
53
57
|
const heartbeat = setInterval(() => {
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
try {
|
|
59
|
+
if (!store.heartbeatPhase2Job(claim.ownershipToken)) {
|
|
60
|
+
heartbeatLost = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
// Transient DB error (e.g. SQLITE_BUSY): don't treat as ownership
|
|
65
|
+
// loss — the token+status-guarded final confirmation below stays
|
|
66
|
+
// authoritative. Uncaught, this would kill the interval silently.
|
|
67
|
+
console.warn("[opencode-codex-memory] phase2 heartbeat error:", err);
|
|
56
68
|
}
|
|
57
69
|
}, 90_000);
|
|
58
70
|
let agentCompleted = false;
|
package/dist/src/redact.js
CHANGED
|
@@ -10,8 +10,11 @@ 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
|
-
|
|
14
|
-
|
|
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]" },
|
|
15
18
|
];
|
|
16
19
|
export function redact(text) {
|
|
17
20
|
let out = text;
|
package/dist/src/source.d.ts
CHANGED
package/dist/src/source.js
CHANGED
|
@@ -5,6 +5,25 @@ import { truncateToTokens } from "./token.js";
|
|
|
5
5
|
import { fillTemplate } from "./llm.js";
|
|
6
6
|
const MEMORY_SUMMARY_TOKEN_LIMIT = 2500;
|
|
7
7
|
const READ_PATH_TEMPLATE = "read_path.md";
|
|
8
|
+
// Tool-dependent guidance for read_path.md. With dedicated_tools on, the
|
|
9
|
+
// prompt points at the memory_* tools (our platform adaptation — the memory
|
|
10
|
+
// dir lives outside the workspace). With them off, it falls back to codex's
|
|
11
|
+
// own wording: the agent reads/writes the memory files directly.
|
|
12
|
+
const SEARCH_STEP_TOOLS = `2. Search {{ base_path }}/MEMORY.md for those keywords with the \`memory_search\`
|
|
13
|
+
tool, or read it with \`memory_read\`.
|
|
14
|
+
- For time-scoped recall ("what was I working on last week / around date X"),
|
|
15
|
+
pass \`since\`/\`until\` to \`memory_search\` — with a query it searches only that
|
|
16
|
+
period's sessions/notes; without a query it lists them chronologically.`;
|
|
17
|
+
const SEARCH_STEP_FILES = `2. Search {{ base_path }}/MEMORY.md using those keywords.`;
|
|
18
|
+
const UPDATE_INSTRUCTIONS_TOOLS = `Use the \`memory_add_note\` tool, which writes
|
|
19
|
+
one small note file under \`extensions/ad_hoc/notes/\` describing what to
|
|
20
|
+
add/delete/update. Do not edit the memory files yourself; the consolidation
|
|
21
|
+
pass will integrate the note.`;
|
|
22
|
+
const UPDATE_INSTRUCTIONS_FILES = `- Write your update in {{ base_path }}/extensions/ad_hoc/notes/
|
|
23
|
+
- Each update must be one small file containing what you want to add/delete/update from the memories.
|
|
24
|
+
- The name of this file must be \`<timestamp>-<short slug>.md\`
|
|
25
|
+
- Do not edit the other memory files yourself; the consolidation pass will
|
|
26
|
+
integrate the note.`;
|
|
8
27
|
let cached = null;
|
|
9
28
|
function readTemplate() {
|
|
10
29
|
const templatePath = path.join(import.meta.dirname, "templates", READ_PATH_TEMPLATE);
|
|
@@ -31,12 +50,15 @@ function readMemorySummary() {
|
|
|
31
50
|
export function invalidateCache() {
|
|
32
51
|
cached = null;
|
|
33
52
|
}
|
|
34
|
-
export function buildMemorySystemPrompt() {
|
|
53
|
+
export function buildMemorySystemPrompt(dedicatedTools) {
|
|
35
54
|
const summary = readMemorySummary();
|
|
36
55
|
if (!summary)
|
|
37
56
|
return null;
|
|
38
57
|
const template = readTemplate();
|
|
39
58
|
return fillTemplate(template, {
|
|
59
|
+
search_step: dedicatedTools ? SEARCH_STEP_TOOLS : SEARCH_STEP_FILES,
|
|
60
|
+
update_instructions: dedicatedTools ? UPDATE_INSTRUCTIONS_TOOLS : UPDATE_INSTRUCTIONS_FILES,
|
|
61
|
+
// Filled last so {{ base_path }} nested inside the snippets above resolves.
|
|
40
62
|
base_path: memoryRoot(),
|
|
41
63
|
memory_summary: summary,
|
|
42
64
|
});
|
package/dist/src/store.d.ts
CHANGED
|
@@ -69,6 +69,11 @@ export declare class MemoryStore {
|
|
|
69
69
|
* still back the consolidated artifacts.
|
|
70
70
|
*/
|
|
71
71
|
markPhase2Succeeded(ownershipToken: string, selected?: Pick<Stage1Output, "session_id" | "source_updated_at">[]): void;
|
|
72
|
+
/** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
|
|
73
|
+
phase2LastSuccess(): {
|
|
74
|
+
finished_at: number | null;
|
|
75
|
+
last_success_watermark: number | null;
|
|
76
|
+
} | null;
|
|
72
77
|
markPhase2Failed(ownershipToken: string, error: string): void;
|
|
73
78
|
/**
|
|
74
79
|
* Phase 2 input set, mirroring codex get_phase2_input_selection:
|
package/dist/src/store.js
CHANGED
|
@@ -78,10 +78,11 @@ export class MemoryStore {
|
|
|
78
78
|
}
|
|
79
79
|
claimStage1Jobs(sessions, excludeSession, maxClaimed) {
|
|
80
80
|
const workerId = newId();
|
|
81
|
-
// Cap per-pass claims at codex's max_rollouts_per_startup (max_claimed
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
81
|
+
// Cap per-pass claims at codex's max_rollouts_per_startup (max_claimed,
|
|
82
|
+
// default 2, clamp 1-128). codex also uses max_claimed as the
|
|
83
|
+
// cross-process running-jobs cap; execution concurrency is limited
|
|
84
|
+
// separately (STAGE1_CONCURRENCY, codex buffer_unordered(8)).
|
|
85
|
+
const claimCap = Math.max(1, maxClaimed ?? 2);
|
|
85
86
|
const claimed = [];
|
|
86
87
|
const claimOne = this.db.transaction((s, ownershipToken, lease) => {
|
|
87
88
|
const activeRow = this.db
|
|
@@ -268,22 +269,38 @@ export class MemoryStore {
|
|
|
268
269
|
// codex stores the completion watermark = max source_updated_at consumed;
|
|
269
270
|
// the 6h cooldown is keyed on finished_at, not on this value.
|
|
270
271
|
const watermark = selected.reduce((max, s) => Math.max(max, s.source_updated_at), 0);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
272
|
+
// One transaction for the job row + the selected-input flags (codex
|
|
273
|
+
// mark_global_phase2_job_succeeded does the same): a crash between them
|
|
274
|
+
// must not leave a done job whose retention flags still describe the
|
|
275
|
+
// previous run — pruning could then delete inputs backing the artifacts.
|
|
276
|
+
this.db.transaction(() => {
|
|
277
|
+
const res = this.db
|
|
278
|
+
.prepare(`UPDATE memory_jobs SET status='done', finished_at=?, lease_until=NULL, last_error=NULL, retry_remaining=?,
|
|
279
|
+
last_success_watermark=MAX(COALESCE(last_success_watermark, 0), ?), retry_at=NULL
|
|
280
|
+
WHERE kind='memory_consolidate_global' AND job_key='global' AND ownership_token=? AND status='running'`)
|
|
281
|
+
.run(nowSec(), DEFAULT_RETRY_REMAINING, watermark, ownershipToken);
|
|
282
|
+
if (res.changes === 0)
|
|
283
|
+
return;
|
|
284
|
+
this.db.exec("UPDATE memory_stage1_outputs SET selected_for_phase2 = 0, selected_for_phase2_source_updated_at = NULL");
|
|
285
|
+
const mark = this.db.prepare(`UPDATE memory_stage1_outputs
|
|
286
|
+
SET selected_for_phase2 = 1, selected_for_phase2_source_updated_at = ?
|
|
287
|
+
WHERE session_id = ? AND source_updated_at = ?`);
|
|
288
|
+
for (const s of selected)
|
|
289
|
+
mark.run(s.source_updated_at, s.session_id, s.source_updated_at);
|
|
290
|
+
}).immediate();
|
|
291
|
+
}
|
|
292
|
+
/** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
|
|
293
|
+
phase2LastSuccess() {
|
|
294
|
+
const row = this.db
|
|
295
|
+
.prepare(`SELECT finished_at, last_success_watermark FROM memory_jobs
|
|
296
|
+
WHERE kind='memory_consolidate_global' AND job_key='global'`)
|
|
297
|
+
.get();
|
|
298
|
+
if (!row || !row.last_success_watermark)
|
|
299
|
+
return null;
|
|
300
|
+
return row;
|
|
284
301
|
}
|
|
285
302
|
markPhase2Failed(ownershipToken, error) {
|
|
286
|
-
this.db
|
|
303
|
+
const res = this.db
|
|
287
304
|
.prepare(`UPDATE memory_jobs SET
|
|
288
305
|
status = 'failed',
|
|
289
306
|
retry_remaining = MAX(0, retry_remaining - 1),
|
|
@@ -293,6 +310,21 @@ export class MemoryStore {
|
|
|
293
310
|
lease_until = NULL
|
|
294
311
|
WHERE kind='memory_consolidate_global' AND job_key='global' AND ownership_token=? AND status='running'`)
|
|
295
312
|
.run(error.slice(0, 4000), nowSec() + PHASE2_RETRY_DELAY_SECONDS, nowSec(), ownershipToken);
|
|
313
|
+
if (res.changes > 0)
|
|
314
|
+
return;
|
|
315
|
+
// codex mark_global_phase2_job_failed_if_unowned: if the owned update
|
|
316
|
+
// matched nothing, recover a stuck running row that lost its owner
|
|
317
|
+
// (ownership_token NULL) so it does not linger until lease expiry.
|
|
318
|
+
this.db
|
|
319
|
+
.prepare(`UPDATE memory_jobs SET
|
|
320
|
+
status = 'failed',
|
|
321
|
+
retry_remaining = MAX(0, retry_remaining - 1),
|
|
322
|
+
last_error = ?,
|
|
323
|
+
retry_at = ?,
|
|
324
|
+
finished_at = ?,
|
|
325
|
+
lease_until = NULL
|
|
326
|
+
WHERE kind='memory_consolidate_global' AND job_key='global' AND status='running' AND ownership_token IS NULL`)
|
|
327
|
+
.run(error.slice(0, 4000), nowSec() + PHASE2_RETRY_DELAY_SECONDS, nowSec());
|
|
296
328
|
}
|
|
297
329
|
/**
|
|
298
330
|
* Phase 2 input set, mirroring codex get_phase2_input_selection:
|