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/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 arrive via message.part.updated once per streaming delta,
35
- // so the same completed block is seen many times. Track which session ids
36
- // were already recorded per part to count each citation once.
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
- export function handleSessionDeleted(sessionId, store = getStore(), schedulePhase2 = () => { void triggerPhase2(); }) {
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
- const sid = props?.sessionID;
291
- if (!sid || isMemorySubSession(sid))
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
- timeoutMs: 180_000,
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
  });
@@ -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;
@@ -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
- export function safeResolveMemoryPath(rel) {
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
  }
@@ -1,4 +1,3 @@
1
1
  export declare function memoryRoot(): string;
2
2
  export declare function memoryDbPath(): string;
3
- export declare function opencodeDbPath(): string;
4
3
  export declare function memorySummaryPath(): string;
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
  }
@@ -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>;
@@ -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
- const TRANSCRIPT_HEAD_CHARS = 360_000;
20
- const TRANSCRIPT_TAIL_CHARS = 240_000;
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())
@@ -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
  }>;
@@ -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
- if (!store.heartbeatPhase2Job(claim.ownershipToken)) {
55
- heartbeatLost = true;
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;
@@ -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
- { re: /(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token)\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
14
- { re: /(?:aws_secret_access_key|aws_access_key_id)\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
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;
@@ -1,3 +1,3 @@
1
1
  export declare function invalidateCache(): void;
2
- export declare function buildMemorySystemPrompt(): string | null;
2
+ export declare function buildMemorySystemPrompt(dedicatedTools: boolean): string | null;
3
3
  export declare function ensureMemoryLayout(): void;
@@ -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
  });
@@ -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) when
82
- // provided, never exceeding the hard concurrency ceiling. codex also uses
83
- // max_claimed as the cross-process running-jobs cap.
84
- const claimCap = Math.max(1, Math.min(maxClaimed ?? STAGE1_CONCURRENCY, STAGE1_CONCURRENCY));
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
- const res = this.db
272
- .prepare(`UPDATE memory_jobs SET status='done', finished_at=?, lease_until=NULL, last_error=NULL, retry_remaining=?,
273
- last_success_watermark=MAX(COALESCE(last_success_watermark, 0), ?), retry_at=NULL
274
- WHERE kind='memory_consolidate_global' AND job_key='global' AND ownership_token=? AND status='running'`)
275
- .run(nowSec(), DEFAULT_RETRY_REMAINING, watermark, ownershipToken);
276
- if (res.changes === 0)
277
- return;
278
- this.db.exec("UPDATE memory_stage1_outputs SET selected_for_phase2 = 0, selected_for_phase2_source_updated_at = NULL");
279
- const mark = this.db.prepare(`UPDATE memory_stage1_outputs
280
- SET selected_for_phase2 = 1, selected_for_phase2_source_updated_at = ?
281
- WHERE session_id = ? AND source_updated_at = ?`);
282
- for (const s of selected)
283
- mark.run(s.source_updated_at, s.session_id, s.source_updated_at);
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: