opencode-codex-memory 0.4.6 → 0.4.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.
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.6"]
55
+ "plugin": ["opencode-codex-memory@0.4.8"]
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.6", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
242
+ ["opencode-codex-memory@0.4.8", { "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.6", { "codex_interop": { "import": true, "export": true } }]
301
+ ["opencode-codex-memory@0.4.8", { "codex_interop": { "import": true, "export": true } }]
302
302
  ]
303
303
  }
304
304
  ```
@@ -351,6 +351,30 @@ The cost is real: with one store, an unrelated project's details can surface in
351
351
  the summary. Codex judged that cheaper than the alternative, and this port
352
352
  mirrors that decision rather than layering scoping back on top.
353
353
 
354
+ ## Troubleshooting
355
+
356
+ When memory does not seem to build, ask the agent to run **`memory_inspect`**.
357
+ It reports:
358
+
359
+ - stage-1 job counts and recent extraction errors
360
+ - phase-2 status / last error / cooldown
361
+ - last session-discovery outcome
362
+ - effective options (after clamping) and config warnings
363
+ - a short eligibility reminder (`min_rollout_idle_hours`, default **6h**)
364
+
365
+ Common causes:
366
+
367
+ | Symptom | Likely cause |
368
+ |---|---|
369
+ | No stage-1 outputs yet | Sessions must stay idle ≥ `min_rollout_idle_hours` (default 6). For a quick local check, set `"min_rollout_idle_hours": 1`. |
370
+ | Discovery failed | Host API/`experimental/session` unavailable; inspect shows the error. Retry after restarting OpenCode. |
371
+ | Pin stuck on old version | OpenCode freezes bare package specs; pin an explicit version and bump it (see Install). |
372
+ | Consolidation never runs | Check `phase2_status` and `phase2_last_error` in inspect; failed artifacts keep the workspace diff for the next run. |
373
+
374
+ Install target: this package runs **inside OpenCode** (Bun). You do not need to
375
+ install it as a standalone Node app; OpenCode resolves the plugin into its own
376
+ package cache.
377
+
354
378
  ## Contributing
355
379
 
356
380
  The port follows Codex closely: same two-phase pipeline, same on-disk artifacts,
@@ -360,6 +384,8 @@ guidance lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md) and
360
384
  [`AGENTS.md`](./AGENTS.md) — in short: this repo exists to port Codex's memory
361
385
  system to OpenCode, and PRs that break that parity will be rejected.
362
386
 
387
+ CI runs `bun test`, typecheck, build, and the packaging smoke test on every PR.
388
+
363
389
  ## License
364
390
 
365
391
  Apache 2.0 — the same license as [OpenAI Codex](https://github.com/openai/codex),
@@ -3,7 +3,7 @@
3
3
  "agent": {
4
4
  "memorize": {
5
5
  "mode": "subagent",
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.",
6
+ "prompt": "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/ under the memory workspace only. Do not read or edit project source files outside that memory root. Keep memory_summary.md under 10000 chars (2500 tokens). Prune stale entries. Do not access the network.",
7
7
  "permission": {
8
8
  "*": "deny",
9
9
  "bash": "deny",
@@ -4,19 +4,8 @@ export interface SessionRow {
4
4
  updated_at: number;
5
5
  directory: string | null;
6
6
  }
7
- /**
8
- * Global session discovery through the official API: opencode's session.list
9
- * is project-scoped, so enumerate projects (project.list) and list each one
10
- * with scope=project (routes the request to that project's instance AND
11
- * widens the filter from the session directory to the whole project).
12
- * Instance contexts created this way are cached by the host for the process
13
- * lifetime, and the whole pass is rate-limited (30s min interval).
14
- *
15
- * Fail-safe at two levels: a failed project.list skips the pass ([]), a
16
- * failed per-project session.list skips that project — neither claims or
17
- * finalizes any job. Transcript loading must NOT be fail-safe — see
18
- * loadTranscript.
19
- */
7
+ /** Test seam. */
8
+ export declare function resetDiscoveryCacheForTest(): void;
20
9
  export declare function listRecentSessions(limit?: number): Promise<SessionRow[]>;
21
10
  export interface TranscriptMessage {
22
11
  type: string;
@@ -1,5 +1,7 @@
1
1
  import { SCAN_LIMIT } from "./store.js";
2
2
  import { getPluginInput } from "./llm.js";
3
+ import { recordDiscoveryStatus } from "./diagnostics.js";
4
+ import { hostPartType, hostSessionMessages, pluginHttpGet } from "./host-client.js";
3
5
  const API_TIMEOUT_MS = 60_000;
4
6
  async function withTimeout(promise, ms, label) {
5
7
  let timer;
@@ -16,72 +18,72 @@ async function withTimeout(promise, ms, label) {
16
18
  }
17
19
  }
18
20
  /**
19
- * Global session discovery through the official API: opencode's session.list
20
- * is project-scoped, so enumerate projects (project.list) and list each one
21
- * with scope=project (routes the request to that project's instance AND
22
- * widens the filter from the session directory to the whole project).
23
- * Instance contexts created this way are cached by the host for the process
24
- * lifetime, and the whole pass is rate-limited (30s min interval).
25
- *
26
- * Fail-safe at two levels: a failed project.list skips the pass ([]), a
27
- * failed per-project session.list skips that project — neither claims or
28
- * finalizes any job. Transcript loading must NOT be fail-safe — see
29
- * loadTranscript.
21
+ * Global session discovery through the official API:
22
+ * `GET /experimental/session?roots=true` (Session.listGlobal) one call across
23
+ * all projects, sorted by most-recently-updated. Available since opencode
24
+ * 1.17.x. Fail-safe: any error skips the pass ([]); never finalizes a job.
25
+ * Transcript loading must NOT be fail-safe see loadTranscript.
30
26
  */
27
+ // Empty phase-1 passes no longer stamp the process rate-limit timer, so idle/
28
+ // chat.message can re-enter often. Coalesce discovery API calls to avoid
29
+ // hammering the host on every no-claim pass (load only; eligibility still
30
+ // uses a fresh filter over this short-lived snapshot).
31
+ const DISCOVERY_CACHE_MS = 30_000;
32
+ let discoveryCache = null;
33
+ /** Test seam. */
34
+ export function resetDiscoveryCacheForTest() {
35
+ discoveryCache = null;
36
+ }
31
37
  export async function listRecentSessions(limit = SCAN_LIMIT) {
32
- const client = getPluginInput()?.client;
33
- if (!client?.project?.list || !client?.session?.list)
34
- return [];
35
- let projects;
36
- try {
37
- const res = await withTimeout(client.project.list(), API_TIMEOUT_MS, "project.list");
38
- if (!res || res.error || !Array.isArray(res.data))
39
- throw new Error(`project.list failed: ${JSON.stringify(res?.error ?? {})}`);
40
- projects = res.data;
38
+ const now = Date.now();
39
+ if (discoveryCache &&
40
+ discoveryCache.limit >= limit &&
41
+ now - discoveryCache.at < DISCOVERY_CACHE_MS) {
42
+ return discoveryCache.rows.slice(0, limit);
41
43
  }
42
- catch (err) {
43
- console.warn("[opencode-codex-memory] project discovery failed; skipping pass:", err);
44
+ const get = pluginHttpGet(getPluginInput()?.client);
45
+ if (!get) {
46
+ recordDiscoveryStatus({ ok: false, count: 0, error: "plugin HTTP client unavailable" });
44
47
  return [];
45
48
  }
46
- const all = [];
47
- for (const project of projects) {
48
- if (!project?.worktree)
49
- continue;
50
- try {
51
- const res = await withTimeout(client.session.list({
52
- // scope/roots/limit are in the server's ListQuery (accepted since
53
- // opencode 1.14.30, well under our 1.18 floor); the pinned SDK types
54
- // still omit them (SessionListData.query is just { directory } as of
55
- // 1.18.1), hence the cast at the call site.
56
- query: { directory: project.worktree, scope: "project", roots: true, limit },
57
- }), API_TIMEOUT_MS, "session.list");
58
- if (!res || res.error || !Array.isArray(res.data))
59
- throw new Error(JSON.stringify(res?.error ?? {}));
60
- for (const s of res.data) {
61
- // Top-level sessions only: task-tool children are summarized into
62
- // their parent, and the plugin's own sub-sessions must never be
63
- // memorized (roots=true drops children server-side; keep both belts).
64
- if (!s?.id || s.parentID)
65
- continue;
66
- if (s.title && s.title.startsWith("codex-memory-"))
67
- continue;
68
- all.push({ id: s.id, updated_at: s.time?.updated ?? 0, directory: s.directory ?? null });
69
- }
49
+ try {
50
+ const res = await withTimeout(get({
51
+ url: "/experimental/session",
52
+ query: { roots: true, limit },
53
+ }), API_TIMEOUT_MS, "experimental.session.list");
54
+ if (!res || res.error || !Array.isArray(res.data)) {
55
+ throw new Error(`experimental.session.list failed: ${JSON.stringify(res?.error ?? {})}`);
70
56
  }
71
- catch (err) {
72
- console.warn(`[opencode-codex-memory] session.list failed for ${project.worktree}; skipping project:`, err);
57
+ const all = [];
58
+ for (const s of res.data) {
59
+ // Top-level sessions only: task-tool children are summarized into their
60
+ // parent, and the plugin's own sub-sessions must never be memorized
61
+ // (roots=true drops children server-side; keep both belts).
62
+ if (!s?.id || s.parentID)
63
+ continue;
64
+ if (s.title && s.title.startsWith("codex-memory-"))
65
+ continue;
66
+ all.push({ id: s.id, updated_at: s.time?.updated ?? 0, directory: s.directory ?? null });
73
67
  }
68
+ // Server already orders by time_updated DESC; re-sort so a lagging host
69
+ // cannot invert eligibility order.
70
+ all.sort((a, b) => b.updated_at - a.updated_at);
71
+ const out = all.slice(0, limit);
72
+ discoveryCache = { at: now, limit, rows: out };
73
+ recordDiscoveryStatus({ ok: true, count: out.length });
74
+ return out;
75
+ }
76
+ catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ console.warn("[opencode-codex-memory] session discovery failed; skipping pass:", err);
79
+ recordDiscoveryStatus({ ok: false, count: 0, error: message });
80
+ // Do not cache failures — next pass should retry the host.
81
+ return [];
74
82
  }
75
- all.sort((a, b) => b.updated_at - a.updated_at);
76
- return all.slice(0, limit);
77
83
  }
78
84
  /** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
79
85
  async function fetchMessagesViaApi(sessionId) {
80
- const client = getPluginInput()?.client;
81
- if (typeof client?.session?.messages !== "function") {
82
- throw new Error("plugin client unavailable; cannot load transcript");
83
- }
84
- const res = await withTimeout(client.session.messages({ path: { id: sessionId } }), API_TIMEOUT_MS, "session.messages");
86
+ const res = await withTimeout(hostSessionMessages(getPluginInput()?.client, sessionId), API_TIMEOUT_MS, "session.messages");
85
87
  if (!res || res.error || !Array.isArray(res.data)) {
86
88
  throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
87
89
  }
@@ -111,7 +113,7 @@ export async function loadTranscript(sessionId) {
111
113
  const role = row?.info?.role;
112
114
  for (const part of row?.parts ?? []) {
113
115
  out.push({
114
- type: part?.type ?? "unknown",
116
+ type: hostPartType(part),
115
117
  role,
116
118
  text: extractText(part),
117
119
  });
@@ -36,3 +36,11 @@ export declare function syncCodexImport(codexMemoryRoot: string): boolean;
36
36
  * placeholder MEMORY.md / empty summary would just be noise.
37
37
  */
38
38
  export declare function exportToCodexMemory(codexMemoryRoot: string): boolean;
39
+ export interface CodexInteropMtimes {
40
+ importMemoryMd: number | null;
41
+ importSummary: number | null;
42
+ exportMemoryMd: number | null;
43
+ exportSummary: number | null;
44
+ }
45
+ /** Last mtimes of interop resource copies (for memory_inspect). */
46
+ export declare function codexInteropMtimes(codexMemoryRoot: string): CodexInteropMtimes;
@@ -311,3 +311,25 @@ export function exportToCodexMemory(codexMemoryRoot) {
311
311
  return false;
312
312
  return syncExtension(memoryRoot(), codexMemoryRoot, EXPORT_EXTENSION, "opencode", EXPORT_INSTRUCTIONS);
313
313
  }
314
+ function mtimeMs(file) {
315
+ try {
316
+ const st = fs.lstatSync(file);
317
+ if (!st.isFile())
318
+ return null;
319
+ return st.mtimeMs;
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ }
325
+ /** Last mtimes of interop resource copies (for memory_inspect). */
326
+ export function codexInteropMtimes(codexMemoryRoot) {
327
+ const importRes = path.join(memoryRoot(), "extensions", IMPORT_EXTENSION, "resources", "codex");
328
+ const exportRes = path.join(codexMemoryRoot, "extensions", EXPORT_EXTENSION, "resources", "opencode");
329
+ return {
330
+ importMemoryMd: mtimeMs(path.join(importRes, "MEMORY.md")),
331
+ importSummary: mtimeMs(path.join(importRes, "memory_summary.md")),
332
+ exportMemoryMd: mtimeMs(path.join(exportRes, "MEMORY.md")),
333
+ exportSummary: mtimeMs(path.join(exportRes, "memory_summary.md")),
334
+ };
335
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Process-local diagnostics for memory_inspect: a small ring buffer of
3
+ * pipeline events plus the last discovery outcome. Not metrics infrastructure
4
+ * (codex OTEL) — just enough to answer "why isn't memory building?" without
5
+ * digging through TUI-invisible console logs.
6
+ */
7
+ export type DiagnosticLevel = "info" | "warn" | "error";
8
+ export interface DiagnosticEvent {
9
+ at: number;
10
+ level: DiagnosticLevel;
11
+ kind: string;
12
+ message: string;
13
+ }
14
+ export interface DiscoveryStatus {
15
+ at: number;
16
+ ok: boolean;
17
+ count: number;
18
+ error?: string;
19
+ }
20
+ export declare function recordDiagnostic(level: DiagnosticLevel, kind: string, message: string): void;
21
+ export declare function recordDiscoveryStatus(status: Omit<DiscoveryStatus, "at">): void;
22
+ export declare function getRecentDiagnostics(limit?: number): readonly DiagnosticEvent[];
23
+ export declare function getDiscoveryStatus(): DiscoveryStatus | null;
24
+ /** Test seam. */
25
+ export declare function resetDiagnosticsForTest(): void;
26
+ export declare function formatDiagnosticLine(e: DiagnosticEvent): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Process-local diagnostics for memory_inspect: a small ring buffer of
3
+ * pipeline events plus the last discovery outcome. Not metrics infrastructure
4
+ * (codex OTEL) — just enough to answer "why isn't memory building?" without
5
+ * digging through TUI-invisible console logs.
6
+ */
7
+ const MAX_EVENTS = 40;
8
+ const events = [];
9
+ let discovery = null;
10
+ export function recordDiagnostic(level, kind, message) {
11
+ events.push({ at: Date.now(), level, kind, message });
12
+ while (events.length > MAX_EVENTS)
13
+ events.shift();
14
+ }
15
+ export function recordDiscoveryStatus(status) {
16
+ discovery = { ...status, at: Date.now() };
17
+ if (!status.ok) {
18
+ recordDiagnostic("warn", "discovery", status.error ?? "session discovery failed");
19
+ }
20
+ else {
21
+ recordDiagnostic("info", "discovery", `listed ${status.count} session(s)`);
22
+ }
23
+ }
24
+ export function getRecentDiagnostics(limit = 12) {
25
+ return events.slice(-limit);
26
+ }
27
+ export function getDiscoveryStatus() {
28
+ return discovery;
29
+ }
30
+ /** Test seam. */
31
+ export function resetDiagnosticsForTest() {
32
+ events.length = 0;
33
+ discovery = null;
34
+ }
35
+ export function formatDiagnosticLine(e) {
36
+ return `${new Date(e.at).toISOString()} [${e.level}] ${e.kind}: ${e.message}`;
37
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Narrow adapters for host SDK surfaces where the generated OpenAPI types lag
3
+ * the server (see BACKLOG "opencode release ritual"). Keep every intentional
4
+ * cast in this file so grepping for `as any` in pipeline code stays clean and
5
+ * each cast can be dropped when `types.gen.d.ts` catches up.
6
+ *
7
+ * Current gaps (as of @opencode-ai/plugin ~1.18.10):
8
+ * - session.create body: metadata + title typing
9
+ * - session.prompt body: `format` (json_schema) omitted from PromptInput
10
+ * - AssistantMessage.info.structured: structured extraction result
11
+ * - client._client.get: experimental routes (no experimental.* namespace on V1)
12
+ * - mcp.status: present on host client, weakly typed in plugin package
13
+ */
14
+ import type { PluginInput } from "@opencode-ai/plugin";
15
+ export type HostHttpGet = (opts: {
16
+ url: string;
17
+ query?: Record<string, unknown>;
18
+ signal?: AbortSignal;
19
+ }) => Promise<{
20
+ error?: unknown;
21
+ data?: unknown;
22
+ }>;
23
+ /** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
24
+ export declare function pluginHttpGet(client: PluginInput["client"] | null | undefined): HostHttpGet | null;
25
+ export interface SessionCreateBody {
26
+ title?: string;
27
+ metadata?: Record<string, unknown>;
28
+ }
29
+ /** session.create with metadata (SDK body type is incomplete). */
30
+ export declare function hostSessionCreate(client: PluginInput["client"], opts: {
31
+ directory: string;
32
+ body: SessionCreateBody;
33
+ }): Promise<{
34
+ error?: unknown;
35
+ data?: {
36
+ id?: string;
37
+ };
38
+ }>;
39
+ export interface HostPromptBody {
40
+ agent: string;
41
+ system?: string;
42
+ model?: {
43
+ providerID: string;
44
+ modelID: string;
45
+ };
46
+ format?: Record<string, unknown>;
47
+ parts: {
48
+ type: "text";
49
+ text: string;
50
+ }[];
51
+ }
52
+ /** session.prompt including `format` for json_schema structured output. */
53
+ export declare function hostSessionPrompt(client: PluginInput["client"], opts: {
54
+ sessionId: string;
55
+ body: HostPromptBody;
56
+ }): Promise<{
57
+ error?: unknown;
58
+ data?: unknown;
59
+ }>;
60
+ /** Read AssistantMessage.structured when the host captured json_schema output. */
61
+ export declare function hostStructuredOutput(data: unknown): Record<string, unknown> | null;
62
+ export declare function hostMcpStatus(client: PluginInput["client"] | null, signal?: AbortSignal): Promise<{
63
+ error?: unknown;
64
+ data?: unknown;
65
+ } | null>;
66
+ /** session.messages — weakly typed parts; centralize the cast. */
67
+ export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string): Promise<{
68
+ error?: unknown;
69
+ data?: unknown;
70
+ }>;
71
+ export declare function hostPartType(part: unknown): string;
72
+ export declare function hostSessionDeletionConfirmed(client: PluginInput["client"], id: string, timeoutMs: number): Promise<boolean>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Narrow adapters for host SDK surfaces where the generated OpenAPI types lag
3
+ * the server (see BACKLOG "opencode release ritual"). Keep every intentional
4
+ * cast in this file so grepping for `as any` in pipeline code stays clean and
5
+ * each cast can be dropped when `types.gen.d.ts` catches up.
6
+ *
7
+ * Current gaps (as of @opencode-ai/plugin ~1.18.10):
8
+ * - session.create body: metadata + title typing
9
+ * - session.prompt body: `format` (json_schema) omitted from PromptInput
10
+ * - AssistantMessage.info.structured: structured extraction result
11
+ * - client._client.get: experimental routes (no experimental.* namespace on V1)
12
+ * - mcp.status: present on host client, weakly typed in plugin package
13
+ */
14
+ /** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
15
+ export function pluginHttpGet(client) {
16
+ const http = client?._client;
17
+ if (!http || typeof http.get !== "function")
18
+ return null;
19
+ return http.get.bind(http);
20
+ }
21
+ /** session.create with metadata (SDK body type is incomplete). */
22
+ export async function hostSessionCreate(client, opts) {
23
+ return client.session.create({
24
+ query: { directory: opts.directory },
25
+ body: opts.body,
26
+ });
27
+ }
28
+ /** session.prompt including `format` for json_schema structured output. */
29
+ export async function hostSessionPrompt(client, opts) {
30
+ return client.session.prompt({
31
+ path: { id: opts.sessionId },
32
+ body: {
33
+ agent: opts.body.agent,
34
+ ...(opts.body.system ? { system: opts.body.system } : {}),
35
+ ...(opts.body.model ? { model: opts.body.model } : {}),
36
+ ...(opts.body.format ? { format: opts.body.format } : {}),
37
+ parts: opts.body.parts,
38
+ },
39
+ });
40
+ }
41
+ /** Read AssistantMessage.structured when the host captured json_schema output. */
42
+ export function hostStructuredOutput(data) {
43
+ const structured = data?.info?.structured;
44
+ if (structured && typeof structured === "object" && !Array.isArray(structured)) {
45
+ return structured;
46
+ }
47
+ return null;
48
+ }
49
+ export async function hostMcpStatus(client, signal) {
50
+ if (!client)
51
+ return null;
52
+ const mcp = client.mcp;
53
+ if (typeof mcp?.status !== "function")
54
+ return null;
55
+ return (await mcp.status({ signal }));
56
+ }
57
+ /** session.messages — weakly typed parts; centralize the cast. */
58
+ export async function hostSessionMessages(client, sessionId) {
59
+ if (!client || typeof client.session?.messages !== "function") {
60
+ throw new Error("plugin client unavailable; cannot load transcript");
61
+ }
62
+ return client.session.messages({ path: { id: sessionId } });
63
+ }
64
+ export function hostPartType(part) {
65
+ return typeof part?.type === "string" ? part.type : "unknown";
66
+ }
67
+ export async function hostSessionDeletionConfirmed(client, id, timeoutMs) {
68
+ const session = client.session;
69
+ if (typeof session?.get !== "function")
70
+ return false;
71
+ const controller = new AbortController();
72
+ let timer;
73
+ try {
74
+ const res = await Promise.race([
75
+ session.get({ path: { id }, signal: controller.signal }),
76
+ new Promise((_, reject) => {
77
+ timer = setTimeout(() => {
78
+ controller.abort();
79
+ reject(new Error(`session.get timed out after ${timeoutMs}ms`));
80
+ }, timeoutMs);
81
+ }),
82
+ ]);
83
+ return res?.response?.status === 404;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ finally {
89
+ clearTimeout(timer);
90
+ }
91
+ }
package/dist/src/index.js CHANGED
@@ -6,8 +6,11 @@ import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
6
6
  import { MemoryStore } from "./store.js";
7
7
  import { runPhase1 } from "./phase1.js";
8
8
  import { runPhase2 } from "./phase2.js";
9
- import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js";
9
+ import { setPluginInput, cleanupOldSubSessions, isMemorySubSession, abortActiveSubSessions } from "./llm.js";
10
10
  import { pluginOptions, recordConfigWarning, clearConfigWarnings, resetPluginOptions } from "./options.js";
11
+ import { beginPluginShutdown, isPluginShuttingDown, resetPluginLifecycle } from "./lifecycle.js";
12
+ import { hostMcpStatus } from "./host-client.js";
13
+ import { recordDiagnostic } from "./diagnostics.js";
11
14
  import fs from "fs";
12
15
  import path from "path";
13
16
  let phase1InFlight = false;
@@ -87,6 +90,8 @@ schedulePhase2 = () => { if (pluginOptions.generate_memories)
87
90
  export default {
88
91
  id: "opencode-codex-memory",
89
92
  async server(input, opts) {
93
+ // A reload after dispose must be able to run the pipeline again.
94
+ resetPluginLifecycle();
90
95
  setPluginInput(input);
91
96
  pluginClient = input.client;
92
97
  mcpStatusInFlight = null;
@@ -222,7 +227,7 @@ async function mcpToolPrefixes() {
222
227
  let timer;
223
228
  try {
224
229
  const res = await Promise.race([
225
- pluginClient.mcp.status({ signal: controller.signal }),
230
+ hostMcpStatus(pluginClient, controller.signal),
226
231
  new Promise((_, reject) => {
227
232
  timer = setTimeout(() => {
228
233
  controller.abort();
@@ -230,9 +235,9 @@ async function mcpToolPrefixes() {
230
235
  }, MCP_STATUS_TIMEOUT_MS);
231
236
  }),
232
237
  ]);
233
- if (res?.error)
238
+ if (!res || res.error)
234
239
  return null;
235
- const servers = res?.data;
240
+ const servers = res.data;
236
241
  if (!servers || typeof servers !== "object" || Array.isArray(servers))
237
242
  return null;
238
243
  const prefixes = [];
@@ -516,6 +521,15 @@ function buildHooks() {
516
521
  }
517
522
  },
518
523
  async dispose() {
524
+ // Stop new pumps, abort the consolidator helper if it is mid-write, and
525
+ // best-effort abort extract sessions so a reload cannot leave two writers.
526
+ beginPluginShutdown();
527
+ try {
528
+ await abortActiveSubSessions();
529
+ }
530
+ catch (err) {
531
+ console.warn("[opencode-codex-memory] dispose abort of sub-sessions failed:", err);
532
+ }
519
533
  invalidateCache();
520
534
  },
521
535
  };
@@ -557,7 +571,7 @@ function buildHooks() {
557
571
  return { ...base, tool };
558
572
  }
559
573
  async function triggerPhase1(currentSessionId) {
560
- if (phase1InFlight || !pluginOptions.generate_memories)
574
+ if (phase1InFlight || !pluginOptions.generate_memories || isPluginShuttingDown())
561
575
  return;
562
576
  phase1InFlight = true;
563
577
  try {
@@ -572,6 +586,7 @@ async function triggerPhase1(currentSessionId) {
572
586
  }
573
587
  catch (err) {
574
588
  console.error("[opencode-codex-memory] phase1 error:", err);
589
+ recordDiagnostic("error", "phase1", err instanceof Error ? err.message : String(err));
575
590
  }
576
591
  finally {
577
592
  phase1InFlight = false;
@@ -579,17 +594,23 @@ async function triggerPhase1(currentSessionId) {
579
594
  void triggerPhase2();
580
595
  }
581
596
  async function triggerPhase2() {
597
+ if (isPluginShuttingDown())
598
+ return;
582
599
  try {
583
600
  // runPhase2 has its own in-flight guard
584
- await runPhase2(getStore(), {
601
+ const result = await runPhase2(getStore(), {
585
602
  maxRaw: pluginOptions.max_raw_memories_for_consolidation,
586
603
  maxUnusedDays: pluginOptions.max_unused_days,
587
604
  extensionRetentionDays: 7,
588
605
  consolidationModel: pluginOptions.consolidation_model,
589
606
  codexInterop: pluginOptions.codex_interop,
590
607
  });
608
+ if (result.status !== "already_running" && result.status !== "skipped_cooldown" && result.status !== "skipped_running") {
609
+ recordDiagnostic(result.status === "succeeded" || result.status === "no_workspace_changes" ? "info" : "warn", "phase2", result.status);
610
+ }
591
611
  }
592
612
  catch (err) {
593
613
  console.error("[opencode-codex-memory] phase2 error:", err);
614
+ recordDiagnostic("error", "phase2", err instanceof Error ? err.message : String(err));
594
615
  }
595
616
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Plugin process lifecycle: shutdown flag + phase-2 abort signal shared by
3
+ * the entry dispose hook and the write pipeline.
4
+ *
5
+ * Opencode can reload plugins while a consolidator helper still holds write
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).
9
+ */
10
+ export declare function isPluginShuttingDown(): boolean;
11
+ /** Begin shutdown: no new phase work, abort any in-flight consolidator. */
12
+ export declare function beginPluginShutdown(): void;
13
+ /**
14
+ * Test / re-boot seam: a fresh server() call clears the previous dispose.
15
+ * Abort any live consolidator controller before dropping the reference so a
16
+ * glitched boot order cannot orphan a still-running phase-2 prompt.
17
+ */
18
+ export declare function resetPluginLifecycle(): void;
19
+ /**
20
+ * AbortSignal for the current phase-2 consolidation run. Created when the job
21
+ * is claimed; aborted on heartbeat loss, dispose, or run end.
22
+ */
23
+ export declare function beginPhase2AbortScope(): AbortSignal;
24
+ export declare function endPhase2AbortScope(): void;
25
+ /** Abort the current consolidator from outside phase2 (dispose). */
26
+ export declare function abortPhase2Consolidation(): void;