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.
@@ -1,23 +1,103 @@
1
+ export class ProviderCapacityError extends Error {
2
+ statusCode;
3
+ constructor(message, statusCode) {
4
+ super(message);
5
+ this.statusCode = statusCode;
6
+ this.name = "ProviderCapacityError";
7
+ }
8
+ }
1
9
  /**
2
- * Process-local anti-stampede for phase 1 only.
10
+ * Process-local anti-stampede for phase 1, plus an observed-quota circuit
11
+ * breaker that stands in for Codex guard.rs.
3
12
  *
4
13
  * Codex has no wall-clock throttle: it reads live provider quota once per
5
14
  * startup (memories/write/src/guard.rs) and fails open when unknown. Opencode
6
- * does not expose provider rate limits to plugins, so this stub keeps
7
- * chat.message/idle from hammering discovery + claim when many sessions go
8
- * idle at once.
15
+ * does not expose provider rate limits to plugins, so this stub:
16
+ * - keeps chat.message/idle from hammering discovery + claim (30s, phase 1)
17
+ * - after a quota/rate-limit API error, skips further claims until the same
18
+ * 1h window Codex uses for job retry_at — so a quota outage cannot burn
19
+ * every eligible session's retry budget
9
20
  *
10
21
  * Semantics deliberately match "do not start another token-using run too
11
22
  * often", not "do not look often":
12
- * - checkRateLimit only reads the clock (empty/no-claim passes do not stamp)
23
+ * - checkRateLimit only reads clocks (empty/no-claim passes do not stamp)
13
24
  * - markRateLimitUsed stamps after a stage-1 claim actually succeeds
14
- * - phase 2 has no process timer; the DB claim + 6h cooldown serialize it
15
- * (same as codex phase2 job outcomes)
25
+ * - noteProviderCapacityExhausted stamps after an observed quota error
26
+ * - phase 2 has no 30s timer; the DB claim + 6h cooldown serialize it.
27
+ * The observed-quota stamp still skips phase 2 (Codex start.rs skips both).
16
28
  */
17
29
  let lastPhase1Work = 0;
30
+ const providerCapacityUntil = new Map();
18
31
  const MIN_PHASE1_INTERVAL_MS = 30_000;
19
- export async function checkRateLimit(kind = "phase1") {
20
- // Phase 2: no process-local gate (codex relies on DB claim/cooldown only).
32
+ export const PROVIDER_CAPACITY_BACKOFF_MS = 3_600_000;
33
+ const PROVIDER_CAPACITY_RE = /usage limit|free usage exceeded|provider capacity exhausted|rate[\s_-]?limit|quota(?:\s+(?:exceeded|exhausted|reached))?|too many requests|\b429\b|resource_exhausted|insufficient_quota|billing.?hard.?limit/i;
34
+ function errorRecord(error) {
35
+ return error && typeof error === "object" ? error : null;
36
+ }
37
+ function providerCapacityStatusCode(error) {
38
+ const record = errorRecord(error);
39
+ const data = errorRecord(record?.data);
40
+ const value = record?.statusCode ?? data?.statusCode;
41
+ if (typeof value === "number")
42
+ return value;
43
+ if (typeof value === "string" && /^\d+$/.test(value))
44
+ return Number(value);
45
+ return null;
46
+ }
47
+ export function providerCapacityMessage(error) {
48
+ try {
49
+ if (typeof error === "string")
50
+ return error;
51
+ if (error instanceof Error)
52
+ return String(error.message ?? "unknown error");
53
+ const record = errorRecord(error);
54
+ const data = errorRecord(record?.data);
55
+ const message = record?.message ?? data?.message;
56
+ if (typeof message === "string")
57
+ return message;
58
+ return String(error ?? "unknown error");
59
+ }
60
+ catch {
61
+ return "unknown error";
62
+ }
63
+ }
64
+ export function isProviderCapacityError(error) {
65
+ if (error instanceof ProviderCapacityError)
66
+ return true;
67
+ if (providerCapacityStatusCode(error) === 429)
68
+ return true;
69
+ return PROVIDER_CAPACITY_RE.test(providerCapacityMessage(error));
70
+ }
71
+ function providerCapacityScope(phase, model) {
72
+ return model ? `model:${model}` : `phase:${phase}:default`;
73
+ }
74
+ export function activeProviderCapacityBackoffs(now = Date.now()) {
75
+ const active = [];
76
+ for (const [scope, until] of providerCapacityUntil) {
77
+ if (until <= now) {
78
+ providerCapacityUntil.delete(scope);
79
+ continue;
80
+ }
81
+ active.push({ scope, retry_at: Math.floor(until / 1000) });
82
+ }
83
+ return active.sort((a, b) => a.scope.localeCompare(b.scope));
84
+ }
85
+ export function isProviderCapacityBlocked(phase, model, now = Date.now()) {
86
+ const until = providerCapacityUntil.get(providerCapacityScope(phase, model));
87
+ return until !== undefined && until > now;
88
+ }
89
+ /** Call after a quota/rate-limit failure so later passes skip claiming. */
90
+ export function noteProviderCapacityExhausted(phase, model, now = Date.now()) {
91
+ providerCapacityUntil.set(providerCapacityScope(phase, model), now + PROVIDER_CAPACITY_BACKOFF_MS);
92
+ }
93
+ export async function checkRateLimit(kind = "phase1", model) {
94
+ if (isProviderCapacityBlocked(kind, model)) {
95
+ return {
96
+ ok: false,
97
+ reason: `provider capacity exhausted for ${providerCapacityScope(kind, model)} (observed quota/rate-limit)`,
98
+ };
99
+ }
100
+ // Phase 2: no 30s gate (codex relies on DB claim/cooldown only).
21
101
  if (kind === "phase2")
22
102
  return { ok: true };
23
103
  const now = Date.now();
@@ -31,7 +111,8 @@ export function markRateLimitUsed(kind = "phase1") {
31
111
  if (kind === "phase1")
32
112
  lastPhase1Work = Date.now();
33
113
  }
34
- /** Test seam: reset the process-local stamp. */
114
+ /** Test seam: reset the process-local stamps. */
35
115
  export function resetRateLimitForTest() {
36
116
  lastPhase1Work = 0;
117
+ providerCapacityUntil.clear();
37
118
  }
@@ -57,6 +57,13 @@ export declare class MemoryStore {
57
57
  /** Extraction succeeded but produced nothing worth keeping: finish the job and drop any stale output. */
58
58
  markStage1SucceededNoOutput(sessionId: string, ownershipToken: string, sourceUpdatedAt: number): void;
59
59
  markStage1Failed(sessionId: string, ownershipToken: string, error: unknown): void;
60
+ /**
61
+ * Re-open stage-1 jobs that exhausted their retry budget solely because of
62
+ * a quota/rate-limit error. Historical completed sessions never get a newer
63
+ * watermark, so without this they stay failed forever after a quota outage.
64
+ * Leaves retry_at alone so an active backoff still holds.
65
+ */
66
+ requeueExhaustedProviderCapacityJobs(): number;
60
67
  /**
61
68
  * Plugin dispose/reload: release a claimed stage-1 job without burning a retry
62
69
  * or imposing the 1h backoff. Leaves status=pending so the next process can
@@ -146,11 +153,20 @@ export declare class MemoryStore {
146
153
  */
147
154
  stage1JobSnapshot(): {
148
155
  by_status: Record<string, number>;
149
- recent_errors: {
150
- session_id: string;
151
- last_error: string;
152
- retry_at: number | null;
153
- status: string;
154
- }[];
156
+ by_failure_class: {
157
+ backoff: number;
158
+ provider_capacity: number;
159
+ other_exhausted: number;
160
+ };
161
+ recent_errors: Stage1RecentError[];
155
162
  };
156
163
  }
164
+ export type Stage1FailureClass = "backoff" | "provider_capacity" | "other_exhausted";
165
+ export interface Stage1RecentError {
166
+ session_id: string;
167
+ last_error: string;
168
+ retry_at: number | null;
169
+ status: string;
170
+ retry_remaining: number;
171
+ failure_class: Stage1FailureClass | null;
172
+ }
package/dist/src/store.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { openDb } from "./db.js";
2
+ import { isProviderCapacityError } from "./ratelimit.js";
2
3
  export const DEFAULT_RETRY_REMAINING = 3;
3
4
  export const STAGE1_LEASE_SECONDS = 3600;
4
5
  export const PHASE2_LEASE_SECONDS = 3600;
@@ -191,6 +192,24 @@ export class MemoryStore {
191
192
  }
192
193
  markStage1Failed(sessionId, ownershipToken, error) {
193
194
  const message = failureMessage(error);
195
+ const tNow = nowSec();
196
+ const retryAt = tNow + STAGE1_RETRY_DELAY_SECONDS;
197
+ // Quota/rate-limit is transient provider capacity, not a bad transcript.
198
+ // Codex avoids claiming in that state via guard.rs; we cannot read quota,
199
+ // so keep the job pending and do not burn retry_remaining. Claim still
200
+ // honors retry_at, so this cannot tight-loop while quota is down.
201
+ if (isProviderCapacityError(error)) {
202
+ this.db
203
+ .prepare(`UPDATE memory_jobs SET
204
+ status = 'pending',
205
+ last_error = ?,
206
+ retry_at = ?,
207
+ finished_at = ?,
208
+ lease_until = NULL
209
+ WHERE kind='memory_stage1' AND job_key=? AND status='running' AND ownership_token=?`)
210
+ .run(message.slice(0, 4000), retryAt, tNow, sessionId, ownershipToken);
211
+ return;
212
+ }
194
213
  this.db
195
214
  .prepare(`UPDATE memory_jobs SET
196
215
  status = CASE WHEN retry_remaining > 1 THEN 'pending' ELSE 'failed' END,
@@ -200,7 +219,30 @@ export class MemoryStore {
200
219
  finished_at = ?,
201
220
  lease_until = NULL
202
221
  WHERE kind='memory_stage1' AND job_key=? AND status='running' AND ownership_token=?`)
203
- .run(message.slice(0, 4000), nowSec() + STAGE1_RETRY_DELAY_SECONDS, nowSec(), sessionId, ownershipToken);
222
+ .run(message.slice(0, 4000), retryAt, tNow, sessionId, ownershipToken);
223
+ }
224
+ /**
225
+ * Re-open stage-1 jobs that exhausted their retry budget solely because of
226
+ * a quota/rate-limit error. Historical completed sessions never get a newer
227
+ * watermark, so without this they stay failed forever after a quota outage.
228
+ * Leaves retry_at alone so an active backoff still holds.
229
+ */
230
+ requeueExhaustedProviderCapacityJobs() {
231
+ const rows = this.db
232
+ .prepare(`SELECT job_key, last_error FROM memory_jobs
233
+ WHERE kind='memory_stage1' AND status='failed' AND last_error IS NOT NULL`)
234
+ .all();
235
+ let n = 0;
236
+ const stmt = this.db.prepare(`UPDATE memory_jobs SET status='pending', retry_remaining=?
237
+ WHERE kind='memory_stage1' AND job_key=? AND status='failed'`);
238
+ this.db.transaction(() => {
239
+ for (const row of rows) {
240
+ if (!isProviderCapacityError(row.last_error))
241
+ continue;
242
+ n += stmt.run(DEFAULT_RETRY_REMAINING, row.job_key).changes;
243
+ }
244
+ }).immediate();
245
+ return n;
204
246
  }
205
247
  /**
206
248
  * Plugin dispose/reload: release a claimed stage-1 job without burning a retry
@@ -529,12 +571,39 @@ export class MemoryStore {
529
571
  const by_status = {};
530
572
  for (const r of rows)
531
573
  by_status[r.status] = r.c;
532
- const recent_errors = this.db
533
- .prepare(`SELECT job_key AS session_id, last_error, retry_at, status FROM memory_jobs
574
+ const tNow = nowSec();
575
+ const errorRows = this.db
576
+ .prepare(`SELECT job_key AS session_id, last_error, retry_at, status, retry_remaining FROM memory_jobs
534
577
  WHERE kind='memory_stage1' AND last_error IS NOT NULL
535
- ORDER BY COALESCE(finished_at, started_at, 0) DESC
536
- LIMIT 5`)
578
+ ORDER BY COALESCE(finished_at, started_at, 0) DESC`)
537
579
  .all();
538
- return { by_status, recent_errors };
580
+ const by_failure_class = { backoff: 0, provider_capacity: 0, other_exhausted: 0 };
581
+ const recent_errors = [];
582
+ for (const row of errorRows) {
583
+ const failure_class = classifyStage1Failure(row, tNow);
584
+ if (failure_class)
585
+ by_failure_class[failure_class]++;
586
+ if (recent_errors.length < 5) {
587
+ recent_errors.push({
588
+ session_id: row.session_id,
589
+ last_error: row.last_error,
590
+ retry_at: row.retry_at,
591
+ status: row.status,
592
+ retry_remaining: row.retry_remaining,
593
+ failure_class,
594
+ });
595
+ }
596
+ }
597
+ return { by_status, by_failure_class, recent_errors };
598
+ }
599
+ }
600
+ function classifyStage1Failure(row, nowSec) {
601
+ if (row.status === "failed" || row.retry_remaining <= 0) {
602
+ return isProviderCapacityError(row.last_error) ? "provider_capacity" : "other_exhausted";
539
603
  }
604
+ if (row.retry_at != null && row.retry_at > nowSec)
605
+ return "backoff";
606
+ if (isProviderCapacityError(row.last_error))
607
+ return "provider_capacity";
608
+ return null;
540
609
  }
@@ -12,6 +12,8 @@ import { codexInteropMtimes, resolveCodexInterop } from "../src/codex-interop.js
12
12
  import { claudeImportStatus, resolveClaudeHome } from "../src/claude-import.js";
13
13
  import { formatDiagnosticLine, getDiscoveryStatus, getRecentDiagnostics, } from "../src/diagnostics.js";
14
14
  import { isPluginShuttingDown } from "../src/lifecycle.js";
15
+ import { getAgentHealth } from "../src/agent-health.js";
16
+ import { activeProviderCapacityBackoffs } from "../src/ratelimit.js";
15
17
  function isSymlinkedRoot() {
16
18
  try {
17
19
  assertMemoryRootSafe();
@@ -137,6 +139,18 @@ function listMemoriesDir() {
137
139
  walk(root, "");
138
140
  return out;
139
141
  }
142
+ function renderAgentHealth() {
143
+ const health = getAgentHealth();
144
+ const lines = [
145
+ `agent_config: ${health.observed ? "observed" : "not observed (config hook has not run)"}`,
146
+ `agent_generation_enabled: ${health.generationEnabled ?? "unknown"}`,
147
+ ];
148
+ for (const name of ["memorize", "memorize-extract"]) {
149
+ const entry = health.agents[name];
150
+ lines.push(` agent_${name}: source=${entry.source} status=${entry.healthy ? "healthy" : "degraded"}`, ...entry.issues.map((issue) => ` issue: ${issue}`));
151
+ }
152
+ return lines;
153
+ }
140
154
  export const memory_reset = tool({
141
155
  description: "Reset all persistent memory. Wipes the plugin's extracted memories and jobs tables and the entire " +
142
156
  "contents of the memories directory (including git history). Per-session memory modes are preserved, " +
@@ -187,7 +201,8 @@ function fmtWatermarkMs(ms) {
187
201
  }
188
202
  export const memory_inspect = tool({
189
203
  description: "Inspect the current memory state. Returns: stage1_outputs count, stage-1 job status " +
190
- "breakdown and recent errors, Phase 2 job status (including last error / retry time), " +
204
+ "breakdown, failure classes (backoff / provider_capacity / other_exhausted), recent errors, " +
205
+ "Phase 2 job status (including last error / retry time), " +
191
206
  "last discovery outcome, pipeline diagnostics, memory_summary token estimate " +
192
207
  "(on-disk; injection caps at ~2500), a listing of the memories directory, the " +
193
208
  "effective plugin options, and any configuration warnings. Use it to verify " +
@@ -231,9 +246,20 @@ export const memory_inspect = tool({
231
246
  const stage1StatusParts = Object.entries(stage1Jobs.by_status)
232
247
  .sort(([a], [b]) => a.localeCompare(b))
233
248
  .map(([s, c]) => `${s}=${c}`);
249
+ const fc = stage1Jobs.by_failure_class;
250
+ const failureParts = [
251
+ fc.backoff > 0 ? `backoff=${fc.backoff}` : "",
252
+ fc.provider_capacity > 0 ? `provider_capacity=${fc.provider_capacity}` : "",
253
+ fc.other_exhausted > 0 ? `other_exhausted=${fc.other_exhausted}` : "",
254
+ ].filter(Boolean);
234
255
  const stage1Lines = [
235
256
  `stage1_jobs: ${stage1StatusParts.length > 0 ? stage1StatusParts.join(" ") : "none"}`,
236
- ...stage1Jobs.recent_errors.map((e) => ` stage1_error ${e.session_id} (${e.status}): ${e.last_error.slice(0, 200)}${e.retry_at ? ` retry_at=${fmtUnixSec(e.retry_at)}` : ""}`),
257
+ `stage1_failures: ${failureParts.length > 0 ? failureParts.join(" ") : "none"}`,
258
+ ...stage1Jobs.recent_errors.map((e) => {
259
+ const klass = e.failure_class ? `, ${e.failure_class}` : "";
260
+ const retry = e.retry_at ? ` retry_at=${fmtUnixSec(e.retry_at)}` : "";
261
+ return ` stage1_error ${e.session_id} (${e.status}${klass}): ${e.last_error.slice(0, 200)}${retry}`;
262
+ }),
237
263
  ];
238
264
  const discovery = getDiscoveryStatus();
239
265
  const discoveryLine = discovery
@@ -247,6 +273,10 @@ export const memory_inspect = tool({
247
273
  `phase2_in_flight: ${isPhase2InFlight()}`,
248
274
  `plugin_shutting_down: ${isPluginShuttingDown()}`,
249
275
  ];
276
+ const capacityBackoffs = activeProviderCapacityBackoffs();
277
+ const capacityLines = capacityBackoffs.length > 0
278
+ ? capacityBackoffs.map((b) => `provider_capacity_backoff ${b.scope}: retry_at=${fmtUnixSec(b.retry_at)}`)
279
+ : ["provider_capacity_backoff: none"];
250
280
  const diagnostics = getRecentDiagnostics(12);
251
281
  const diagnosticLines = diagnostics.length > 0
252
282
  ? ["recent_events:", ...diagnostics.map((e) => ` ${formatDiagnosticLine(e)}`)]
@@ -258,12 +288,15 @@ export const memory_inspect = tool({
258
288
  discoveryLine,
259
289
  eligibilityHint,
260
290
  ...processLines,
291
+ ...capacityLines,
261
292
  `memory_summary_chars: ${summaryChars}`,
262
293
  `memory_summary_tokens_est: ${summaryTokens} (on disk; injection caps at ~2500)`,
263
294
  `memories_dir_entries: ${listing.length}`,
264
295
  "",
265
296
  ...renderEffectiveConfig(),
266
297
  "",
298
+ ...renderAgentHealth(),
299
+ "",
267
300
  ...diagnosticLines,
268
301
  "",
269
302
  "Files:",
@@ -274,6 +307,7 @@ export const memory_inspect = tool({
274
307
  metadata: {
275
308
  stage1_count: outputs.length,
276
309
  stage1_jobs: stage1Jobs.by_status,
310
+ stage1_failures: stage1Jobs.by_failure_class,
277
311
  stage1_recent_errors: stage1Jobs.recent_errors,
278
312
  phase2_status: phase2?.status ?? null,
279
313
  phase2_last_error: phase2?.last_error ?? null,
@@ -281,6 +315,7 @@ export const memory_inspect = tool({
281
315
  phase2_last_attempt_finished_at: phase2?.finished_at ?? null,
282
316
  phase2_last_success_watermark: phase2?.last_success_watermark ?? null,
283
317
  phase2_last_success_finished_at: phase2?.success_finished_at ?? null,
318
+ provider_capacity_backoffs: capacityBackoffs,
284
319
  // Back-compat aliases used by earlier inspect consumers.
285
320
  phase2_last_finished_at: phase2?.success_finished_at ?? null,
286
321
  discovery,
@@ -298,6 +333,7 @@ export const memory_inspect = tool({
298
333
  },
299
334
  },
300
335
  config_warnings: [...getConfigWarnings()],
336
+ agent_health: getAgentHealth(),
301
337
  recent_events: diagnostics,
302
338
  },
303
339
  };
@@ -15,11 +15,13 @@ export declare const memory_list: {
15
15
  description: string;
16
16
  args: {
17
17
  path: import("zod").ZodDefault<import("zod").ZodString>;
18
+ cursor: import("zod").ZodOptional<import("zod").ZodString>;
18
19
  max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
19
20
  };
20
21
  execute(args: {
21
22
  path: string;
22
23
  max_results: number;
24
+ cursor?: string | undefined;
23
25
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
24
26
  };
25
27
  export declare const memory_search: {
@@ -20,7 +20,9 @@ export const memory_read = tool({
20
20
  }
21
21
  const stat = fs.lstatSync(fullPath);
22
22
  if (stat.isDirectory()) {
23
- const entries = fs.readdirSync(fullPath);
23
+ const entries = visibleEntries(fullPath)
24
+ .sort((a, b) => comparePathNames(a.name, b.name))
25
+ .map((e) => e.name);
24
26
  return {
25
27
  output: `Directory ${args.path}/\n` + entries.map((e) => `- ${e}`).join("\n") + "\n(use memory_list for sorted, typed listings)",
26
28
  metadata: { kind: "directory", entries },
@@ -92,15 +94,23 @@ function visibleEntries(dir) {
92
94
  return out;
93
95
  }
94
96
  const LIST_MAX_RESULTS = 2000;
97
+ // Codex sorts paths lexically (`Path` ordering), not with locale collation.
98
+ // Keep ordering stable across hosts and match ASCII path ordering.
99
+ function comparePathNames(a, b) {
100
+ return a < b ? -1 : a > b ? 1 : 0;
101
+ }
95
102
  export const memory_list = tool({
96
103
  description: "List the immediate entries of a directory in the persistent memory workspace, sorted by name, " +
97
- "with entry types. Hidden files and symlinks are skipped. Use path '' (empty) for the memory root.",
104
+ "with entry types. Hidden files and symlinks are skipped. Supports cursor pagination and listing " +
105
+ "a single file. Use path '' (empty) for the memory root.",
98
106
  args: {
99
107
  path: tool.schema.string().default("").describe("Relative directory path inside the memory workspace ('' for the root)."),
108
+ cursor: tool.schema.string().optional().describe("Pagination cursor from a previous response's next_cursor."),
100
109
  max_results: tool.schema.number().int().min(1).max(LIST_MAX_RESULTS).default(LIST_MAX_RESULTS).describe("Maximum entries to return."),
101
110
  },
102
111
  async execute(args) {
103
112
  try {
113
+ const root = assertMemoryRootSafe();
104
114
  const fullPath = safeResolveMemoryPath(args.path || ".");
105
115
  if (!fs.existsSync(fullPath))
106
116
  return { output: `Not found: ${args.path}` };
@@ -111,19 +121,49 @@ export const memory_list = tool({
111
121
  if (st.isSymbolicLink()) {
112
122
  return { output: `memory_list error: symlinks are not allowed in the memory workspace: ${args.path}` };
113
123
  }
114
- if (!st.isDirectory())
115
- return { output: `memory_list error: not a directory: ${args.path}` };
116
- const entries = visibleEntries(fullPath).sort((a, b) => a.name.localeCompare(b.name));
117
- const truncated = entries.length > args.max_results;
118
- const shown = entries.slice(0, args.max_results);
119
- const prefix = args.path ? `${args.path.replace(/\/+$/, "")}/` : "";
120
- const listing = shown.map((e) => ({ path: `${prefix}${e.name}`, entry_type: e.isDir ? "directory" : "file" }));
121
- if (listing.length === 0)
122
- return { output: `Directory ${args.path || "."} is empty.` };
124
+ const entries = st.isFile()
125
+ ? [{ path: path.relative(root, fullPath).split(path.sep).join("/"), entry_type: "file" }]
126
+ : st.isDirectory()
127
+ ? visibleEntries(fullPath)
128
+ .sort((a, b) => comparePathNames(a.name, b.name))
129
+ .map((e) => ({
130
+ path: path.relative(root, path.join(fullPath, e.name)).split(path.sep).join("/"),
131
+ entry_type: e.isDir ? "directory" : "file",
132
+ }))
133
+ : [];
134
+ let startIndex = 0;
135
+ if (args.cursor !== undefined) {
136
+ if (!/^\d+$/.test(args.cursor)) {
137
+ return { output: `memory_list error: invalid cursor "${args.cursor}" (must be a non-negative integer).` };
138
+ }
139
+ startIndex = Number(args.cursor);
140
+ if (!Number.isSafeInteger(startIndex)) {
141
+ return { output: `memory_list error: invalid cursor "${args.cursor}" (must be a non-negative integer).` };
142
+ }
143
+ }
144
+ if (startIndex > entries.length) {
145
+ return { output: `memory_list error: cursor ${args.cursor} exceeds result count ${entries.length}.` };
146
+ }
147
+ const maxResults = args.max_results ?? LIST_MAX_RESULTS;
148
+ const endIndex = Math.min(startIndex + maxResults, entries.length);
149
+ const nextCursor = endIndex < entries.length ? String(endIndex) : null;
150
+ const truncated = nextCursor !== null;
151
+ const listing = entries.slice(startIndex, endIndex);
152
+ if (listing.length === 0) {
153
+ const output = st.isDirectory()
154
+ ? entries.length === 0
155
+ ? `Directory ${args.path || "."} is empty.`
156
+ : `No entries at cursor ${startIndex} for directory ${args.path || "."}.`
157
+ : "";
158
+ return {
159
+ output,
160
+ metadata: { path: args.path, entries: [], next_cursor: nextCursor, truncated },
161
+ };
162
+ }
123
163
  return {
124
164
  output: listing.map((e) => `${e.entry_type === "directory" ? "d" : "f"} ${e.path}`).join("\n") +
125
- (truncated ? `\n[truncated: ${entries.length - args.max_results} more entries]` : ""),
126
- metadata: { path: args.path, entries: listing, truncated },
165
+ (truncated ? `\n[truncated: ${entries.length - endIndex} more entries; pass cursor=${nextCursor}]` : ""),
166
+ metadata: { path: args.path, entries: listing, next_cursor: nextCursor, truncated },
127
167
  };
128
168
  }
129
169
  catch (err) {
@@ -156,7 +196,7 @@ function parseDateArg(value, endOfDay) {
156
196
  function collectSearchFiles(start, prefix) {
157
197
  const files = [];
158
198
  const walk = (dir, rel) => {
159
- const entries = visibleEntries(dir).sort((a, b) => a.name.localeCompare(b.name));
199
+ const entries = visibleEntries(dir).sort((a, b) => comparePathNames(a.name, b.name));
160
200
  for (const { name, isDir } of entries) {
161
201
  const abs = path.join(dir, name);
162
202
  const relPath = rel ? `${rel}/${name}` : name;
@@ -328,7 +368,21 @@ export const memory_search = tool({
328
368
  }
329
369
  const rangeLabel = timeFiltered ? ` in ${args.since ?? "..."}..${args.until ?? "..."}` : "";
330
370
  if (queries.length === 0) {
331
- const listing = files.slice(0, args.max_results).map((f) => {
371
+ let startIndex = 0;
372
+ if (args.cursor !== undefined) {
373
+ startIndex = Number.parseInt(args.cursor, 10);
374
+ if (!Number.isInteger(startIndex) || startIndex < 0 || String(startIndex) !== args.cursor.trim()) {
375
+ return { output: `memory_search error: invalid cursor "${args.cursor}" (must be a non-negative integer).` };
376
+ }
377
+ if (startIndex > files.length) {
378
+ return { output: `memory_search error: cursor ${startIndex} exceeds result count ${files.length}.` };
379
+ }
380
+ }
381
+ const endIndex = Math.min(startIndex + (args.max_results ?? SEARCH_MAX_RESULTS), files.length);
382
+ const page = files.slice(startIndex, endIndex);
383
+ const nextCursor = endIndex < files.length ? String(endIndex) : null;
384
+ const truncated = nextCursor !== null;
385
+ const listing = page.map((f) => {
332
386
  let content = "";
333
387
  try {
334
388
  content = readRegularFileNoFollow(f.abs).content.toString("utf8");
@@ -337,11 +391,25 @@ export const memory_search = tool({
337
391
  }
338
392
  return `${new Date(f.ts).toISOString()} ${f.rel} — ${firstContentLine(content)}`;
339
393
  });
340
- if (listing.length === 0)
394
+ if (files.length === 0)
341
395
  return { output: `No time-anchored memory files${rangeLabel}.` };
396
+ if (listing.length === 0) {
397
+ return {
398
+ output: `No memory files at cursor ${startIndex}${rangeLabel}.`,
399
+ metadata: { count: 0, next_cursor: nextCursor, truncated, since: args.since, until: args.until },
400
+ };
401
+ }
342
402
  return {
343
- output: `${listing.length} memory file(s)${rangeLabel}:\n${listing.join("\n")}`,
344
- metadata: { count: listing.length, since: args.since, until: args.until },
403
+ output: `${listing.length} of ${files.length} memory file(s)${rangeLabel}` +
404
+ `${truncated ? ` (more available; pass cursor=${nextCursor})` : ""}:\n` +
405
+ listing.join("\n"),
406
+ metadata: {
407
+ count: listing.length,
408
+ next_cursor: nextCursor,
409
+ truncated,
410
+ since: args.since,
411
+ until: args.until,
412
+ },
345
413
  };
346
414
  }
347
415
  const caseSensitive = args.case_sensitive ?? true;
@@ -364,7 +432,7 @@ export const memory_search = tool({
364
432
  continue; // binary, like codex's InvalidData skip
365
433
  searchFileContent(f, content.split(/\r?\n/), queries, preparedQueries, mode, args.line_count ?? 1, args.context_lines ?? 0, caseSensitive, normalized, all);
366
434
  }
367
- all.sort((a, b) => a.path.localeCompare(b.path) || a.match_line_number - b.match_line_number);
435
+ all.sort((a, b) => comparePathNames(a.path, b.path) || a.match_line_number - b.match_line_number);
368
436
  let startIndex = 0;
369
437
  if (args.cursor !== undefined) {
370
438
  startIndex = Number.parseInt(args.cursor, 10);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",