opencode-codex-memory 0.4.3 → 0.4.5
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 +5 -3
- package/dist/src/codex-interop.js +3 -6
- package/dist/src/db.js +11 -7
- package/dist/src/git-baseline.js +3 -6
- package/dist/src/index.js +45 -20
- package/dist/src/llm.d.ts +5 -1
- package/dist/src/llm.js +44 -7
- package/dist/src/options.d.ts +1 -0
- package/dist/src/options.js +12 -1
- package/dist/src/path-guard.d.ts +15 -0
- package/dist/src/path-guard.js +80 -0
- package/dist/src/phase1.d.ts +2 -1
- package/dist/src/phase1.js +8 -2
- package/dist/src/phase2.d.ts +2 -0
- package/dist/src/phase2.js +30 -7
- package/dist/src/source.js +16 -24
- package/dist/src/store.d.ts +17 -1
- package/dist/src/store.js +46 -5
- package/dist/src/workspace.js +8 -8
- package/dist/tools/control.js +46 -15
- package/dist/tools/memory.js +7 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ project is a faithful port of the memory system in OpenAI's Codex. It works out
|
|
|
18
18
|
of the box with zero extra configuration and uses whatever models you already
|
|
19
19
|
have set up in OpenCode.
|
|
20
20
|
|
|
21
|
+
See the [changelog](./CHANGELOG.md) for release history.
|
|
22
|
+
|
|
21
23
|
**Local-first by design.** Memory is plain markdown files plus a small SQLite
|
|
22
24
|
database on your own machine — no memory service to sign up for, no MCP server,
|
|
23
25
|
no separate process, no sync. Installing it is one line in your `opencode.json`;
|
|
@@ -50,7 +52,7 @@ If you want the mental model before the details, jump to
|
|
|
50
52
|
|
|
51
53
|
```json
|
|
52
54
|
{
|
|
53
|
-
"plugin": ["opencode-codex-memory@0.4.
|
|
55
|
+
"plugin": ["opencode-codex-memory@0.4.5"]
|
|
54
56
|
}
|
|
55
57
|
```
|
|
56
58
|
|
|
@@ -237,7 +239,7 @@ To set options, turn the plugin entry into a `[name, options]` pair:
|
|
|
237
239
|
```json
|
|
238
240
|
{
|
|
239
241
|
"plugin": [
|
|
240
|
-
["opencode-codex-memory@0.4.
|
|
242
|
+
["opencode-codex-memory@0.4.5", { "disable_on_external_context": true, "min_rollout_idle_hours": 2 }]
|
|
241
243
|
]
|
|
242
244
|
}
|
|
243
245
|
```
|
|
@@ -296,7 +298,7 @@ directions:
|
|
|
296
298
|
```json
|
|
297
299
|
{
|
|
298
300
|
"plugin": [
|
|
299
|
-
["opencode-codex-memory@0.4.
|
|
301
|
+
["opencode-codex-memory@0.4.5", { "codex_interop": { "import": true, "export": true } }]
|
|
300
302
|
]
|
|
301
303
|
}
|
|
302
304
|
```
|
|
@@ -2,7 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import os from "os";
|
|
4
4
|
import { memoryRoot } from "./paths.js";
|
|
5
|
-
import { safeResolveUnderRoot } from "./path-guard.js";
|
|
5
|
+
import { readRegularFileNoFollow, safeResolveUnderRoot, writeRegularFileNoFollow } from "./path-guard.js";
|
|
6
6
|
/**
|
|
7
7
|
* Codex interop: memory exchange with an upstream Codex CLI installation on
|
|
8
8
|
* the same machine, in both directions, through the generic extensions
|
|
@@ -202,10 +202,7 @@ export function resolveCodexInterop(opts) {
|
|
|
202
202
|
}
|
|
203
203
|
function readIfFile(file) {
|
|
204
204
|
try {
|
|
205
|
-
|
|
206
|
-
if (!st.isFile())
|
|
207
|
-
return null;
|
|
208
|
-
return fs.readFileSync(file);
|
|
205
|
+
return readRegularFileNoFollow(file).content;
|
|
209
206
|
}
|
|
210
207
|
catch {
|
|
211
208
|
return null;
|
|
@@ -226,7 +223,7 @@ function writeIfChanged(file, content) {
|
|
|
226
223
|
}
|
|
227
224
|
catch { }
|
|
228
225
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
229
|
-
|
|
226
|
+
writeRegularFileNoFollow(file, next);
|
|
230
227
|
return true;
|
|
231
228
|
}
|
|
232
229
|
/**
|
package/dist/src/db.js
CHANGED
|
@@ -68,13 +68,17 @@ function runMigrations(db) {
|
|
|
68
68
|
version INTEGER NOT NULL,
|
|
69
69
|
applied_at INTEGER NOT NULL
|
|
70
70
|
)`);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
db.transaction(() => {
|
|
72
|
+
// Read the version only after taking the write lock so concurrent plugin
|
|
73
|
+
// instances cannot both apply the same ALTER TABLE.
|
|
74
|
+
const current = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get();
|
|
75
|
+
const currentVersion = current?.version ?? 0;
|
|
76
|
+
if (currentVersion < 1) {
|
|
77
|
+
for (const stmt of SCHEMA_V1)
|
|
78
|
+
db.run(stmt);
|
|
79
|
+
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run(1, Date.now());
|
|
80
|
+
}
|
|
81
|
+
}).immediate();
|
|
78
82
|
}
|
|
79
83
|
export function closeDb() {
|
|
80
84
|
if (dbInstance) {
|
package/dist/src/git-baseline.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { memoryRoot } from "./paths.js";
|
|
4
4
|
import * as isogit from "isomorphic-git";
|
|
5
5
|
import { createPatch } from "diff";
|
|
6
|
+
import { readRegularFileNoFollow, safeResolveUnderRoot } from "./path-guard.js";
|
|
6
7
|
const AUTHOR = { name: "opencode-codex-memory", email: "memory@opencode.local" };
|
|
7
8
|
// Generated prompt artifact; removed before diffing and before baseline
|
|
8
9
|
// commits (mirrors codex's remove_workspace_diff) so it never enters the
|
|
@@ -120,12 +121,8 @@ async function readBaselineText(dir, headOid, filepath) {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
function readWorkdirText(dir, filepath) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return "";
|
|
128
|
-
}
|
|
124
|
+
const file = safeResolveUnderRoot(dir, filepath);
|
|
125
|
+
return readRegularFileNoFollow(file).content.toString("utf8");
|
|
129
126
|
}
|
|
130
127
|
// Throws on failure: codex fails the phase-2 job on workspace-status errors
|
|
131
128
|
// (failed_workspace_status). Swallowing the error here would make an errored
|
package/dist/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { MemoryStore } from "./store.js";
|
|
|
7
7
|
import { runPhase1 } from "./phase1.js";
|
|
8
8
|
import { runPhase2 } from "./phase2.js";
|
|
9
9
|
import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js";
|
|
10
|
-
import { pluginOptions, recordConfigWarning, clearConfigWarnings } from "./options.js";
|
|
10
|
+
import { pluginOptions, recordConfigWarning, clearConfigWarnings, resetPluginOptions } from "./options.js";
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
13
|
let phase1InFlight = false;
|
|
@@ -96,6 +96,8 @@ export default {
|
|
|
96
96
|
clearConfigWarnings();
|
|
97
97
|
if (opts)
|
|
98
98
|
applyPluginOptions(opts);
|
|
99
|
+
else
|
|
100
|
+
resetPluginOptions();
|
|
99
101
|
// Finish bounded reseeding before hooks can see a surviving memory
|
|
100
102
|
// sub-session after a plugin reload.
|
|
101
103
|
await cleanupOldSubSessions();
|
|
@@ -116,18 +118,23 @@ const KNOWN_OPTION_KEYS = new Set([
|
|
|
116
118
|
"min_rollout_idle_hours",
|
|
117
119
|
"codex_interop",
|
|
118
120
|
]);
|
|
121
|
+
const KNOWN_CODEX_INTEROP_KEYS = new Set(["import", "export", "codex_home"]);
|
|
119
122
|
// codex clamps numeric knobs in From<MemoriesToml> for MemoriesConfig
|
|
120
123
|
// (config/src/types.rs); mirror the exact ranges. Non-finite values fall back
|
|
121
124
|
// to the default.
|
|
122
|
-
function clampInt(value, min, max, fallback) {
|
|
123
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
125
|
+
function clampInt(key, value, min, max, fallback) {
|
|
126
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
127
|
+
recordConfigWarning(`${key} must be a finite number; using default ${fallback}`);
|
|
124
128
|
return fallback;
|
|
129
|
+
}
|
|
125
130
|
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
126
131
|
}
|
|
127
132
|
export function applyPluginOptions(opts) {
|
|
128
133
|
// Fresh pass per apply so memory_inspect never shows warnings for keys the
|
|
129
134
|
// caller has since fixed. server() clears too, for boots without options.
|
|
130
135
|
clearConfigWarnings();
|
|
136
|
+
resetPluginOptions();
|
|
137
|
+
const raw = opts;
|
|
131
138
|
for (const key of Object.keys(opts)) {
|
|
132
139
|
if (!KNOWN_OPTION_KEYS.has(key)) {
|
|
133
140
|
// codex uses deny_unknown_fields; a plugin can only warn (recorded for
|
|
@@ -136,32 +143,50 @@ export function applyPluginOptions(opts) {
|
|
|
136
143
|
recordConfigWarning(`unknown/unsupported option '${key}' ignored`);
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
for (const key of ["generate_memories", "use_memories", "dedicated_tools", "disable_on_external_context"]) {
|
|
147
|
+
if (!(key in raw))
|
|
148
|
+
continue;
|
|
149
|
+
if (typeof raw[key] === "boolean")
|
|
150
|
+
pluginOptions[key] = raw[key];
|
|
151
|
+
else
|
|
152
|
+
recordConfigWarning(`${key} must be a boolean; using default ${pluginOptions[key]}`);
|
|
153
|
+
}
|
|
154
|
+
for (const key of ["extract_model", "consolidation_model"]) {
|
|
155
|
+
if (!(key in raw))
|
|
156
|
+
continue;
|
|
157
|
+
if (typeof raw[key] === "string")
|
|
158
|
+
pluginOptions[key] = raw[key];
|
|
159
|
+
else
|
|
160
|
+
recordConfigWarning(`${key} must be a string; using the opencode model default`);
|
|
161
|
+
}
|
|
151
162
|
if ("max_raw_memories_for_consolidation" in opts)
|
|
152
|
-
pluginOptions.max_raw_memories_for_consolidation = clampInt(opts.max_raw_memories_for_consolidation, 1, 4096, 256);
|
|
163
|
+
pluginOptions.max_raw_memories_for_consolidation = clampInt("max_raw_memories_for_consolidation", opts.max_raw_memories_for_consolidation, 1, 4096, 256);
|
|
153
164
|
if ("max_unused_days" in opts)
|
|
154
|
-
pluginOptions.max_unused_days = clampInt(opts.max_unused_days, 0, 365, 30);
|
|
165
|
+
pluginOptions.max_unused_days = clampInt("max_unused_days", opts.max_unused_days, 0, 365, 30);
|
|
155
166
|
if ("max_rollout_age_days" in opts)
|
|
156
|
-
pluginOptions.max_rollout_age_days = clampInt(opts.max_rollout_age_days, 0, 90, 10);
|
|
167
|
+
pluginOptions.max_rollout_age_days = clampInt("max_rollout_age_days", opts.max_rollout_age_days, 0, 90, 10);
|
|
157
168
|
if ("max_rollouts_per_startup" in opts)
|
|
158
|
-
pluginOptions.max_rollouts_per_startup = clampInt(opts.max_rollouts_per_startup, 1, 128, 2);
|
|
169
|
+
pluginOptions.max_rollouts_per_startup = clampInt("max_rollouts_per_startup", opts.max_rollouts_per_startup, 1, 128, 2);
|
|
159
170
|
if ("min_rollout_idle_hours" in opts)
|
|
160
|
-
pluginOptions.min_rollout_idle_hours = clampInt(opts.min_rollout_idle_hours, 1, 48, 6);
|
|
171
|
+
pluginOptions.min_rollout_idle_hours = clampInt("min_rollout_idle_hours", opts.min_rollout_idle_hours, 1, 48, 6);
|
|
161
172
|
if ("codex_interop" in opts) {
|
|
162
173
|
const raw = opts.codex_interop;
|
|
163
174
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
164
175
|
const o = raw;
|
|
176
|
+
for (const key of Object.keys(o)) {
|
|
177
|
+
if (!KNOWN_CODEX_INTEROP_KEYS.has(key)) {
|
|
178
|
+
recordConfigWarning(`unknown codex_interop option '${key}' ignored`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if ("import" in o && typeof o.import !== "boolean") {
|
|
182
|
+
recordConfigWarning("codex_interop.import must be a boolean; using false");
|
|
183
|
+
}
|
|
184
|
+
if ("export" in o && typeof o.export !== "boolean") {
|
|
185
|
+
recordConfigWarning("codex_interop.export must be a boolean; using false");
|
|
186
|
+
}
|
|
187
|
+
if ("codex_home" in o && (typeof o.codex_home !== "string" || o.codex_home.length === 0)) {
|
|
188
|
+
recordConfigWarning("codex_interop.codex_home must be a non-empty string; using the default Codex home");
|
|
189
|
+
}
|
|
165
190
|
pluginOptions.codex_interop = {
|
|
166
191
|
import: o.import === true,
|
|
167
192
|
export: o.export === true,
|
package/dist/src/llm.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export declare function isMemorySubSession(sessionId: string): boolean;
|
|
|
15
15
|
export declare class SubagentTimeoutError extends Error {
|
|
16
16
|
constructor(timeoutMs: number);
|
|
17
17
|
}
|
|
18
|
+
/** Thrown after an external owner cancels a running sub-agent prompt. */
|
|
19
|
+
export declare class SubagentCancelledError extends Error {
|
|
20
|
+
constructor();
|
|
21
|
+
}
|
|
18
22
|
/**
|
|
19
23
|
* Thrown when a sub-agent session could not be closed. Codex treats a failed
|
|
20
24
|
* consolidation-agent shutdown as "the agent may still be alive", so the caller
|
|
@@ -31,7 +35,7 @@ export interface ExtractOptions {
|
|
|
31
35
|
}
|
|
32
36
|
/** Returns null when the extractor reported a no-op (nothing worth remembering). */
|
|
33
37
|
export declare function extractViaSubagent(sessionId: string, transcript: string, opts?: ExtractOptions): Promise<ExtractionResult | null>;
|
|
34
|
-
export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void>;
|
|
38
|
+
export declare function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string, signal?: AbortSignal): Promise<void>;
|
|
35
39
|
export declare function cleanupOldSubSessions(maxAgeMinutes?: number, timeoutMs?: number): Promise<void>;
|
|
36
40
|
export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
|
|
37
41
|
export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
|
package/dist/src/llm.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
let inputRef = null;
|
|
4
4
|
export function setPluginInput(input) {
|
|
5
5
|
inputRef = input;
|
|
6
|
+
configModels = null;
|
|
6
7
|
}
|
|
7
8
|
export function getPluginInput() {
|
|
8
9
|
return inputRef;
|
|
@@ -15,6 +16,7 @@ const SUBSESSION_METADATA_KEY = "opencode-codex-memory";
|
|
|
15
16
|
const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
|
|
16
17
|
const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
|
|
17
18
|
const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
|
|
19
|
+
const SUBSESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
18
20
|
export function isMemorySubSession(sessionId) {
|
|
19
21
|
return activeSubSessions.has(sessionId);
|
|
20
22
|
}
|
|
@@ -81,6 +83,13 @@ export class SubagentTimeoutError extends Error {
|
|
|
81
83
|
this.name = "SubagentTimeoutError";
|
|
82
84
|
}
|
|
83
85
|
}
|
|
86
|
+
/** Thrown after an external owner cancels a running sub-agent prompt. */
|
|
87
|
+
export class SubagentCancelledError extends Error {
|
|
88
|
+
constructor() {
|
|
89
|
+
super("sub-agent prompt cancelled");
|
|
90
|
+
this.name = "SubagentCancelledError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
84
93
|
/**
|
|
85
94
|
* Thrown when a sub-agent session could not be closed. Codex treats a failed
|
|
86
95
|
* consolidation-agent shutdown as "the agent may still be alive", so the caller
|
|
@@ -123,6 +132,10 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
123
132
|
const input = getPluginInput();
|
|
124
133
|
if (!input)
|
|
125
134
|
throw new Error("plugin input not initialized");
|
|
135
|
+
if (opts.signal?.aborted) {
|
|
136
|
+
await abortSession(sessionId);
|
|
137
|
+
throw new SubagentCancelledError();
|
|
138
|
+
}
|
|
126
139
|
const model = opts.model ? parseModelRef(opts.model) : null;
|
|
127
140
|
const promptPromise = input.client.session.prompt({
|
|
128
141
|
path: { id: sessionId },
|
|
@@ -137,9 +150,17 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
137
150
|
},
|
|
138
151
|
});
|
|
139
152
|
let timer;
|
|
153
|
+
let onAbort;
|
|
140
154
|
try {
|
|
155
|
+
const cancellation = new Promise((_, reject) => {
|
|
156
|
+
if (!opts.signal)
|
|
157
|
+
return;
|
|
158
|
+
onAbort = () => reject(new SubagentCancelledError());
|
|
159
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
160
|
+
});
|
|
141
161
|
const res = await Promise.race([
|
|
142
162
|
promptPromise,
|
|
163
|
+
cancellation,
|
|
143
164
|
new Promise((_, reject) => {
|
|
144
165
|
timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
|
|
145
166
|
}),
|
|
@@ -154,16 +175,18 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
154
175
|
return res.data;
|
|
155
176
|
}
|
|
156
177
|
catch (err) {
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// burning — deleteSession in the caller
|
|
160
|
-
if (err instanceof SubagentTimeoutError) {
|
|
178
|
+
// A timeout or owner cancellation can leave the turn running server-side;
|
|
179
|
+
// other failures mean the request already settled. Stop the live run so
|
|
180
|
+
// tokens stop burning — deleteSession in the caller is the backup.
|
|
181
|
+
if (err instanceof SubagentTimeoutError || err instanceof SubagentCancelledError) {
|
|
161
182
|
await abortSession(sessionId);
|
|
162
183
|
}
|
|
163
184
|
throw err;
|
|
164
185
|
}
|
|
165
186
|
finally {
|
|
166
187
|
clearTimeout(timer);
|
|
188
|
+
if (onAbort)
|
|
189
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
192
|
async function promptSession(sessionId, prompt, agent, opts = {}) {
|
|
@@ -245,7 +268,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
245
268
|
// its INIT pass is explicitly allowed to run long ("do not be lazy"). A short
|
|
246
269
|
// timeout here would fail the job after the workspace was already synced.
|
|
247
270
|
const CONSOLIDATION_TIMEOUT_MS = 3600_000;
|
|
248
|
-
export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
|
|
271
|
+
export async function consolidateViaSubagent(memoryRoot, diffFileName, model, signal) {
|
|
249
272
|
const agent = "memorize";
|
|
250
273
|
const subId = await createSession(agent, "codex-memory-consolidate");
|
|
251
274
|
let promptError;
|
|
@@ -254,7 +277,7 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
|
|
|
254
277
|
const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
|
|
255
278
|
// consolidation_model option > opencode model (main) > session default.
|
|
256
279
|
const resolved = model ?? (await getConfigModels()).model;
|
|
257
|
-
await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS });
|
|
280
|
+
await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS, signal });
|
|
258
281
|
}
|
|
259
282
|
catch (err) {
|
|
260
283
|
promptError = err;
|
|
@@ -334,8 +357,19 @@ async function deleteSession(id) {
|
|
|
334
357
|
const input = getPluginInput();
|
|
335
358
|
if (!input)
|
|
336
359
|
return false;
|
|
360
|
+
const controller = new AbortController();
|
|
361
|
+
let timer;
|
|
337
362
|
try {
|
|
338
|
-
const res = await
|
|
363
|
+
const res = await Promise.race([
|
|
364
|
+
input.client.session.delete({ path: { id }, signal: controller.signal }),
|
|
365
|
+
new Promise((_, reject) => {
|
|
366
|
+
timer = setTimeout(() => {
|
|
367
|
+
controller.abort();
|
|
368
|
+
reject(new Error(`session.delete timed out after ${SUBSESSION_DELETE_TIMEOUT_MS}ms`));
|
|
369
|
+
}, SUBSESSION_DELETE_TIMEOUT_MS);
|
|
370
|
+
timer.unref?.();
|
|
371
|
+
}),
|
|
372
|
+
]);
|
|
339
373
|
if (res.error) {
|
|
340
374
|
console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
|
|
341
375
|
return false;
|
|
@@ -354,6 +388,9 @@ async function deleteSession(id) {
|
|
|
354
388
|
console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
|
|
355
389
|
return false;
|
|
356
390
|
}
|
|
391
|
+
finally {
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
}
|
|
357
394
|
}
|
|
358
395
|
async function sessionDeletionConfirmed(client, id) {
|
|
359
396
|
const session = client.session;
|
package/dist/src/options.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface PluginOptionsState {
|
|
|
24
24
|
codex_interop: CodexInteropOptions;
|
|
25
25
|
}
|
|
26
26
|
export declare const pluginOptions: PluginOptionsState;
|
|
27
|
+
export declare function resetPluginOptions(): void;
|
|
27
28
|
export declare function recordConfigWarning(message: string): void;
|
|
28
29
|
export declare function getConfigWarnings(): readonly string[];
|
|
29
30
|
/** Drop warnings from a previous apply pass (server boot / option re-apply). */
|
package/dist/src/options.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
const DEFAULT_PLUGIN_OPTIONS = {
|
|
2
2
|
generate_memories: true,
|
|
3
3
|
use_memories: true,
|
|
4
4
|
dedicated_tools: true,
|
|
@@ -10,6 +10,17 @@ export const pluginOptions = {
|
|
|
10
10
|
min_rollout_idle_hours: 6,
|
|
11
11
|
codex_interop: { import: false, export: false },
|
|
12
12
|
};
|
|
13
|
+
export const pluginOptions = {
|
|
14
|
+
...DEFAULT_PLUGIN_OPTIONS,
|
|
15
|
+
codex_interop: { ...DEFAULT_PLUGIN_OPTIONS.codex_interop },
|
|
16
|
+
};
|
|
17
|
+
export function resetPluginOptions() {
|
|
18
|
+
delete pluginOptions.extract_model;
|
|
19
|
+
delete pluginOptions.consolidation_model;
|
|
20
|
+
Object.assign(pluginOptions, DEFAULT_PLUGIN_OPTIONS, {
|
|
21
|
+
codex_interop: { ...DEFAULT_PLUGIN_OPTIONS.codex_interop },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
13
24
|
/**
|
|
14
25
|
* Config problems noticed while applying plugin options (unknown keys,
|
|
15
26
|
* malformed values). The plugin never hard-fails on bad options — codex uses
|
package/dist/src/path-guard.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
/**
|
|
2
3
|
* Safe path resolution that cannot escape the memory root, mirroring codex
|
|
3
4
|
* ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
|
|
@@ -19,3 +20,17 @@ export declare function assertMemoryRootSafe(): string;
|
|
|
19
20
|
export declare function safeResolveMemoryPath(rel: string): string;
|
|
20
21
|
/** Resolve a relative path under an arbitrary trusted root without following symlinks. */
|
|
21
22
|
export declare function safeResolveUnderRoot(root: string, rel: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Opens a regular file without following its final path component. The lstat
|
|
25
|
+
* after open is a fallback for platforms without O_NOFOLLOW and also verifies
|
|
26
|
+
* that a path-swap race did not give us a different inode.
|
|
27
|
+
*/
|
|
28
|
+
export declare function withRegularFileNoFollow<T>(file: string, flags: number, fn: (fd: number, stat: fs.Stats) => T): T;
|
|
29
|
+
export declare function readRegularFileNoFollow(file: string): {
|
|
30
|
+
content: Buffer;
|
|
31
|
+
stat: fs.Stats;
|
|
32
|
+
};
|
|
33
|
+
/** Overwrite or exclusively create a regular file without following symlinks. */
|
|
34
|
+
export declare function writeRegularFileNoFollow(file: string, content: string | Uint8Array, options?: {
|
|
35
|
+
exclusive?: boolean;
|
|
36
|
+
}): void;
|
package/dist/src/path-guard.js
CHANGED
|
@@ -81,3 +81,83 @@ export function safeResolveUnderRoot(root, rel) {
|
|
|
81
81
|
}
|
|
82
82
|
return current;
|
|
83
83
|
}
|
|
84
|
+
function sameFile(a, b) {
|
|
85
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Opens a regular file without following its final path component. The lstat
|
|
89
|
+
* after open is a fallback for platforms without O_NOFOLLOW and also verifies
|
|
90
|
+
* that a path-swap race did not give us a different inode.
|
|
91
|
+
*/
|
|
92
|
+
export function withRegularFileNoFollow(file, flags, fn) {
|
|
93
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
94
|
+
const nonBlock = fs.constants.O_NONBLOCK ?? 0;
|
|
95
|
+
let fd;
|
|
96
|
+
try {
|
|
97
|
+
fd = fs.openSync(file, flags | noFollow | nonBlock);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err.code === "ELOOP") {
|
|
101
|
+
throw new Error(`symlinks are not allowed in the memory workspace: ${file}`);
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const opened = fs.fstatSync(fd);
|
|
107
|
+
const current = fs.lstatSync(file);
|
|
108
|
+
if (current.isSymbolicLink() || !current.isFile() || !opened.isFile() || !sameFile(opened, current)) {
|
|
109
|
+
throw new Error(`refusing non-regular or replaced file: ${file}`);
|
|
110
|
+
}
|
|
111
|
+
return fn(fd, opened);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
fs.closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function readRegularFileNoFollow(file) {
|
|
118
|
+
return withRegularFileNoFollow(file, fs.constants.O_RDONLY, (fd, stat) => ({
|
|
119
|
+
content: fs.readFileSync(fd),
|
|
120
|
+
stat,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
/** Overwrite or exclusively create a regular file without following symlinks. */
|
|
124
|
+
export function writeRegularFileNoFollow(file, content, options = {}) {
|
|
125
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
126
|
+
const create = () => {
|
|
127
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, 0o666);
|
|
128
|
+
try {
|
|
129
|
+
const opened = fs.fstatSync(fd);
|
|
130
|
+
const current = fs.lstatSync(file);
|
|
131
|
+
if (!opened.isFile() || !current.isFile() || !sameFile(opened, current)) {
|
|
132
|
+
throw new Error(`refusing non-regular or replaced file: ${file}`);
|
|
133
|
+
}
|
|
134
|
+
fs.writeFileSync(fd, content);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
fs.closeSync(fd);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
if (options.exclusive) {
|
|
141
|
+
create();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let current;
|
|
145
|
+
try {
|
|
146
|
+
current = fs.lstatSync(file);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err.code === "ENOENT") {
|
|
150
|
+
create();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
if (current.isSymbolicLink() || !current.isFile()) {
|
|
156
|
+
throw new Error(`refusing to overwrite non-regular file: ${file}`);
|
|
157
|
+
}
|
|
158
|
+
withRegularFileNoFollow(file, fs.constants.O_WRONLY, (fd) => {
|
|
159
|
+
// Do not truncate until the descriptor and current path are verified.
|
|
160
|
+
fs.ftruncateSync(fd, 0);
|
|
161
|
+
fs.writeFileSync(fd, content);
|
|
162
|
+
});
|
|
163
|
+
}
|
package/dist/src/phase1.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MemoryStore } from "./store.js";
|
|
2
|
+
import { checkRateLimit } from "./ratelimit.js";
|
|
2
3
|
export interface Phase1Options {
|
|
3
4
|
maxAgeDays: number;
|
|
4
5
|
minIdleHours: number;
|
|
@@ -8,5 +9,5 @@ export interface Phase1Options {
|
|
|
8
9
|
extractModel?: string;
|
|
9
10
|
}
|
|
10
11
|
export declare const DEFAULT_PHASE1_OPTIONS: Phase1Options;
|
|
11
|
-
export declare function runPhase1(store: MemoryStore, opts?: Phase1Options): Promise<void>;
|
|
12
|
+
export declare function runPhase1(store: MemoryStore, opts?: Phase1Options, rateLimitCheck?: typeof checkRateLimit): Promise<void>;
|
|
12
13
|
export declare function buildTranscript(sessionId: string): Promise<string>;
|
package/dist/src/phase1.js
CHANGED
|
@@ -19,9 +19,9 @@ const TRANSCRIPT_MAX_CHARS = 600_000;
|
|
|
19
19
|
// budget 50/50 between head and tail (truncate.rs split_budget).
|
|
20
20
|
const TRANSCRIPT_HEAD_CHARS = 300_000;
|
|
21
21
|
const TRANSCRIPT_TAIL_CHARS = 300_000;
|
|
22
|
-
export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
|
|
22
|
+
export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitCheck = checkRateLimit) {
|
|
23
23
|
store.pruneStage1Outputs(opts.maxUnusedDays ?? 30);
|
|
24
|
-
const rl = await
|
|
24
|
+
const rl = await rateLimitCheck("phase1");
|
|
25
25
|
if (!rl.ok) {
|
|
26
26
|
console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason);
|
|
27
27
|
return;
|
|
@@ -40,6 +40,12 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
|
|
|
40
40
|
const sourceUpdatedAt = session?.updated_at ?? Date.now();
|
|
41
41
|
const transcript = await buildTranscript(sid);
|
|
42
42
|
if (!transcript.trim()) {
|
|
43
|
+
// A newly empty chat is a legitimate no-output result. An existing
|
|
44
|
+
// extraction plus an empty API success is anomalous: retry instead of
|
|
45
|
+
// permanently forgetting memory because of a transient host glitch.
|
|
46
|
+
if (store.hasStage1Output(sid)) {
|
|
47
|
+
throw new Error(`empty transcript for previously extracted session ${sid}`);
|
|
48
|
+
}
|
|
43
49
|
store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt);
|
|
44
50
|
return;
|
|
45
51
|
}
|
package/dist/src/phase2.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface Phase2Options {
|
|
|
7
7
|
extensionRetentionDays: number;
|
|
8
8
|
consolidationModel?: string;
|
|
9
9
|
codexInterop?: CodexInteropOptions;
|
|
10
|
+
/** Override the 90s heartbeat interval (tests / advanced). */
|
|
11
|
+
heartbeatIntervalMs?: number;
|
|
10
12
|
}
|
|
11
13
|
export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
|
|
12
14
|
/** True while THIS process runs a consolidation (memory_reset refuses then). */
|
package/dist/src/phase2.js
CHANGED
|
@@ -89,21 +89,40 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
|
|
|
89
89
|
}
|
|
90
90
|
writeWorkspaceDiff(diff);
|
|
91
91
|
let heartbeatLost = false;
|
|
92
|
-
|
|
92
|
+
let heartbeatFailure = "ownership lost";
|
|
93
|
+
const consolidationAbort = new AbortController();
|
|
94
|
+
const heartbeatOnce = () => {
|
|
95
|
+
if (heartbeatLost)
|
|
96
|
+
return false;
|
|
93
97
|
try {
|
|
94
98
|
if (!store.heartbeatPhase2Job(claim.ownershipToken)) {
|
|
95
99
|
heartbeatLost = true;
|
|
100
|
+
consolidationAbort.abort();
|
|
101
|
+
return false;
|
|
96
102
|
}
|
|
97
103
|
}
|
|
98
104
|
catch (err) {
|
|
99
|
-
// Transient DB error (e.g. SQLITE_BUSY): don't treat as ownership
|
|
100
|
-
// loss — the token+status-guarded final confirmation below stays
|
|
101
|
-
// authoritative. Uncaught, this would kill the interval silently.
|
|
102
105
|
console.warn("[opencode-codex-memory] phase2 heartbeat error:", err);
|
|
106
|
+
// Codex stops the consolidation agent on heartbeat Ok(false) OR Err.
|
|
107
|
+
// Fail closed: without a refreshed lease, another process may reclaim
|
|
108
|
+
// the job while this helper still has live write access.
|
|
109
|
+
heartbeatLost = true;
|
|
110
|
+
heartbeatFailure = err;
|
|
111
|
+
consolidationAbort.abort();
|
|
112
|
+
return false;
|
|
103
113
|
}
|
|
104
|
-
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
// Workspace preparation can itself be slow. Confirm ownership before
|
|
117
|
+
// granting a new helper write access, then keep the lease alive while it
|
|
118
|
+
// runs. This also mirrors tokio::time::interval's immediate first tick.
|
|
119
|
+
if (!heartbeatOnce()) {
|
|
120
|
+
store.markPhase2Failed(claim.ownershipToken, heartbeatFailure);
|
|
121
|
+
return { status: "heartbeat_lost" };
|
|
122
|
+
}
|
|
123
|
+
const heartbeat = setInterval(heartbeatOnce, opts.heartbeatIntervalMs ?? 90_000);
|
|
105
124
|
try {
|
|
106
|
-
await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
|
|
125
|
+
await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel, consolidationAbort.signal);
|
|
107
126
|
}
|
|
108
127
|
catch (err) {
|
|
109
128
|
// codex phase2.rs: when the consolidation agent's shutdown fails, keep
|
|
@@ -114,6 +133,10 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
|
|
|
114
133
|
console.warn(`[opencode-codex-memory] ${err.message}; holding the phase2 lease until it expires`);
|
|
115
134
|
return { status: "shutdown_failed" };
|
|
116
135
|
}
|
|
136
|
+
if (heartbeatLost) {
|
|
137
|
+
store.markPhase2Failed(claim.ownershipToken, heartbeatFailure);
|
|
138
|
+
return { status: "heartbeat_lost" };
|
|
139
|
+
}
|
|
117
140
|
throw err;
|
|
118
141
|
}
|
|
119
142
|
finally {
|
|
@@ -126,7 +149,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS, rateLimitC
|
|
|
126
149
|
// heartbeat is token+status guarded, so it fails once ownership is lost;
|
|
127
150
|
// markPhase2Failed is equally guarded and becomes a no-op then.
|
|
128
151
|
if (heartbeatLost || !store.heartbeatPhase2Job(claim.ownershipToken)) {
|
|
129
|
-
store.markPhase2Failed(claim.ownershipToken,
|
|
152
|
+
store.markPhase2Failed(claim.ownershipToken, heartbeatFailure);
|
|
130
153
|
return { status: "heartbeat_lost" };
|
|
131
154
|
}
|
|
132
155
|
// codex failed_invalid_artifacts: do not reset baseline on bad output so
|
package/dist/src/source.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { memoryRoot } from "./paths.js";
|
|
4
|
-
import { assertMemoryRootSafe, safeResolveMemoryPath } from "./path-guard.js";
|
|
4
|
+
import { assertMemoryRootSafe, safeResolveMemoryPath, withRegularFileNoFollow } from "./path-guard.js";
|
|
5
5
|
import { truncateToTokens } from "./token.js";
|
|
6
6
|
import { fillTemplate } from "./llm.js";
|
|
7
7
|
const MEMORY_SUMMARY_TOKEN_LIMIT = 2500;
|
|
@@ -31,36 +31,28 @@ function readTemplate() {
|
|
|
31
31
|
return fs.readFileSync(templatePath, "utf8");
|
|
32
32
|
}
|
|
33
33
|
function readMemorySummary() {
|
|
34
|
-
let summaryPath;
|
|
35
|
-
let fd;
|
|
36
34
|
try {
|
|
37
35
|
// Use the same component-by-component symlink refusal as the memory tools:
|
|
38
36
|
// neither the root nor memory_summary.md may redirect outside the workspace.
|
|
39
|
-
summaryPath = safeResolveMemoryPath("memory_summary.md");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
};
|
|
55
|
-
return truncated;
|
|
37
|
+
const summaryPath = safeResolveMemoryPath("memory_summary.md");
|
|
38
|
+
return withRegularFileNoFollow(summaryPath, fs.constants.O_RDONLY, (fd, stat) => {
|
|
39
|
+
if (cached && cached.mtime === stat.mtimeMs) {
|
|
40
|
+
return cached.content;
|
|
41
|
+
}
|
|
42
|
+
const raw = fs.readFileSync(fd, "utf8").trim();
|
|
43
|
+
if (!raw)
|
|
44
|
+
return null;
|
|
45
|
+
const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT);
|
|
46
|
+
cached = {
|
|
47
|
+
content: truncated,
|
|
48
|
+
mtime: stat.mtimeMs,
|
|
49
|
+
};
|
|
50
|
+
return truncated;
|
|
51
|
+
});
|
|
56
52
|
}
|
|
57
53
|
catch {
|
|
58
54
|
return null;
|
|
59
55
|
}
|
|
60
|
-
finally {
|
|
61
|
-
if (fd !== undefined)
|
|
62
|
-
fs.closeSync(fd);
|
|
63
|
-
}
|
|
64
56
|
}
|
|
65
57
|
export function invalidateCache() {
|
|
66
58
|
cached = null;
|
package/dist/src/store.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export declare class MemoryStore {
|
|
|
44
44
|
private db;
|
|
45
45
|
constructor(db?: Database);
|
|
46
46
|
stage1Outputs(): Stage1Output[];
|
|
47
|
+
hasStage1Output(sessionId: string): boolean;
|
|
47
48
|
/**
|
|
48
49
|
* Deletes stale rows; snapshots consumed by the last successful Phase 2 are
|
|
49
50
|
* protected. Stalest-first, capped per run (codex PRUNE_BATCH_SIZE).
|
|
@@ -69,7 +70,22 @@ export declare class MemoryStore {
|
|
|
69
70
|
* still back the consolidated artifacts.
|
|
70
71
|
*/
|
|
71
72
|
markPhase2Succeeded(ownershipToken: string, selected?: Pick<Stage1Output, "session_id" | "source_updated_at">[]): void;
|
|
72
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Phase-2 job snapshot for memory_inspect. Always returns the global job row
|
|
75
|
+
* when it exists (including failed/running), so diagnostics are not limited
|
|
76
|
+
* to clean successes. `success_finished_at` is set only for a clean success
|
|
77
|
+
* (never a failure timestamp); `last_success_watermark` follows codex
|
|
78
|
+
* (preserved across later attempts; zero only counts while clean).
|
|
79
|
+
*/
|
|
80
|
+
phase2JobSnapshot(): {
|
|
81
|
+
status: string;
|
|
82
|
+
last_error: string | null;
|
|
83
|
+
finished_at: number | null;
|
|
84
|
+
retry_at: number | null;
|
|
85
|
+
success_finished_at: number | null;
|
|
86
|
+
last_success_watermark: number | null;
|
|
87
|
+
} | null;
|
|
88
|
+
/** Last recorded phase-2 success info. Null when phase 2 never succeeded. */
|
|
73
89
|
phase2LastSuccess(): {
|
|
74
90
|
finished_at: number | null;
|
|
75
91
|
last_success_watermark: number | null;
|
package/dist/src/store.js
CHANGED
|
@@ -37,6 +37,11 @@ export class MemoryStore {
|
|
|
37
37
|
.prepare("SELECT * FROM memory_stage1_outputs ORDER BY source_updated_at DESC")
|
|
38
38
|
.all();
|
|
39
39
|
}
|
|
40
|
+
hasStage1Output(sessionId) {
|
|
41
|
+
return this.db
|
|
42
|
+
.prepare("SELECT 1 FROM memory_stage1_outputs WHERE session_id = ?")
|
|
43
|
+
.get(sessionId) !== null;
|
|
44
|
+
}
|
|
40
45
|
/**
|
|
41
46
|
* Deletes stale rows; snapshots consumed by the last successful Phase 2 are
|
|
42
47
|
* protected. Stalest-first, capped per run (codex PRUNE_BATCH_SIZE).
|
|
@@ -306,15 +311,51 @@ export class MemoryStore {
|
|
|
306
311
|
mark.run(s.source_updated_at, s.session_id, s.source_updated_at);
|
|
307
312
|
}).immediate();
|
|
308
313
|
}
|
|
309
|
-
/**
|
|
310
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Phase-2 job snapshot for memory_inspect. Always returns the global job row
|
|
316
|
+
* when it exists (including failed/running), so diagnostics are not limited
|
|
317
|
+
* to clean successes. `success_finished_at` is set only for a clean success
|
|
318
|
+
* (never a failure timestamp); `last_success_watermark` follows codex
|
|
319
|
+
* (preserved across later attempts; zero only counts while clean).
|
|
320
|
+
*/
|
|
321
|
+
phase2JobSnapshot() {
|
|
311
322
|
const row = this.db
|
|
312
|
-
.prepare(`SELECT finished_at, last_success_watermark FROM memory_jobs
|
|
323
|
+
.prepare(`SELECT status, finished_at, last_error, retry_at, last_success_watermark FROM memory_jobs
|
|
313
324
|
WHERE kind='memory_consolidate_global' AND job_key='global'`)
|
|
314
325
|
.get();
|
|
315
|
-
if (!row
|
|
326
|
+
if (!row)
|
|
327
|
+
return null;
|
|
328
|
+
const cleanSuccess = row.last_error === null &&
|
|
329
|
+
row.finished_at !== null &&
|
|
330
|
+
(row.status === "done" || row.status === "pending");
|
|
331
|
+
// Codex initializes pending global jobs with watermark 0, so zero proves a
|
|
332
|
+
// success only while the row itself is a clean completed attempt.
|
|
333
|
+
const watermark = row.last_success_watermark === null
|
|
334
|
+
? null
|
|
335
|
+
: row.last_success_watermark === 0 && !cleanSuccess
|
|
336
|
+
? null
|
|
337
|
+
: row.last_success_watermark;
|
|
338
|
+
return {
|
|
339
|
+
status: row.status,
|
|
340
|
+
last_error: row.last_error,
|
|
341
|
+
finished_at: row.finished_at,
|
|
342
|
+
retry_at: row.retry_at,
|
|
343
|
+
// Codex preserves last_success_watermark across later attempts, while the
|
|
344
|
+
// job finished_at describes only the latest attempt. Never label a failure
|
|
345
|
+
// timestamp as a success finish time.
|
|
346
|
+
success_finished_at: cleanSuccess ? row.finished_at : null,
|
|
347
|
+
last_success_watermark: watermark,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** Last recorded phase-2 success info. Null when phase 2 never succeeded. */
|
|
351
|
+
phase2LastSuccess() {
|
|
352
|
+
const snap = this.phase2JobSnapshot();
|
|
353
|
+
if (!snap || snap.last_success_watermark === null)
|
|
316
354
|
return null;
|
|
317
|
-
return
|
|
355
|
+
return {
|
|
356
|
+
finished_at: snap.success_finished_at,
|
|
357
|
+
last_success_watermark: snap.last_success_watermark,
|
|
358
|
+
};
|
|
318
359
|
}
|
|
319
360
|
markPhase2Failed(ownershipToken, error) {
|
|
320
361
|
const message = failureMessage(error);
|
package/dist/src/workspace.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "crypto";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { memoryRoot } from "./paths.js";
|
|
5
|
-
import { assertMemoryRootSafe, safeResolveMemoryPath } from "./path-guard.js";
|
|
5
|
+
import { assertMemoryRootSafe, readRegularFileNoFollow, safeResolveMemoryPath, writeRegularFileNoFollow, } from "./path-guard.js";
|
|
6
6
|
import { DIFF_ARTIFACT } from "./git-baseline.js";
|
|
7
7
|
const RAW_MEMORIES_FILE = "raw_memories.md";
|
|
8
8
|
const ROLLOUT_DIR = "rollout_summaries";
|
|
@@ -38,13 +38,13 @@ export function ensureLayout() {
|
|
|
38
38
|
}
|
|
39
39
|
const memoryMd = safeResolveMemoryPath("MEMORY.md");
|
|
40
40
|
if (!fs.existsSync(memoryMd))
|
|
41
|
-
|
|
41
|
+
writeRegularFileNoFollow(memoryMd, "# MEMORY.md\n\n_Searchable index of memories._\n");
|
|
42
42
|
const summary = safeResolveMemoryPath("memory_summary.md");
|
|
43
43
|
if (!fs.existsSync(summary))
|
|
44
|
-
|
|
44
|
+
writeRegularFileNoFollow(summary, "");
|
|
45
45
|
const adhocInstructions = safeResolveMemoryPath(path.join(EXTENSIONS_DIR, "ad_hoc", "instructions.md"));
|
|
46
46
|
if (!fs.existsSync(adhocInstructions))
|
|
47
|
-
|
|
47
|
+
writeRegularFileNoFollow(adhocInstructions, ADHOC_INSTRUCTIONS);
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
50
|
* Mirrors codex `validate_consolidation_artifacts` (workspace.rs): after
|
|
@@ -68,7 +68,7 @@ export function validateConsolidationArtifacts(root = memoryRoot()) {
|
|
|
68
68
|
if (!fs.lstatSync(summaryPath).isFile()) {
|
|
69
69
|
return { ok: false, reason: `memory summary artifact is not a file: ${summaryPath}` };
|
|
70
70
|
}
|
|
71
|
-
summary =
|
|
71
|
+
summary = readRegularFileNoFollow(summaryPath).content.toString("utf8");
|
|
72
72
|
}
|
|
73
73
|
catch {
|
|
74
74
|
return { ok: false, reason: `missing memory summary artifact: ${summaryPath}` };
|
|
@@ -119,7 +119,7 @@ export function rebuildRawMemories(outputs) {
|
|
|
119
119
|
content += "\n\n";
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
writeRegularFileNoFollow(safeResolveMemoryPath(RAW_MEMORIES_FILE), content);
|
|
123
123
|
return content;
|
|
124
124
|
}
|
|
125
125
|
export function writeRolloutSummaries(outputs) {
|
|
@@ -142,7 +142,7 @@ export function writeRolloutSummaries(outputs) {
|
|
|
142
142
|
`usage_count: ${o.usage_count}\n\n` +
|
|
143
143
|
o.rollout_summary +
|
|
144
144
|
"\n";
|
|
145
|
-
|
|
145
|
+
writeRegularFileNoFollow(file, body);
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
// Resource filenames start with an ISO-like timestamp: 2026-07-03T05-11-22_slug.md
|
|
@@ -229,6 +229,6 @@ export function writeWorkspaceDiff(diff) {
|
|
|
229
229
|
rendered += "\n## Diff\n\n```diff\n" + body + (body.endsWith("\n") ? "" : "\n") + "```\n";
|
|
230
230
|
}
|
|
231
231
|
const file = safeResolveMemoryPath(DIFF_ARTIFACT);
|
|
232
|
-
|
|
232
|
+
writeRegularFileNoFollow(file, rendered);
|
|
233
233
|
return file;
|
|
234
234
|
}
|
package/dist/tools/control.js
CHANGED
|
@@ -5,7 +5,7 @@ import { memoryRoot, memorySummaryPath } from "../src/paths.js";
|
|
|
5
5
|
import { MemoryStore } from "../src/store.js";
|
|
6
6
|
import { invalidateCache } from "../src/source.js";
|
|
7
7
|
import { estimateTokens } from "../src/token.js";
|
|
8
|
-
import { assertMemoryRootSafe } from "../src/path-guard.js";
|
|
8
|
+
import { assertMemoryRootSafe, readRegularFileNoFollow } from "../src/path-guard.js";
|
|
9
9
|
import { isPhase2InFlight } from "../src/phase2.js";
|
|
10
10
|
import { pluginOptions, getConfigWarnings } from "../src/options.js";
|
|
11
11
|
import { resolveCodexInterop } from "../src/codex-interop.js";
|
|
@@ -150,11 +150,22 @@ export const memory_reset = tool({
|
|
|
150
150
|
}
|
|
151
151
|
},
|
|
152
152
|
});
|
|
153
|
+
function fmtUnixSec(sec) {
|
|
154
|
+
return sec ? new Date(sec * 1000).toISOString() : "none";
|
|
155
|
+
}
|
|
156
|
+
function fmtWatermarkMs(ms) {
|
|
157
|
+
if (ms === 0)
|
|
158
|
+
return "0 (no consumed inputs)";
|
|
159
|
+
if (ms === null || ms === undefined)
|
|
160
|
+
return "none";
|
|
161
|
+
return new Date(ms).toISOString();
|
|
162
|
+
}
|
|
153
163
|
export const memory_inspect = tool({
|
|
154
|
-
description: "Inspect the current memory state. Returns: stage1_outputs count,
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"configuration
|
|
164
|
+
description: "Inspect the current memory state. Returns: stage1_outputs count, Phase 2 job status " +
|
|
165
|
+
"(including last error / retry time when failed), last Phase 2 success watermark, " +
|
|
166
|
+
"memory_summary token estimate (on-disk; injection caps at ~2500), a listing of the " +
|
|
167
|
+
"memories directory, the effective plugin options, and any configuration warnings " +
|
|
168
|
+
"(unknown/malformed options). Use it to verify the plugin configuration took effect. Read-only.",
|
|
158
169
|
args: {},
|
|
159
170
|
async execute() {
|
|
160
171
|
try {
|
|
@@ -166,21 +177,35 @@ export const memory_inspect = tool({
|
|
|
166
177
|
let summaryChars = 0;
|
|
167
178
|
let summaryTokens = 0;
|
|
168
179
|
if (fs.existsSync(summaryPath)) {
|
|
169
|
-
const text =
|
|
180
|
+
const text = readRegularFileNoFollow(summaryPath).content.toString("utf8");
|
|
170
181
|
summaryChars = text.length;
|
|
171
182
|
summaryTokens = estimateTokens(text);
|
|
172
183
|
}
|
|
173
184
|
const listing = listMemoriesDir();
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
const phase2 = store.phase2JobSnapshot();
|
|
186
|
+
const phase2Lines = phase2
|
|
187
|
+
? [
|
|
188
|
+
`phase2_status: ${phase2.status}`,
|
|
189
|
+
`phase2_last_error: ${phase2.last_error ?? "none"}`,
|
|
190
|
+
`phase2_retry_at: ${fmtUnixSec(phase2.retry_at)}`,
|
|
191
|
+
`phase2_last_attempt_finished_at: ${fmtUnixSec(phase2.finished_at)}`,
|
|
192
|
+
`phase2_last_success_watermark: ${fmtWatermarkMs(phase2.last_success_watermark)}`,
|
|
193
|
+
// Clean-success finish only — never a failure timestamp.
|
|
194
|
+
`phase2_last_success_finished_at: ${fmtUnixSec(phase2.success_finished_at)}`,
|
|
195
|
+
]
|
|
196
|
+
: [
|
|
197
|
+
"phase2_status: none",
|
|
198
|
+
"phase2_last_error: none",
|
|
199
|
+
"phase2_retry_at: none",
|
|
200
|
+
"phase2_last_attempt_finished_at: none",
|
|
201
|
+
"phase2_last_success_watermark: none",
|
|
202
|
+
"phase2_last_success_finished_at: none",
|
|
203
|
+
];
|
|
178
204
|
const out = [
|
|
179
205
|
`stage1_outputs: ${outputs.length}`,
|
|
180
|
-
|
|
181
|
-
`phase2_last_finished_at: ${finishedAt}`,
|
|
206
|
+
...phase2Lines,
|
|
182
207
|
`memory_summary_chars: ${summaryChars}`,
|
|
183
|
-
`memory_summary_tokens_est: ${summaryTokens}`,
|
|
208
|
+
`memory_summary_tokens_est: ${summaryTokens} (on disk; injection caps at ~2500)`,
|
|
184
209
|
`memories_dir_entries: ${listing.length}`,
|
|
185
210
|
"",
|
|
186
211
|
...renderEffectiveConfig(),
|
|
@@ -192,8 +217,14 @@ export const memory_inspect = tool({
|
|
|
192
217
|
output: out,
|
|
193
218
|
metadata: {
|
|
194
219
|
stage1_count: outputs.length,
|
|
220
|
+
phase2_status: phase2?.status ?? null,
|
|
221
|
+
phase2_last_error: phase2?.last_error ?? null,
|
|
222
|
+
phase2_retry_at: phase2?.retry_at ?? null,
|
|
223
|
+
phase2_last_attempt_finished_at: phase2?.finished_at ?? null,
|
|
195
224
|
phase2_last_success_watermark: phase2?.last_success_watermark ?? null,
|
|
196
|
-
|
|
225
|
+
phase2_last_success_finished_at: phase2?.success_finished_at ?? null,
|
|
226
|
+
// Back-compat aliases used by earlier inspect consumers.
|
|
227
|
+
phase2_last_finished_at: phase2?.success_finished_at ?? null,
|
|
197
228
|
summary_chars: summaryChars,
|
|
198
229
|
summary_tokens_est: summaryTokens,
|
|
199
230
|
files: listing,
|
|
@@ -208,7 +239,7 @@ export const memory_inspect = tool({
|
|
|
208
239
|
},
|
|
209
240
|
});
|
|
210
241
|
export const memory_mode = tool({
|
|
211
|
-
description: "Set the memory mode for the current session. 'enabled' allows Phase 1 extraction. " +
|
|
242
|
+
description: "Set the memory mode for the target session (current session by default). 'enabled' allows Phase 1 extraction. " +
|
|
212
243
|
"'disabled' excludes this session from extraction. 'polluted' marks it as having external context " +
|
|
213
244
|
"(websearch/webfetch) that should not be trusted for memory.",
|
|
214
245
|
args: {
|
package/dist/tools/memory.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { safeResolveMemoryPath, assertMemoryRootSafe } from "../src/path-guard.js";
|
|
3
|
+
import { safeResolveMemoryPath, assertMemoryRootSafe, readRegularFileNoFollow, writeRegularFileNoFollow, } from "../src/path-guard.js";
|
|
4
4
|
import { tool } from "@opencode-ai/plugin";
|
|
5
5
|
const MAX_READ_BYTES = 256 * 1024;
|
|
6
6
|
export const memory_read = tool({
|
|
@@ -18,7 +18,7 @@ export const memory_read = tool({
|
|
|
18
18
|
if (!fs.existsSync(fullPath)) {
|
|
19
19
|
return { output: `Not found: ${args.path}` };
|
|
20
20
|
}
|
|
21
|
-
const stat = fs.
|
|
21
|
+
const stat = fs.lstatSync(fullPath);
|
|
22
22
|
if (stat.isDirectory()) {
|
|
23
23
|
const entries = fs.readdirSync(fullPath);
|
|
24
24
|
return {
|
|
@@ -29,7 +29,7 @@ export const memory_read = tool({
|
|
|
29
29
|
// Read the whole file and apply the line window FIRST; the byte cap
|
|
30
30
|
// applies to the WINDOWED output. Capping the raw read used to make
|
|
31
31
|
// lines beyond the first 256 KiB unreachable regardless of line_offset.
|
|
32
|
-
const text =
|
|
32
|
+
const text = readRegularFileNoFollow(fullPath).content.toString("utf8");
|
|
33
33
|
// Line windowing mirrors codex memories/read: 1-indexed offset, bounded
|
|
34
34
|
// line count, and the start line reported so file:line citations work.
|
|
35
35
|
const startLine = args.line_offset ?? 1;
|
|
@@ -303,7 +303,7 @@ export const memory_search = tool({
|
|
|
303
303
|
const start = safeResolveMemoryPath(args.path);
|
|
304
304
|
let st;
|
|
305
305
|
try {
|
|
306
|
-
st = fs.
|
|
306
|
+
st = fs.lstatSync(start);
|
|
307
307
|
}
|
|
308
308
|
catch {
|
|
309
309
|
return { output: `Not found: ${args.path}` };
|
|
@@ -326,7 +326,7 @@ export const memory_search = tool({
|
|
|
326
326
|
const listing = files.slice(0, args.max_results).map((f) => {
|
|
327
327
|
let content = "";
|
|
328
328
|
try {
|
|
329
|
-
content =
|
|
329
|
+
content = readRegularFileNoFollow(f.abs).content.toString("utf8");
|
|
330
330
|
}
|
|
331
331
|
catch {
|
|
332
332
|
}
|
|
@@ -350,7 +350,7 @@ export const memory_search = tool({
|
|
|
350
350
|
for (const f of files) {
|
|
351
351
|
let content;
|
|
352
352
|
try {
|
|
353
|
-
content =
|
|
353
|
+
content = readRegularFileNoFollow(f.abs).content.toString("utf8");
|
|
354
354
|
}
|
|
355
355
|
catch {
|
|
356
356
|
continue;
|
|
@@ -425,7 +425,7 @@ export const memory_add_note = tool({
|
|
|
425
425
|
let file = safeResolveMemoryPath(path.join(NOTES_DIR, `${stem}.md`));
|
|
426
426
|
for (let i = 2;; i++) {
|
|
427
427
|
try {
|
|
428
|
-
|
|
428
|
+
writeRegularFileNoFollow(file, header + args.note + "\n", { exclusive: true });
|
|
429
429
|
break;
|
|
430
430
|
}
|
|
431
431
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-codex-memory",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|