opencode-codex-memory 0.5.0 → 0.6.1

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.5.0"]
55
+ "plugin": ["opencode-codex-memory@0.6.1"]
56
56
  }
57
57
  ```
58
58
 
@@ -191,6 +191,20 @@ them — it's yours. (The `memories/` folder also holds a few working files and
191
191
  an internal `.git/` the plugin uses for change tracking; `memory_reset` wipes
192
192
  those too.)
193
193
 
194
+ ### Backup and restore
195
+
196
+ Back up the whole OpenCode data directory while OpenCode is stopped. The
197
+ SQLite database and `memories/` workspace are a pair: restoring only one can
198
+ leave job state, Git baseline, and memory files out of sync. Include hidden
199
+ files, especially `memories/.git/`, and SQLite sidecars such as `memory.db-wal`
200
+ or `memory.db-shm` when present.
201
+
202
+ The directory is `$XDG_DATA_HOME/opencode` when `XDG_DATA_HOME` is set,
203
+ otherwise `~/.local/share/opencode`. Copy that whole directory to a dated
204
+ backup location. To restore, stop OpenCode, replace the current `opencode/`
205
+ data directory with the backup copy, then start OpenCode again. Do not restore
206
+ while OpenCode is running or copy only `memory.db` or only `memories/`.
207
+
194
208
  ## Privacy & safety
195
209
 
196
210
  - **Local only.** There is no remote storage option to enable, by accident or
@@ -240,7 +254,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
240
254
  ```json
241
255
  {
242
256
  "plugin": [
243
- ["opencode-codex-memory@0.5.0", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
257
+ ["opencode-codex-memory@0.6.1", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
244
258
  ]
245
259
  }
246
260
  ```
@@ -302,7 +316,7 @@ Off by default; no changes to Codex's own config are required.
302
316
  {
303
317
  "plugin": [
304
318
  [
305
- "opencode-codex-memory@0.5.0",
319
+ "opencode-codex-memory@0.6.1",
306
320
  { "codex_interop": { "import": true, "export": true } }
307
321
  ]
308
322
  ]
@@ -359,7 +373,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
359
373
  ```json
360
374
  {
361
375
  "plugin": [
362
- ["opencode-codex-memory@0.5.0", { "claude_import": { "enabled": true } }]
376
+ ["opencode-codex-memory@0.6.1", { "claude_import": { "enabled": true } }]
363
377
  ]
364
378
  }
365
379
  ```
@@ -386,7 +400,7 @@ Claude names each project with an opaque id (a folder under
386
400
  {
387
401
  "plugin": [
388
402
  [
389
- "opencode-codex-memory@0.5.0",
403
+ "opencode-codex-memory@0.6.1",
390
404
  {
391
405
  "claude_import": {
392
406
  "enabled": true,
@@ -471,10 +485,12 @@ mirrors that decision rather than layering scoping back on top.
471
485
  When memory does not seem to build, ask the agent to run **`memory_inspect`**.
472
486
  It reports:
473
487
 
474
- - stage-1 job counts and recent extraction errors
488
+ - stage-1 job counts, failure classes (`backoff` / `provider_capacity` / `other_exhausted`), and recent extraction errors
489
+ - active provider/model quota backoffs and their retry times
475
490
  - phase-2 status / last error / cooldown
476
491
  - last session-discovery outcome
477
492
  - effective options (after clamping) and config warnings
493
+ - effective memory-agent health, including user overrides and required permissions
478
494
  - a short eligibility reminder (`min_rollout_idle_hours`, default **6h**)
479
495
 
480
496
  Common causes:
@@ -485,6 +501,7 @@ Common causes:
485
501
  | Discovery failed | Host API/`experimental/session` unavailable; inspect shows the error. Retry after restarting OpenCode. |
486
502
  | Pin stuck on old version | OpenCode freezes bare package specs; pin an explicit version and bump it (see Install). |
487
503
  | Consolidation never runs | Check `phase2_status` and `phase2_last_error` in inspect; failed artifacts keep the workspace diff for the next run. |
504
+ | `stage1_error` mentions usage/rate limit | Temporary provider quota. Inspect shows `provider_capacity` or `backoff`; those jobs retry after about an hour once quota returns. Permanent holes are `other_exhausted`. |
488
505
 
489
506
  Install target: this package runs **inside OpenCode** (Bun). You do not need to
490
507
  install it as a standalone Node app; OpenCode resolves the plugin into its own
@@ -0,0 +1,21 @@
1
+ declare const AGENT_NAMES: readonly ["memorize", "memorize-extract"];
2
+ type AgentName = (typeof AGENT_NAMES)[number];
3
+ export interface AgentHealthEntry {
4
+ source: "shipped" | "user_override" | "missing";
5
+ healthy: boolean;
6
+ issues: string[];
7
+ }
8
+ export interface AgentHealthSnapshot {
9
+ observed: boolean;
10
+ generationEnabled: boolean | null;
11
+ agents: Record<AgentName, AgentHealthEntry>;
12
+ }
13
+ export declare function loadBundledAgentDefinitions(): Record<string, unknown>;
14
+ /** Record the effective agent config after the plugin config hook runs. */
15
+ export declare function recordAgentConfig(config: {
16
+ agent?: Record<string, unknown>;
17
+ }, generationEnabled: boolean, shipped: Record<string, unknown>): void;
18
+ export declare function getAgentHealth(): AgentHealthSnapshot;
19
+ /** Test seam and boot boundary. */
20
+ export declare function resetAgentHealth(): void;
21
+ export {};
@@ -0,0 +1,133 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { memoryRoot } from "./paths.js";
4
+ const AGENT_NAMES = ["memorize", "memorize-extract"];
5
+ const REQUIRED_ALLOWS = {
6
+ memorize: ["read", "edit", "write", "glob", "grep"],
7
+ "memorize-extract": ["StructuredOutput"],
8
+ };
9
+ const SAFE_ALLOWS = {
10
+ memorize: new Set(["read", "edit", "write", "glob", "grep", "external_directory"]),
11
+ "memorize-extract": new Set(["StructuredOutput"]),
12
+ };
13
+ const initialEntry = () => ({ source: "missing", healthy: false, issues: ["config hook has not run"] });
14
+ let snapshot = {
15
+ observed: false,
16
+ generationEnabled: null,
17
+ agents: {
18
+ memorize: initialEntry(),
19
+ "memorize-extract": initialEntry(),
20
+ },
21
+ };
22
+ export function loadBundledAgentDefinitions() {
23
+ const raw = fs.readFileSync(path.join(import.meta.dirname, "..", "opencode.json"), "utf8");
24
+ return JSON.parse(raw).agent ?? {};
25
+ }
26
+ function asRecord(value) {
27
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
28
+ }
29
+ function hasNonDenyAction(value) {
30
+ const rules = asRecord(value);
31
+ return rules ? Object.values(rules).some((action) => action !== "deny") : value !== "deny";
32
+ }
33
+ /** Structural compare — config reload re-parses shipped defs into new objects. */
34
+ function definitionsEqual(a, b) {
35
+ try {
36
+ return JSON.stringify(a) === JSON.stringify(b);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function permissionIssues(name, definition) {
43
+ const issues = [];
44
+ const record = asRecord(definition);
45
+ if (record?.mode !== "subagent")
46
+ issues.push("agent mode must be 'subagent'");
47
+ const permission = asRecord(record?.permission);
48
+ if (!permission) {
49
+ issues.push("missing permission map");
50
+ return issues;
51
+ }
52
+ const keys = Object.keys(permission);
53
+ if (keys[0] !== "*")
54
+ issues.push("permission wildcard '*' must be the first rule");
55
+ if (permission["*"] !== "deny")
56
+ issues.push("permission wildcard '*' must be 'deny'");
57
+ for (const toolName of REQUIRED_ALLOWS[name]) {
58
+ if (permission[toolName] !== "allow") {
59
+ issues.push(`required permission '${toolName}: allow' is missing`);
60
+ }
61
+ }
62
+ for (const [toolName, value] of Object.entries(permission)) {
63
+ if (toolName === "*")
64
+ continue;
65
+ if (!SAFE_ALLOWS[name].has(toolName)) {
66
+ if (hasNonDenyAction(value))
67
+ issues.push(`unexpected permission '${toolName}' must be denied`);
68
+ }
69
+ }
70
+ if (name === "memorize") {
71
+ const external = asRecord(permission.external_directory);
72
+ const expectedPath = path.join(memoryRoot(), "*");
73
+ if (external?.[expectedPath] !== "allow") {
74
+ issues.push(`consolidator must allow external_directory '${expectedPath}'`);
75
+ }
76
+ if (external) {
77
+ for (const [grantedPath, action] of Object.entries(external)) {
78
+ if (grantedPath === expectedPath)
79
+ continue;
80
+ if (hasNonDenyAction(action)) {
81
+ issues.push(`consolidator must deny extra external_directory '${grantedPath}'`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ else if (permission.external_directory !== undefined) {
87
+ issues.push("extractor must not have external_directory access");
88
+ }
89
+ return issues;
90
+ }
91
+ function inspectAgent(name, definition, source) {
92
+ const issues = permissionIssues(name, definition);
93
+ return { source, healthy: issues.length === 0, issues };
94
+ }
95
+ /** Record the effective agent config after the plugin config hook runs. */
96
+ export function recordAgentConfig(config, generationEnabled, shipped) {
97
+ const configured = asRecord(config.agent);
98
+ const agents = {};
99
+ for (const name of AGENT_NAMES) {
100
+ const definition = configured?.[name];
101
+ const source = definition === undefined
102
+ ? "missing"
103
+ : definitionsEqual(shipped[name], definition)
104
+ ? "shipped"
105
+ : "user_override";
106
+ agents[name] = inspectAgent(name, definition, source);
107
+ if (!generationEnabled && definition === undefined) {
108
+ agents[name] = { source: "missing", healthy: true, issues: ["generation disabled; agent not injected"] };
109
+ }
110
+ }
111
+ snapshot = { observed: true, generationEnabled, agents };
112
+ }
113
+ export function getAgentHealth() {
114
+ return {
115
+ observed: snapshot.observed,
116
+ generationEnabled: snapshot.generationEnabled,
117
+ agents: {
118
+ memorize: { ...snapshot.agents.memorize, issues: [...snapshot.agents.memorize.issues] },
119
+ "memorize-extract": { ...snapshot.agents["memorize-extract"], issues: [...snapshot.agents["memorize-extract"].issues] },
120
+ },
121
+ };
122
+ }
123
+ /** Test seam and boot boundary. */
124
+ export function resetAgentHealth() {
125
+ snapshot = {
126
+ observed: false,
127
+ generationEnabled: null,
128
+ agents: {
129
+ memorize: initialEntry(),
130
+ "memorize-extract": initialEntry(),
131
+ },
132
+ };
133
+ }
@@ -1,22 +1,8 @@
1
1
  import { SCAN_LIMIT } from "./store.js";
2
2
  import { getPluginInput } from "./llm.js";
3
3
  import { recordDiscoveryStatus } from "./diagnostics.js";
4
- import { hostPartType, hostSessionMessages, pluginHttpGet } from "./host-client.js";
4
+ import { hostListSessionsGlobal, hostPartType, hostSessionMessages, pluginHttpGet, withHostTimeout } from "./host-client.js";
5
5
  const API_TIMEOUT_MS = 60_000;
6
- async function withTimeout(promise, ms, label) {
7
- let timer;
8
- try {
9
- return await Promise.race([
10
- promise,
11
- new Promise((_, reject) => {
12
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
13
- }),
14
- ]);
15
- }
16
- finally {
17
- clearTimeout(timer);
18
- }
19
- }
20
6
  /**
21
7
  * Global session discovery through the official API:
22
8
  * `GET /experimental/session?roots=true` (Session.listGlobal) — one call across
@@ -41,22 +27,14 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
41
27
  now - discoveryCache.at < DISCOVERY_CACHE_MS) {
42
28
  return discoveryCache.rows.slice(0, limit);
43
29
  }
44
- const get = pluginHttpGet(getPluginInput()?.client);
45
- if (!get) {
30
+ const client = getPluginInput()?.client;
31
+ if (!pluginHttpGet(client)) {
46
32
  recordDiscoveryStatus({ ok: false, count: 0, error: "plugin HTTP client unavailable" });
47
33
  return [];
48
34
  }
49
35
  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.
56
- const res = await withTimeout(get({
57
- url: "/experimental/session",
58
- query: { roots: true, limit, directory: "" },
59
- }), API_TIMEOUT_MS, "experimental.session.list");
36
+ const controller = new AbortController();
37
+ const res = await withHostTimeout(hostListSessionsGlobal(client, { limit, signal: controller.signal }), API_TIMEOUT_MS, "experimental.session.list", controller);
60
38
  if (!res || res.error || !Array.isArray(res.data)) {
61
39
  throw new Error(`experimental.session.list failed: ${JSON.stringify(res?.error ?? {})}`);
62
40
  }
@@ -89,7 +67,8 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
89
67
  }
90
68
  /** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
91
69
  async function fetchMessagesViaApi(sessionId) {
92
- const res = await withTimeout(hostSessionMessages(getPluginInput()?.client, sessionId), API_TIMEOUT_MS, "session.messages");
70
+ const controller = new AbortController();
71
+ const res = await withHostTimeout(hostSessionMessages(getPluginInput()?.client, sessionId, controller.signal), API_TIMEOUT_MS, "session.messages", controller);
93
72
  if (!res || res.error || !Array.isArray(res.data)) {
94
73
  throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
95
74
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Current gaps (as of @opencode-ai/plugin ~1.18.10):
8
8
  * - session.create body: metadata + title typing
9
- * - session.prompt body: `format` (json_schema) omitted from PromptInput
9
+ * - session.prompt body: `format` (json_schema) and `variant` omitted from PromptInput
10
10
  * - AssistantMessage.info.structured: structured extraction result
11
11
  * - client._client.get: experimental routes (no experimental.* namespace on V1)
12
12
  * - mcp.status: present on host client, weakly typed in plugin package
@@ -22,6 +22,25 @@ export type HostHttpGet = (opts: {
22
22
  }>;
23
23
  /** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
24
24
  export declare function pluginHttpGet(client: PluginInput["client"] | null | undefined): HostHttpGet | null;
25
+ /**
26
+ * Host-wide session list. The SDK injects `directory` from
27
+ * x-opencode-directory on every GET; a truthy value makes the experimental
28
+ * handler filter to that instance's project. Pass `directory:""` so rewrite
29
+ * does not re-inject and the handler sees a falsy value → listGlobal.
30
+ * Used by discovery and helper-session cleanup (memory is global).
31
+ */
32
+ export declare function hostListSessionsGlobal(client: PluginInput["client"] | null | undefined, opts: {
33
+ limit: number;
34
+ cursor?: number;
35
+ search?: string;
36
+ signal?: AbortSignal;
37
+ }): Promise<{
38
+ error?: unknown;
39
+ data?: unknown;
40
+ }>;
41
+ /** Attach a no-op catch so a raced-away promise cannot become unhandled. */
42
+ export declare function ignoreLateRejection(promise: Promise<unknown>): void;
43
+ export declare function withHostTimeout<T>(promise: Promise<T>, ms: number, label: string, abort?: AbortController): Promise<T>;
25
44
  export interface SessionCreateBody {
26
45
  title?: string;
27
46
  metadata?: Record<string, unknown>;
@@ -30,6 +49,7 @@ export interface SessionCreateBody {
30
49
  export declare function hostSessionCreate(client: PluginInput["client"], opts: {
31
50
  directory: string;
32
51
  body: SessionCreateBody;
52
+ signal?: AbortSignal;
33
53
  }): Promise<{
34
54
  error?: unknown;
35
55
  data?: {
@@ -44,6 +64,7 @@ export interface HostPromptBody {
44
64
  modelID: string;
45
65
  };
46
66
  format?: Record<string, unknown>;
67
+ variant?: string;
47
68
  parts: {
48
69
  type: "text";
49
70
  text: string;
@@ -64,9 +85,12 @@ export declare function hostMcpStatus(client: PluginInput["client"] | null, sign
64
85
  data?: unknown;
65
86
  } | null>;
66
87
  /** session.messages — weakly typed parts; centralize the cast. */
67
- export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string): Promise<{
88
+ export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string, signal?: AbortSignal): Promise<{
68
89
  error?: unknown;
69
90
  data?: unknown;
70
91
  }>;
71
92
  export declare function hostPartType(part: unknown): string;
93
+ export type SessionLiveness = "live" | "gone" | "unknown";
94
+ /** Codex re-checks the live threads table; 404 = gone, anything else stays. */
95
+ export declare function hostSessionLiveness(client: PluginInput["client"] | null | undefined, id: string, timeoutMs?: number): Promise<SessionLiveness>;
72
96
  export declare function hostSessionDeletionConfirmed(client: PluginInput["client"], id: string, timeoutMs: number): Promise<boolean>;
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Current gaps (as of @opencode-ai/plugin ~1.18.10):
8
8
  * - session.create body: metadata + title typing
9
- * - session.prompt body: `format` (json_schema) omitted from PromptInput
9
+ * - session.prompt body: `format` (json_schema) and `variant` omitted from PromptInput
10
10
  * - AssistantMessage.info.structured: structured extraction result
11
11
  * - client._client.get: experimental routes (no experimental.* namespace on V1)
12
12
  * - mcp.status: present on host client, weakly typed in plugin package
@@ -18,11 +18,58 @@ export function pluginHttpGet(client) {
18
18
  return null;
19
19
  return http.get.bind(http);
20
20
  }
21
+ /**
22
+ * Host-wide session list. The SDK injects `directory` from
23
+ * x-opencode-directory on every GET; a truthy value makes the experimental
24
+ * handler filter to that instance's project. Pass `directory:""` so rewrite
25
+ * does not re-inject and the handler sees a falsy value → listGlobal.
26
+ * Used by discovery and helper-session cleanup (memory is global).
27
+ */
28
+ export async function hostListSessionsGlobal(client, opts) {
29
+ const get = pluginHttpGet(client);
30
+ if (!get)
31
+ throw new Error("plugin HTTP client unavailable");
32
+ return get({
33
+ url: "/experimental/session",
34
+ query: {
35
+ roots: true,
36
+ limit: opts.limit,
37
+ directory: "",
38
+ ...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
39
+ ...(opts.search !== undefined ? { search: opts.search } : {}),
40
+ },
41
+ signal: opts.signal,
42
+ });
43
+ }
44
+ /** Attach a no-op catch so a raced-away promise cannot become unhandled. */
45
+ export function ignoreLateRejection(promise) {
46
+ void promise.catch(() => { });
47
+ }
48
+ export async function withHostTimeout(promise, ms, label, abort) {
49
+ ignoreLateRejection(promise);
50
+ let timer;
51
+ try {
52
+ return await Promise.race([
53
+ promise,
54
+ new Promise((_, reject) => {
55
+ timer = setTimeout(() => {
56
+ abort?.abort();
57
+ reject(new Error(`${label} timed out after ${ms}ms`));
58
+ }, ms);
59
+ timer.unref?.();
60
+ }),
61
+ ]);
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
21
67
  /** session.create with metadata (SDK body type is incomplete). */
22
68
  export async function hostSessionCreate(client, opts) {
23
69
  return client.session.create({
24
70
  query: { directory: opts.directory },
25
71
  body: opts.body,
72
+ ...(opts.signal ? { signal: opts.signal } : {}),
26
73
  });
27
74
  }
28
75
  /** session.prompt including `format` for json_schema structured output. */
@@ -34,6 +81,7 @@ export async function hostSessionPrompt(client, opts) {
34
81
  ...(opts.body.system ? { system: opts.body.system } : {}),
35
82
  ...(opts.body.model ? { model: opts.body.model } : {}),
36
83
  ...(opts.body.format ? { format: opts.body.format } : {}),
84
+ ...(opts.body.variant ? { variant: opts.body.variant } : {}),
37
85
  parts: opts.body.parts,
38
86
  },
39
87
  });
@@ -55,15 +103,50 @@ export async function hostMcpStatus(client, signal) {
55
103
  return (await mcp.status({ signal }));
56
104
  }
57
105
  /** session.messages — weakly typed parts; centralize the cast. */
58
- export async function hostSessionMessages(client, sessionId) {
106
+ export async function hostSessionMessages(client, sessionId, signal) {
59
107
  if (!client || typeof client.session?.messages !== "function") {
60
108
  throw new Error("plugin client unavailable; cannot load transcript");
61
109
  }
62
- return client.session.messages({ path: { id: sessionId } });
110
+ return client.session.messages({
111
+ path: { id: sessionId },
112
+ ...(signal ? { signal } : {}),
113
+ });
63
114
  }
64
115
  export function hostPartType(part) {
65
116
  return typeof part?.type === "string" ? part.type : "unknown";
66
117
  }
118
+ function isNotFoundStatus(res) {
119
+ if (res?.response?.status === 404)
120
+ return true;
121
+ const err = res?.error;
122
+ if (err && typeof err === "object") {
123
+ const rec = err;
124
+ if (rec.status === 404)
125
+ return true;
126
+ if (typeof rec.name === "string" && rec.name.toLowerCase().includes("notfound"))
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ const SESSION_LIVE_TIMEOUT_MS = 1_000;
132
+ /** Codex re-checks the live threads table; 404 = gone, anything else stays. */
133
+ export async function hostSessionLiveness(client, id, timeoutMs = SESSION_LIVE_TIMEOUT_MS) {
134
+ const session = client?.session;
135
+ if (typeof session?.get !== "function")
136
+ return "unknown";
137
+ const controller = new AbortController();
138
+ try {
139
+ const res = await withHostTimeout(session.get({ path: { id }, signal: controller.signal }), timeoutMs, "session.get", controller);
140
+ if (isNotFoundStatus(res))
141
+ return "gone";
142
+ if (res?.error)
143
+ return "unknown";
144
+ return "live";
145
+ }
146
+ catch {
147
+ return "unknown";
148
+ }
149
+ }
67
150
  export async function hostSessionDeletionConfirmed(client, id, timeoutMs) {
68
151
  const session = client.session;
69
152
  if (typeof session?.get !== "function")
@@ -1,5 +1,7 @@
1
1
  import { MemoryStore } from "./store.js";
2
2
  import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
3
+ /** Test seam: wait for all hook-launched work, including follow-up phase 2. */
4
+ export declare function waitForBackgroundTasks(): Promise<void>;
3
5
  export declare function takeNewCitations(partKey: string, ids: string[]): string[];
4
6
  export declare function markTurnSeen(sessionId: string): boolean;
5
7
  export declare function shouldHandleIdle(sessionId: string, now?: number): boolean;
@@ -58,11 +60,13 @@ declare const _default: {
58
60
  description: string;
59
61
  args: {
60
62
  path: import("zod").ZodDefault<import("zod").ZodString>;
63
+ cursor: import("zod").ZodOptional<import("zod").ZodString>;
61
64
  max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
62
65
  };
63
66
  execute(args: {
64
67
  path: string;
65
68
  max_results: number;
69
+ cursor?: string | undefined;
66
70
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
67
71
  };
68
72
  memory_add_note: {
package/dist/src/index.js CHANGED
@@ -11,10 +11,26 @@ import { pluginOptions, recordConfigWarning, clearConfigWarnings, resetPluginOpt
11
11
  import { beginPluginShutdown, isPluginShuttingDown, resetPluginLifecycle } from "./lifecycle.js";
12
12
  import { hostMcpStatus } from "./host-client.js";
13
13
  import { recordDiagnostic } from "./diagnostics.js";
14
- import fs from "fs";
14
+ import { loadBundledAgentDefinitions, recordAgentConfig, resetAgentHealth } from "./agent-health.js";
15
15
  import path from "path";
16
16
  let phase1InFlight = false;
17
17
  let pluginClient = null;
18
+ const backgroundTasks = new Set();
19
+ function trackBackgroundTask(task) {
20
+ // Hooks must remain non-blocking, but test teardown needs a way to wait until
21
+ // work started by a hook has released its DB handle.
22
+ const tracked = task.catch((err) => {
23
+ console.error("[opencode-codex-memory] background task error:", err);
24
+ });
25
+ backgroundTasks.add(tracked);
26
+ void tracked.then(() => backgroundTasks.delete(tracked));
27
+ }
28
+ /** Test seam: wait for all hook-launched work, including follow-up phase 2. */
29
+ export async function waitForBackgroundTasks() {
30
+ while (backgroundTasks.size > 0) {
31
+ await Promise.all([...backgroundTasks]);
32
+ }
33
+ }
18
34
  // Single-flight guard for mcp.status(); see mcpToolPrefixes below.
19
35
  let mcpStatusInFlight = null;
20
36
  const MCP_STATUS_TIMEOUT_MS = 1_000;
@@ -83,7 +99,7 @@ export function handleSessionDeleted(sessionId, store = getStore(),
83
99
  // enqueued job runs when generation is re-enabled (codex: delete only
84
100
  // enqueues; the pipeline itself is gated elsewhere).
85
101
  schedulePhase2 = () => { if (pluginOptions.generate_memories)
86
- void triggerPhase2(); }) {
102
+ trackBackgroundTask(triggerPhase2()); }) {
87
103
  if (store.deleteSessionMemory(sessionId))
88
104
  schedulePhase2();
89
105
  }
@@ -94,6 +110,7 @@ export default {
94
110
  resetPluginLifecycle();
95
111
  setPluginInput(input);
96
112
  pluginClient = input.client;
113
+ resetAgentHealth();
97
114
  mcpStatusInFlight = null;
98
115
  // Unconditional, like the caches above: a boot WITHOUT options must not
99
116
  // inherit the previous boot's warnings (opencode can host several
@@ -320,8 +337,7 @@ async function classifyExternalContextTool(toolName) {
320
337
  export function injectAgentDefinitions(config) {
321
338
  let defs;
322
339
  try {
323
- const raw = fs.readFileSync(path.join(import.meta.dirname, "..", "opencode.json"), "utf8");
324
- defs = JSON.parse(raw).agent ?? {};
340
+ defs = loadBundledAgentDefinitions();
325
341
  }
326
342
  catch (err) {
327
343
  console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
@@ -339,6 +355,7 @@ export function injectAgentDefinitions(config) {
339
355
  if (!config.agent[name])
340
356
  config.agent[name] = def;
341
357
  }
358
+ recordAgentConfig(config, true, defs);
342
359
  }
343
360
  function buildHooks() {
344
361
  const base = {
@@ -346,8 +363,15 @@ function buildHooks() {
346
363
  try {
347
364
  // The write pipeline is the only consumer of the sub-agents; with
348
365
  // generation off they would just pollute the user's agent list.
349
- if (!pluginOptions.generate_memories)
366
+ if (!pluginOptions.generate_memories) {
367
+ try {
368
+ recordAgentConfig(input, false, loadBundledAgentDefinitions());
369
+ }
370
+ catch (err) {
371
+ console.warn("[opencode-codex-memory] could not inspect bundled agent definitions:", err);
372
+ }
350
373
  return;
374
+ }
351
375
  injectAgentDefinitions(input);
352
376
  }
353
377
  catch (err) {
@@ -450,7 +474,7 @@ function buildHooks() {
450
474
  catch (e) {
451
475
  console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
452
476
  }
453
- void triggerPhase1(sid);
477
+ trackBackgroundTask(triggerPhase1(sid));
454
478
  }
455
479
  catch (err) {
456
480
  console.error("[opencode-codex-memory] chat.message error:", err);
@@ -578,7 +602,7 @@ function buildHooks() {
578
602
  catch (e) {
579
603
  console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
580
604
  }
581
- void triggerPhase1(sid);
605
+ trackBackgroundTask(triggerPhase1(sid));
582
606
  }
583
607
  // Control tools (reset/inspect/mode) are always available. The memory
584
608
  // read/search/list/add-note tools require BOTH use_memories and
@@ -622,7 +646,7 @@ async function triggerPhase1(currentSessionId) {
622
646
  finally {
623
647
  phase1InFlight = false;
624
648
  }
625
- void triggerPhase2();
649
+ trackBackgroundTask(triggerPhase2());
626
650
  }
627
651
  async function triggerPhase2() {
628
652
  if (isPluginShuttingDown())
package/dist/src/llm.d.ts CHANGED
@@ -6,7 +6,15 @@ export interface ExtractionResult {
6
6
  }
7
7
  export declare function setPluginInput(input: PluginInput): void;
8
8
  export declare function getPluginInput(): PluginInput | null;
9
+ /** Test seam. */
10
+ export declare function setSubSessionCreateTimeoutForTest(ms?: number): void;
11
+ /** Test seam. */
12
+ export declare function setConfigGetTimeoutForTest(ms?: number): void;
13
+ /** Test seam. */
14
+ export declare function setStaleDeleteBatchTimeoutForTest(ms?: number): void;
9
15
  export declare function isMemorySubSession(sessionId: string): boolean;
16
+ export declare function resolveExtractionModel(configured?: string): Promise<string | undefined>;
17
+ export declare function resolveConsolidationModel(configured?: string): Promise<string | undefined>;
10
18
  /**
11
19
  * Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
12
20
  * than matching on the message text) is what tells the catch below that the