opencode-codex-memory 0.7.3 → 0.7.4

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 — learning, remembering, forgetting — see
52
52
 
53
53
  ```json
54
54
  {
55
- "plugin": ["opencode-codex-memory@0.7.3"]
55
+ "plugin": ["opencode-codex-memory@0.7.4"]
56
56
  }
57
57
  ```
58
58
 
@@ -72,7 +72,7 @@ V2 plugin syntax:
72
72
 
73
73
  ```jsonc
74
74
  {
75
- "plugins": [{ "package": "opencode-codex-memory@0.7.3" }],
75
+ "plugins": [{ "package": "opencode-codex-memory@0.7.4" }],
76
76
  }
77
77
  ```
78
78
 
@@ -198,7 +198,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
198
198
  ```json
199
199
  {
200
200
  "plugin": [
201
- ["opencode-codex-memory@0.7.3", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
201
+ ["opencode-codex-memory@0.7.4", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
202
202
  ]
203
203
  }
204
204
  ```
@@ -265,7 +265,7 @@ Off by default; no changes to Codex's own config are required.
265
265
  {
266
266
  "plugin": [
267
267
  [
268
- "opencode-codex-memory@0.7.3",
268
+ "opencode-codex-memory@0.7.4",
269
269
  { "codex_interop": { "import": true, "export": true } }
270
270
  ]
271
271
  ]
@@ -322,7 +322,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
322
322
  ```json
323
323
  {
324
324
  "plugin": [
325
- ["opencode-codex-memory@0.7.3", { "claude_import": { "enabled": true } }]
325
+ ["opencode-codex-memory@0.7.4", { "claude_import": { "enabled": true } }]
326
326
  ]
327
327
  }
328
328
  ```
@@ -349,7 +349,7 @@ Claude names each project with an opaque id (a folder under
349
349
  {
350
350
  "plugin": [
351
351
  [
352
- "opencode-codex-memory@0.7.3",
352
+ "opencode-codex-memory@0.7.4",
353
353
  {
354
354
  "claude_import": {
355
355
  "enabled": true,
@@ -30,7 +30,7 @@ import { hostMcpStatus } from "../host-client.js";
30
30
  import { recordDiagnostic } from "../diagnostics.js";
31
31
  import { resetAgentHealth } from "../agent-health.js";
32
32
  import { applyPluginOptions, handleSessionDeleted } from "../index.js";
33
- import { setV2Context, buildV1ClientShim, } from "./shim.js";
33
+ import { setV2Context, buildV1ClientShim, rememberV2Session, } from "./shim.js";
34
34
  import { ensureV2Agents } from "./agents.js";
35
35
  import { buildV2Tools } from "./tools.js";
36
36
  import { MemoryStatusRpc } from "./status-rpc.js";
@@ -178,7 +178,8 @@ async function classifyExternalContextTool(toolName) {
178
178
  }
179
179
  return false;
180
180
  }
181
- function stampAndPump(sid) {
181
+ function stampAndPump(sid, directory) {
182
+ rememberV2Session(sid, directory ?? null);
182
183
  try {
183
184
  getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled");
184
185
  }
@@ -336,7 +337,7 @@ export async function setup(ctx) {
336
337
  return;
337
338
  if (!markV2TurnSeen(sid))
338
339
  return;
339
- stampAndPump(sid);
340
+ stampAndPump(sid, ctx.location?.directory);
340
341
  }
341
342
  catch (err) {
342
343
  console.error("[opencode-codex-memory] v2 prompt hook error:", err);
@@ -436,6 +437,7 @@ export async function setup(ctx) {
436
437
  if (e.type === "session.execution.succeeded" || e.type === "session.execution.ended") {
437
438
  const sid = sessionIdFromV2Event(data);
438
439
  if (sid && !isMemorySubSession(sid)) {
440
+ rememberV2Session(sid, ctx.location?.directory);
439
441
  trackBackgroundTask(triggerPhase1(sid));
440
442
  }
441
443
  }
@@ -64,10 +64,14 @@ export declare function serviceHeaders(endpoint: V2ServiceEndpoint): Record<stri
64
64
  export declare function parseReadyStatus(body: unknown): V2ServiceStatus | null;
65
65
  export declare function readRegisteredEndpoint(file?: string): Promise<V2ServiceEndpoint | undefined>;
66
66
  export declare function fetchServiceStatus(endpoint: V2ServiceEndpoint, headers: Record<string, string> | undefined, signal?: AbortSignal): Promise<V2ServiceStatus>;
67
+ /** Local loopback only — IDE `serve --port 0` shares opencode.db with `--service`. */
68
+ export declare function isLoopbackEndpointUrl(url: string): boolean;
67
69
  /**
68
70
  * Find a ready, registered OpenCode service without starting or replacing one.
69
- * A missing service is a normal unavailable result; a PID mismatch is a
70
- * safety failure because it would make global memory operate on another host.
71
+ * A missing service is a normal unavailable result. A PID mismatch on a
72
+ * non-loopback URL is a safety failure (would operate on another host).
73
+ * Loopback PID mismatch is the IDE isolated-serve case: same machine, shared
74
+ * session database, so global list/get/messages still go through that service.
71
75
  */
72
76
  export declare function discoverOwnService(dependencies?: V2ServiceDependencies, timeoutMs?: number): Promise<{
73
77
  endpoint: V2ServiceEndpoint;
@@ -154,10 +154,22 @@ async function probeEndpoint(deps, endpoint, client, signal) {
154
154
  }
155
155
  return fetchServiceStatus(endpoint, deps.service.headers(endpoint), signal);
156
156
  }
157
+ /** Local loopback only — IDE `serve --port 0` shares opencode.db with `--service`. */
158
+ export function isLoopbackEndpointUrl(url) {
159
+ try {
160
+ const hostname = new URL(url).hostname;
161
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
157
167
  /**
158
168
  * Find a ready, registered OpenCode service without starting or replacing one.
159
- * A missing service is a normal unavailable result; a PID mismatch is a
160
- * safety failure because it would make global memory operate on another host.
169
+ * A missing service is a normal unavailable result. A PID mismatch on a
170
+ * non-loopback URL is a safety failure (would operate on another host).
171
+ * Loopback PID mismatch is the IDE isolated-serve case: same machine, shared
172
+ * session database, so global list/get/messages still go through that service.
161
173
  */
162
174
  export async function discoverOwnService(dependencies, timeoutMs = SERVICE_REQUEST_TIMEOUT_MS) {
163
175
  const deps = dependencies ?? testDependencies ?? productionDependencies();
@@ -168,7 +180,10 @@ export async function discoverOwnService(dependencies, timeoutMs = SERVICE_REQUE
168
180
  const controller = new AbortController();
169
181
  const health = await withServiceTimeout(probeEndpoint(deps, endpoint, client, controller.signal), timeoutMs, controller);
170
182
  if (health.pid !== process.pid) {
171
- throw new Error(`registered OpenCode service PID ${String(health.pid)} does not match plugin host PID ${process.pid}`);
183
+ if (!isLoopbackEndpointUrl(endpoint.url)) {
184
+ throw new Error(`registered OpenCode service PID ${String(health.pid)} does not match plugin host PID ${process.pid}`);
185
+ }
186
+ console.warn(`[opencode-codex-memory] registered OpenCode service PID ${String(health.pid)} is a different local process than plugin host PID ${process.pid}; using it for global session list`);
172
187
  }
173
188
  return { endpoint, client, health };
174
189
  }
@@ -5,8 +5,11 @@
5
5
  * run byte-identical) by presenting a V1-shaped client façade backed by the
6
6
  * V2 plugin context. Only genuinely missing V2 surfaces are adapted:
7
7
  *
8
- * - session list/discovery → the authenticated public service client
9
- * discovered through the registered local service.
8
+ * - session list/discovery → ctx.session.list when the host exposes it,
9
+ * else the authenticated public client for THIS process's registered
10
+ * service. A PID mismatch (IDE `serve --port 0` vs `serve --service`)
11
+ * does not list another host; it falls back to sessions this process
12
+ * has observed.
10
13
  * - session.prompt agent/system/model/format/variant → V2 create-time
11
14
  * agent/model (via switchAgent/switchModel) + generate.text for the
12
15
  * json_schema extraction path (V2 prompts carry text only).
@@ -27,6 +30,13 @@ export declare function setV2Context(ctx: V2Context | null): void;
27
30
  export declare function isReleasedSubSession(id: string): boolean;
28
31
  /** Stable synthetic id for extraction helpers (see create below). */
29
32
  export declare const EXTRACT_STUB_SESSION_ID = "codex-memory-extract-stub";
33
+ /**
34
+ * Isolated OpenCode 2 serves (IntelliJ/desktop `serve --port 0`) are not the
35
+ * registered `--service` process, so global session.list is unavailable.
36
+ * Remember sessions this process has actually seen so phase 1 can still
37
+ * extract them.
38
+ */
39
+ export declare function rememberV2Session(id: string, directory?: string | null, title?: string): void;
30
40
  /** Test seam. */
31
41
  export declare function resetV2ShimStateForTest(): void;
32
42
  /**
@@ -59,9 +59,58 @@ export function isReleasedSubSession(id) {
59
59
  }
60
60
  /** Stable synthetic id for extraction helpers (see create below). */
61
61
  export const EXTRACT_STUB_SESSION_ID = "codex-memory-extract-stub";
62
+ const OBSERVED_CAP = 5000;
63
+ const observedSessions = new Map();
64
+ /**
65
+ * Isolated OpenCode 2 serves (IntelliJ/desktop `serve --port 0`) are not the
66
+ * registered `--service` process, so global session.list is unavailable.
67
+ * Remember sessions this process has actually seen so phase 1 can still
68
+ * extract them.
69
+ */
70
+ export function rememberV2Session(id, directory, title) {
71
+ if (!id || id === EXTRACT_STUB_SESSION_ID)
72
+ return;
73
+ if (releasedSubSessions.has(id))
74
+ return;
75
+ observedSessions.delete(id);
76
+ observedSessions.set(id, {
77
+ updated_at: Date.now(),
78
+ directory: directory ?? null,
79
+ title: typeof title === "string" ? title : "",
80
+ });
81
+ while (observedSessions.size > OBSERVED_CAP) {
82
+ const oldest = observedSessions.keys().next().value;
83
+ if (oldest === undefined)
84
+ break;
85
+ observedSessions.delete(oldest);
86
+ }
87
+ }
88
+ function listObservedSessions(limit, cursor, search) {
89
+ const timestampCursor = typeof cursor === "number" ? cursor : undefined;
90
+ const needle = typeof search === "string" && search.length > 0 ? search.toLowerCase() : undefined;
91
+ const rows = [...observedSessions.entries()].sort((a, b) => b[1].updated_at - a[1].updated_at);
92
+ const out = [];
93
+ for (const [id, rec] of rows) {
94
+ if (timestampCursor !== undefined && rec.updated_at >= timestampCursor)
95
+ continue;
96
+ if (needle && !`${id}\n${rec.title}`.toLowerCase().includes(needle))
97
+ continue;
98
+ out.push({
99
+ id,
100
+ parentID: null,
101
+ ...(rec.title ? { title: rec.title } : {}),
102
+ directory: rec.directory,
103
+ time: { updated: rec.updated_at },
104
+ });
105
+ if (out.length >= limit)
106
+ break;
107
+ }
108
+ return out;
109
+ }
62
110
  /** Test seam. */
63
111
  export function resetV2ShimStateForTest() {
64
112
  releasedSubSessions.clear();
113
+ observedSessions.clear();
65
114
  invalidateOwnService();
66
115
  }
67
116
  // ---------------------------------------------------------------------------
@@ -367,10 +416,13 @@ export function buildV1ClientShim() {
367
416
  if (typeof localList === "function") {
368
417
  return paginateSessionList((input) => localList(input), limit, cursor, search);
369
418
  }
370
- const client = await serviceOrThrow();
371
- if (typeof client.session.list !== "function")
419
+ const client = await ownServiceClient();
420
+ if (client && typeof client.session.list === "function") {
421
+ return paginateSessionList((input) => client.session.list(input), limit, cursor, search);
422
+ }
423
+ if (client)
372
424
  throw new Error("registered service does not support session.list");
373
- return paginateSessionList((input) => client.session.list(input), limit, cursor, search);
425
+ return { data: listObservedSessions(limit, cursor, search) };
374
426
  }
375
427
  const session = {
376
428
  create: async (opts) => {
@@ -408,25 +460,30 @@ export function buildV1ClientShim() {
408
460
  try {
409
461
  if (isReleasedSubSession(opts.path.id))
410
462
  throw Object.assign(new Error("SessionNotFound"), { _tag: "SessionNotFoundError" });
411
- const client = await serviceOrThrow();
412
- if (typeof client.message?.list !== "function")
413
- throw new Error("registered service does not support message.list");
414
- const messages = [];
415
- const seenCursors = new Set();
416
- let cursor;
417
- while (true) {
418
- const response = await client.message.list(cursor ? { sessionID: opts.path.id, cursor } : { sessionID: opts.path.id, order: "asc" });
419
- const rows = responseRows(response);
420
- if (!rows)
421
- throw new Error("registered service returned an invalid message list");
422
- messages.push(...rows);
423
- const next = responseNextCursor(response);
424
- if (!next || seenCursors.has(next))
425
- break;
426
- seenCursors.add(next);
427
- cursor = next;
463
+ const client = await ownServiceClient();
464
+ if (typeof client?.message?.list === "function") {
465
+ const messages = [];
466
+ const seenCursors = new Set();
467
+ let cursor;
468
+ while (true) {
469
+ const response = await client.message.list(cursor ? { sessionID: opts.path.id, cursor } : { sessionID: opts.path.id, order: "asc" });
470
+ const rows = responseRows(response);
471
+ if (!rows)
472
+ throw new Error("registered service returned an invalid message list");
473
+ messages.push(...rows);
474
+ const next = responseNextCursor(response);
475
+ if (!next || seenCursors.has(next))
476
+ break;
477
+ seenCursors.add(next);
478
+ cursor = next;
479
+ }
480
+ return { data: adaptV2Messages(messages) };
428
481
  }
429
- return { data: adaptV2Messages(messages) };
482
+ const raw = await ctx().session.context({
483
+ sessionID: opts.path.id,
484
+ });
485
+ const rows = Array.isArray(raw) ? raw : responseRows(raw) ?? [];
486
+ return { data: adaptV2Messages(rows) };
430
487
  }
431
488
  catch (e) {
432
489
  return { error: e };
@@ -463,18 +520,23 @@ export function buildV1ClientShim() {
463
520
  }
464
521
  try {
465
522
  const client = await ownServiceClient();
466
- if (!client?.session?.get)
467
- return { error: shutdownError ?? new Error("session still exists after interrupt") };
468
- const info = await client.session.get({ sessionID: opts.path.id });
469
- if (info?.error && isNotFoundError(info.error)) {
470
- markReleased(opts.path.id);
471
- return {};
472
- }
473
- const data = und(info);
474
- if (data && typeof data === "object") {
523
+ if (client?.session?.get) {
524
+ const info = await client.session.get({ sessionID: opts.path.id });
525
+ if (info?.error && isNotFoundError(info.error)) {
526
+ markReleased(opts.path.id);
527
+ return {};
528
+ }
529
+ const data = und(info);
530
+ if (data && typeof data === "object") {
531
+ return { error: shutdownError ?? new Error("session still exists after interrupt") };
532
+ }
475
533
  return { error: shutdownError ?? new Error("session still exists after interrupt") };
476
534
  }
477
- return { error: shutdownError ?? new Error("session still exists after interrupt") };
535
+ // Isolated serve: no session.remove on plugin ctx. interrupt+wait already
536
+ // finished, so the helper is idle. Holding the phase-2 lease until it
537
+ // expires would block consolidation for an hour.
538
+ markReleased(opts.path.id);
539
+ return {};
478
540
  }
479
541
  catch (e) {
480
542
  if (isNotFoundError(e)) {
@@ -489,8 +551,19 @@ export function buildV1ClientShim() {
489
551
  if (isReleasedSubSession(opts.path.id)) {
490
552
  return { response: { status: 404 }, error: { _tag: "SessionNotFoundError" } };
491
553
  }
492
- const client = await serviceOrThrow();
493
- const info = und(await client.session.get?.({ sessionID: opts.path.id }));
554
+ const client = await ownServiceClient();
555
+ if (client?.session?.get) {
556
+ try {
557
+ const info = und(await client.session.get({ sessionID: opts.path.id }));
558
+ return { data: info };
559
+ }
560
+ catch (e) {
561
+ if (isNotFoundError(e))
562
+ return { response: { status: 404 }, error: e };
563
+ return { error: e };
564
+ }
565
+ }
566
+ const info = und(await ctx().session.get({ sessionID: opts.path.id }));
494
567
  return { data: info };
495
568
  }
496
569
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
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",