opencode-codex-memory 0.1.6 → 0.1.8

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.
@@ -5,6 +5,7 @@
5
5
  "mode": "subagent",
6
6
  "prompt": "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/ to reflect the latest memories. Keep memory_summary.md under 10000 chars (2500 tokens). Prune stale entries. Do not access the network.",
7
7
  "permission": {
8
+ "*": "deny",
8
9
  "bash": "deny",
9
10
  "webfetch": "deny",
10
11
  "websearch": "deny",
@@ -21,6 +22,7 @@
21
22
  "mode": "subagent",
22
23
  "prompt": "You are a memory extraction agent. Read the session transcript and extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
23
24
  "permission": {
25
+ "*": "deny",
24
26
  "bash": "deny",
25
27
  "webfetch": "deny",
26
28
  "websearch": "deny",
@@ -1,5 +1,7 @@
1
+ import { MemoryStore } from "./store.js";
1
2
  import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
2
3
  export declare function takeNewCitations(partKey: string, ids: string[]): string[];
4
+ export declare function handleSessionDeleted(sessionId: string, store?: Pick<MemoryStore, "deleteSessionMemory">, schedulePhase2?: () => void): void;
3
5
  declare const _default: {
4
6
  id: string;
5
7
  server(input: PluginInput, opts?: PluginOptions): Promise<{
package/dist/src/index.js CHANGED
@@ -52,6 +52,10 @@ export function takeNewCitations(partKey, ids) {
52
52
  seen.add(id);
53
53
  return fresh;
54
54
  }
55
+ export function handleSessionDeleted(sessionId, store = getStore(), schedulePhase2 = () => { void triggerPhase2(); }) {
56
+ if (store.deleteSessionMemory(sessionId))
57
+ schedulePhase2();
58
+ }
55
59
  export default {
56
60
  id: "opencode-codex-memory",
57
61
  async server(input, opts) {
@@ -267,13 +271,13 @@ function buildHooks() {
267
271
  }
268
272
  if (ev.type === "session.deleted") {
269
273
  // Mirrors codex delete_thread_memory: drop the extracted memory and
270
- // its job when the session is deleted; the file disappears at the
271
- // next phase-2 rebuild and the diff drives forgetting.
274
+ // its job when the session is deleted. If phase 2 had consumed it,
275
+ // enqueue and attempt consolidation so the diff drives forgetting.
272
276
  const props = ev.properties;
273
277
  const sid = props?.info?.id;
274
278
  if (sid) {
275
279
  try {
276
- getStore().deleteSessionMemory(sid);
280
+ handleSessionDeleted(sid);
277
281
  }
278
282
  catch (e) {
279
283
  console.error("[opencode-codex-memory] deleteSessionMemory failed:", e);
package/dist/src/llm.d.ts CHANGED
@@ -15,5 +15,6 @@ export declare function extractViaSubagent(sessionId: string, transcript: string
15
15
  export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
16
16
  export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
17
17
  export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
18
+ export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
18
19
  /** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
19
20
  export declare function parseExtraction(raw: string): ExtractionResult | null;
package/dist/src/llm.js CHANGED
@@ -209,10 +209,46 @@ function buildExtractionInput(sessionId, cwd, transcript) {
209
209
  transcript,
210
210
  });
211
211
  }
212
- function buildConsolidationPrompt(memoryRoot, diffFileName) {
212
+ // codex lib.rs prompt_blocks: rendered into consolidation.md's
213
+ // {{ memory_extensions_* }} placeholders when <memory_root>/extensions exists
214
+ // (prompts.rs build_consolidation_prompt), empty strings otherwise.
215
+ const EXTENSIONS_FOLDER_STRUCTURE = `
216
+ Memory extensions (under {{ memory_extensions_root }}/):
217
+
218
+ - <extension_name>/instructions.md
219
+ - Source-specific guidance for interpreting additional memory signals. If an
220
+ extension folder exists, you must read its instructions.md to determine how to use this memory
221
+ source.
222
+
223
+ If the user has any memory extensions, you MUST read the instructions for each extension to
224
+ determine how to use the memory source. If the workspace diff shows deleted extension resource files,
225
+ remove stale memories derived only from those resources. If it has no extension folders, continue
226
+ with the standard memory inputs only.
227
+ `;
228
+ const EXTENSIONS_PRIMARY_INPUTS = `
229
+ Optional source-specific inputs:
230
+ Under \`{{ memory_extensions_root }}/\`:
231
+
232
+ - \`<extension_name>/instructions.md\`
233
+ - If extension folders exist, read each instructions.md first and follow it when interpreting
234
+ that extension's memory source.
235
+
236
+ If the workspace diff shows deleted memory extension resources, use that extension-specific deletion
237
+ signal to remove stale memories derived only from those resources.
238
+ `;
239
+ export function buildConsolidationPrompt(memoryRoot, diffFileName) {
240
+ const extensionsRoot = path.join(memoryRoot, "extensions");
241
+ let extensionsExist = false;
242
+ try {
243
+ extensionsExist = fs.statSync(extensionsRoot).isDirectory();
244
+ }
245
+ catch { }
246
+ const blockVars = { memory_extensions_root: extensionsRoot };
213
247
  return fillTemplate(readTemplate("consolidation.md"), {
214
248
  memory_root: memoryRoot,
215
249
  phase2_workspace_diff_file: diffFileName,
250
+ memory_extensions_folder_structure: extensionsExist ? fillTemplate(EXTENSIONS_FOLDER_STRUCTURE, blockVars) : "",
251
+ memory_extensions_primary_inputs: extensionsExist ? fillTemplate(EXTENSIONS_PRIMARY_INPUTS, blockVars) : "",
216
252
  });
217
253
  }
218
254
  function readTemplate(name) {
@@ -231,7 +267,8 @@ export function parseExtraction(raw) {
231
267
  if (typeof obj.raw_memory !== "string" || typeof obj.rollout_summary !== "string") {
232
268
  throw new Error("extraction response missing required fields");
233
269
  }
234
- if (!obj.raw_memory.trim() && !obj.rollout_summary.trim()) {
270
+ // codex phase1: either field empty → SucceededNoOutput (not a partial upsert).
271
+ if (!obj.raw_memory.trim() || !obj.rollout_summary.trim()) {
235
272
  return null;
236
273
  }
237
274
  // Guard against the model echoing the format skeleton from the system prompt.
@@ -1,4 +1,4 @@
1
- import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff } from "./workspace.js";
1
+ import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff, validateConsolidationArtifacts, } from "./workspace.js";
2
2
  import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js";
3
3
  import { consolidateViaSubagent } from "./llm.js";
4
4
  import { invalidateCache } from "./source.js";
@@ -37,9 +37,16 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
37
37
  writeRolloutSummaries(outputs);
38
38
  pruneExtensionResources(opts.extensionRetentionDays);
39
39
  const diff = await captureWorkspaceDiff();
40
+ // codex: early succeed only when there are no changes AND artifacts are
41
+ // already valid. Invalid/empty summary (e.g. ensureLayout's empty file)
42
+ // falls through so the consolidator can INIT/repair.
40
43
  if (diff.changes.length === 0) {
41
- store.markPhase2Succeeded(claim.ownershipToken, outputs);
42
- return { status: "no_workspace_changes" };
44
+ const valid = validateConsolidationArtifacts();
45
+ if (valid.ok) {
46
+ store.markPhase2Succeeded(claim.ownershipToken, outputs);
47
+ return { status: "no_workspace_changes" };
48
+ }
49
+ console.warn("[opencode-codex-memory] no workspace changes but artifacts invalid; running consolidator:", valid.reason);
43
50
  }
44
51
  writeWorkspaceDiff(diff);
45
52
  let heartbeatLost = false;
@@ -48,8 +55,10 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
48
55
  heartbeatLost = true;
49
56
  }
50
57
  }, 90_000);
58
+ let agentCompleted = false;
51
59
  try {
52
60
  await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
61
+ agentCompleted = true;
53
62
  }
54
63
  finally {
55
64
  clearInterval(heartbeat);
@@ -64,6 +73,17 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
64
73
  store.markPhase2Failed(claim.ownershipToken, "ownership lost");
65
74
  return { status: "heartbeat_lost" };
66
75
  }
76
+ if (!agentCompleted) {
77
+ store.markPhase2Failed(claim.ownershipToken, "failed_agent");
78
+ return { status: "failed_agent" };
79
+ }
80
+ // codex failed_invalid_artifacts: do not reset baseline on bad output so
81
+ // the next run still sees a diff / can re-INIT.
82
+ const artifacts = validateConsolidationArtifacts();
83
+ if (!artifacts.ok) {
84
+ store.markPhase2Failed(claim.ownershipToken, `failed_invalid_artifacts: ${artifacts.reason}`);
85
+ return { status: "failed_invalid_artifacts" };
86
+ }
67
87
  if (!await resetBaseline()) {
68
88
  store.markPhase2Failed(claim.ownershipToken, "baseline reset failed");
69
89
  return { status: "baseline_reset_failed" };
@@ -56,6 +56,11 @@ export declare class MemoryStore {
56
56
  /** Extraction succeeded but produced nothing worth keeping: finish the job and drop any stale output. */
57
57
  markStage1SucceededNoOutput(sessionId: string, ownershipToken: string, sourceUpdatedAt: number): void;
58
58
  markStage1Failed(sessionId: string, ownershipToken: string, error: string): void;
59
+ /**
60
+ * Enqueues global consolidation after stage-1 state changes. If phase 2 is
61
+ * already running, preserve its lease and advance only the input watermark.
62
+ */
63
+ private enqueueGlobalConsolidation;
59
64
  claimGlobalPhase2Job(): Phase2ClaimResult;
60
65
  heartbeatPhase2Job(ownershipToken: string): boolean;
61
66
  /**
@@ -74,8 +79,11 @@ export declare class MemoryStore {
74
79
  * - ranked by usage, then recency
75
80
  */
76
81
  getPhase2InputSelection(maxRaw: number, maxUnusedDays: number): Stage1Output[];
77
- /** Mirrors codex delete_thread_memory: remove a deleted session's output + job. */
78
- deleteSessionMemory(sessionId: string): void;
82
+ /**
83
+ * Mirrors codex delete_thread_memory: remove a deleted session's output and
84
+ * job, then enqueue forgetting if phase 2 had consumed that output.
85
+ */
86
+ deleteSessionMemory(sessionId: string): boolean;
79
87
  /**
80
88
  * codex clear_memory_data deletes extracted memories and jobs but explicitly
81
89
  * preserves per-session memory modes: a reset must not re-enable sessions
package/dist/src/store.js CHANGED
@@ -146,8 +146,10 @@ export class MemoryStore {
146
146
  .run(nowSec(), out.source_updated_at, sessionId, ownershipToken);
147
147
  // Ownership lost (lease expired, job re-claimed): do not clobber the new
148
148
  // owner's output. Mirrors codex mark_stage1_job_succeeded.
149
- if (res.changes > 0)
149
+ if (res.changes > 0) {
150
150
  this.upsertStage1Output(out);
151
+ this.enqueueGlobalConsolidation(out.source_updated_at);
152
+ }
151
153
  }).immediate();
152
154
  }
153
155
  /** Extraction succeeded but produced nothing worth keeping: finish the job and drop any stale output. */
@@ -158,8 +160,11 @@ export class MemoryStore {
158
160
  last_success_watermark=?, retry_at=NULL
159
161
  WHERE kind='memory_stage1' AND job_key=? AND status='running' AND ownership_token=?`)
160
162
  .run(nowSec(), sourceUpdatedAt, sessionId, ownershipToken);
161
- if (res.changes > 0)
162
- this.db.prepare("DELETE FROM memory_stage1_outputs WHERE session_id = ?").run(sessionId);
163
+ if (res.changes === 0)
164
+ return;
165
+ const deleted = this.db.prepare("DELETE FROM memory_stage1_outputs WHERE session_id = ?").run(sessionId);
166
+ if (deleted.changes > 0)
167
+ this.enqueueGlobalConsolidation(sourceUpdatedAt);
163
168
  }).immediate();
164
169
  }
165
170
  markStage1Failed(sessionId, ownershipToken, error) {
@@ -174,6 +179,32 @@ export class MemoryStore {
174
179
  WHERE kind='memory_stage1' AND job_key=? AND status='running' AND ownership_token=?`)
175
180
  .run(error.slice(0, 4000), nowSec() + STAGE1_RETRY_DELAY_SECONDS, nowSec(), sessionId, ownershipToken);
176
181
  }
182
+ /**
183
+ * Enqueues global consolidation after stage-1 state changes. If phase 2 is
184
+ * already running, preserve its lease and advance only the input watermark.
185
+ */
186
+ enqueueGlobalConsolidation(inputWatermark) {
187
+ this.db
188
+ .prepare(`INSERT INTO memory_jobs
189
+ (kind, job_key, status, retry_remaining, input_watermark, last_success_watermark)
190
+ VALUES ('memory_consolidate_global', 'global', 'pending', ?, ?, 0)
191
+ ON CONFLICT(kind, job_key) DO UPDATE SET
192
+ status = CASE
193
+ WHEN memory_jobs.status = 'running' THEN 'running'
194
+ ELSE 'pending'
195
+ END,
196
+ retry_at = CASE
197
+ WHEN memory_jobs.status = 'running' THEN memory_jobs.retry_at
198
+ ELSE NULL
199
+ END,
200
+ retry_remaining = MAX(memory_jobs.retry_remaining, excluded.retry_remaining),
201
+ input_watermark = CASE
202
+ WHEN excluded.input_watermark > COALESCE(memory_jobs.input_watermark, 0)
203
+ THEN excluded.input_watermark
204
+ ELSE COALESCE(memory_jobs.input_watermark, 0) + 1
205
+ END`)
206
+ .run(DEFAULT_RETRY_REMAINING, inputWatermark);
207
+ }
177
208
  claimGlobalPhase2Job() {
178
209
  const workerId = newId();
179
210
  const ownershipToken = newId();
@@ -287,11 +318,21 @@ export class MemoryStore {
287
318
  LIMIT ?`)
288
319
  .all(cutoff, cutoff, maxRaw);
289
320
  }
290
- /** Mirrors codex delete_thread_memory: remove a deleted session's output + job. */
321
+ /**
322
+ * Mirrors codex delete_thread_memory: remove a deleted session's output and
323
+ * job, then enqueue forgetting if phase 2 had consumed that output.
324
+ */
291
325
  deleteSessionMemory(sessionId) {
292
- this.db.transaction(() => {
293
- this.db.prepare("DELETE FROM memory_stage1_outputs WHERE session_id = ?").run(sessionId);
326
+ return this.db.transaction(() => {
327
+ const existing = this.db
328
+ .prepare("SELECT selected_for_phase2 FROM memory_stage1_outputs WHERE session_id = ?")
329
+ .get(sessionId);
330
+ const deleted = this.db.prepare("DELETE FROM memory_stage1_outputs WHERE session_id = ?").run(sessionId);
294
331
  this.db.prepare("DELETE FROM memory_jobs WHERE kind='memory_stage1' AND job_key = ?").run(sessionId);
332
+ const shouldConsolidate = deleted.changes > 0 && existing !== null && existing.selected_for_phase2 !== 0;
333
+ if (shouldConsolidate)
334
+ this.enqueueGlobalConsolidation(now());
335
+ return shouldConsolidate;
295
336
  }).immediate();
296
337
  }
297
338
  /**