opencode-codex-memory 0.4.9 → 0.4.11

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 CHANGED
@@ -52,7 +52,7 @@ If you want the mental model before the details, jump to
52
52
 
53
53
  ```json
54
54
  {
55
- "plugin": ["opencode-codex-memory@0.4.9"]
55
+ "plugin": ["opencode-codex-memory@0.4.11"]
56
56
  }
57
57
  ```
58
58
 
@@ -239,7 +239,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
239
239
  ```json
240
240
  {
241
241
  "plugin": [
242
- ["opencode-codex-memory@0.4.9", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
242
+ ["opencode-codex-memory@0.4.11", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
243
243
  ]
244
244
  }
245
245
  ```
@@ -298,7 +298,7 @@ directions:
298
298
  ```json
299
299
  {
300
300
  "plugin": [
301
- ["opencode-codex-memory@0.4.9", { "codex_interop": { "import": true, "export": true } }]
301
+ ["opencode-codex-memory@0.4.11", { "codex_interop": { "import": true, "export": true } }]
302
302
  ]
303
303
  }
304
304
  ```
@@ -47,9 +47,15 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
47
47
  return [];
48
48
  }
49
49
  try {
50
+ // Opencode's SDK client injects `directory` from x-opencode-directory on
51
+ // every GET (sdk client rewrite). The experimental session handler treats
52
+ // any present directory query as "filter to this instance's project",
53
+ // which would hide every other project's idle sessions — memory is global.
54
+ // Pass an empty directory so rewrite does not re-inject, and the handler
55
+ // sees a falsy value → listGlobal without a directory filter.
50
56
  const res = await withTimeout(get({
51
57
  url: "/experimental/session",
52
- query: { roots: true, limit },
58
+ query: { roots: true, limit, directory: "" },
53
59
  }), API_TIMEOUT_MS, "experimental.session.list");
54
60
  if (!res || res.error || !Array.isArray(res.data)) {
55
61
  throw new Error(`experimental.session.list failed: ${JSON.stringify(res?.error ?? {})}`);
package/dist/src/index.js CHANGED
@@ -291,14 +291,9 @@ export function injectAgentDefinitions(config) {
291
291
  console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
292
292
  return;
293
293
  }
294
- // opencode gates file tools outside the session's project behind the
295
- // `external_directory` permission, and the memory workspace is global —
296
- // outside every project so the consolidator's reads/writes there always
297
- // trigger that ask. The bundled `"*": "deny"` matches it (permission rules
298
- // are wildcard-on-name, last match wins), which would block consolidation
299
- // entirely. Grant the memory root here rather than in opencode.json: the
300
- // path is homedir/env-dependent (src/paths.ts is its single source of
301
- // truth). Appended last so it out-ranks the wildcard deny.
294
+ // Sub-sessions use directory=memoryRoot (llm.ts), so memory paths are usually
295
+ // in-bounds. Keep an explicit external_directory allow for the memory root as
296
+ // belt-and-suspenders (path is homedir/env-dependent; out-ranks `"*": deny`).
302
297
  const memorize = defs["memorize"];
303
298
  if (memorize?.permission && !("external_directory" in memorize.permission)) {
304
299
  memorize.permission["external_directory"] = { [path.join(memoryRoot(), "*")]: "allow" };
@@ -1,14 +1,20 @@
1
1
  /**
2
- * Plugin process lifecycle: shutdown flag + phase-2 abort signal shared by
3
- * the entry dispose hook and the write pipeline.
2
+ * Plugin process lifecycle: shutdown flag + abort signals shared by the entry
3
+ * dispose hook and the write pipeline.
4
4
  *
5
5
  * Opencode can reload plugins while a consolidator helper still holds write
6
6
  * access to the memory root. dispose() sets the flag (so new pumps stop),
7
- * aborts the in-flight consolidation prompt, and best-effort aborts active
8
- * sub-sessions (llm.ts).
7
+ * aborts pluginShutdownSignal (extract + any other waiters) and the in-flight
8
+ * phase-2 AbortSignal (consolidateViaSubagent), and best-effort session.aborts
9
+ * active sub-sessions (llm.ts abortActiveSubSessions).
10
+ *
11
+ * Phase-2 keeps its own scope so heartbeat loss can cancel the consolidator
12
+ * without marking the whole plugin as shutting down.
9
13
  */
10
14
  export declare function isPluginShuttingDown(): boolean;
11
- /** Begin shutdown: no new phase work, abort any in-flight consolidator. */
15
+ /** Aborted when dispose begins; replaced on resetPluginLifecycle / re-boot. */
16
+ export declare function pluginShutdownSignal(): AbortSignal;
17
+ /** Begin shutdown: no new phase work, abort extract + consolidator waiters. */
12
18
  export declare function beginPluginShutdown(): void;
13
19
  /**
14
20
  * Test / re-boot seam: a fresh server() call clears the previous dispose.
@@ -1,20 +1,32 @@
1
1
  /**
2
- * Plugin process lifecycle: shutdown flag + phase-2 abort signal shared by
3
- * the entry dispose hook and the write pipeline.
2
+ * Plugin process lifecycle: shutdown flag + abort signals shared by the entry
3
+ * dispose hook and the write pipeline.
4
4
  *
5
5
  * Opencode can reload plugins while a consolidator helper still holds write
6
6
  * access to the memory root. dispose() sets the flag (so new pumps stop),
7
- * aborts the in-flight consolidation prompt, and best-effort aborts active
8
- * sub-sessions (llm.ts).
7
+ * aborts pluginShutdownSignal (extract + any other waiters) and the in-flight
8
+ * phase-2 AbortSignal (consolidateViaSubagent), and best-effort session.aborts
9
+ * active sub-sessions (llm.ts abortActiveSubSessions).
10
+ *
11
+ * Phase-2 keeps its own scope so heartbeat loss can cancel the consolidator
12
+ * without marking the whole plugin as shutting down.
9
13
  */
10
14
  let shuttingDown = false;
11
15
  let phase2Abort = null;
16
+ /** Fresh each boot; aborted on dispose. Extract (and others) subscribe here. */
17
+ let shutdownAbort = new AbortController();
12
18
  export function isPluginShuttingDown() {
13
19
  return shuttingDown;
14
20
  }
15
- /** Begin shutdown: no new phase work, abort any in-flight consolidator. */
21
+ /** Aborted when dispose begins; replaced on resetPluginLifecycle / re-boot. */
22
+ export function pluginShutdownSignal() {
23
+ return shutdownAbort.signal;
24
+ }
25
+ /** Begin shutdown: no new phase work, abort extract + consolidator waiters. */
16
26
  export function beginPluginShutdown() {
17
27
  shuttingDown = true;
28
+ if (!shutdownAbort.signal.aborted)
29
+ shutdownAbort.abort();
18
30
  phase2Abort?.abort();
19
31
  }
20
32
  /**
@@ -25,6 +37,7 @@ export function beginPluginShutdown() {
25
37
  export function resetPluginLifecycle() {
26
38
  phase2Abort?.abort();
27
39
  phase2Abort = null;
40
+ shutdownAbort = new AbortController();
28
41
  shuttingDown = false;
29
42
  }
30
43
  /**
package/dist/src/llm.d.ts CHANGED
@@ -32,8 +32,20 @@ export interface ExtractOptions {
32
32
  model?: string;
33
33
  /** Override the default 1h extract timeout (tests / advanced). */
34
34
  timeoutMs?: number;
35
+ /**
36
+ * Cancel in-flight extract. Defaults to pluginShutdownSignal() so dispose
37
+ * unblocks phase1 without waiting on the host to reject session.prompt.
38
+ */
39
+ signal?: AbortSignal;
35
40
  }
36
- /** Returns null when the extractor reported a no-op (nothing worth remembering). */
41
+ /**
42
+ * Returns null when the extractor reported a no-op (nothing worth remembering).
43
+ *
44
+ * Defaults to pluginShutdownSignal so dispose cancels the in-flight prompt
45
+ * race the same way as consolidateViaSubagent (phase-2 scope). dispose still
46
+ * session.aborts the sub-session for host-side cleanup; extractor has no FS
47
+ * write tools (D2) so a lingering server turn cannot dual-write the memory root.
48
+ */
37
49
  export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
38
50
  export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string, signal?: AbortSignal): Promise<void>;
39
51
  export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
package/dist/src/llm.js CHANGED
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { memoryRoot } from "./paths.js";
4
4
  import { hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, } from "./host-client.js";
5
+ import { pluginShutdownSignal } from "./lifecycle.js";
5
6
  let inputRef = null;
6
7
  export function setPluginInput(input) {
7
8
  inputRef = input;
@@ -171,8 +172,13 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
171
172
  const cancellation = new Promise((_, reject) => {
172
173
  if (!opts.signal)
173
174
  return;
175
+ // Abort may fire between the pre-check above and this setup (e.g. dispose
176
+ // during hostSessionPrompt construction). Already-aborted signals do not
177
+ // re-emit; observe current state after attaching the listener.
174
178
  onAbort = () => reject(new SubagentCancelledError());
175
179
  opts.signal.addEventListener("abort", onAbort, { once: true });
180
+ if (opts.signal.aborted)
181
+ onAbort();
176
182
  });
177
183
  const res = await Promise.race([
178
184
  promptPromise,
@@ -243,7 +249,14 @@ const EXTRACTION_SCHEMA = {
243
249
  },
244
250
  required: ["raw_memory", "rollout_summary", "rollout_slug"],
245
251
  };
246
- /** Returns null when the extractor reported a no-op (nothing worth remembering). */
252
+ /**
253
+ * Returns null when the extractor reported a no-op (nothing worth remembering).
254
+ *
255
+ * Defaults to pluginShutdownSignal so dispose cancels the in-flight prompt
256
+ * race the same way as consolidateViaSubagent (phase-2 scope). dispose still
257
+ * session.aborts the sub-session for host-side cleanup; extractor has no FS
258
+ * write tools (D2) so a lingering server turn cannot dual-write the memory root.
259
+ */
247
260
  export async function extractViaSubagent(sessionId, transcript, opts = {}) {
248
261
  const agent = "memorize-extract";
249
262
  const subId = await createSession(agent, `codex-memory-extract-${sessionId}`);
@@ -258,6 +271,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
258
271
  timeoutMs: opts.timeoutMs ?? 3600_000,
259
272
  system: readTemplate("stage_one_system.md"),
260
273
  model,
274
+ signal: opts.signal ?? pluginShutdownSignal(),
261
275
  // opencode enforces json_schema output via a forced StructuredOutput tool
262
276
  // call (toolChoice: required) — which is why memorize-extract must allow
263
277
  // that one otherwise-denied tool.
@@ -2,7 +2,7 @@ import { STAGE1_CONCURRENCY } from "./store.js";
2
2
  import { loadTranscript, selectEligibleSessions } from "./capture.js";
3
3
  import { redact, isMemoryExcludedFragment } from "./redact.js";
4
4
  import { stripCitations } from "./citation.js";
5
- import { extractViaSubagent } from "./llm.js";
5
+ import { extractViaSubagent, SubagentCancelledError } from "./llm.js";
6
6
  import { checkRateLimit, markRateLimitUsed } from "./ratelimit.js";
7
7
  import { isPluginShuttingDown } from "./lifecycle.js";
8
8
  import { recordDiagnostic } from "./diagnostics.js";
@@ -38,7 +38,9 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitC
38
38
  // Empty claim: do not stamp the process timer (codex only metrics
39
39
  // skipped_no_candidates and leaves the next startup free to try again).
40
40
  if (claimed.length === 0) {
41
- recordDiagnostic("info", "phase1", `no claims (eligible=${eligible.length})`);
41
+ // eligible ≠ claimable: already done at this watermark, exhausted retries,
42
+ // or lease held. Surface that so inspect is not "memory stuck".
43
+ recordDiagnostic("info", "phase1", `no claims (eligible=${eligible.length}; already extracted, in backoff, or at claim cap)`);
42
44
  return;
43
45
  }
44
46
  markRateLimitUsed("phase1");
@@ -95,9 +97,9 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitC
95
97
  });
96
98
  }
97
99
  catch (err) {
98
- // Aborted mid-extract on dispose: release for immediate reclaim, do not
99
- // burn a retry or impose the 1h failure backoff.
100
- if (isPluginShuttingDown()) {
100
+ // Aborted mid-extract on dispose (shutdown signal / flag): release for
101
+ // immediate reclaim, do not burn a retry or impose the 1h failure backoff.
102
+ if (err instanceof SubagentCancelledError || isPluginShuttingDown()) {
101
103
  store.releaseStage1OnShutdown(sid, claim.ownershipToken);
102
104
  }
103
105
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",