opencode-codex-memory 0.7.2 → 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.2"]
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.2" }],
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.2", { "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.2",
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.2", { "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.2",
352
+ "opencode-codex-memory@0.7.4",
353
353
  {
354
354
  "claude_import": {
355
355
  "enabled": true,
@@ -29,7 +29,7 @@ async function ensureInit(dir) {
29
29
  const gitDir = path.join(dir, ".git");
30
30
  let recreate = false;
31
31
  try {
32
- recreate = containsSymlink(gitDir) || !fs.lstatSync(gitDir).isDirectory();
32
+ recreate = gitMetadataUnusable(gitDir);
33
33
  }
34
34
  catch (err) {
35
35
  if (err.code !== "ENOENT")
@@ -41,17 +41,14 @@ async function ensureInit(dir) {
41
41
  await isogit.init({ fs, dir });
42
42
  }
43
43
  }
44
- function containsSymlink(root) {
45
- const st = fs.lstatSync(root);
46
- if (st.isSymbolicLink())
47
- return true;
48
- if (!st.isDirectory())
49
- return false;
50
- for (const name of fs.readdirSync(root)) {
51
- if (containsSymlink(path.join(root, name)))
52
- return true;
53
- }
54
- return false;
44
+ /**
45
+ * Only the `.git` entry itself. A recursive walk of git objects hangs on
46
+ * Windows junctions / symlink cycles and is not needed: we only refuse a
47
+ * `.git` that is a symlink or a non-directory.
48
+ */
49
+ function gitMetadataUnusable(gitDir) {
50
+ const st = fs.lstatSync(gitDir);
51
+ return st.isSymbolicLink() || !st.isDirectory();
55
52
  }
56
53
  // statusMatrix rows are [filepath, head, workdir, stage]; head !== workdir
57
54
  // means the working tree differs from HEAD (added, modified, or deleted).
@@ -2,7 +2,7 @@ import { checkRateLimit, isProviderCapacityError, noteProviderCapacityExhausted
2
2
  import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff, validateConsolidationArtifacts, removeMemorySymlinks, } from "./workspace.js";
3
3
  import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js";
4
4
  import { consolidateViaSubagent, getPluginInput, SubagentCancelledError, SubagentShutdownError, } from "./llm.js";
5
- import { hostSessionLiveness } from "./host-client.js";
5
+ import { hostSessionLiveness, withHostTimeout } from "./host-client.js";
6
6
  import { invalidateCache } from "./source.js";
7
7
  import { memoryRoot } from "./paths.js";
8
8
  import { abortPhase2Consolidation, beginPhase2AbortScope, endPhase2AbortScope, isPluginShuttingDown, } from "./lifecycle.js";
@@ -16,6 +16,7 @@ export const DEFAULT_PHASE2_OPTIONS = {
16
16
  // Export runs only after a successful phase 2 (fresh, validated artifacts) and
17
17
  // must never fail the run — Codex's workspace is best-effort foreign territory.
18
18
  const PHASE2_LIVE_CHECK_CONCURRENCY = 8;
19
+ const GIT_TIMEOUT_MS = 120_000;
19
20
  /**
20
21
  * Codex get_phase2_input_selection re-validates each row against the live
21
22
  * threads table. We only drop a row on a confirmed 404 (same as session.deleted);
@@ -132,7 +133,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
132
133
  // ad-hoc notes added since then reach consolidation. Stale stage-1
133
134
  // output pruning happens in phase 1, before the rate gate (codex
134
135
  // start.rs ordering).
135
- if (!await ensureBaseline()) {
136
+ if (!await withHostTimeout(ensureBaseline(), GIT_TIMEOUT_MS, "ensureBaseline")) {
136
137
  store.markPhase2Failed(claim.ownershipToken, "git baseline failed");
137
138
  return { status: "baseline_failed" };
138
139
  }
@@ -171,7 +172,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
171
172
  console.warn("[opencode-codex-memory] claude import sync failed:", err);
172
173
  }
173
174
  }
174
- const diff = await captureWorkspaceDiff();
175
+ const diff = await withHostTimeout(captureWorkspaceDiff(), GIT_TIMEOUT_MS, "captureWorkspaceDiff");
175
176
  if (releaseIfShuttingDown(store, claim.ownershipToken)) {
176
177
  return { status: "shutting_down" };
177
178
  }
@@ -273,7 +274,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
273
274
  store.markPhase2Failed(claim.ownershipToken, `failed_invalid_artifacts: ${artifacts.reason}`);
274
275
  return { status: "failed_invalid_artifacts" };
275
276
  }
276
- if (!await resetBaseline()) {
277
+ if (!await withHostTimeout(resetBaseline(), GIT_TIMEOUT_MS, "resetBaseline")) {
277
278
  store.markPhase2Failed(claim.ownershipToken, "baseline reset failed");
278
279
  return { status: "baseline_reset_failed" };
279
280
  }
@@ -101,6 +101,7 @@ export declare class MemoryStore {
101
101
  phase2JobSnapshot(): {
102
102
  status: string;
103
103
  last_error: string | null;
104
+ started_at: number | null;
104
105
  finished_at: number | null;
105
106
  retry_at: number | null;
106
107
  lease_until: number | null;
package/dist/src/store.js CHANGED
@@ -434,7 +434,7 @@ export class MemoryStore {
434
434
  */
435
435
  phase2JobSnapshot() {
436
436
  const row = this.db
437
- .prepare(`SELECT status, finished_at, last_error, retry_at, lease_until, last_success_watermark FROM memory_jobs
437
+ .prepare(`SELECT status, started_at, finished_at, last_error, retry_at, lease_until, last_success_watermark FROM memory_jobs
438
438
  WHERE kind='memory_consolidate_global' AND job_key='global'`)
439
439
  .get();
440
440
  if (!row)
@@ -452,6 +452,7 @@ export class MemoryStore {
452
452
  return {
453
453
  status: row.status,
454
454
  last_error: row.last_error,
455
+ started_at: row.started_at,
455
456
  finished_at: row.finished_at,
456
457
  retry_at: row.retry_at,
457
458
  lease_until: row.lease_until,
@@ -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) {
@@ -192,6 +192,16 @@ export const memory_reset = tool({
192
192
  function fmtUnixSec(sec) {
193
193
  return sec ? new Date(sec * 1000).toISOString() : "none";
194
194
  }
195
+ function fmtElapsedSec(startedAtSec) {
196
+ if (!startedAtSec)
197
+ return "unknown";
198
+ const sec = Math.max(0, Math.floor(Date.now() / 1000) - startedAtSec);
199
+ if (sec < 60)
200
+ return `${sec}s`;
201
+ if (sec < 3600)
202
+ return `${Math.floor(sec / 60)}m ${sec % 60}s`;
203
+ return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
204
+ }
195
205
  function fmtWatermarkMs(ms) {
196
206
  if (ms === 0)
197
207
  return "0 (no consumed inputs)";
@@ -229,6 +239,9 @@ export const memory_inspect = tool({
229
239
  ? [
230
240
  `phase2_status: ${phase2.status}`,
231
241
  `phase2_last_error: ${phase2.last_error ?? "none"}`,
242
+ `phase2_started_at: ${fmtUnixSec(phase2.started_at)}`,
243
+ `phase2_running_for: ${phase2.status === "running" ? fmtElapsedSec(phase2.started_at) : "n/a"}`,
244
+ `phase2_lease_until: ${fmtUnixSec(phase2.lease_until)}`,
232
245
  `phase2_retry_at: ${fmtUnixSec(phase2.retry_at)}`,
233
246
  `phase2_last_attempt_finished_at: ${fmtUnixSec(phase2.finished_at)}`,
234
247
  `phase2_last_success_watermark: ${fmtWatermarkMs(phase2.last_success_watermark)}`,
@@ -238,6 +251,9 @@ export const memory_inspect = tool({
238
251
  : [
239
252
  "phase2_status: none",
240
253
  "phase2_last_error: none",
254
+ "phase2_started_at: none",
255
+ "phase2_running_for: n/a",
256
+ "phase2_lease_until: none",
241
257
  "phase2_retry_at: none",
242
258
  "phase2_last_attempt_finished_at: none",
243
259
  "phase2_last_success_watermark: none",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.7.2",
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",