opencode-codex-memory 0.6.3 → 0.6.5

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
@@ -48,55 +48,33 @@ If you want the mental model before the details, jump to
48
48
 
49
49
  ## Install
50
50
 
51
- 1. Add the plugin to your `~/.config/opencode/opencode.json`:
51
+ **1. Add the plugin** to your `~/.config/opencode/opencode.json`:
52
52
 
53
53
  ```json
54
54
  {
55
- "plugin": ["opencode-codex-memory@0.6.3"]
55
+ "plugin": ["opencode-codex-memory@0.6.5"]
56
56
  }
57
57
  ```
58
58
 
59
- **Pin the version** (here and for any OpenCode plugin). OpenCode installs a
60
- plugin spec once into its package cache and never re-resolves it, so a bare
61
- `"opencode-codex-memory"` is not "always latest" — it freezes at whatever
62
- was latest the first time OpenCode started. With a version tag you decide
63
- which release runs, and bumping the tag installs the new one. Check
64
- [npm](https://www.npmjs.com/package/opencode-codex-memory) for the current
65
- version.
66
-
67
- Since updating is then just editing version numbers, it's worth automating
68
- with a custom command — one that reads your `opencode.json`, compares each
69
- pin against npm, and rewrites the ones you confirm. If you'd rather not
70
- write your own, this repo has one you can copy:
71
- [`docs/commands/update-plugins.md`](./docs/commands/update-plugins.md) →
72
- `~/.config/opencode/command/update-plugins.md`, then run `/update-plugins`.
73
- It's documentation, not part of the package: nothing about it is installed
74
- with the plugin, and it works for every pinned plugin in your config, not
75
- just this one.
76
-
77
- (While developing locally, point it at an absolute path to your checkout
78
- instead of the package name.)
79
-
80
- 2. That's it. The memory workspace is created on first use. Installing the
81
- plugin is the opt-in: background learning and summary injection are active
82
- immediately (Codex ships the same system behind a default-off feature flag
83
- with a consent prompt; a standalone memory plugin *is* the consent).
84
-
85
- Requires OpenCode 1.18 or newer (official release). Git is bundled
86
- (`isomorphic-git`) — no `git` binary or any other external tool needed.
87
-
88
- The two restricted sub-agents that do the background learning (`memorize`,
89
- `memorize-extract`) register themselves automatically while background learning
90
- is enabled. To choose which models
91
- they use, set the `extract_model` / `consolidation_model` plugin options (see
92
- [Configuration](#configuration)) — don't override the agents for that. Defining
93
- an agent with the same name in your own config is only for advanced tweaks
94
- (e.g. permissions); your definition then replaces the shipped one. If you
95
- override `memorize`, keep an `external_directory` allow for
96
- `~/.local/share/opencode/memories/*` (e.g.
97
- `"external_directory": { "$HOME/.local/share/opencode/memories/*": "allow" }`
98
- after the wildcard deny) — the memory folder lives outside your project, and
99
- without that grant OpenCode blocks the consolidator's file access.
59
+ Pin the version OpenCode installs a plugin spec once and never re-resolves
60
+ it, so a bare `"opencode-codex-memory"` is not "always latest". Check
61
+ [npm](https://www.npmjs.com/package/opencode-codex-memory) for the current
62
+ version.
63
+
64
+ **2. That's it.** The memory workspace is created on first use; background
65
+ learning starts immediately.
66
+
67
+ Requires OpenCode 1.18 or newer. Models and other options: see
68
+ [Configuration](#configuration).
69
+
70
+ ### Installation hints
71
+
72
+ To bump pins, copy
73
+ [`docs/commands/update-plugins.md`](./docs/commands/update-plugins.md) to
74
+ `~/.config/opencode/command/update-plugins.md` and run `/update-plugins`.
75
+ This command is not part of the plugin package — nothing about it is
76
+ installed with the plugin. It works for every pinned plugin in your config,
77
+ not just this one.
100
78
 
101
79
  ## Try it
102
80
 
@@ -123,51 +101,20 @@ echo 'I prefer TypeScript strict mode and 2-space indentation.' \
123
101
 
124
102
  ## How it works
125
103
 
126
- You don't need to know any of this to use the plugin. The design is Codex's,
127
- ported as-is, and it does what any memory system has to do: decide what's worth
128
- keeping, write it down so it can be found again, surface the right piece at the
129
- right moment, and forget what stopped being useful.
130
-
131
- Think of it as three jobs: two background writers and one reader. **Nothing here
132
- runs while you're waiting for a reply** an assistant that stops to take notes
133
- mid-answer would be slower and more expensive, so the learning happens after the
134
- fact, on transcripts of conversations that are already over.
135
-
136
- **Phase 1 — read one finished session, write notes about it.** Once a
137
- conversation has been idle long enough that it's clearly done (default 6 h), the
138
- plugin fetches that transcript, strips secrets out of it, and hands it to a
139
- cheap model with one question: *what from this is worth keeping?* The answer
140
- comes back as structured data — a detailed note plus a short recap of the
141
- session — and lands in a local SQLite database. One session in, one record out.
142
- Sessions are independent, so this part is easy to parallelize and to retry when
143
- it fails.
144
-
145
- **Phase 2 — merge all those notes into one memory.** Every few hours at most
146
- (and only one run at a time across all your OpenCode windows), a second pass
147
- takes the most relevant per-session notes and rewrites the actual memory files:
148
- `MEMORY.md` as the full index, `memory_summary.md` as the short version, and
149
- `skills/` for procedures worth repeating. This is where the interesting work
150
- happens — ten similar observations collapse into one rule, contradictions get
151
- resolved, and notes nothing ever used age out. Forgetting is a feature: memory
152
- that only grows is memory that stops being useful.
153
-
154
- The split exists because the two halves have opposite needs. Phase 1 is
155
- per-session and can run many at once; phase 2 touches the single shared memory,
156
- so it has to be serialized. Keeping them apart means one slow or failing session
157
- extraction can't corrupt or block the shared store.
158
-
159
- **The read path — actually remembering.** Every turn, the short summary is
160
- appended to the system prompt (capped at ~2500 tokens, so the cost is small and
161
- predictable). That's the always-on layer. When a task looks related to past
162
- work, the agent goes further and searches the full memory itself with the
163
- `memory_*` tools — the equivalent of "I've seen this before, let me look it up"
164
- rather than carrying everything around all the time.
165
-
166
- **The feedback loop.** When the agent uses a memory, it cites it. The citation
167
- is recorded and then stripped before it reaches your screen, and those usage
168
- counts feed back into phase 2's ranking. Memories that keep proving useful get
169
- kept and sharpened; memories nothing has touched in a month drop out. The system
170
- finds out which of its own notes were worth writing.
104
+ You don't need to know any of this to use the plugin. In short: two background
105
+ writers and one reader. Once a session has been idle long enough (default 6 h),
106
+ a cheap model reviews its transcript and writes a structured note (**phase 1
107
+ extraction**). Every few hours, a stronger model merges those notes into the
108
+ actual memory files — the index, the short summary, reusable skills — resolving
109
+ duplicates and dropping what went stale (**phase 2 consolidation**). On every
110
+ turn, the short summary goes into the system prompt, and the agent searches
111
+ deeper layers itself when a task looks familiar (**read path**). When the agent
112
+ uses a memory it cites it; those citations feed consolidation's ranking, so
113
+ useful memories survive and unused ones age out.
114
+
115
+ For the full architecture learning, remembering, forgetting, and the
116
+ trade-offs behind each see
117
+ [How OpenCode Codex Memory works](./docs/how-ai-memory-works.md).
171
118
 
172
119
  ## Where your data lives
173
120
 
@@ -254,7 +201,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
254
201
  ```json
255
202
  {
256
203
  "plugin": [
257
- ["opencode-codex-memory@0.6.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
204
+ ["opencode-codex-memory@0.6.5", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
258
205
  ]
259
206
  }
260
207
  ```
@@ -288,6 +235,11 @@ on your own `memorize-extract`/`memorize` agent definition, if you overrode
288
235
  one → the provider's default model. Note that the first two pass the model
289
236
  explicitly, so they win over an agent-level `model`.
290
237
 
238
+ Reasoning effort follows Codex (`low` for extraction, `medium` for
239
+ consolidation) when the model lists that name. Otherwise the plugin picks the
240
+ nearest OpenCode variant on `none < minimal < low < medium < high < xhigh < max`
241
+ (ties go higher). No listed efforts → no pin, host default.
242
+
291
243
  > Note: `dedicated_tools` defaults to `true` here (Codex defaults it to `false`).
292
244
  > This is the one intentional default difference — the tools are a core part of a
293
245
  > standalone memory plugin. Everything else matches Codex's defaults.
@@ -316,7 +268,7 @@ Off by default; no changes to Codex's own config are required.
316
268
  {
317
269
  "plugin": [
318
270
  [
319
- "opencode-codex-memory@0.6.3",
271
+ "opencode-codex-memory@0.6.5",
320
272
  { "codex_interop": { "import": true, "export": true } }
321
273
  ]
322
274
  ]
@@ -373,7 +325,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
373
325
  ```json
374
326
  {
375
327
  "plugin": [
376
- ["opencode-codex-memory@0.6.3", { "claude_import": { "enabled": true } }]
328
+ ["opencode-codex-memory@0.6.5", { "claude_import": { "enabled": true } }]
377
329
  ]
378
330
  }
379
331
  ```
@@ -400,7 +352,7 @@ Claude names each project with an opaque id (a folder under
400
352
  {
401
353
  "plugin": [
402
354
  [
403
- "opencode-codex-memory@0.6.3",
355
+ "opencode-codex-memory@0.6.5",
404
356
  {
405
357
  "claude_import": {
406
358
  "enabled": true,
@@ -10,6 +10,7 @@
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
13
+ * - provider.list model.variants omitted from V1 OpenAPI types
13
14
  */
14
15
  import type { PluginInput } from "@opencode-ai/plugin";
15
16
  export type HostHttpGet = (opts: {
@@ -45,6 +46,13 @@ export interface SessionCreateBody {
45
46
  title?: string;
46
47
  metadata?: Record<string, unknown>;
47
48
  }
49
+ /** GET /provider. V1 SDK types omit model.variants; parse the payload loosely. */
50
+ export declare function hostProviderList(client: PluginInput["client"] | null | undefined, opts?: {
51
+ signal?: AbortSignal;
52
+ }): Promise<{
53
+ error?: unknown;
54
+ data?: unknown;
55
+ }>;
48
56
  /** session.create with metadata (SDK body type is incomplete). */
49
57
  export declare function hostSessionCreate(client: PluginInput["client"], opts: {
50
58
  directory: string;
@@ -10,6 +10,7 @@
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
13
+ * - provider.list model.variants omitted from V1 OpenAPI types
13
14
  */
14
15
  /** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
15
16
  export function pluginHttpGet(client) {
@@ -64,6 +65,17 @@ export async function withHostTimeout(promise, ms, label, abort) {
64
65
  clearTimeout(timer);
65
66
  }
66
67
  }
68
+ /** GET /provider. V1 SDK types omit model.variants; parse the payload loosely. */
69
+ export async function hostProviderList(client, opts) {
70
+ const list = client?.provider?.list;
71
+ if (typeof list === "function") {
72
+ return (await list(opts?.signal ? { signal: opts.signal } : undefined));
73
+ }
74
+ const get = pluginHttpGet(client);
75
+ if (!get)
76
+ return { error: { message: "provider.list unavailable" } };
77
+ return get({ url: "/provider", ...(opts?.signal ? { signal: opts.signal } : {}) });
78
+ }
67
79
  /** session.create with metadata (SDK body type is incomplete). */
68
80
  export async function hostSessionCreate(client, opts) {
69
81
  return client.session.create({
package/dist/src/index.js CHANGED
@@ -326,6 +326,10 @@ async function classifyExternalContextTool(toolName) {
326
326
  }
327
327
  return false;
328
328
  }
329
+ const TITLE_GENERATOR_MARKER = "You are a title generator. You output ONLY a thread title.";
330
+ function isTitleGenerationPrompt(system) {
331
+ return system.some((block) => block.includes(TITLE_GENERATOR_MARKER));
332
+ }
329
333
  /**
330
334
  * Registers the memorize / memorize-extract sub-agents through the config
331
335
  * hook so installing the plugin requires no manual agent setup. Definitions
@@ -383,9 +387,13 @@ function buildHooks() {
383
387
  if (!pluginOptions.use_memories)
384
388
  return;
385
389
  // OpenCode also invokes this hook while generating agent definitions,
386
- // without a session. Memory belongs only in real conversation prompts.
390
+ // without a session, and while naming a session (hidden `title` agent,
391
+ // same sessionID; hook has no agent field). Memory belongs only in
392
+ // real conversation prompts.
387
393
  if (!input.sessionID || isMemorySubSession(input.sessionID))
388
394
  return;
395
+ if (isTitleGenerationPrompt(output.system))
396
+ return;
389
397
  ensureMemoryLayout();
390
398
  const memoryPrompt = buildMemorySystemPrompt(pluginOptions.dedicated_tools);
391
399
  if (memoryPrompt) {
package/dist/src/llm.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare function setSubSessionCreateTimeoutForTest(ms?: number): void;
11
11
  /** Test seam. */
12
12
  export declare function setConfigGetTimeoutForTest(ms?: number): void;
13
13
  /** Test seam. */
14
+ export declare function setProviderListTimeoutForTest(ms?: number): void;
15
+ /** Test seam. */
14
16
  export declare function setStaleDeleteBatchTimeoutForTest(ms?: number): void;
15
17
  export declare function isMemorySubSession(sessionId: string): boolean;
16
18
  export declare function resolveExtractionModel(configured?: string): Promise<string | undefined>;
package/dist/src/llm.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { memoryRoot } from "./paths.js";
4
- import { hostListSessionsGlobal, hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, ignoreLateRejection, pluginHttpGet, withHostTimeout, } from "./host-client.js";
4
+ import { hostListSessionsGlobal, hostProviderList, hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, ignoreLateRejection, pluginHttpGet, withHostTimeout, } from "./host-client.js";
5
+ import { catalogVariantKeys, nearestReasoningVariant } from "./reasoning-variant.js";
5
6
  import { isPluginShuttingDown, pluginShutdownSignal } from "./lifecycle.js";
6
7
  import { SCAN_LIMIT } from "./store.js";
7
8
  import { isProviderCapacityError, ProviderCapacityError } from "./ratelimit.js";
@@ -12,6 +13,8 @@ export function setPluginInput(input) {
12
13
  inputGeneration++;
13
14
  configModels = null;
14
15
  configModelsInFlight = null;
16
+ providerCatalog = null;
17
+ providerCatalogInFlight = null;
15
18
  }
16
19
  export function getPluginInput() {
17
20
  return inputRef;
@@ -27,10 +30,12 @@ const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
27
30
  const SUBSESSION_DELETE_TIMEOUT_MS = 10_000;
28
31
  const SUBSESSION_CREATE_TIMEOUT_MS = 10_000;
29
32
  const CONFIG_GET_TIMEOUT_MS = 5_000;
33
+ const PROVIDER_LIST_TIMEOUT_MS = 5_000;
30
34
  const SUBSESSION_DELETE_CONCURRENCY = 8;
31
35
  const SUBSESSION_DELETE_BATCH_TIMEOUT_MS = 30_000;
32
36
  let createTimeoutMs = SUBSESSION_CREATE_TIMEOUT_MS;
33
37
  let configGetTimeoutMs = CONFIG_GET_TIMEOUT_MS;
38
+ let providerListTimeoutMs = PROVIDER_LIST_TIMEOUT_MS;
34
39
  let staleDeleteBatchTimeoutMs = SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
35
40
  /** Test seam. */
36
41
  export function setSubSessionCreateTimeoutForTest(ms) {
@@ -41,6 +46,10 @@ export function setConfigGetTimeoutForTest(ms) {
41
46
  configGetTimeoutMs = ms ?? CONFIG_GET_TIMEOUT_MS;
42
47
  }
43
48
  /** Test seam. */
49
+ export function setProviderListTimeoutForTest(ms) {
50
+ providerListTimeoutMs = ms ?? PROVIDER_LIST_TIMEOUT_MS;
51
+ }
52
+ /** Test seam. */
44
53
  export function setStaleDeleteBatchTimeoutForTest(ms) {
45
54
  staleDeleteBatchTimeoutMs = ms ?? SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
46
55
  }
@@ -142,6 +151,53 @@ function parseModelRef(ref) {
142
151
  return null;
143
152
  return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
144
153
  }
154
+ const EXTRACT_VARIANT = "low";
155
+ const CONSOLIDATE_VARIANT = "medium";
156
+ let providerCatalog = null;
157
+ let providerCatalogInFlight = null;
158
+ async function getProviderCatalog() {
159
+ if (providerCatalog)
160
+ return providerCatalog.data;
161
+ if (providerCatalogInFlight)
162
+ return providerCatalogInFlight;
163
+ const input = getPluginInput();
164
+ if (!input)
165
+ return null;
166
+ const generation = inputGeneration;
167
+ const request = (async () => {
168
+ const controller = new AbortController();
169
+ try {
170
+ const res = await withHostTimeout(hostProviderList(input.client, { signal: controller.signal }), providerListTimeoutMs, "provider.list", controller);
171
+ if (res.error || res.data == null || generation !== inputGeneration)
172
+ return null;
173
+ providerCatalog = { data: res.data };
174
+ return res.data;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ })();
180
+ providerCatalogInFlight = request;
181
+ try {
182
+ return await request;
183
+ }
184
+ finally {
185
+ if (providerCatalogInFlight === request)
186
+ providerCatalogInFlight = null;
187
+ }
188
+ }
189
+ async function resolveReasoningVariant(preferred, modelRef) {
190
+ const parsed = modelRef ? parseModelRef(modelRef) : null;
191
+ if (!parsed)
192
+ return preferred;
193
+ const catalog = await getProviderCatalog();
194
+ if (catalog == null)
195
+ return preferred;
196
+ const keys = catalogVariantKeys(catalog, parsed.providerID, parsed.modelID);
197
+ if (keys === undefined)
198
+ return preferred;
199
+ return nearestReasoningVariant(preferred, keys);
200
+ }
145
201
  /**
146
202
  * Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
147
203
  * than matching on the message text) is what tells the catch below that the
@@ -338,9 +394,9 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
338
394
  // call (toolChoice: required) — which is why memorize-extract must allow
339
395
  // that one otherwise-denied tool.
340
396
  format: { type: "json_schema", schema: EXTRACTION_SCHEMA },
341
- // Codex extraction ReasoningEffort::Low. Host maps variant → reasoningEffort;
342
- // missing variant on the model is a no-op.
343
- variant: "low",
397
+ // Codex extraction ReasoningEffort::Low. Host maps variant → reasoningEffort.
398
+ // If the model has no `low`, pick the nearest listed effort (host-only).
399
+ variant: await resolveReasoningVariant(EXTRACT_VARIANT, model),
344
400
  });
345
401
  // The captured JSON lands on AssistantMessage.structured (schema
346
402
  // v1/session.ts; absent from the generated SDK type — see host-client.ts).
@@ -376,8 +432,8 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model, si
376
432
  model: resolved,
377
433
  timeoutMs: CONSOLIDATION_TIMEOUT_MS,
378
434
  signal,
379
- // Codex consolidation ReasoningEffort::Medium.
380
- variant: "medium",
435
+ // Codex consolidation ReasoningEffort::Medium. Nearest listed effort if missing.
436
+ variant: await resolveReasoningVariant(CONSOLIDATE_VARIANT, resolved),
381
437
  });
382
438
  }
383
439
  catch (err) {
@@ -0,0 +1,5 @@
1
+ export declare const REASONING_VARIANT_LADDER: readonly ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
2
+ export type ReasoningVariant = (typeof REASONING_VARIANT_LADDER)[number];
3
+ export declare function nearestReasoningVariant(preferred: string, available: Iterable<string>): string | undefined;
4
+ export declare function enabledVariantKeys(variants: unknown): string[];
5
+ export declare function catalogVariantKeys(data: unknown, providerID: string, modelID: string): string[] | undefined;
@@ -0,0 +1,53 @@
1
+ export const REASONING_VARIANT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
2
+ export function nearestReasoningVariant(preferred, available) {
3
+ const have = new Set(available);
4
+ if (have.has(preferred))
5
+ return preferred;
6
+ const prefIdx = REASONING_VARIANT_LADDER.indexOf(preferred);
7
+ if (prefIdx < 0)
8
+ return undefined;
9
+ let best;
10
+ let bestDist = Infinity;
11
+ let bestIdx = -1;
12
+ for (let idx = 0; idx < REASONING_VARIANT_LADDER.length; idx++) {
13
+ const name = REASONING_VARIANT_LADDER[idx];
14
+ if (!have.has(name))
15
+ continue;
16
+ const dist = Math.abs(idx - prefIdx);
17
+ if (dist < bestDist || (dist === bestDist && idx > bestIdx)) {
18
+ best = name;
19
+ bestDist = dist;
20
+ bestIdx = idx;
21
+ }
22
+ }
23
+ return best;
24
+ }
25
+ export function enabledVariantKeys(variants) {
26
+ if (!variants || typeof variants !== "object" || Array.isArray(variants))
27
+ return [];
28
+ const out = [];
29
+ for (const [name, value] of Object.entries(variants)) {
30
+ if (value && typeof value === "object" && !Array.isArray(value) && value.disabled === true) {
31
+ continue;
32
+ }
33
+ out.push(name);
34
+ }
35
+ return out;
36
+ }
37
+ export function catalogVariantKeys(data, providerID, modelID) {
38
+ if (!data || typeof data !== "object")
39
+ return undefined;
40
+ const root = data;
41
+ const all = Array.isArray(root.all) ? root.all : Array.isArray(root.providers) ? root.providers : undefined;
42
+ if (!all)
43
+ return undefined;
44
+ const provider = all.find((item) => item && typeof item === "object" && item.id === providerID);
45
+ if (!provider?.models || typeof provider.models !== "object")
46
+ return undefined;
47
+ const model = provider.models[modelID];
48
+ if (!model || typeof model !== "object" || Array.isArray(model))
49
+ return undefined;
50
+ if (!Object.prototype.hasOwnProperty.call(model, "variants"))
51
+ return undefined;
52
+ return enabledVariantKeys(model.variants);
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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",