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/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.
|
|
55
|
+
"plugin": ["opencode-codex-memory@0.6.1"]
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -191,6 +191,20 @@ them — it's yours. (The `memories/` folder also holds a few working files and
|
|
|
191
191
|
an internal `.git/` the plugin uses for change tracking; `memory_reset` wipes
|
|
192
192
|
those too.)
|
|
193
193
|
|
|
194
|
+
### Backup and restore
|
|
195
|
+
|
|
196
|
+
Back up the whole OpenCode data directory while OpenCode is stopped. The
|
|
197
|
+
SQLite database and `memories/` workspace are a pair: restoring only one can
|
|
198
|
+
leave job state, Git baseline, and memory files out of sync. Include hidden
|
|
199
|
+
files, especially `memories/.git/`, and SQLite sidecars such as `memory.db-wal`
|
|
200
|
+
or `memory.db-shm` when present.
|
|
201
|
+
|
|
202
|
+
The directory is `$XDG_DATA_HOME/opencode` when `XDG_DATA_HOME` is set,
|
|
203
|
+
otherwise `~/.local/share/opencode`. Copy that whole directory to a dated
|
|
204
|
+
backup location. To restore, stop OpenCode, replace the current `opencode/`
|
|
205
|
+
data directory with the backup copy, then start OpenCode again. Do not restore
|
|
206
|
+
while OpenCode is running or copy only `memory.db` or only `memories/`.
|
|
207
|
+
|
|
194
208
|
## Privacy & safety
|
|
195
209
|
|
|
196
210
|
- **Local only.** There is no remote storage option to enable, by accident or
|
|
@@ -240,7 +254,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
|
|
|
240
254
|
```json
|
|
241
255
|
{
|
|
242
256
|
"plugin": [
|
|
243
|
-
["opencode-codex-memory@0.
|
|
257
|
+
["opencode-codex-memory@0.6.1", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
|
|
244
258
|
]
|
|
245
259
|
}
|
|
246
260
|
```
|
|
@@ -302,7 +316,7 @@ Off by default; no changes to Codex's own config are required.
|
|
|
302
316
|
{
|
|
303
317
|
"plugin": [
|
|
304
318
|
[
|
|
305
|
-
"opencode-codex-memory@0.
|
|
319
|
+
"opencode-codex-memory@0.6.1",
|
|
306
320
|
{ "codex_interop": { "import": true, "export": true } }
|
|
307
321
|
]
|
|
308
322
|
]
|
|
@@ -359,7 +373,7 @@ from the project memories Claude already keeps on your machine. **One-way only**
|
|
|
359
373
|
```json
|
|
360
374
|
{
|
|
361
375
|
"plugin": [
|
|
362
|
-
["opencode-codex-memory@0.
|
|
376
|
+
["opencode-codex-memory@0.6.1", { "claude_import": { "enabled": true } }]
|
|
363
377
|
]
|
|
364
378
|
}
|
|
365
379
|
```
|
|
@@ -386,7 +400,7 @@ Claude names each project with an opaque id (a folder under
|
|
|
386
400
|
{
|
|
387
401
|
"plugin": [
|
|
388
402
|
[
|
|
389
|
-
"opencode-codex-memory@0.
|
|
403
|
+
"opencode-codex-memory@0.6.1",
|
|
390
404
|
{
|
|
391
405
|
"claude_import": {
|
|
392
406
|
"enabled": true,
|
|
@@ -471,10 +485,12 @@ mirrors that decision rather than layering scoping back on top.
|
|
|
471
485
|
When memory does not seem to build, ask the agent to run **`memory_inspect`**.
|
|
472
486
|
It reports:
|
|
473
487
|
|
|
474
|
-
- 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
|
|
475
490
|
- phase-2 status / last error / cooldown
|
|
476
491
|
- last session-discovery outcome
|
|
477
492
|
- effective options (after clamping) and config warnings
|
|
493
|
+
- effective memory-agent health, including user overrides and required permissions
|
|
478
494
|
- a short eligibility reminder (`min_rollout_idle_hours`, default **6h**)
|
|
479
495
|
|
|
480
496
|
Common causes:
|
|
@@ -485,6 +501,7 @@ Common causes:
|
|
|
485
501
|
| Discovery failed | Host API/`experimental/session` unavailable; inspect shows the error. Retry after restarting OpenCode. |
|
|
486
502
|
| Pin stuck on old version | OpenCode freezes bare package specs; pin an explicit version and bump it (see Install). |
|
|
487
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`. |
|
|
488
505
|
|
|
489
506
|
Install target: this package runs **inside OpenCode** (Bun). You do not need to
|
|
490
507
|
install it as a standalone Node app; OpenCode resolves the plugin into its own
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
declare const AGENT_NAMES: readonly ["memorize", "memorize-extract"];
|
|
2
|
+
type AgentName = (typeof AGENT_NAMES)[number];
|
|
3
|
+
export interface AgentHealthEntry {
|
|
4
|
+
source: "shipped" | "user_override" | "missing";
|
|
5
|
+
healthy: boolean;
|
|
6
|
+
issues: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface AgentHealthSnapshot {
|
|
9
|
+
observed: boolean;
|
|
10
|
+
generationEnabled: boolean | null;
|
|
11
|
+
agents: Record<AgentName, AgentHealthEntry>;
|
|
12
|
+
}
|
|
13
|
+
export declare function loadBundledAgentDefinitions(): Record<string, unknown>;
|
|
14
|
+
/** Record the effective agent config after the plugin config hook runs. */
|
|
15
|
+
export declare function recordAgentConfig(config: {
|
|
16
|
+
agent?: Record<string, unknown>;
|
|
17
|
+
}, generationEnabled: boolean, shipped: Record<string, unknown>): void;
|
|
18
|
+
export declare function getAgentHealth(): AgentHealthSnapshot;
|
|
19
|
+
/** Test seam and boot boundary. */
|
|
20
|
+
export declare function resetAgentHealth(): void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { memoryRoot } from "./paths.js";
|
|
4
|
+
const AGENT_NAMES = ["memorize", "memorize-extract"];
|
|
5
|
+
const REQUIRED_ALLOWS = {
|
|
6
|
+
memorize: ["read", "edit", "write", "glob", "grep"],
|
|
7
|
+
"memorize-extract": ["StructuredOutput"],
|
|
8
|
+
};
|
|
9
|
+
const SAFE_ALLOWS = {
|
|
10
|
+
memorize: new Set(["read", "edit", "write", "glob", "grep", "external_directory"]),
|
|
11
|
+
"memorize-extract": new Set(["StructuredOutput"]),
|
|
12
|
+
};
|
|
13
|
+
const initialEntry = () => ({ source: "missing", healthy: false, issues: ["config hook has not run"] });
|
|
14
|
+
let snapshot = {
|
|
15
|
+
observed: false,
|
|
16
|
+
generationEnabled: null,
|
|
17
|
+
agents: {
|
|
18
|
+
memorize: initialEntry(),
|
|
19
|
+
"memorize-extract": initialEntry(),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
export function loadBundledAgentDefinitions() {
|
|
23
|
+
const raw = fs.readFileSync(path.join(import.meta.dirname, "..", "opencode.json"), "utf8");
|
|
24
|
+
return JSON.parse(raw).agent ?? {};
|
|
25
|
+
}
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
28
|
+
}
|
|
29
|
+
function hasNonDenyAction(value) {
|
|
30
|
+
const rules = asRecord(value);
|
|
31
|
+
return rules ? Object.values(rules).some((action) => action !== "deny") : value !== "deny";
|
|
32
|
+
}
|
|
33
|
+
/** Structural compare — config reload re-parses shipped defs into new objects. */
|
|
34
|
+
function definitionsEqual(a, b) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function permissionIssues(name, definition) {
|
|
43
|
+
const issues = [];
|
|
44
|
+
const record = asRecord(definition);
|
|
45
|
+
if (record?.mode !== "subagent")
|
|
46
|
+
issues.push("agent mode must be 'subagent'");
|
|
47
|
+
const permission = asRecord(record?.permission);
|
|
48
|
+
if (!permission) {
|
|
49
|
+
issues.push("missing permission map");
|
|
50
|
+
return issues;
|
|
51
|
+
}
|
|
52
|
+
const keys = Object.keys(permission);
|
|
53
|
+
if (keys[0] !== "*")
|
|
54
|
+
issues.push("permission wildcard '*' must be the first rule");
|
|
55
|
+
if (permission["*"] !== "deny")
|
|
56
|
+
issues.push("permission wildcard '*' must be 'deny'");
|
|
57
|
+
for (const toolName of REQUIRED_ALLOWS[name]) {
|
|
58
|
+
if (permission[toolName] !== "allow") {
|
|
59
|
+
issues.push(`required permission '${toolName}: allow' is missing`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [toolName, value] of Object.entries(permission)) {
|
|
63
|
+
if (toolName === "*")
|
|
64
|
+
continue;
|
|
65
|
+
if (!SAFE_ALLOWS[name].has(toolName)) {
|
|
66
|
+
if (hasNonDenyAction(value))
|
|
67
|
+
issues.push(`unexpected permission '${toolName}' must be denied`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (name === "memorize") {
|
|
71
|
+
const external = asRecord(permission.external_directory);
|
|
72
|
+
const expectedPath = path.join(memoryRoot(), "*");
|
|
73
|
+
if (external?.[expectedPath] !== "allow") {
|
|
74
|
+
issues.push(`consolidator must allow external_directory '${expectedPath}'`);
|
|
75
|
+
}
|
|
76
|
+
if (external) {
|
|
77
|
+
for (const [grantedPath, action] of Object.entries(external)) {
|
|
78
|
+
if (grantedPath === expectedPath)
|
|
79
|
+
continue;
|
|
80
|
+
if (hasNonDenyAction(action)) {
|
|
81
|
+
issues.push(`consolidator must deny extra external_directory '${grantedPath}'`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (permission.external_directory !== undefined) {
|
|
87
|
+
issues.push("extractor must not have external_directory access");
|
|
88
|
+
}
|
|
89
|
+
return issues;
|
|
90
|
+
}
|
|
91
|
+
function inspectAgent(name, definition, source) {
|
|
92
|
+
const issues = permissionIssues(name, definition);
|
|
93
|
+
return { source, healthy: issues.length === 0, issues };
|
|
94
|
+
}
|
|
95
|
+
/** Record the effective agent config after the plugin config hook runs. */
|
|
96
|
+
export function recordAgentConfig(config, generationEnabled, shipped) {
|
|
97
|
+
const configured = asRecord(config.agent);
|
|
98
|
+
const agents = {};
|
|
99
|
+
for (const name of AGENT_NAMES) {
|
|
100
|
+
const definition = configured?.[name];
|
|
101
|
+
const source = definition === undefined
|
|
102
|
+
? "missing"
|
|
103
|
+
: definitionsEqual(shipped[name], definition)
|
|
104
|
+
? "shipped"
|
|
105
|
+
: "user_override";
|
|
106
|
+
agents[name] = inspectAgent(name, definition, source);
|
|
107
|
+
if (!generationEnabled && definition === undefined) {
|
|
108
|
+
agents[name] = { source: "missing", healthy: true, issues: ["generation disabled; agent not injected"] };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
snapshot = { observed: true, generationEnabled, agents };
|
|
112
|
+
}
|
|
113
|
+
export function getAgentHealth() {
|
|
114
|
+
return {
|
|
115
|
+
observed: snapshot.observed,
|
|
116
|
+
generationEnabled: snapshot.generationEnabled,
|
|
117
|
+
agents: {
|
|
118
|
+
memorize: { ...snapshot.agents.memorize, issues: [...snapshot.agents.memorize.issues] },
|
|
119
|
+
"memorize-extract": { ...snapshot.agents["memorize-extract"], issues: [...snapshot.agents["memorize-extract"].issues] },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Test seam and boot boundary. */
|
|
124
|
+
export function resetAgentHealth() {
|
|
125
|
+
snapshot = {
|
|
126
|
+
observed: false,
|
|
127
|
+
generationEnabled: null,
|
|
128
|
+
agents: {
|
|
129
|
+
memorize: initialEntry(),
|
|
130
|
+
"memorize-extract": initialEntry(),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
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/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { MemoryStore } from "./store.js";
|
|
2
2
|
import type { PluginInput, PluginOptions } from "@opencode-ai/plugin";
|
|
3
|
+
/** Test seam: wait for all hook-launched work, including follow-up phase 2. */
|
|
4
|
+
export declare function waitForBackgroundTasks(): Promise<void>;
|
|
3
5
|
export declare function takeNewCitations(partKey: string, ids: string[]): string[];
|
|
4
6
|
export declare function markTurnSeen(sessionId: string): boolean;
|
|
5
7
|
export declare function shouldHandleIdle(sessionId: string, now?: number): boolean;
|
|
@@ -58,11 +60,13 @@ declare const _default: {
|
|
|
58
60
|
description: string;
|
|
59
61
|
args: {
|
|
60
62
|
path: import("zod").ZodDefault<import("zod").ZodString>;
|
|
63
|
+
cursor: import("zod").ZodOptional<import("zod").ZodString>;
|
|
61
64
|
max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
|
|
62
65
|
};
|
|
63
66
|
execute(args: {
|
|
64
67
|
path: string;
|
|
65
68
|
max_results: number;
|
|
69
|
+
cursor?: string | undefined;
|
|
66
70
|
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
67
71
|
};
|
|
68
72
|
memory_add_note: {
|
package/dist/src/index.js
CHANGED
|
@@ -11,10 +11,26 @@ import { pluginOptions, recordConfigWarning, clearConfigWarnings, resetPluginOpt
|
|
|
11
11
|
import { beginPluginShutdown, isPluginShuttingDown, resetPluginLifecycle } from "./lifecycle.js";
|
|
12
12
|
import { hostMcpStatus } from "./host-client.js";
|
|
13
13
|
import { recordDiagnostic } from "./diagnostics.js";
|
|
14
|
-
import
|
|
14
|
+
import { loadBundledAgentDefinitions, recordAgentConfig, resetAgentHealth } from "./agent-health.js";
|
|
15
15
|
import path from "path";
|
|
16
16
|
let phase1InFlight = false;
|
|
17
17
|
let pluginClient = null;
|
|
18
|
+
const backgroundTasks = new Set();
|
|
19
|
+
function trackBackgroundTask(task) {
|
|
20
|
+
// Hooks must remain non-blocking, but test teardown needs a way to wait until
|
|
21
|
+
// work started by a hook has released its DB handle.
|
|
22
|
+
const tracked = task.catch((err) => {
|
|
23
|
+
console.error("[opencode-codex-memory] background task error:", err);
|
|
24
|
+
});
|
|
25
|
+
backgroundTasks.add(tracked);
|
|
26
|
+
void tracked.then(() => backgroundTasks.delete(tracked));
|
|
27
|
+
}
|
|
28
|
+
/** Test seam: wait for all hook-launched work, including follow-up phase 2. */
|
|
29
|
+
export async function waitForBackgroundTasks() {
|
|
30
|
+
while (backgroundTasks.size > 0) {
|
|
31
|
+
await Promise.all([...backgroundTasks]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
// Single-flight guard for mcp.status(); see mcpToolPrefixes below.
|
|
19
35
|
let mcpStatusInFlight = null;
|
|
20
36
|
const MCP_STATUS_TIMEOUT_MS = 1_000;
|
|
@@ -83,7 +99,7 @@ export function handleSessionDeleted(sessionId, store = getStore(),
|
|
|
83
99
|
// enqueued job runs when generation is re-enabled (codex: delete only
|
|
84
100
|
// enqueues; the pipeline itself is gated elsewhere).
|
|
85
101
|
schedulePhase2 = () => { if (pluginOptions.generate_memories)
|
|
86
|
-
|
|
102
|
+
trackBackgroundTask(triggerPhase2()); }) {
|
|
87
103
|
if (store.deleteSessionMemory(sessionId))
|
|
88
104
|
schedulePhase2();
|
|
89
105
|
}
|
|
@@ -94,6 +110,7 @@ export default {
|
|
|
94
110
|
resetPluginLifecycle();
|
|
95
111
|
setPluginInput(input);
|
|
96
112
|
pluginClient = input.client;
|
|
113
|
+
resetAgentHealth();
|
|
97
114
|
mcpStatusInFlight = null;
|
|
98
115
|
// Unconditional, like the caches above: a boot WITHOUT options must not
|
|
99
116
|
// inherit the previous boot's warnings (opencode can host several
|
|
@@ -320,8 +337,7 @@ async function classifyExternalContextTool(toolName) {
|
|
|
320
337
|
export function injectAgentDefinitions(config) {
|
|
321
338
|
let defs;
|
|
322
339
|
try {
|
|
323
|
-
|
|
324
|
-
defs = JSON.parse(raw).agent ?? {};
|
|
340
|
+
defs = loadBundledAgentDefinitions();
|
|
325
341
|
}
|
|
326
342
|
catch (err) {
|
|
327
343
|
console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err);
|
|
@@ -339,6 +355,7 @@ export function injectAgentDefinitions(config) {
|
|
|
339
355
|
if (!config.agent[name])
|
|
340
356
|
config.agent[name] = def;
|
|
341
357
|
}
|
|
358
|
+
recordAgentConfig(config, true, defs);
|
|
342
359
|
}
|
|
343
360
|
function buildHooks() {
|
|
344
361
|
const base = {
|
|
@@ -346,8 +363,15 @@ function buildHooks() {
|
|
|
346
363
|
try {
|
|
347
364
|
// The write pipeline is the only consumer of the sub-agents; with
|
|
348
365
|
// generation off they would just pollute the user's agent list.
|
|
349
|
-
if (!pluginOptions.generate_memories)
|
|
366
|
+
if (!pluginOptions.generate_memories) {
|
|
367
|
+
try {
|
|
368
|
+
recordAgentConfig(input, false, loadBundledAgentDefinitions());
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
console.warn("[opencode-codex-memory] could not inspect bundled agent definitions:", err);
|
|
372
|
+
}
|
|
350
373
|
return;
|
|
374
|
+
}
|
|
351
375
|
injectAgentDefinitions(input);
|
|
352
376
|
}
|
|
353
377
|
catch (err) {
|
|
@@ -450,7 +474,7 @@ function buildHooks() {
|
|
|
450
474
|
catch (e) {
|
|
451
475
|
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
452
476
|
}
|
|
453
|
-
|
|
477
|
+
trackBackgroundTask(triggerPhase1(sid));
|
|
454
478
|
}
|
|
455
479
|
catch (err) {
|
|
456
480
|
console.error("[opencode-codex-memory] chat.message error:", err);
|
|
@@ -578,7 +602,7 @@ function buildHooks() {
|
|
|
578
602
|
catch (e) {
|
|
579
603
|
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e);
|
|
580
604
|
}
|
|
581
|
-
|
|
605
|
+
trackBackgroundTask(triggerPhase1(sid));
|
|
582
606
|
}
|
|
583
607
|
// Control tools (reset/inspect/mode) are always available. The memory
|
|
584
608
|
// read/search/list/add-note tools require BOTH use_memories and
|
|
@@ -622,7 +646,7 @@ async function triggerPhase1(currentSessionId) {
|
|
|
622
646
|
finally {
|
|
623
647
|
phase1InFlight = false;
|
|
624
648
|
}
|
|
625
|
-
|
|
649
|
+
trackBackgroundTask(triggerPhase2());
|
|
626
650
|
}
|
|
627
651
|
async function triggerPhase2() {
|
|
628
652
|
if (isPluginShuttingDown())
|
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
|