opencode-codex-memory 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -6
- package/dist/src/agent-health.d.ts +21 -0
- package/dist/src/agent-health.js +133 -0
- package/dist/src/capture.js +7 -28
- package/dist/src/host-client.d.ts +26 -2
- package/dist/src/host-client.js +86 -3
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +32 -8
- package/dist/src/llm.d.ts +8 -0
- package/dist/src/llm.js +169 -51
- package/dist/src/phase1.js +17 -3
- package/dist/src/phase2.d.ts +12 -0
- package/dist/src/phase2.js +66 -6
- package/dist/src/ratelimit.d.ts +18 -2
- package/dist/src/ratelimit.js +91 -10
- package/dist/src/store.d.ts +22 -6
- package/dist/src/store.js +75 -6
- package/dist/tools/control.js +38 -2
- package/dist/tools/memory.d.ts +2 -0
- package/dist/tools/memory.js +87 -19
- package/package.json +1 -1
package/dist/src/ratelimit.js
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
* chat.message/idle from hammering discovery + claim
|
|
8
|
-
*
|
|
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
|
|
23
|
+
* - checkRateLimit only reads clocks (empty/no-claim passes do not stamp)
|
|
13
24
|
* - markRateLimitUsed stamps after a stage-1 claim actually succeeds
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
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
|
|
20
|
-
|
|
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
|
|
114
|
+
/** Test seam: reset the process-local stamps. */
|
|
35
115
|
export function resetRateLimitForTest() {
|
|
36
116
|
lastPhase1Work = 0;
|
|
117
|
+
providerCapacityUntil.clear();
|
|
37
118
|
}
|
package/dist/src/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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),
|
|
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
|
|
533
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/control.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
};
|
package/dist/tools/memory.d.ts
CHANGED
|
@@ -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: {
|
package/dist/tools/memory.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 -
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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}
|
|
344
|
-
|
|
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
|
|
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.
|
|
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",
|