opencode-codex-memory 0.4.7 → 0.4.9
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 +29 -3
- package/dist/opencode.json +1 -1
- package/dist/src/capture.d.ts +2 -7
- package/dist/src/capture.js +32 -23
- package/dist/src/codex-interop.d.ts +8 -0
- package/dist/src/codex-interop.js +22 -0
- package/dist/src/diagnostics.d.ts +26 -0
- package/dist/src/diagnostics.js +37 -0
- package/dist/src/host-client.d.ts +72 -0
- package/dist/src/host-client.js +91 -0
- package/dist/src/index.js +27 -6
- package/dist/src/lifecycle.d.ts +26 -0
- package/dist/src/lifecycle.js +45 -0
- package/dist/src/llm.d.ts +2 -0
- package/dist/src/llm.js +15 -36
- package/dist/src/phase1.js +35 -3
- package/dist/src/phase2.d.ts +1 -2
- package/dist/src/phase2.js +49 -10
- package/dist/src/ratelimit.d.ts +4 -0
- package/dist/src/ratelimit.js +31 -14
- package/dist/src/redact.js +5 -2
- package/dist/src/store.d.ts +31 -0
- package/dist/src/store.js +62 -0
- package/dist/tools/control.js +53 -9
- package/dist/tools/memory.js +19 -14
- package/opencode.json +1 -1
- 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.4.
|
|
55
|
+
"plugin": ["opencode-codex-memory@0.4.9"]
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -239,7 +239,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
|
|
|
239
239
|
```json
|
|
240
240
|
{
|
|
241
241
|
"plugin": [
|
|
242
|
-
["opencode-codex-memory@0.4.
|
|
242
|
+
["opencode-codex-memory@0.4.9", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
|
|
243
243
|
]
|
|
244
244
|
}
|
|
245
245
|
```
|
|
@@ -298,7 +298,7 @@ directions:
|
|
|
298
298
|
```json
|
|
299
299
|
{
|
|
300
300
|
"plugin": [
|
|
301
|
-
["opencode-codex-memory@0.4.
|
|
301
|
+
["opencode-codex-memory@0.4.9", { "codex_interop": { "import": true, "export": true } }]
|
|
302
302
|
]
|
|
303
303
|
}
|
|
304
304
|
```
|
|
@@ -351,6 +351,30 @@ The cost is real: with one store, an unrelated project's details can surface in
|
|
|
351
351
|
the summary. Codex judged that cheaper than the alternative, and this port
|
|
352
352
|
mirrors that decision rather than layering scoping back on top.
|
|
353
353
|
|
|
354
|
+
## Troubleshooting
|
|
355
|
+
|
|
356
|
+
When memory does not seem to build, ask the agent to run **`memory_inspect`**.
|
|
357
|
+
It reports:
|
|
358
|
+
|
|
359
|
+
- stage-1 job counts and recent extraction errors
|
|
360
|
+
- phase-2 status / last error / cooldown
|
|
361
|
+
- last session-discovery outcome
|
|
362
|
+
- effective options (after clamping) and config warnings
|
|
363
|
+
- a short eligibility reminder (`min_rollout_idle_hours`, default **6h**)
|
|
364
|
+
|
|
365
|
+
Common causes:
|
|
366
|
+
|
|
367
|
+
| Symptom | Likely cause |
|
|
368
|
+
|---|---|
|
|
369
|
+
| No stage-1 outputs yet | Sessions must stay idle ≥ `min_rollout_idle_hours` (default 6). For a quick local check, set `"min_rollout_idle_hours": 1`. |
|
|
370
|
+
| Discovery failed | Host API/`experimental/session` unavailable; inspect shows the error. Retry after restarting OpenCode. |
|
|
371
|
+
| Pin stuck on old version | OpenCode freezes bare package specs; pin an explicit version and bump it (see Install). |
|
|
372
|
+
| Consolidation never runs | Check `phase2_status` and `phase2_last_error` in inspect; failed artifacts keep the workspace diff for the next run. |
|
|
373
|
+
|
|
374
|
+
Install target: this package runs **inside OpenCode** (Bun). You do not need to
|
|
375
|
+
install it as a standalone Node app; OpenCode resolves the plugin into its own
|
|
376
|
+
package cache.
|
|
377
|
+
|
|
354
378
|
## Contributing
|
|
355
379
|
|
|
356
380
|
The port follows Codex closely: same two-phase pipeline, same on-disk artifacts,
|
|
@@ -360,6 +384,8 @@ guidance lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md) and
|
|
|
360
384
|
[`AGENTS.md`](./AGENTS.md) — in short: this repo exists to port Codex's memory
|
|
361
385
|
system to OpenCode, and PRs that break that parity will be rejected.
|
|
362
386
|
|
|
387
|
+
CI runs `bun test`, typecheck, build, and the packaging smoke test on every PR.
|
|
388
|
+
|
|
363
389
|
## License
|
|
364
390
|
|
|
365
391
|
Apache 2.0 — the same license as [OpenAI Codex](https://github.com/openai/codex),
|
package/dist/opencode.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"agent": {
|
|
4
4
|
"memorize": {
|
|
5
5
|
"mode": "subagent",
|
|
6
|
-
"prompt": "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/
|
|
6
|
+
"prompt": "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/ under the memory workspace only. Do not read or edit project source files outside that memory root. Keep memory_summary.md under 10000 chars (2500 tokens). Prune stale entries. Do not access the network.",
|
|
7
7
|
"permission": {
|
|
8
8
|
"*": "deny",
|
|
9
9
|
"bash": "deny",
|
package/dist/src/capture.d.ts
CHANGED
|
@@ -4,13 +4,8 @@ export interface SessionRow {
|
|
|
4
4
|
updated_at: number;
|
|
5
5
|
directory: string | null;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
* `GET /experimental/session?roots=true` (Session.listGlobal) — one call across
|
|
10
|
-
* all projects, sorted by most-recently-updated. Available since opencode
|
|
11
|
-
* 1.17.x. Fail-safe: any error skips the pass ([]); never finalizes a job.
|
|
12
|
-
* Transcript loading must NOT be fail-safe — see loadTranscript.
|
|
13
|
-
*/
|
|
7
|
+
/** Test seam. */
|
|
8
|
+
export declare function resetDiscoveryCacheForTest(): void;
|
|
14
9
|
export declare function listRecentSessions(limit?: number): Promise<SessionRow[]>;
|
|
15
10
|
export interface TranscriptMessage {
|
|
16
11
|
type: string;
|
package/dist/src/capture.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SCAN_LIMIT } from "./store.js";
|
|
2
2
|
import { getPluginInput } from "./llm.js";
|
|
3
|
+
import { recordDiscoveryStatus } from "./diagnostics.js";
|
|
4
|
+
import { hostPartType, hostSessionMessages, pluginHttpGet } from "./host-client.js";
|
|
3
5
|
const API_TIMEOUT_MS = 60_000;
|
|
4
6
|
async function withTimeout(promise, ms, label) {
|
|
5
7
|
let timer;
|
|
@@ -15,19 +17,6 @@ async function withTimeout(promise, ms, label) {
|
|
|
15
17
|
clearTimeout(timer);
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Hey-api transport on PluginInput.client. The V1 SDK has no
|
|
20
|
-
* `experimental.*` namespace, but the host-built client already carries
|
|
21
|
-
* baseUrl + auth headers; `_client.get` is the supported escape hatch for
|
|
22
|
-
* routes the generated surface lags on (same pattern as the scope/roots casts
|
|
23
|
-
* we used to need on session.list).
|
|
24
|
-
*/
|
|
25
|
-
function pluginHttp() {
|
|
26
|
-
const http = getPluginInput()?.client?._client;
|
|
27
|
-
if (!http || typeof http.get !== "function")
|
|
28
|
-
return null;
|
|
29
|
-
return http;
|
|
30
|
-
}
|
|
31
20
|
/**
|
|
32
21
|
* Global session discovery through the official API:
|
|
33
22
|
* `GET /experimental/session?roots=true` (Session.listGlobal) — one call across
|
|
@@ -35,12 +24,30 @@ function pluginHttp() {
|
|
|
35
24
|
* 1.17.x. Fail-safe: any error skips the pass ([]); never finalizes a job.
|
|
36
25
|
* Transcript loading must NOT be fail-safe — see loadTranscript.
|
|
37
26
|
*/
|
|
27
|
+
// Empty phase-1 passes no longer stamp the process rate-limit timer, so idle/
|
|
28
|
+
// chat.message can re-enter often. Coalesce discovery API calls to avoid
|
|
29
|
+
// hammering the host on every no-claim pass (load only; eligibility still
|
|
30
|
+
// uses a fresh filter over this short-lived snapshot).
|
|
31
|
+
const DISCOVERY_CACHE_MS = 30_000;
|
|
32
|
+
let discoveryCache = null;
|
|
33
|
+
/** Test seam. */
|
|
34
|
+
export function resetDiscoveryCacheForTest() {
|
|
35
|
+
discoveryCache = null;
|
|
36
|
+
}
|
|
38
37
|
export async function listRecentSessions(limit = SCAN_LIMIT) {
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (discoveryCache &&
|
|
40
|
+
discoveryCache.limit >= limit &&
|
|
41
|
+
now - discoveryCache.at < DISCOVERY_CACHE_MS) {
|
|
42
|
+
return discoveryCache.rows.slice(0, limit);
|
|
43
|
+
}
|
|
44
|
+
const get = pluginHttpGet(getPluginInput()?.client);
|
|
45
|
+
if (!get) {
|
|
46
|
+
recordDiscoveryStatus({ ok: false, count: 0, error: "plugin HTTP client unavailable" });
|
|
41
47
|
return [];
|
|
48
|
+
}
|
|
42
49
|
try {
|
|
43
|
-
const res = await withTimeout(
|
|
50
|
+
const res = await withTimeout(get({
|
|
44
51
|
url: "/experimental/session",
|
|
45
52
|
query: { roots: true, limit },
|
|
46
53
|
}), API_TIMEOUT_MS, "experimental.session.list");
|
|
@@ -61,20 +68,22 @@ export async function listRecentSessions(limit = SCAN_LIMIT) {
|
|
|
61
68
|
// Server already orders by time_updated DESC; re-sort so a lagging host
|
|
62
69
|
// cannot invert eligibility order.
|
|
63
70
|
all.sort((a, b) => b.updated_at - a.updated_at);
|
|
64
|
-
|
|
71
|
+
const out = all.slice(0, limit);
|
|
72
|
+
discoveryCache = { at: now, limit, rows: out };
|
|
73
|
+
recordDiscoveryStatus({ ok: true, count: out.length });
|
|
74
|
+
return out;
|
|
65
75
|
}
|
|
66
76
|
catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
78
|
console.warn("[opencode-codex-memory] session discovery failed; skipping pass:", err);
|
|
79
|
+
recordDiscoveryStatus({ ok: false, count: 0, error: message });
|
|
80
|
+
// Do not cache failures — next pass should retry the host.
|
|
68
81
|
return [];
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
/** Official transcript surface: GET /session/{id}/message via the plugin's authenticated client. */
|
|
72
85
|
async function fetchMessagesViaApi(sessionId) {
|
|
73
|
-
const
|
|
74
|
-
if (typeof client?.session?.messages !== "function") {
|
|
75
|
-
throw new Error("plugin client unavailable; cannot load transcript");
|
|
76
|
-
}
|
|
77
|
-
const res = await withTimeout(client.session.messages({ path: { id: sessionId } }), API_TIMEOUT_MS, "session.messages");
|
|
86
|
+
const res = await withTimeout(hostSessionMessages(getPluginInput()?.client, sessionId), API_TIMEOUT_MS, "session.messages");
|
|
78
87
|
if (!res || res.error || !Array.isArray(res.data)) {
|
|
79
88
|
throw new Error(`session.messages failed: ${JSON.stringify(res?.error ?? {})}`);
|
|
80
89
|
}
|
|
@@ -104,7 +113,7 @@ export async function loadTranscript(sessionId) {
|
|
|
104
113
|
const role = row?.info?.role;
|
|
105
114
|
for (const part of row?.parts ?? []) {
|
|
106
115
|
out.push({
|
|
107
|
-
type: part
|
|
116
|
+
type: hostPartType(part),
|
|
108
117
|
role,
|
|
109
118
|
text: extractText(part),
|
|
110
119
|
});
|
|
@@ -36,3 +36,11 @@ export declare function syncCodexImport(codexMemoryRoot: string): boolean;
|
|
|
36
36
|
* placeholder MEMORY.md / empty summary would just be noise.
|
|
37
37
|
*/
|
|
38
38
|
export declare function exportToCodexMemory(codexMemoryRoot: string): boolean;
|
|
39
|
+
export interface CodexInteropMtimes {
|
|
40
|
+
importMemoryMd: number | null;
|
|
41
|
+
importSummary: number | null;
|
|
42
|
+
exportMemoryMd: number | null;
|
|
43
|
+
exportSummary: number | null;
|
|
44
|
+
}
|
|
45
|
+
/** Last mtimes of interop resource copies (for memory_inspect). */
|
|
46
|
+
export declare function codexInteropMtimes(codexMemoryRoot: string): CodexInteropMtimes;
|
|
@@ -311,3 +311,25 @@ export function exportToCodexMemory(codexMemoryRoot) {
|
|
|
311
311
|
return false;
|
|
312
312
|
return syncExtension(memoryRoot(), codexMemoryRoot, EXPORT_EXTENSION, "opencode", EXPORT_INSTRUCTIONS);
|
|
313
313
|
}
|
|
314
|
+
function mtimeMs(file) {
|
|
315
|
+
try {
|
|
316
|
+
const st = fs.lstatSync(file);
|
|
317
|
+
if (!st.isFile())
|
|
318
|
+
return null;
|
|
319
|
+
return st.mtimeMs;
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Last mtimes of interop resource copies (for memory_inspect). */
|
|
326
|
+
export function codexInteropMtimes(codexMemoryRoot) {
|
|
327
|
+
const importRes = path.join(memoryRoot(), "extensions", IMPORT_EXTENSION, "resources", "codex");
|
|
328
|
+
const exportRes = path.join(codexMemoryRoot, "extensions", EXPORT_EXTENSION, "resources", "opencode");
|
|
329
|
+
return {
|
|
330
|
+
importMemoryMd: mtimeMs(path.join(importRes, "MEMORY.md")),
|
|
331
|
+
importSummary: mtimeMs(path.join(importRes, "memory_summary.md")),
|
|
332
|
+
exportMemoryMd: mtimeMs(path.join(exportRes, "MEMORY.md")),
|
|
333
|
+
exportSummary: mtimeMs(path.join(exportRes, "memory_summary.md")),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local diagnostics for memory_inspect: a small ring buffer of
|
|
3
|
+
* pipeline events plus the last discovery outcome. Not metrics infrastructure
|
|
4
|
+
* (codex OTEL) — just enough to answer "why isn't memory building?" without
|
|
5
|
+
* digging through TUI-invisible console logs.
|
|
6
|
+
*/
|
|
7
|
+
export type DiagnosticLevel = "info" | "warn" | "error";
|
|
8
|
+
export interface DiagnosticEvent {
|
|
9
|
+
at: number;
|
|
10
|
+
level: DiagnosticLevel;
|
|
11
|
+
kind: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DiscoveryStatus {
|
|
15
|
+
at: number;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
count: number;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function recordDiagnostic(level: DiagnosticLevel, kind: string, message: string): void;
|
|
21
|
+
export declare function recordDiscoveryStatus(status: Omit<DiscoveryStatus, "at">): void;
|
|
22
|
+
export declare function getRecentDiagnostics(limit?: number): readonly DiagnosticEvent[];
|
|
23
|
+
export declare function getDiscoveryStatus(): DiscoveryStatus | null;
|
|
24
|
+
/** Test seam. */
|
|
25
|
+
export declare function resetDiagnosticsForTest(): void;
|
|
26
|
+
export declare function formatDiagnosticLine(e: DiagnosticEvent): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local diagnostics for memory_inspect: a small ring buffer of
|
|
3
|
+
* pipeline events plus the last discovery outcome. Not metrics infrastructure
|
|
4
|
+
* (codex OTEL) — just enough to answer "why isn't memory building?" without
|
|
5
|
+
* digging through TUI-invisible console logs.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_EVENTS = 40;
|
|
8
|
+
const events = [];
|
|
9
|
+
let discovery = null;
|
|
10
|
+
export function recordDiagnostic(level, kind, message) {
|
|
11
|
+
events.push({ at: Date.now(), level, kind, message });
|
|
12
|
+
while (events.length > MAX_EVENTS)
|
|
13
|
+
events.shift();
|
|
14
|
+
}
|
|
15
|
+
export function recordDiscoveryStatus(status) {
|
|
16
|
+
discovery = { ...status, at: Date.now() };
|
|
17
|
+
if (!status.ok) {
|
|
18
|
+
recordDiagnostic("warn", "discovery", status.error ?? "session discovery failed");
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
recordDiagnostic("info", "discovery", `listed ${status.count} session(s)`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function getRecentDiagnostics(limit = 12) {
|
|
25
|
+
return events.slice(-limit);
|
|
26
|
+
}
|
|
27
|
+
export function getDiscoveryStatus() {
|
|
28
|
+
return discovery;
|
|
29
|
+
}
|
|
30
|
+
/** Test seam. */
|
|
31
|
+
export function resetDiagnosticsForTest() {
|
|
32
|
+
events.length = 0;
|
|
33
|
+
discovery = null;
|
|
34
|
+
}
|
|
35
|
+
export function formatDiagnosticLine(e) {
|
|
36
|
+
return `${new Date(e.at).toISOString()} [${e.level}] ${e.kind}: ${e.message}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow adapters for host SDK surfaces where the generated OpenAPI types lag
|
|
3
|
+
* the server (see BACKLOG "opencode release ritual"). Keep every intentional
|
|
4
|
+
* cast in this file so grepping for `as any` in pipeline code stays clean and
|
|
5
|
+
* each cast can be dropped when `types.gen.d.ts` catches up.
|
|
6
|
+
*
|
|
7
|
+
* Current gaps (as of @opencode-ai/plugin ~1.18.10):
|
|
8
|
+
* - session.create body: metadata + title typing
|
|
9
|
+
* - session.prompt body: `format` (json_schema) omitted from PromptInput
|
|
10
|
+
* - AssistantMessage.info.structured: structured extraction result
|
|
11
|
+
* - client._client.get: experimental routes (no experimental.* namespace on V1)
|
|
12
|
+
* - mcp.status: present on host client, weakly typed in plugin package
|
|
13
|
+
*/
|
|
14
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
15
|
+
export type HostHttpGet = (opts: {
|
|
16
|
+
url: string;
|
|
17
|
+
query?: Record<string, unknown>;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}) => Promise<{
|
|
20
|
+
error?: unknown;
|
|
21
|
+
data?: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
/** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
|
|
24
|
+
export declare function pluginHttpGet(client: PluginInput["client"] | null | undefined): HostHttpGet | null;
|
|
25
|
+
export interface SessionCreateBody {
|
|
26
|
+
title?: string;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
/** session.create with metadata (SDK body type is incomplete). */
|
|
30
|
+
export declare function hostSessionCreate(client: PluginInput["client"], opts: {
|
|
31
|
+
directory: string;
|
|
32
|
+
body: SessionCreateBody;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
error?: unknown;
|
|
35
|
+
data?: {
|
|
36
|
+
id?: string;
|
|
37
|
+
};
|
|
38
|
+
}>;
|
|
39
|
+
export interface HostPromptBody {
|
|
40
|
+
agent: string;
|
|
41
|
+
system?: string;
|
|
42
|
+
model?: {
|
|
43
|
+
providerID: string;
|
|
44
|
+
modelID: string;
|
|
45
|
+
};
|
|
46
|
+
format?: Record<string, unknown>;
|
|
47
|
+
parts: {
|
|
48
|
+
type: "text";
|
|
49
|
+
text: string;
|
|
50
|
+
}[];
|
|
51
|
+
}
|
|
52
|
+
/** session.prompt including `format` for json_schema structured output. */
|
|
53
|
+
export declare function hostSessionPrompt(client: PluginInput["client"], opts: {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
body: HostPromptBody;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
error?: unknown;
|
|
58
|
+
data?: unknown;
|
|
59
|
+
}>;
|
|
60
|
+
/** Read AssistantMessage.structured when the host captured json_schema output. */
|
|
61
|
+
export declare function hostStructuredOutput(data: unknown): Record<string, unknown> | null;
|
|
62
|
+
export declare function hostMcpStatus(client: PluginInput["client"] | null, signal?: AbortSignal): Promise<{
|
|
63
|
+
error?: unknown;
|
|
64
|
+
data?: unknown;
|
|
65
|
+
} | null>;
|
|
66
|
+
/** session.messages — weakly typed parts; centralize the cast. */
|
|
67
|
+
export declare function hostSessionMessages(client: PluginInput["client"] | null | undefined, sessionId: string): Promise<{
|
|
68
|
+
error?: unknown;
|
|
69
|
+
data?: unknown;
|
|
70
|
+
}>;
|
|
71
|
+
export declare function hostPartType(part: unknown): string;
|
|
72
|
+
export declare function hostSessionDeletionConfirmed(client: PluginInput["client"], id: string, timeoutMs: number): Promise<boolean>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow adapters for host SDK surfaces where the generated OpenAPI types lag
|
|
3
|
+
* the server (see BACKLOG "opencode release ritual"). Keep every intentional
|
|
4
|
+
* cast in this file so grepping for `as any` in pipeline code stays clean and
|
|
5
|
+
* each cast can be dropped when `types.gen.d.ts` catches up.
|
|
6
|
+
*
|
|
7
|
+
* Current gaps (as of @opencode-ai/plugin ~1.18.10):
|
|
8
|
+
* - session.create body: metadata + title typing
|
|
9
|
+
* - session.prompt body: `format` (json_schema) omitted from PromptInput
|
|
10
|
+
* - AssistantMessage.info.structured: structured extraction result
|
|
11
|
+
* - client._client.get: experimental routes (no experimental.* namespace on V1)
|
|
12
|
+
* - mcp.status: present on host client, weakly typed in plugin package
|
|
13
|
+
*/
|
|
14
|
+
/** Hey-api transport on PluginInput.client for routes the V1 surface lags on. */
|
|
15
|
+
export function pluginHttpGet(client) {
|
|
16
|
+
const http = client?._client;
|
|
17
|
+
if (!http || typeof http.get !== "function")
|
|
18
|
+
return null;
|
|
19
|
+
return http.get.bind(http);
|
|
20
|
+
}
|
|
21
|
+
/** session.create with metadata (SDK body type is incomplete). */
|
|
22
|
+
export async function hostSessionCreate(client, opts) {
|
|
23
|
+
return client.session.create({
|
|
24
|
+
query: { directory: opts.directory },
|
|
25
|
+
body: opts.body,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** session.prompt including `format` for json_schema structured output. */
|
|
29
|
+
export async function hostSessionPrompt(client, opts) {
|
|
30
|
+
return client.session.prompt({
|
|
31
|
+
path: { id: opts.sessionId },
|
|
32
|
+
body: {
|
|
33
|
+
agent: opts.body.agent,
|
|
34
|
+
...(opts.body.system ? { system: opts.body.system } : {}),
|
|
35
|
+
...(opts.body.model ? { model: opts.body.model } : {}),
|
|
36
|
+
...(opts.body.format ? { format: opts.body.format } : {}),
|
|
37
|
+
parts: opts.body.parts,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Read AssistantMessage.structured when the host captured json_schema output. */
|
|
42
|
+
export function hostStructuredOutput(data) {
|
|
43
|
+
const structured = data?.info?.structured;
|
|
44
|
+
if (structured && typeof structured === "object" && !Array.isArray(structured)) {
|
|
45
|
+
return structured;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export async function hostMcpStatus(client, signal) {
|
|
50
|
+
if (!client)
|
|
51
|
+
return null;
|
|
52
|
+
const mcp = client.mcp;
|
|
53
|
+
if (typeof mcp?.status !== "function")
|
|
54
|
+
return null;
|
|
55
|
+
return (await mcp.status({ signal }));
|
|
56
|
+
}
|
|
57
|
+
/** session.messages — weakly typed parts; centralize the cast. */
|
|
58
|
+
export async function hostSessionMessages(client, sessionId) {
|
|
59
|
+
if (!client || typeof client.session?.messages !== "function") {
|
|
60
|
+
throw new Error("plugin client unavailable; cannot load transcript");
|
|
61
|
+
}
|
|
62
|
+
return client.session.messages({ path: { id: sessionId } });
|
|
63
|
+
}
|
|
64
|
+
export function hostPartType(part) {
|
|
65
|
+
return typeof part?.type === "string" ? part.type : "unknown";
|
|
66
|
+
}
|
|
67
|
+
export async function hostSessionDeletionConfirmed(client, id, timeoutMs) {
|
|
68
|
+
const session = client.session;
|
|
69
|
+
if (typeof session?.get !== "function")
|
|
70
|
+
return false;
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
let timer;
|
|
73
|
+
try {
|
|
74
|
+
const res = await Promise.race([
|
|
75
|
+
session.get({ path: { id }, signal: controller.signal }),
|
|
76
|
+
new Promise((_, reject) => {
|
|
77
|
+
timer = setTimeout(() => {
|
|
78
|
+
controller.abort();
|
|
79
|
+
reject(new Error(`session.get timed out after ${timeoutMs}ms`));
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
return res?.response?.status === 404;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -6,8 +6,11 @@ import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js";
|
|
|
6
6
|
import { MemoryStore } from "./store.js";
|
|
7
7
|
import { runPhase1 } from "./phase1.js";
|
|
8
8
|
import { runPhase2 } from "./phase2.js";
|
|
9
|
-
import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js";
|
|
9
|
+
import { setPluginInput, cleanupOldSubSessions, isMemorySubSession, abortActiveSubSessions } from "./llm.js";
|
|
10
10
|
import { pluginOptions, recordConfigWarning, clearConfigWarnings, resetPluginOptions } from "./options.js";
|
|
11
|
+
import { beginPluginShutdown, isPluginShuttingDown, resetPluginLifecycle } from "./lifecycle.js";
|
|
12
|
+
import { hostMcpStatus } from "./host-client.js";
|
|
13
|
+
import { recordDiagnostic } from "./diagnostics.js";
|
|
11
14
|
import fs from "fs";
|
|
12
15
|
import path from "path";
|
|
13
16
|
let phase1InFlight = false;
|
|
@@ -87,6 +90,8 @@ schedulePhase2 = () => { if (pluginOptions.generate_memories)
|
|
|
87
90
|
export default {
|
|
88
91
|
id: "opencode-codex-memory",
|
|
89
92
|
async server(input, opts) {
|
|
93
|
+
// A reload after dispose must be able to run the pipeline again.
|
|
94
|
+
resetPluginLifecycle();
|
|
90
95
|
setPluginInput(input);
|
|
91
96
|
pluginClient = input.client;
|
|
92
97
|
mcpStatusInFlight = null;
|
|
@@ -222,7 +227,7 @@ async function mcpToolPrefixes() {
|
|
|
222
227
|
let timer;
|
|
223
228
|
try {
|
|
224
229
|
const res = await Promise.race([
|
|
225
|
-
pluginClient
|
|
230
|
+
hostMcpStatus(pluginClient, controller.signal),
|
|
226
231
|
new Promise((_, reject) => {
|
|
227
232
|
timer = setTimeout(() => {
|
|
228
233
|
controller.abort();
|
|
@@ -230,9 +235,9 @@ async function mcpToolPrefixes() {
|
|
|
230
235
|
}, MCP_STATUS_TIMEOUT_MS);
|
|
231
236
|
}),
|
|
232
237
|
]);
|
|
233
|
-
if (res
|
|
238
|
+
if (!res || res.error)
|
|
234
239
|
return null;
|
|
235
|
-
const servers = res
|
|
240
|
+
const servers = res.data;
|
|
236
241
|
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
237
242
|
return null;
|
|
238
243
|
const prefixes = [];
|
|
@@ -516,6 +521,15 @@ function buildHooks() {
|
|
|
516
521
|
}
|
|
517
522
|
},
|
|
518
523
|
async dispose() {
|
|
524
|
+
// Stop new pumps, abort the consolidator helper if it is mid-write, and
|
|
525
|
+
// best-effort abort extract sessions so a reload cannot leave two writers.
|
|
526
|
+
beginPluginShutdown();
|
|
527
|
+
try {
|
|
528
|
+
await abortActiveSubSessions();
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
console.warn("[opencode-codex-memory] dispose abort of sub-sessions failed:", err);
|
|
532
|
+
}
|
|
519
533
|
invalidateCache();
|
|
520
534
|
},
|
|
521
535
|
};
|
|
@@ -557,7 +571,7 @@ function buildHooks() {
|
|
|
557
571
|
return { ...base, tool };
|
|
558
572
|
}
|
|
559
573
|
async function triggerPhase1(currentSessionId) {
|
|
560
|
-
if (phase1InFlight || !pluginOptions.generate_memories)
|
|
574
|
+
if (phase1InFlight || !pluginOptions.generate_memories || isPluginShuttingDown())
|
|
561
575
|
return;
|
|
562
576
|
phase1InFlight = true;
|
|
563
577
|
try {
|
|
@@ -572,6 +586,7 @@ async function triggerPhase1(currentSessionId) {
|
|
|
572
586
|
}
|
|
573
587
|
catch (err) {
|
|
574
588
|
console.error("[opencode-codex-memory] phase1 error:", err);
|
|
589
|
+
recordDiagnostic("error", "phase1", err instanceof Error ? err.message : String(err));
|
|
575
590
|
}
|
|
576
591
|
finally {
|
|
577
592
|
phase1InFlight = false;
|
|
@@ -579,17 +594,23 @@ async function triggerPhase1(currentSessionId) {
|
|
|
579
594
|
void triggerPhase2();
|
|
580
595
|
}
|
|
581
596
|
async function triggerPhase2() {
|
|
597
|
+
if (isPluginShuttingDown())
|
|
598
|
+
return;
|
|
582
599
|
try {
|
|
583
600
|
// runPhase2 has its own in-flight guard
|
|
584
|
-
await runPhase2(getStore(), {
|
|
601
|
+
const result = await runPhase2(getStore(), {
|
|
585
602
|
maxRaw: pluginOptions.max_raw_memories_for_consolidation,
|
|
586
603
|
maxUnusedDays: pluginOptions.max_unused_days,
|
|
587
604
|
extensionRetentionDays: 7,
|
|
588
605
|
consolidationModel: pluginOptions.consolidation_model,
|
|
589
606
|
codexInterop: pluginOptions.codex_interop,
|
|
590
607
|
});
|
|
608
|
+
if (result.status !== "already_running" && result.status !== "skipped_cooldown" && result.status !== "skipped_running") {
|
|
609
|
+
recordDiagnostic(result.status === "succeeded" || result.status === "no_workspace_changes" ? "info" : "warn", "phase2", result.status);
|
|
610
|
+
}
|
|
591
611
|
}
|
|
592
612
|
catch (err) {
|
|
593
613
|
console.error("[opencode-codex-memory] phase2 error:", err);
|
|
614
|
+
recordDiagnostic("error", "phase2", err instanceof Error ? err.message : String(err));
|
|
594
615
|
}
|
|
595
616
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin process lifecycle: shutdown flag + phase-2 abort signal shared by
|
|
3
|
+
* the entry dispose hook and the write pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Opencode can reload plugins while a consolidator helper still holds write
|
|
6
|
+
* access to the memory root. dispose() sets the flag (so new pumps stop),
|
|
7
|
+
* aborts the in-flight consolidation prompt, and best-effort aborts active
|
|
8
|
+
* sub-sessions (llm.ts).
|
|
9
|
+
*/
|
|
10
|
+
export declare function isPluginShuttingDown(): boolean;
|
|
11
|
+
/** Begin shutdown: no new phase work, abort any in-flight consolidator. */
|
|
12
|
+
export declare function beginPluginShutdown(): void;
|
|
13
|
+
/**
|
|
14
|
+
* Test / re-boot seam: a fresh server() call clears the previous dispose.
|
|
15
|
+
* Abort any live consolidator controller before dropping the reference so a
|
|
16
|
+
* glitched boot order cannot orphan a still-running phase-2 prompt.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resetPluginLifecycle(): void;
|
|
19
|
+
/**
|
|
20
|
+
* AbortSignal for the current phase-2 consolidation run. Created when the job
|
|
21
|
+
* is claimed; aborted on heartbeat loss, dispose, or run end.
|
|
22
|
+
*/
|
|
23
|
+
export declare function beginPhase2AbortScope(): AbortSignal;
|
|
24
|
+
export declare function endPhase2AbortScope(): void;
|
|
25
|
+
/** Abort the current consolidator from outside phase2 (dispose). */
|
|
26
|
+
export declare function abortPhase2Consolidation(): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin process lifecycle: shutdown flag + phase-2 abort signal shared by
|
|
3
|
+
* the entry dispose hook and the write pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Opencode can reload plugins while a consolidator helper still holds write
|
|
6
|
+
* access to the memory root. dispose() sets the flag (so new pumps stop),
|
|
7
|
+
* aborts the in-flight consolidation prompt, and best-effort aborts active
|
|
8
|
+
* sub-sessions (llm.ts).
|
|
9
|
+
*/
|
|
10
|
+
let shuttingDown = false;
|
|
11
|
+
let phase2Abort = null;
|
|
12
|
+
export function isPluginShuttingDown() {
|
|
13
|
+
return shuttingDown;
|
|
14
|
+
}
|
|
15
|
+
/** Begin shutdown: no new phase work, abort any in-flight consolidator. */
|
|
16
|
+
export function beginPluginShutdown() {
|
|
17
|
+
shuttingDown = true;
|
|
18
|
+
phase2Abort?.abort();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Test / re-boot seam: a fresh server() call clears the previous dispose.
|
|
22
|
+
* Abort any live consolidator controller before dropping the reference so a
|
|
23
|
+
* glitched boot order cannot orphan a still-running phase-2 prompt.
|
|
24
|
+
*/
|
|
25
|
+
export function resetPluginLifecycle() {
|
|
26
|
+
phase2Abort?.abort();
|
|
27
|
+
phase2Abort = null;
|
|
28
|
+
shuttingDown = false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* AbortSignal for the current phase-2 consolidation run. Created when the job
|
|
32
|
+
* is claimed; aborted on heartbeat loss, dispose, or run end.
|
|
33
|
+
*/
|
|
34
|
+
export function beginPhase2AbortScope() {
|
|
35
|
+
phase2Abort?.abort();
|
|
36
|
+
phase2Abort = new AbortController();
|
|
37
|
+
return phase2Abort.signal;
|
|
38
|
+
}
|
|
39
|
+
export function endPhase2AbortScope() {
|
|
40
|
+
phase2Abort = null;
|
|
41
|
+
}
|
|
42
|
+
/** Abort the current consolidator from outside phase2 (dispose). */
|
|
43
|
+
export function abortPhase2Consolidation() {
|
|
44
|
+
phase2Abort?.abort();
|
|
45
|
+
}
|
package/dist/src/llm.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ExtractOptions {
|
|
|
37
37
|
export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
|
|
38
38
|
export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string, signal?: AbortSignal): Promise<void>;
|
|
39
39
|
export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
|
|
40
|
+
/** Best-effort abort of every memory sub-session (plugin dispose / reload). */
|
|
41
|
+
export declare function abortActiveSubSessions(): Promise<void>;
|
|
40
42
|
export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
|
|
41
43
|
export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
|
|
42
44
|
/**
|