opencode-codex-memory 0.6.0 → 0.6.2
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 +8 -6
- 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/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 +79 -7
- 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/src/workspace.d.ts +2 -0
- package/dist/src/workspace.js +47 -0
- package/dist/tools/control.js +22 -2
- package/dist/tools/memory.js +37 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ If you want the mental model before the details, jump to
|
|
|
52
52
|
|
|
53
53
|
```json
|
|
54
54
|
{
|
|
55
|
-
"plugin": ["opencode-codex-memory@0.6.
|
|
55
|
+
"plugin": ["opencode-codex-memory@0.6.2"]
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -254,7 +254,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
|
|
|
254
254
|
```json
|
|
255
255
|
{
|
|
256
256
|
"plugin": [
|
|
257
|
-
["opencode-codex-memory@0.6.
|
|
257
|
+
["opencode-codex-memory@0.6.2", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
|
|
258
258
|
]
|
|
259
259
|
}
|
|
260
260
|
```
|
|
@@ -316,7 +316,7 @@ Off by default; no changes to Codex's own config are required.
|
|
|
316
316
|
{
|
|
317
317
|
"plugin": [
|
|
318
318
|
[
|
|
319
|
-
"opencode-codex-memory@0.6.
|
|
319
|
+
"opencode-codex-memory@0.6.2",
|
|
320
320
|
{ "codex_interop": { "import": true, "export": true } }
|
|
321
321
|
]
|
|
322
322
|
]
|
|
@@ -373,7 +373,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
|
|
|
373
373
|
```json
|
|
374
374
|
{
|
|
375
375
|
"plugin": [
|
|
376
|
-
["opencode-codex-memory@0.6.
|
|
376
|
+
["opencode-codex-memory@0.6.2", { "claude_import": { "enabled": true } }]
|
|
377
377
|
]
|
|
378
378
|
}
|
|
379
379
|
```
|
|
@@ -400,7 +400,7 @@ Claude names each project with an opaque id (a folder under
|
|
|
400
400
|
{
|
|
401
401
|
"plugin": [
|
|
402
402
|
[
|
|
403
|
-
"opencode-codex-memory@0.6.
|
|
403
|
+
"opencode-codex-memory@0.6.2",
|
|
404
404
|
{
|
|
405
405
|
"claude_import": {
|
|
406
406
|
"enabled": true,
|
|
@@ -485,7 +485,8 @@ mirrors that decision rather than layering scoping back on top.
|
|
|
485
485
|
When memory does not seem to build, ask the agent to run **`memory_inspect`**.
|
|
486
486
|
It reports:
|
|
487
487
|
|
|
488
|
-
- stage-1 job counts and recent extraction errors
|
|
488
|
+
- stage-1 job counts, failure classes (`backoff` / `provider_capacity` / `other_exhausted`), and recent extraction errors
|
|
489
|
+
- active provider/model quota backoffs and their retry times
|
|
489
490
|
- phase-2 status / last error / cooldown
|
|
490
491
|
- last session-discovery outcome
|
|
491
492
|
- effective options (after clamping) and config warnings
|
|
@@ -500,6 +501,7 @@ Common causes:
|
|
|
500
501
|
| Discovery failed | Host API/`experimental/session` unavailable; inspect shows the error. Retry after restarting OpenCode. |
|
|
501
502
|
| Pin stuck on old version | OpenCode freezes bare package specs; pin an explicit version and bump it (see Install). |
|
|
502
503
|
| Consolidation never runs | Check `phase2_status` and `phase2_last_error` in inspect; failed artifacts keep the workspace diff for the next run. |
|
|
504
|
+
| `stage1_error` mentions usage/rate limit | Temporary provider quota. Inspect shows `provider_capacity` or `backoff`; those jobs retry after about an hour once quota returns. Permanent holes are `other_exhausted`. |
|
|
503
505
|
|
|
504
506
|
Install target: this package runs **inside OpenCode** (Bun). You do not need to
|
|
505
507
|
install it as a standalone Node app; OpenCode resolves the plugin into its own
|
package/dist/src/capture.js
CHANGED
|
@@ -1,22 +1,8 @@
|
|
|
1
1
|
import { SCAN_LIMIT } from "./store.js";
|
|
2
2
|
import { getPluginInput } from "./llm.js";
|
|
3
3
|
import { recordDiscoveryStatus } from "./diagnostics.js";
|
|
4
|
-
import { hostPartType, hostSessionMessages, pluginHttpGet } from "./host-client.js";
|
|
4
|
+
import { hostListSessionsGlobal, hostPartType, hostSessionMessages, pluginHttpGet, withHostTimeout } from "./host-client.js";
|
|
5
5
|
const API_TIMEOUT_MS = 60_000;
|
|
6
|
-
async function withTimeout(promise, ms, label) {
|
|
7
|
-
let timer;
|
|
8
|
-
try {
|
|
9
|
-
return await Promise.race([
|
|
10
|
-
promise,
|
|
11
|
-
new Promise((_, reject) => {
|
|
12
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
13
|
-
}),
|
|
14
|
-
]);
|
|
15
|
-
}
|
|
16
|
-
finally {
|
|
17
|
-
clearTimeout(timer);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
6
|
/**
|
|
21
7
|
* Global session discovery through the official API:
|
|
22
8
|
* `GET /experimental/session?roots=true` (Session.listGlobal) — one call across
|
|
@@ -41,22 +27,14 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
|
|
|
41
27
|
now - discoveryCache.at < DISCOVERY_CACHE_MS) {
|
|
42
28
|
return discoveryCache.rows.slice(0, limit);
|
|
43
29
|
}
|
|
44
|
-
const
|
|
45
|
-
if (!
|
|
30
|
+
const client = getPluginInput()?.client;
|
|
31
|
+
if (!pluginHttpGet(client)) {
|
|
46
32
|
recordDiscoveryStatus({ ok: false, count: 0, error: "plugin HTTP client unavailable" });
|
|
47
33
|
return [];
|
|
48
34
|
}
|
|
49
35
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// any present directory query as "filter to this instance's project",
|
|
53
|
-
// which would hide every other project's idle sessions — memory is global.
|
|
54
|
-
// Pass an empty directory so rewrite does not re-inject, and the handler
|
|
55
|
-
// sees a falsy value → listGlobal without a directory filter.
|
|
56
|
-
const res = await withTimeout(get({
|
|
57
|
-
url: "/experimental/session",
|
|
58
|
-
query: { roots: true, limit, directory: "" },
|
|
59
|
-
}), API_TIMEOUT_MS, "experimental.session.list");
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const res = await withHostTimeout(hostListSessionsGlobal(client, { limit, signal: controller.signal }), API_TIMEOUT_MS, "experimental.session.list", controller);
|
|
60
38
|
if (!res || res.error || !Array.isArray(res.data)) {
|
|
61
39
|
throw new Error(`experimental.session.list failed: ${JSON.stringify(res?.error ?? {})}`);
|
|
62
40
|
}
|
|
@@ -89,7 +67,8 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
|
|
|
89
67
|
}
|
|
90
68
|
/** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
|
|
91
69
|
async function fetchMessagesViaApi(sessionId) {
|
|
92
|
-
const
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const res = await withHostTimeout(hostSessionMessages(getPluginInput()?.client, sessionId, controller.signal), API_TIMEOUT_MS, "session.messages", controller);
|
|
93
72
|
if (!res || res.error || !Array.isArray(res.data)) {
|
|
94
73
|
throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
|
|
95
74
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Current gaps (as of @opencode-ai/plugin ~1.18.10):
|
|
8
8
|
* - session.create body: metadata + title typing
|
|
9
|
-
* - session.prompt body: `format` (json_schema) omitted from PromptInput
|
|
9
|
+
* - session.prompt body: `format` (json_schema) and `variant` omitted from PromptInput
|
|
10
10
|
* - AssistantMessage.info.structured: structured extraction result
|
|
11
11
|
* - client._client.get: experimental routes (no experimental.* namespace on V1)
|
|
12
12
|
* - mcp.status: present on host client, weakly typed in plugin package
|
|
@@ -22,6 +22,25 @@ export type HostHttpGet = (opts: {
|
|
|
22
22
|
}>;
|
|
23
23
|
/** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
|
|
24
24
|
export declare function pluginHttpGet(client: PluginInput["client"] | null | undefined): HostHttpGet | null;
|
|
25
|
+
/**
|
|
26
|
+
* Host-wide session list. The SDK injects `directory` from
|
|
27
|
+
* x-opencode-directory on every GET; a truthy value makes the experimental
|
|
28
|
+
* handler filter to that instance's project. Pass `directory:""` so rewrite
|
|
29
|
+
* does not re-inject and the handler sees a falsy value → listGlobal.
|
|
30
|
+
* Used by discovery and helper-session cleanup (memory is global).
|
|
31
|
+
*/
|
|
32
|
+
export declare function hostListSessionsGlobal(client: PluginInput["client"] | null | undefined, opts: {
|
|
33
|
+
limit: number;
|
|
34
|
+
cursor?: number;
|
|
35
|
+
search?: string;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
error?: unknown;
|
|
39
|
+
data?: unknown;
|
|
40
|
+
}>;
|
|
41
|
+
/** Attach a no-op catch so a raced-away promise cannot become unhandled. */
|
|
42
|
+
export declare function ignoreLateRejection(promise: Promise<unknown>): void;
|
|
43
|
+
export declare function withHostTimeout<T>(promise: Promise<T>, ms: number, label: string, abort?: AbortController): Promise<T>;
|
|
25
44
|
export interface SessionCreateBody {
|
|
26
45
|
title?: string;
|
|
27
46
|
metadata?: Record<string, unknown>;
|
|
@@ -30,6 +49,7 @@ export interface SessionCreateBody {
|
|
|
30
49
|
export declare function hostSessionCreate(client: PluginInput["client"], opts: {
|
|
31
50
|
directory: string;
|
|
32
51
|
body: SessionCreateBody;
|
|
52
|
+
signal?: AbortSignal;
|
|
33
53
|
}): Promise<{
|
|
34
54
|
error?: unknown;
|
|
35
55
|
data?: {
|
|
@@ -44,6 +64,7 @@ export interface HostPromptBody {
|
|
|
44
64
|
modelID: string;
|
|
45
65
|
};
|
|
46
66
|
format?: Record<string, unknown>;
|
|
67
|
+
variant?: string;
|
|
47
68
|
parts: {
|
|
48
69
|
type: "text";
|
|
49
70
|
text: string;
|
|
@@ -64,9 +85,12 @@ export declare function hostMcpStatus(client: PluginInput["client"] | null, sign
|
|
|
64
85
|
data?: unknown;
|
|
65
86
|
} | null>;
|
|
66
87
|
/** session.messages — weakly typed parts; centralize the cast. */
|
|
67
|
-
export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string): Promise<{
|
|
88
|
+
export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string, signal?: AbortSignal): Promise<{
|
|
68
89
|
error?: unknown;
|
|
69
90
|
data?: unknown;
|
|
70
91
|
}>;
|
|
71
92
|
export declare function hostPartType(part: unknown): string;
|
|
93
|
+
export type SessionLiveness = "live" | "gone" | "unknown";
|
|
94
|
+
/** Codex re-checks the live threads table; 404 = gone, anything else stays. */
|
|
95
|
+
export declare function hostSessionLiveness(client: PluginInput["client"] | null | undefined, id: string, timeoutMs?: number): Promise<SessionLiveness>;
|
|
72
96
|
export declare function hostSessionDeletionConfirmed(client: PluginInput["client"], id: string, timeoutMs: number): Promise<boolean>;
|
package/dist/src/host-client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Current gaps (as of @opencode-ai/plugin ~1.18.10):
|
|
8
8
|
* - session.create body: metadata + title typing
|
|
9
|
-
* - session.prompt body: `format` (json_schema) omitted from PromptInput
|
|
9
|
+
* - session.prompt body: `format` (json_schema) and `variant` omitted from PromptInput
|
|
10
10
|
* - AssistantMessage.info.structured: structured extraction result
|
|
11
11
|
* - client._client.get: experimental routes (no experimental.* namespace on V1)
|
|
12
12
|
* - mcp.status: present on host client, weakly typed in plugin package
|
|
@@ -18,11 +18,58 @@ export function pluginHttpGet(client) {
|
|
|
18
18
|
return null;
|
|
19
19
|
return http.get.bind(http);
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Host-wide session list. The SDK injects `directory` from
|
|
23
|
+
* x-opencode-directory on every GET; a truthy value makes the experimental
|
|
24
|
+
* handler filter to that instance's project. Pass `directory:""` so rewrite
|
|
25
|
+
* does not re-inject and the handler sees a falsy value → listGlobal.
|
|
26
|
+
* Used by discovery and helper-session cleanup (memory is global).
|
|
27
|
+
*/
|
|
28
|
+
export async function hostListSessionsGlobal(client, opts) {
|
|
29
|
+
const get = pluginHttpGet(client);
|
|
30
|
+
if (!get)
|
|
31
|
+
throw new Error("plugin HTTP client unavailable");
|
|
32
|
+
return get({
|
|
33
|
+
url: "/experimental/session",
|
|
34
|
+
query: {
|
|
35
|
+
roots: true,
|
|
36
|
+
limit: opts.limit,
|
|
37
|
+
directory: "",
|
|
38
|
+
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
|
|
39
|
+
...(opts.search !== undefined ? { search: opts.search } : {}),
|
|
40
|
+
},
|
|
41
|
+
signal: opts.signal,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Attach a no-op catch so a raced-away promise cannot become unhandled. */
|
|
45
|
+
export function ignoreLateRejection(promise) {
|
|
46
|
+
void promise.catch(() => { });
|
|
47
|
+
}
|
|
48
|
+
export async function withHostTimeout(promise, ms, label, abort) {
|
|
49
|
+
ignoreLateRejection(promise);
|
|
50
|
+
let timer;
|
|
51
|
+
try {
|
|
52
|
+
return await Promise.race([
|
|
53
|
+
promise,
|
|
54
|
+
new Promise((_, reject) => {
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
abort?.abort();
|
|
57
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
58
|
+
}, ms);
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
21
67
|
/** session.create with metadata (SDK body type is incomplete). */
|
|
22
68
|
export async function hostSessionCreate(client, opts) {
|
|
23
69
|
return client.session.create({
|
|
24
70
|
query: { directory: opts.directory },
|
|
25
71
|
body: opts.body,
|
|
72
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
26
73
|
});
|
|
27
74
|
}
|
|
28
75
|
/** session.prompt including `format` for json_schema structured output. */
|
|
@@ -34,6 +81,7 @@ export async function hostSessionPrompt(client, opts) {
|
|
|
34
81
|
...(opts.body.system ? { system: opts.body.system } : {}),
|
|
35
82
|
...(opts.body.model ? { model: opts.body.model } : {}),
|
|
36
83
|
...(opts.body.format ? { format: opts.body.format } : {}),
|
|
84
|
+
...(opts.body.variant ? { variant: opts.body.variant } : {}),
|
|
37
85
|
parts: opts.body.parts,
|
|
38
86
|
},
|
|
39
87
|
});
|
|
@@ -55,15 +103,50 @@ export async function hostMcpStatus(client, signal) {
|
|
|
55
103
|
return (await mcp.status({ signal }));
|
|
56
104
|
}
|
|
57
105
|
/** session.messages — weakly typed parts; centralize the cast. */
|
|
58
|
-
export async function hostSessionMessages(client, sessionId) {
|
|
106
|
+
export async function hostSessionMessages(client, sessionId, signal) {
|
|
59
107
|
if (!client || typeof client.session?.messages !== "function") {
|
|
60
108
|
throw new Error("plugin client unavailable; cannot load transcript");
|
|
61
109
|
}
|
|
62
|
-
return client.session.messages({
|
|
110
|
+
return client.session.messages({
|
|
111
|
+
path: { id: sessionId },
|
|
112
|
+
...(signal ? { signal } : {}),
|
|
113
|
+
});
|
|
63
114
|
}
|
|
64
115
|
export function hostPartType(part) {
|
|
65
116
|
return typeof part?.type === "string" ? part.type : "unknown";
|
|
66
117
|
}
|
|
118
|
+
function isNotFoundStatus(res) {
|
|
119
|
+
if (res?.response?.status === 404)
|
|
120
|
+
return true;
|
|
121
|
+
const err = res?.error;
|
|
122
|
+
if (err && typeof err === "object") {
|
|
123
|
+
const rec = err;
|
|
124
|
+
if (rec.status === 404)
|
|
125
|
+
return true;
|
|
126
|
+
if (typeof rec.name === "string" && rec.name.toLowerCase().includes("notfound"))
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const SESSION_LIVE_TIMEOUT_MS = 1_000;
|
|
132
|
+
/** Codex re-checks the live threads table; 404 = gone, anything else stays. */
|
|
133
|
+
export async function hostSessionLiveness(client, id, timeoutMs = SESSION_LIVE_TIMEOUT_MS) {
|
|
134
|
+
const session = client?.session;
|
|
135
|
+
if (typeof session?.get !== "function")
|
|
136
|
+
return "unknown";
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
try {
|
|
139
|
+
const res = await withHostTimeout(session.get({ path: { id }, signal: controller.signal }), timeoutMs, "session.get", controller);
|
|
140
|
+
if (isNotFoundStatus(res))
|
|
141
|
+
return "gone";
|
|
142
|
+
if (res?.error)
|
|
143
|
+
return "unknown";
|
|
144
|
+
return "live";
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return "unknown";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
67
150
|
export async function hostSessionDeletionConfirmed(client, id, timeoutMs) {
|
|
68
151
|
const session = client.session;
|
|
69
152
|
if (typeof session?.get !== "function")
|
package/dist/src/llm.d.ts
CHANGED
|
@@ -6,7 +6,15 @@ export interface ExtractionResult {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function setPluginInput(input: PluginInput): void;
|
|
8
8
|
export declare function getPluginInput(): PluginInput | null;
|
|
9
|
+
/** Test seam. */
|
|
10
|
+
export declare function setSubSessionCreateTimeoutForTest(ms?: number): void;
|
|
11
|
+
/** Test seam. */
|
|
12
|
+
export declare function setConfigGetTimeoutForTest(ms?: number): void;
|
|
13
|
+
/** Test seam. */
|
|
14
|
+
export declare function setStaleDeleteBatchTimeoutForTest(ms?: number): void;
|
|
9
15
|
export declare function isMemorySubSession(sessionId: string): boolean;
|
|
16
|
+
export declare function resolveExtractionModel(configured?: string): Promise<string | undefined>;
|
|
17
|
+
export declare function resolveConsolidationModel(configured?: string): Promise<string | undefined>;
|
|
10
18
|
/**
|
|
11
19
|
* Thrown when a sub-agent prompt exceeds its budget. A distinct type (rather
|
|
12
20
|
* than matching on the message text) is what tells the catch below that the
|
package/dist/src/llm.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { memoryRoot } from "./paths.js";
|
|
4
|
-
import { hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, } from "./host-client.js";
|
|
5
|
-
import { pluginShutdownSignal } from "./lifecycle.js";
|
|
4
|
+
import { hostListSessionsGlobal, hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, ignoreLateRejection, pluginHttpGet, withHostTimeout, } from "./host-client.js";
|
|
5
|
+
import { isPluginShuttingDown, pluginShutdownSignal } from "./lifecycle.js";
|
|
6
|
+
import { SCAN_LIMIT } from "./store.js";
|
|
7
|
+
import { isProviderCapacityError, ProviderCapacityError } from "./ratelimit.js";
|
|
6
8
|
let inputRef = null;
|
|
9
|
+
let inputGeneration = 0;
|
|
7
10
|
export function setPluginInput(input) {
|
|
8
11
|
inputRef = input;
|
|
12
|
+
inputGeneration++;
|
|
9
13
|
configModels = null;
|
|
14
|
+
configModelsInFlight = null;
|
|
10
15
|
}
|
|
11
16
|
export function getPluginInput() {
|
|
12
17
|
return inputRef;
|
|
@@ -20,6 +25,25 @@ const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
|
|
|
20
25
|
const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
|
|
21
26
|
const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
|
|
22
27
|
const SUBSESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
28
|
+
const SUBSESSION_CREATE_TIMEOUT_MS = 10_000;
|
|
29
|
+
const CONFIG_GET_TIMEOUT_MS = 5_000;
|
|
30
|
+
const SUBSESSION_DELETE_CONCURRENCY = 8;
|
|
31
|
+
const SUBSESSION_DELETE_BATCH_TIMEOUT_MS = 30_000;
|
|
32
|
+
let createTimeoutMs = SUBSESSION_CREATE_TIMEOUT_MS;
|
|
33
|
+
let configGetTimeoutMs = CONFIG_GET_TIMEOUT_MS;
|
|
34
|
+
let staleDeleteBatchTimeoutMs = SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
|
|
35
|
+
/** Test seam. */
|
|
36
|
+
export function setSubSessionCreateTimeoutForTest(ms) {
|
|
37
|
+
createTimeoutMs = ms ?? SUBSESSION_CREATE_TIMEOUT_MS;
|
|
38
|
+
}
|
|
39
|
+
/** Test seam. */
|
|
40
|
+
export function setConfigGetTimeoutForTest(ms) {
|
|
41
|
+
configGetTimeoutMs = ms ?? CONFIG_GET_TIMEOUT_MS;
|
|
42
|
+
}
|
|
43
|
+
/** Test seam. */
|
|
44
|
+
export function setStaleDeleteBatchTimeoutForTest(ms) {
|
|
45
|
+
staleDeleteBatchTimeoutMs = ms ?? SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
|
|
46
|
+
}
|
|
23
47
|
export function isMemorySubSession(sessionId) {
|
|
24
48
|
return activeSubSessions.has(sessionId);
|
|
25
49
|
}
|
|
@@ -39,16 +63,20 @@ async function createSession(agent, title) {
|
|
|
39
63
|
const input = getPluginInput();
|
|
40
64
|
if (!input)
|
|
41
65
|
throw new Error("plugin input not initialized");
|
|
66
|
+
if (isPluginShuttingDown())
|
|
67
|
+
throw new SubagentCancelledError();
|
|
42
68
|
const directory = resolveSubSessionDirectory();
|
|
43
69
|
// directory is a query param (not body); without it the client inherits
|
|
44
70
|
// PluginInput.directory, which may be a deleted project path.
|
|
45
|
-
const
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const res = await withHostTimeout(hostSessionCreate(input.client, {
|
|
46
73
|
directory,
|
|
47
74
|
body: {
|
|
48
75
|
title: title ?? `codex-memory-${agent}`,
|
|
49
76
|
metadata: { [SUBSESSION_METADATA_KEY]: true },
|
|
50
77
|
},
|
|
51
|
-
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
}), createTimeoutMs, "session.create", controller);
|
|
52
80
|
if (!res.data)
|
|
53
81
|
throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
|
|
54
82
|
const body = res.data;
|
|
@@ -66,23 +94,46 @@ async function createSession(agent, title) {
|
|
|
66
94
|
* plugin instance — opencode reloads plugins on config change.
|
|
67
95
|
*/
|
|
68
96
|
let configModels = null;
|
|
97
|
+
let configModelsInFlight = null;
|
|
69
98
|
async function getConfigModels() {
|
|
70
99
|
if (configModels)
|
|
71
100
|
return configModels;
|
|
101
|
+
if (configModelsInFlight)
|
|
102
|
+
return configModelsInFlight;
|
|
72
103
|
const input = getPluginInput();
|
|
73
104
|
if (!input)
|
|
74
105
|
return {};
|
|
106
|
+
const generation = inputGeneration;
|
|
107
|
+
const request = (async () => {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
try {
|
|
110
|
+
const res = await withHostTimeout(input.client.config.get({ signal: controller.signal }), configGetTimeoutMs, "config.get", controller);
|
|
111
|
+
if (res.error || !res.data || generation !== inputGeneration)
|
|
112
|
+
return {};
|
|
113
|
+
const resolved = { model: res.data.model, smallModel: res.data.small_model };
|
|
114
|
+
configModels = resolved;
|
|
115
|
+
return resolved;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Config endpoint unavailable: leave models unset so the sub-agent runs
|
|
119
|
+
// on the session default. Do not cache failures: the next call can recover.
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
configModelsInFlight = request;
|
|
75
124
|
try {
|
|
76
|
-
|
|
77
|
-
const cfg = res?.data;
|
|
78
|
-
configModels = { model: cfg?.model, smallModel: cfg?.small_model };
|
|
125
|
+
return await request;
|
|
79
126
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
configModels = {};
|
|
127
|
+
finally {
|
|
128
|
+
if (configModelsInFlight === request)
|
|
129
|
+
configModelsInFlight = null;
|
|
84
130
|
}
|
|
85
|
-
|
|
131
|
+
}
|
|
132
|
+
export async function resolveExtractionModel(configured) {
|
|
133
|
+
return configured ?? (await getConfigModels()).smallModel;
|
|
134
|
+
}
|
|
135
|
+
export async function resolveConsolidationModel(configured) {
|
|
136
|
+
return configured ?? (await getConfigModels()).model;
|
|
86
137
|
}
|
|
87
138
|
// extract_model / consolidation model strings are "providerID/modelID".
|
|
88
139
|
function parseModelRef(ref) {
|
|
@@ -163,9 +214,11 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
163
214
|
...(opts.system ? { system: opts.system } : {}),
|
|
164
215
|
...(model ? { model } : {}),
|
|
165
216
|
...(opts.format ? { format: opts.format } : {}),
|
|
217
|
+
...(opts.variant ? { variant: opts.variant } : {}),
|
|
166
218
|
parts: [{ type: "text", text: prompt }],
|
|
167
219
|
},
|
|
168
220
|
});
|
|
221
|
+
ignoreLateRejection(promptPromise);
|
|
169
222
|
let timer;
|
|
170
223
|
let onAbort;
|
|
171
224
|
try {
|
|
@@ -187,12 +240,21 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
187
240
|
timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
|
|
188
241
|
}),
|
|
189
242
|
]);
|
|
190
|
-
if (!res.data)
|
|
191
|
-
|
|
243
|
+
if (!res.data) {
|
|
244
|
+
const message = `prompt failed: ${JSON.stringify(res.error ?? {})}`;
|
|
245
|
+
if (isProviderCapacityError(res.error))
|
|
246
|
+
throw new ProviderCapacityError(message);
|
|
247
|
+
throw new Error(message);
|
|
248
|
+
}
|
|
192
249
|
const promptError = res.data.info?.error;
|
|
193
250
|
if (promptError) {
|
|
194
251
|
const detail = promptError.data?.message;
|
|
195
|
-
|
|
252
|
+
const status = promptError.data?.statusCode;
|
|
253
|
+
const message = `sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}` +
|
|
254
|
+
`${detail ? `: ${detail}` : ""}${status !== undefined ? ` (HTTP ${status})` : ""}`;
|
|
255
|
+
if (isProviderCapacityError(promptError))
|
|
256
|
+
throw new ProviderCapacityError(message, status);
|
|
257
|
+
throw new Error(message);
|
|
196
258
|
}
|
|
197
259
|
return res.data;
|
|
198
260
|
}
|
|
@@ -263,7 +325,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
263
325
|
try {
|
|
264
326
|
const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
|
|
265
327
|
// extract_model option > opencode small_model > session default.
|
|
266
|
-
const model = opts.model
|
|
328
|
+
const model = await resolveExtractionModel(opts.model);
|
|
267
329
|
const data = await runPrompt(subId, prompt, agent, {
|
|
268
330
|
// Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
|
|
269
331
|
// and a near-600k-char transcript on a slow model can easily exceed a
|
|
@@ -276,6 +338,9 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
276
338
|
// call (toolChoice: required) — which is why memorize-extract must allow
|
|
277
339
|
// that one otherwise-denied tool.
|
|
278
340
|
format: { type: "json_schema", schema: EXTRACTION_SCHEMA },
|
|
341
|
+
// Codex extraction ReasoningEffort::Low. Host maps variant → reasoningEffort;
|
|
342
|
+
// missing variant on the model is a no-op.
|
|
343
|
+
variant: "low",
|
|
279
344
|
});
|
|
280
345
|
// The captured JSON lands on AssistantMessage.structured (schema
|
|
281
346
|
// v1/session.ts; absent from the generated SDK type — see host-client.ts).
|
|
@@ -306,8 +371,14 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model, si
|
|
|
306
371
|
try {
|
|
307
372
|
const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
|
|
308
373
|
// consolidation_model option > opencode model (main) > session default.
|
|
309
|
-
const resolved =
|
|
310
|
-
await promptSession(subId, prompt, agent, {
|
|
374
|
+
const resolved = await resolveConsolidationModel(model);
|
|
375
|
+
await promptSession(subId, prompt, agent, {
|
|
376
|
+
model: resolved,
|
|
377
|
+
timeoutMs: CONSOLIDATION_TIMEOUT_MS,
|
|
378
|
+
signal,
|
|
379
|
+
// Codex consolidation ReasoningEffort::Medium.
|
|
380
|
+
variant: "medium",
|
|
381
|
+
});
|
|
311
382
|
}
|
|
312
383
|
catch (err) {
|
|
313
384
|
promptError = err;
|
|
@@ -331,46 +402,92 @@ export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBS
|
|
|
331
402
|
const input = getPluginInput();
|
|
332
403
|
if (!input)
|
|
333
404
|
return;
|
|
334
|
-
|
|
405
|
+
if (!pluginHttpGet(input.client))
|
|
406
|
+
return;
|
|
407
|
+
const controller = new AbortController();
|
|
408
|
+
const staleSessionIds = [];
|
|
335
409
|
try {
|
|
336
|
-
if (typeof input.client?.session?.list !== "function")
|
|
337
|
-
return;
|
|
338
|
-
const res = await Promise.race([
|
|
339
|
-
input.client.session.list(),
|
|
340
|
-
new Promise((_, reject) => {
|
|
341
|
-
timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
342
|
-
}),
|
|
343
|
-
]);
|
|
344
|
-
if (!res.data)
|
|
345
|
-
return;
|
|
346
|
-
const list = res.data;
|
|
347
410
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
411
|
+
const deadline = Date.now() + timeoutMs;
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
let cursor;
|
|
414
|
+
let pageLimit = SCAN_LIMIT;
|
|
415
|
+
while (true) {
|
|
416
|
+
const remaining = deadline - Date.now();
|
|
417
|
+
if (remaining <= 0)
|
|
418
|
+
return;
|
|
419
|
+
const res = await withHostTimeout(hostListSessionsGlobal(input.client, {
|
|
420
|
+
limit: pageLimit,
|
|
421
|
+
cursor,
|
|
422
|
+
search: "codex-memory-",
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
}), remaining, "experimental.session.list", controller);
|
|
425
|
+
if (res.error || !Array.isArray(res.data))
|
|
426
|
+
return;
|
|
427
|
+
const list = res.data;
|
|
428
|
+
let newSessionCount = 0;
|
|
429
|
+
for (const s of list) {
|
|
430
|
+
if (!s.id || seen.has(s.id))
|
|
431
|
+
continue;
|
|
432
|
+
seen.add(s.id);
|
|
433
|
+
newSessionCount++;
|
|
434
|
+
const pluginTitle = isPluginSubSessionTitle(s.title);
|
|
435
|
+
const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
|
|
436
|
+
const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
|
|
437
|
+
if (!owned && !legacy)
|
|
438
|
+
continue;
|
|
439
|
+
// Durable ownership requires marker + generated title; a legacy title
|
|
440
|
+
// alone can reseed the skip set but never authorizes deletion.
|
|
441
|
+
activeSubSessions.add(s.id);
|
|
442
|
+
if (!owned)
|
|
443
|
+
continue;
|
|
444
|
+
const created = s.time?.created ?? 0;
|
|
445
|
+
if (created && created < cutoff) {
|
|
446
|
+
staleSessionIds.push(s.id);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (list.length < pageLimit)
|
|
450
|
+
return;
|
|
451
|
+
const updates = list.map((s) => s.time?.updated).filter((updated) => typeof updated === "number" && Number.isFinite(updated) && updated >= 0);
|
|
452
|
+
if (updates.length === 0)
|
|
453
|
+
return;
|
|
454
|
+
// listGlobal uses `updated < cursor`. Add one millisecond so sessions
|
|
455
|
+
// tied at the page boundary remain visible, then dedupe repeated rows.
|
|
456
|
+
const nextCursor = updates.reduce((min, updated) => Math.min(min, updated), Infinity) + 1;
|
|
457
|
+
if (cursor !== undefined && (nextCursor >= cursor || newSessionCount === 0)) {
|
|
458
|
+
// A full page can consist entirely of the same timestamp. Increase the
|
|
459
|
+
// page size until unseen tied rows appear or the overall deadline wins.
|
|
460
|
+
pageLimit += SCAN_LIMIT;
|
|
360
461
|
continue;
|
|
361
|
-
const created = s.time?.created ?? 0;
|
|
362
|
-
if (created && created < cutoff) {
|
|
363
|
-
void deleteSession(s.id);
|
|
364
462
|
}
|
|
463
|
+
cursor = nextCursor;
|
|
464
|
+
pageLimit = SCAN_LIMIT;
|
|
365
465
|
}
|
|
366
466
|
}
|
|
367
467
|
catch {
|
|
368
468
|
// best effort only
|
|
369
469
|
}
|
|
370
470
|
finally {
|
|
371
|
-
|
|
471
|
+
void deleteStaleSubSessions(staleSessionIds, input, staleDeleteBatchTimeoutMs);
|
|
372
472
|
}
|
|
373
473
|
}
|
|
474
|
+
async function deleteStaleSubSessions(sessionIds, input, timeoutMs) {
|
|
475
|
+
let cursor = 0;
|
|
476
|
+
const deadline = Date.now() + timeoutMs;
|
|
477
|
+
const workers = Array.from({ length: Math.min(SUBSESSION_DELETE_CONCURRENCY, sessionIds.length) }, async () => {
|
|
478
|
+
while (cursor < sessionIds.length && Date.now() < deadline && !isPluginShuttingDown()) {
|
|
479
|
+
const id = sessionIds[cursor++];
|
|
480
|
+
try {
|
|
481
|
+
await deleteSession(id, input, Math.max(1, Math.min(SUBSESSION_DELETE_TIMEOUT_MS, deadline - Date.now())));
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// deleteSession is best-effort; one unexpected failure must not stop
|
|
485
|
+
// the remaining stale-helper cleanup.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
await Promise.all(workers);
|
|
490
|
+
}
|
|
374
491
|
function isPluginSubSessionTitle(title) {
|
|
375
492
|
return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
|
|
376
493
|
}
|
|
@@ -383,20 +500,21 @@ function isPluginSubSessionTitle(title) {
|
|
|
383
500
|
* really gone?) and only governs ownership tracking, never the shutdown result:
|
|
384
501
|
* hosts without `session.get` would otherwise never report a clean shutdown.
|
|
385
502
|
*/
|
|
386
|
-
async function deleteSession(id) {
|
|
387
|
-
const input = getPluginInput();
|
|
503
|
+
async function deleteSession(id, input = getPluginInput(), timeoutMs = SUBSESSION_DELETE_TIMEOUT_MS) {
|
|
388
504
|
if (!input)
|
|
389
505
|
return false;
|
|
390
506
|
const controller = new AbortController();
|
|
391
507
|
let timer;
|
|
392
508
|
try {
|
|
509
|
+
const deletePromise = input.client.session.delete({ path: { id }, signal: controller.signal });
|
|
510
|
+
ignoreLateRejection(deletePromise);
|
|
393
511
|
const res = await Promise.race([
|
|
394
|
-
|
|
512
|
+
deletePromise,
|
|
395
513
|
new Promise((_, reject) => {
|
|
396
514
|
timer = setTimeout(() => {
|
|
397
515
|
controller.abort();
|
|
398
|
-
reject(new Error(`session.delete timed out after ${
|
|
399
|
-
},
|
|
516
|
+
reject(new Error(`session.delete timed out after ${timeoutMs}ms`));
|
|
517
|
+
}, timeoutMs);
|
|
400
518
|
timer.unref?.();
|
|
401
519
|
}),
|
|
402
520
|
]);
|