opencode-codex-memory 0.1.3 → 0.1.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/dist/src/capture.d.ts +19 -0
- package/dist/src/capture.js +120 -0
- package/dist/src/citation.d.ts +14 -0
- package/dist/src/citation.js +81 -0
- package/dist/src/db.d.ts +3 -0
- package/dist/src/db.js +78 -0
- package/dist/src/git-baseline.d.ts +24 -0
- package/dist/src/git-baseline.js +150 -0
- package/dist/src/index.d.ts +163 -0
- package/dist/src/index.js +365 -0
- package/dist/src/llm.d.ts +19 -0
- package/dist/src/llm.js +251 -0
- package/dist/src/path-guard.d.ts +10 -0
- package/dist/src/path-guard.js +44 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +23 -0
- package/dist/src/phase1.d.ts +11 -0
- package/dist/src/phase1.js +104 -0
- package/dist/src/phase2.d.ts +11 -0
- package/dist/src/phase2.js +83 -0
- package/dist/src/ratelimit.d.ts +5 -0
- package/dist/src/ratelimit.js +20 -0
- package/dist/src/redact.d.ts +8 -0
- package/dist/src/redact.js +37 -0
- package/dist/src/source.d.ts +3 -0
- package/dist/src/source.js +46 -0
- package/dist/src/store.d.ts +96 -0
- package/dist/src/store.js +346 -0
- package/dist/src/token.d.ts +8 -0
- package/dist/src/token.js +19 -0
- package/dist/src/workspace.d.ts +8 -0
- package/dist/src/workspace.js +194 -0
- package/dist/tools/control.d.ts +29 -0
- package/dist/tools/control.js +153 -0
- package/dist/tools/memory.d.ts +52 -0
- package/dist/tools/memory.js +322 -0
- package/package.json +23 -6
- package/src/capture.ts +0 -135
- package/src/citation.ts +0 -94
- package/src/db.ts +0 -80
- package/src/git-baseline.ts +0 -162
- package/src/index.ts +0 -366
- package/src/llm.ts +0 -267
- package/src/path-guard.ts +0 -44
- package/src/paths.ts +0 -29
- package/src/phase1.ts +0 -116
- package/src/phase2.ts +0 -99
- package/src/ratelimit.ts +0 -26
- package/src/redact.ts +0 -44
- package/src/source.ts +0 -59
- package/src/store.ts +0 -430
- package/src/templates/consolidation.md +0 -448
- package/src/templates/read_path.md +0 -104
- package/src/templates/stage_one_input.md +0 -11
- package/src/templates/stage_one_system.md +0 -333
- package/src/token.ts +0 -21
- package/src/workspace.ts +0 -181
- package/tools/control.ts +0 -145
- package/tools/memory.ts +0 -318
package/src/index.ts
DELETED
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
import { ensureMemoryLayout, buildMemorySystemPrompt, invalidateCache } from "./source.js"
|
|
2
|
-
import { stripCitations, extractCitedSessionIds } from "./citation.js"
|
|
3
|
-
import { memory_read, memory_search, memory_list, memory_add_note } from "../tools/memory.js"
|
|
4
|
-
import { memory_reset, memory_inspect, memory_mode } from "../tools/control.js"
|
|
5
|
-
import { MemoryStore } from "./store.js"
|
|
6
|
-
import { runPhase1 } from "./phase1.js"
|
|
7
|
-
import { runPhase2 } from "./phase2.js"
|
|
8
|
-
import { setPluginInput, cleanupOldSubSessions, isMemorySubSession } from "./llm.js"
|
|
9
|
-
import type { PluginInput, PluginOptions } from "@opencode-ai/plugin"
|
|
10
|
-
import fs from "fs"
|
|
11
|
-
import path from "path"
|
|
12
|
-
|
|
13
|
-
let phase1InFlight = false
|
|
14
|
-
let pluginClient: PluginInput["client"] | null = null
|
|
15
|
-
// Configured MCP server names, fetched lazily; null until first successful fetch.
|
|
16
|
-
let mcpServerNames: Set<string> | null = null
|
|
17
|
-
|
|
18
|
-
// Option names and defaults mirror codex's MemoriesToml/MemoriesConfig
|
|
19
|
-
// (codex-rs/config/src/types.rs). Keep them 1:1 so the drift script and manual
|
|
20
|
-
// syncing stay trivial; do not rename for taste.
|
|
21
|
-
let pluginOptions: {
|
|
22
|
-
generate_memories: boolean
|
|
23
|
-
use_memories: boolean
|
|
24
|
-
dedicated_tools: boolean
|
|
25
|
-
disable_on_external_context: boolean
|
|
26
|
-
extract_model?: string
|
|
27
|
-
consolidation_model?: string
|
|
28
|
-
max_raw_memories_for_consolidation: number
|
|
29
|
-
max_unused_days: number
|
|
30
|
-
max_rollout_age_days: number
|
|
31
|
-
max_rollouts_per_startup: number
|
|
32
|
-
min_rollout_idle_hours: number
|
|
33
|
-
} = {
|
|
34
|
-
generate_memories: true,
|
|
35
|
-
use_memories: true,
|
|
36
|
-
dedicated_tools: true,
|
|
37
|
-
disable_on_external_context: false,
|
|
38
|
-
max_raw_memories_for_consolidation: 256,
|
|
39
|
-
max_unused_days: 30,
|
|
40
|
-
max_rollout_age_days: 10,
|
|
41
|
-
max_rollouts_per_startup: 2,
|
|
42
|
-
min_rollout_idle_hours: 6,
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Deliberately uncached: openDb() is already a singleton, and caching a store
|
|
46
|
-
// here would hold a stale handle across closeDb() (e.g. after memory_reset).
|
|
47
|
-
function getStore(): MemoryStore {
|
|
48
|
-
return new MemoryStore()
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Citation blocks arrive via message.part.updated once per streaming delta,
|
|
52
|
-
// so the same completed block is seen many times. Track which session ids
|
|
53
|
-
// were already recorded per part to count each citation once.
|
|
54
|
-
const recordedCitations = new Map<string, Set<string>>()
|
|
55
|
-
const MAX_TRACKED_PARTS = 500
|
|
56
|
-
|
|
57
|
-
export function takeNewCitations(partKey: string, ids: string[]): string[] {
|
|
58
|
-
let seen = recordedCitations.get(partKey)
|
|
59
|
-
if (!seen) {
|
|
60
|
-
seen = new Set()
|
|
61
|
-
recordedCitations.set(partKey, seen)
|
|
62
|
-
if (recordedCitations.size > MAX_TRACKED_PARTS) {
|
|
63
|
-
const oldest = recordedCitations.keys().next().value
|
|
64
|
-
if (oldest !== undefined) recordedCitations.delete(oldest)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
const fresh = ids.filter((id) => !seen.has(id))
|
|
68
|
-
for (const id of fresh) seen.add(id)
|
|
69
|
-
return fresh
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export default {
|
|
73
|
-
id: "opencode-codex-memory",
|
|
74
|
-
async server(input: PluginInput, opts?: PluginOptions) {
|
|
75
|
-
setPluginInput(input)
|
|
76
|
-
pluginClient = input.client
|
|
77
|
-
if (opts) applyPluginOptions(opts)
|
|
78
|
-
void cleanupOldSubSessions().catch(() => {})
|
|
79
|
-
return buildHooks()
|
|
80
|
-
},
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const KNOWN_OPTION_KEYS = new Set([
|
|
84
|
-
"generate_memories",
|
|
85
|
-
"use_memories",
|
|
86
|
-
"dedicated_tools",
|
|
87
|
-
"disable_on_external_context",
|
|
88
|
-
"extract_model",
|
|
89
|
-
"consolidation_model",
|
|
90
|
-
"max_raw_memories_for_consolidation",
|
|
91
|
-
"max_unused_days",
|
|
92
|
-
"max_rollout_age_days",
|
|
93
|
-
"max_rollouts_per_startup",
|
|
94
|
-
"min_rollout_idle_hours",
|
|
95
|
-
])
|
|
96
|
-
|
|
97
|
-
// codex clamps numeric knobs in From<MemoriesToml> for MemoriesConfig
|
|
98
|
-
// (config/src/types.rs); mirror the exact ranges. Non-finite values fall back
|
|
99
|
-
// to the default.
|
|
100
|
-
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
101
|
-
if (typeof value !== "number" || !Number.isFinite(value)) return fallback
|
|
102
|
-
return Math.min(max, Math.max(min, Math.floor(value)))
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function applyPluginOptions(opts: PluginOptions): void {
|
|
106
|
-
for (const key of Object.keys(opts)) {
|
|
107
|
-
if (!KNOWN_OPTION_KEYS.has(key)) {
|
|
108
|
-
// codex uses deny_unknown_fields; a plugin can only warn. Covers typos
|
|
109
|
-
// and the deliberately unimplemented min_rate_limit_remaining_percent.
|
|
110
|
-
console.warn(`[opencode-codex-memory] unknown/unsupported option '${key}' ignored`)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
if (typeof opts.generate_memories === "boolean") pluginOptions.generate_memories = opts.generate_memories
|
|
114
|
-
if (typeof opts.use_memories === "boolean") pluginOptions.use_memories = opts.use_memories
|
|
115
|
-
if (typeof opts.dedicated_tools === "boolean") pluginOptions.dedicated_tools = opts.dedicated_tools
|
|
116
|
-
if (typeof opts.disable_on_external_context === "boolean") pluginOptions.disable_on_external_context = opts.disable_on_external_context
|
|
117
|
-
if (typeof opts.extract_model === "string") pluginOptions.extract_model = opts.extract_model
|
|
118
|
-
if (typeof opts.consolidation_model === "string") pluginOptions.consolidation_model = opts.consolidation_model
|
|
119
|
-
if ("max_raw_memories_for_consolidation" in opts)
|
|
120
|
-
pluginOptions.max_raw_memories_for_consolidation = clampInt(opts.max_raw_memories_for_consolidation, 1, 4096, 256)
|
|
121
|
-
if ("max_unused_days" in opts) pluginOptions.max_unused_days = clampInt(opts.max_unused_days, 0, 365, 30)
|
|
122
|
-
if ("max_rollout_age_days" in opts) pluginOptions.max_rollout_age_days = clampInt(opts.max_rollout_age_days, 0, 90, 10)
|
|
123
|
-
if ("max_rollouts_per_startup" in opts) pluginOptions.max_rollouts_per_startup = clampInt(opts.max_rollouts_per_startup, 1, 128, 2)
|
|
124
|
-
if ("min_rollout_idle_hours" in opts) pluginOptions.min_rollout_idle_hours = clampInt(opts.min_rollout_idle_hours, 1, 48, 6)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* codex marks every MCP server as memory-polluting unconditionally
|
|
129
|
-
* (codex-mcp server.rs pollutes_memory: true). opencode registers MCP tools
|
|
130
|
-
* as "<server>_<tool>", so match tool names against the configured server
|
|
131
|
-
* list. Fails closed to the web-tools-only check when the list is unavailable.
|
|
132
|
-
*/
|
|
133
|
-
async function isExternalContextTool(toolName: string): Promise<boolean> {
|
|
134
|
-
if (toolName === "websearch" || toolName === "webfetch") return true
|
|
135
|
-
if (!mcpServerNames && pluginClient) {
|
|
136
|
-
try {
|
|
137
|
-
const res = await (pluginClient as any).mcp.status()
|
|
138
|
-
const servers = (res as any)?.data ?? res
|
|
139
|
-
if (servers && typeof servers === "object") {
|
|
140
|
-
mcpServerNames = new Set(Object.keys(servers))
|
|
141
|
-
}
|
|
142
|
-
} catch {
|
|
143
|
-
// MCP status unavailable (older opencode); keep web-tools-only checks.
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if (!mcpServerNames) return false
|
|
147
|
-
for (const server of mcpServerNames) {
|
|
148
|
-
if (toolName.startsWith(`${server}_`)) return true
|
|
149
|
-
}
|
|
150
|
-
return false
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Registers the memorize / memorize-extract sub-agents through the config
|
|
155
|
-
* hook so installing the plugin requires no manual agent setup. Definitions
|
|
156
|
-
* are read from the plugin's bundled opencode.json (single source of truth
|
|
157
|
-
* with the dev checkout). A user-defined agent of the same name always wins —
|
|
158
|
-
* only missing entries are filled. opencode-specific packaging: codex ships
|
|
159
|
-
* its memory agents inside the binary.
|
|
160
|
-
*/
|
|
161
|
-
export function injectAgentDefinitions(config: { agent?: Record<string, unknown> }): void {
|
|
162
|
-
let defs: Record<string, unknown>
|
|
163
|
-
try {
|
|
164
|
-
const raw = fs.readFileSync(path.join(import.meta.dirname, "..", "opencode.json"), "utf8")
|
|
165
|
-
defs = (JSON.parse(raw) as { agent?: Record<string, unknown> }).agent ?? {}
|
|
166
|
-
} catch (err) {
|
|
167
|
-
console.warn("[opencode-codex-memory] could not load bundled agent definitions:", err)
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
config.agent ??= {}
|
|
171
|
-
for (const [name, def] of Object.entries(defs)) {
|
|
172
|
-
if (!config.agent[name]) config.agent[name] = def
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function buildHooks() {
|
|
177
|
-
const base = {
|
|
178
|
-
async config(input: { agent?: Record<string, unknown> }): Promise<void> {
|
|
179
|
-
try {
|
|
180
|
-
// The write pipeline is the only consumer of the sub-agents; with
|
|
181
|
-
// generation off they would just pollute the user's agent list.
|
|
182
|
-
if (!pluginOptions.generate_memories) return
|
|
183
|
-
injectAgentDefinitions(input)
|
|
184
|
-
} catch (err) {
|
|
185
|
-
console.error("[opencode-codex-memory] config hook error:", err)
|
|
186
|
-
}
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
async "experimental.chat.system.transform"(
|
|
190
|
-
input: { sessionID?: string; model: unknown },
|
|
191
|
-
output: { system: string[] },
|
|
192
|
-
): Promise<void> {
|
|
193
|
-
try {
|
|
194
|
-
if (!pluginOptions.use_memories) return
|
|
195
|
-
if (input.sessionID && isMemorySubSession(input.sessionID)) return
|
|
196
|
-
ensureMemoryLayout()
|
|
197
|
-
const memoryPrompt = buildMemorySystemPrompt()
|
|
198
|
-
if (memoryPrompt) {
|
|
199
|
-
output.system.push(memoryPrompt)
|
|
200
|
-
}
|
|
201
|
-
} catch (err) {
|
|
202
|
-
console.error("[opencode-codex-memory] system.transform error:", err)
|
|
203
|
-
}
|
|
204
|
-
},
|
|
205
|
-
|
|
206
|
-
async "experimental.chat.messages.transform"(
|
|
207
|
-
_input: unknown,
|
|
208
|
-
output: { messages: { info: { role?: string }; parts: { type: string; text?: string }[] }[] },
|
|
209
|
-
): Promise<void> {
|
|
210
|
-
try {
|
|
211
|
-
for (const msg of output.messages) {
|
|
212
|
-
if (msg.info?.role !== "assistant") continue
|
|
213
|
-
for (const part of msg.parts) {
|
|
214
|
-
if (part.type === "text" && typeof part.text === "string" && part.text.includes("<memory-citation>")) {
|
|
215
|
-
const before = part.text
|
|
216
|
-
part.text = stripCitations(part.text)
|
|
217
|
-
if (part.text.includes("<memory-citation>")) {
|
|
218
|
-
console.warn("[opencode-codex-memory] citation marker still present after stripCitations — hook contract may have changed")
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch (err) {
|
|
224
|
-
console.error("[opencode-codex-memory] messages.transform error:", err)
|
|
225
|
-
}
|
|
226
|
-
},
|
|
227
|
-
|
|
228
|
-
async event(input: { event: { type: string; properties: unknown } }): Promise<void> {
|
|
229
|
-
try {
|
|
230
|
-
const ev = input.event
|
|
231
|
-
if (ev.type === "message.part.updated") {
|
|
232
|
-
const part = (ev.properties as { part?: { id?: string; type: string; text?: string; sessionID?: string } }).part
|
|
233
|
-
if (!part || part.type !== "text" || typeof part.text !== "string") return
|
|
234
|
-
if (part.sessionID && isMemorySubSession(part.sessionID)) return
|
|
235
|
-
if (!part.text.includes("<memory-citation>")) return
|
|
236
|
-
let ids: string[] = []
|
|
237
|
-
try {
|
|
238
|
-
ids = extractCitedSessionIds(part.text)
|
|
239
|
-
} catch {
|
|
240
|
-
return
|
|
241
|
-
}
|
|
242
|
-
const fresh = takeNewCitations(`${part.sessionID ?? ""}:${part.id ?? ""}`, ids)
|
|
243
|
-
if (fresh.length > 0) {
|
|
244
|
-
try {
|
|
245
|
-
getStore().recordUsage(fresh)
|
|
246
|
-
} catch (e) {
|
|
247
|
-
console.error("[opencode-codex-memory] recordUsage failed:", e)
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (ev.type === "tool.execute.after") {
|
|
254
|
-
// Mirrors codex: external context (web search or any MCP tool) only
|
|
255
|
-
// pollutes the session's memory when disable_on_external_context is
|
|
256
|
-
// enabled. Off by default.
|
|
257
|
-
if (!pluginOptions.disable_on_external_context) return
|
|
258
|
-
const props = ev.properties as { tool?: string; sessionID?: string }
|
|
259
|
-
const toolName = props?.tool ?? ""
|
|
260
|
-
if (props.sessionID && (await isExternalContextTool(toolName))) {
|
|
261
|
-
try {
|
|
262
|
-
getStore().markPolluted(props.sessionID)
|
|
263
|
-
} catch (e) {
|
|
264
|
-
console.error("[opencode-codex-memory] markPolluted failed:", e)
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (ev.type === "session.deleted") {
|
|
271
|
-
// Mirrors codex delete_thread_memory: drop the extracted memory and
|
|
272
|
-
// its job when the session is deleted; the file disappears at the
|
|
273
|
-
// next phase-2 rebuild and the diff drives forgetting.
|
|
274
|
-
const props = ev.properties as { info?: { id?: string } }
|
|
275
|
-
const sid = props?.info?.id
|
|
276
|
-
if (sid) {
|
|
277
|
-
try {
|
|
278
|
-
getStore().deleteSessionMemory(sid)
|
|
279
|
-
} catch (e) {
|
|
280
|
-
console.error("[opencode-codex-memory] deleteSessionMemory failed:", e)
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (ev.type === "session.idle") {
|
|
287
|
-
const props = ev.properties as { sessionID?: string }
|
|
288
|
-
const sid = props?.sessionID
|
|
289
|
-
if (!sid || isMemorySubSession(sid)) return
|
|
290
|
-
// codex stamps memory_mode at thread creation from generate_memories:
|
|
291
|
-
// sessions seen while generation is off stay excluded permanently,
|
|
292
|
-
// even if the option is re-enabled later.
|
|
293
|
-
try {
|
|
294
|
-
getStore().stampMemoryModeIfAbsent(sid, pluginOptions.generate_memories ? "enabled" : "disabled")
|
|
295
|
-
} catch (e) {
|
|
296
|
-
console.error("[opencode-codex-memory] stampMemoryModeIfAbsent failed:", e)
|
|
297
|
-
}
|
|
298
|
-
void triggerPhase1(sid)
|
|
299
|
-
return
|
|
300
|
-
}
|
|
301
|
-
} catch (err) {
|
|
302
|
-
console.error("[opencode-codex-memory] event error:", err)
|
|
303
|
-
}
|
|
304
|
-
},
|
|
305
|
-
|
|
306
|
-
async dispose(): Promise<void> {
|
|
307
|
-
invalidateCache()
|
|
308
|
-
},
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Control tools (reset/inspect/mode) are always available. The memory
|
|
312
|
-
// read/search/list/add-note tools require BOTH use_memories and
|
|
313
|
-
// dedicated_tools, mirroring codex's MemoriesExtension: use_memories=false
|
|
314
|
-
// disables the whole extension including its tools (extension.rs).
|
|
315
|
-
const tool =
|
|
316
|
-
pluginOptions.use_memories && pluginOptions.dedicated_tools
|
|
317
|
-
? {
|
|
318
|
-
memory_read,
|
|
319
|
-
memory_search,
|
|
320
|
-
memory_list,
|
|
321
|
-
memory_add_note,
|
|
322
|
-
memory_reset,
|
|
323
|
-
memory_inspect,
|
|
324
|
-
memory_mode,
|
|
325
|
-
}
|
|
326
|
-
: {
|
|
327
|
-
memory_reset,
|
|
328
|
-
memory_inspect,
|
|
329
|
-
memory_mode,
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return { ...base, tool }
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
async function triggerPhase1(currentSessionId: string): Promise<void> {
|
|
336
|
-
if (phase1InFlight || !pluginOptions.generate_memories) return
|
|
337
|
-
phase1InFlight = true
|
|
338
|
-
try {
|
|
339
|
-
await runPhase1(getStore(), {
|
|
340
|
-
maxAgeDays: pluginOptions.max_rollout_age_days,
|
|
341
|
-
minIdleHours: pluginOptions.min_rollout_idle_hours,
|
|
342
|
-
maxClaimed: pluginOptions.max_rollouts_per_startup,
|
|
343
|
-
excludeSession: currentSessionId,
|
|
344
|
-
extractModel: pluginOptions.extract_model,
|
|
345
|
-
})
|
|
346
|
-
} catch (err) {
|
|
347
|
-
console.error("[opencode-codex-memory] phase1 error:", err)
|
|
348
|
-
} finally {
|
|
349
|
-
phase1InFlight = false
|
|
350
|
-
}
|
|
351
|
-
void triggerPhase2()
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function triggerPhase2(): Promise<void> {
|
|
355
|
-
try {
|
|
356
|
-
// runPhase2 has its own in-flight guard
|
|
357
|
-
await runPhase2(getStore(), {
|
|
358
|
-
maxRaw: pluginOptions.max_raw_memories_for_consolidation,
|
|
359
|
-
maxUnusedDays: pluginOptions.max_unused_days,
|
|
360
|
-
extensionRetentionDays: 7,
|
|
361
|
-
consolidationModel: pluginOptions.consolidation_model,
|
|
362
|
-
})
|
|
363
|
-
} catch (err) {
|
|
364
|
-
console.error("[opencode-codex-memory] phase2 error:", err)
|
|
365
|
-
}
|
|
366
|
-
}
|
package/src/llm.ts
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
import fs from "fs"
|
|
2
|
-
import path from "path"
|
|
3
|
-
import type { PluginInput } from "@opencode-ai/plugin"
|
|
4
|
-
|
|
5
|
-
export interface ExtractionResult {
|
|
6
|
-
raw_memory: string
|
|
7
|
-
rollout_summary: string
|
|
8
|
-
rollout_slug: string | null
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
let inputRef: PluginInput | null = null
|
|
12
|
-
|
|
13
|
-
export function setPluginInput(input: PluginInput): void {
|
|
14
|
-
inputRef = input
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function getPluginInput(): PluginInput | null {
|
|
18
|
-
return inputRef
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Sessions this plugin spawned for extraction/consolidation. The main
|
|
22
|
-
// hooks skip these so the plugin never injects memory into (or memorizes) its
|
|
23
|
-
// own sub-agents.
|
|
24
|
-
const activeSubSessions = new Set<string>()
|
|
25
|
-
|
|
26
|
-
export function isMemorySubSession(sessionId: string): boolean {
|
|
27
|
-
return activeSubSessions.has(sessionId)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function createSession(agent: string, title?: string): Promise<string> {
|
|
31
|
-
const input = getPluginInput()
|
|
32
|
-
if (!input) throw new Error("plugin input not initialized")
|
|
33
|
-
const res = await input.client.session.create({
|
|
34
|
-
body: { title: title ?? `codex-memory-${agent}` },
|
|
35
|
-
})
|
|
36
|
-
if (!res.data) throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`)
|
|
37
|
-
const body = res.data as { id?: string }
|
|
38
|
-
const id = body.id
|
|
39
|
-
if (!id) throw new Error(`session create returned no id: ${JSON.stringify(body)}`)
|
|
40
|
-
activeSubSessions.add(id)
|
|
41
|
-
return id
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface PromptOptions {
|
|
45
|
-
timeoutMs?: number
|
|
46
|
-
system?: string
|
|
47
|
-
model?: string
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* opencode's config carries the same split codex expresses with provider
|
|
52
|
-
* model preferences: `small_model` for cheap background work (codex:
|
|
53
|
-
* memory_extraction_preferred_model = gpt-5.4-mini) and `model` for capable
|
|
54
|
-
* work (codex: memory_consolidation_preferred_model = gpt-5.4). Cached per
|
|
55
|
-
* plugin instance — opencode reloads plugins on config change.
|
|
56
|
-
*/
|
|
57
|
-
let configModels: { model?: string; smallModel?: string } | null = null
|
|
58
|
-
|
|
59
|
-
async function getConfigModels(): Promise<{ model?: string; smallModel?: string }> {
|
|
60
|
-
if (configModels) return configModels
|
|
61
|
-
const input = getPluginInput()
|
|
62
|
-
if (!input) return {}
|
|
63
|
-
try {
|
|
64
|
-
const res = await input.client.config.get()
|
|
65
|
-
const cfg = (res as { data?: { model?: string; small_model?: string } })?.data
|
|
66
|
-
configModels = { model: cfg?.model, smallModel: cfg?.small_model }
|
|
67
|
-
} catch {
|
|
68
|
-
// Config endpoint unavailable: leave models unset so the sub-agent runs
|
|
69
|
-
// on the session default, the previous behavior.
|
|
70
|
-
configModels = {}
|
|
71
|
-
}
|
|
72
|
-
return configModels
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// extract_model / consolidation model strings are "providerID/modelID".
|
|
76
|
-
function parseModelRef(ref: string): { providerID: string; modelID: string } | null {
|
|
77
|
-
const slash = ref.indexOf("/")
|
|
78
|
-
if (slash <= 0 || slash === ref.length - 1) return null
|
|
79
|
-
return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function promptSession(sessionId: string, prompt: string, agent: string, opts: PromptOptions = {}): Promise<string> {
|
|
83
|
-
const timeoutMs = opts.timeoutMs ?? 300_000
|
|
84
|
-
const input = getPluginInput()
|
|
85
|
-
if (!input) throw new Error("plugin input not initialized")
|
|
86
|
-
const model = opts.model ? parseModelRef(opts.model) : null
|
|
87
|
-
const promptPromise = input.client.session.prompt({
|
|
88
|
-
path: { id: sessionId },
|
|
89
|
-
body: {
|
|
90
|
-
agent,
|
|
91
|
-
...(opts.system ? { system: opts.system } : {}),
|
|
92
|
-
...(model ? { model } : {}),
|
|
93
|
-
parts: [{ type: "text", text: prompt } as any],
|
|
94
|
-
},
|
|
95
|
-
})
|
|
96
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
97
|
-
try {
|
|
98
|
-
const res = await Promise.race([
|
|
99
|
-
promptPromise,
|
|
100
|
-
new Promise<never>((_, reject) => {
|
|
101
|
-
timer = setTimeout(() => reject(new Error(`sub-agent prompt timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
102
|
-
}),
|
|
103
|
-
])
|
|
104
|
-
if (!res.data) throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`)
|
|
105
|
-
return extractAssistantText(res.data)
|
|
106
|
-
} finally {
|
|
107
|
-
clearTimeout(timer)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function extractAssistantText(body: any): string {
|
|
112
|
-
if (!body) return ""
|
|
113
|
-
if (typeof body === "string") return body
|
|
114
|
-
if (Array.isArray(body)) return body.map(extractAssistantText).join("\n")
|
|
115
|
-
if (typeof body.text === "string") return body.text
|
|
116
|
-
if (body.parts && Array.isArray(body.parts)) return body.parts.map((p: any) => p?.text ?? "").filter(Boolean).join("\n")
|
|
117
|
-
if (body.messages && Array.isArray(body.messages)) {
|
|
118
|
-
return body.messages
|
|
119
|
-
.filter((m: any) => m?.info?.role === "assistant")
|
|
120
|
-
.flatMap((m: any) => (m.parts ?? []).map((p: any) => p?.text ?? ""))
|
|
121
|
-
.filter(Boolean)
|
|
122
|
-
.join("\n")
|
|
123
|
-
}
|
|
124
|
-
if (body.output && typeof body.output === "string") return body.output
|
|
125
|
-
return JSON.stringify(body)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface ExtractOptions {
|
|
129
|
-
cwd?: string
|
|
130
|
-
model?: string
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Returns null when the extractor reported a no-op (nothing worth remembering). */
|
|
134
|
-
export async function extractViaSubagent(sessionId: string, transcript: string, opts: ExtractOptions = {}): Promise<ExtractionResult | null> {
|
|
135
|
-
const agent = "memorize-extract"
|
|
136
|
-
const subId = await createSession(agent, `codex-memory-extract-${sessionId}`)
|
|
137
|
-
try {
|
|
138
|
-
const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript)
|
|
139
|
-
// extract_model option > opencode small_model > session default.
|
|
140
|
-
const model = opts.model ?? (await getConfigModels()).smallModel
|
|
141
|
-
const raw = await promptSession(subId, prompt, agent, {
|
|
142
|
-
timeoutMs: 180_000,
|
|
143
|
-
system: readTemplate("stage_one_system.md"),
|
|
144
|
-
model,
|
|
145
|
-
})
|
|
146
|
-
return parseExtraction(raw)
|
|
147
|
-
} finally {
|
|
148
|
-
void deleteSession(subId).catch(() => {})
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// codex runs the consolidation agent under a 1h job lease with heartbeats;
|
|
153
|
-
// its INIT pass is explicitly allowed to run long ("do not be lazy"). A short
|
|
154
|
-
// timeout here would fail the job after the workspace was already synced.
|
|
155
|
-
const CONSOLIDATION_TIMEOUT_MS = 3600_000
|
|
156
|
-
|
|
157
|
-
export async function consolidateViaSubagent(memoryRoot: string, diffFileName: string, model?: string): Promise<void> {
|
|
158
|
-
const agent = "memorize"
|
|
159
|
-
const subId = await createSession(agent, "codex-memory-consolidate")
|
|
160
|
-
try {
|
|
161
|
-
const prompt = buildConsolidationPrompt(memoryRoot, diffFileName)
|
|
162
|
-
// consolidation_model option > opencode model (main) > session default.
|
|
163
|
-
const resolved = model ?? (await getConfigModels()).model
|
|
164
|
-
await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS })
|
|
165
|
-
} finally {
|
|
166
|
-
void deleteSession(subId).catch(() => {})
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Must exceed the longest legitimate sub-session lifetime (consolidation may
|
|
171
|
-
// run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
|
|
172
|
-
// plugin reload would delete a working sub-session mid-run.
|
|
173
|
-
export async function cleanupOldSubSessions(maxAgeMinutes = 90): Promise<void> {
|
|
174
|
-
const input = getPluginInput()
|
|
175
|
-
if (!input) return
|
|
176
|
-
try {
|
|
177
|
-
const res = await input.client.session.list()
|
|
178
|
-
if (!res.data) return
|
|
179
|
-
const list = res.data as Array<{ id: string; title?: string; time?: { created?: number } }>
|
|
180
|
-
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000
|
|
181
|
-
for (const s of list) {
|
|
182
|
-
if (s.title && s.title.startsWith("codex-memory-")) {
|
|
183
|
-
const created = s.time?.created ?? 0
|
|
184
|
-
if (created && created < cutoff) {
|
|
185
|
-
await deleteSession(s.id)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
} catch {
|
|
190
|
-
// best effort only
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function deleteSession(id: string): Promise<void> {
|
|
195
|
-
activeSubSessions.delete(id)
|
|
196
|
-
const input = getPluginInput()
|
|
197
|
-
if (!input) return
|
|
198
|
-
try {
|
|
199
|
-
const res = await input.client.session.delete({ path: { id } })
|
|
200
|
-
if (res.error) {
|
|
201
|
-
console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`)
|
|
202
|
-
}
|
|
203
|
-
} catch (err) {
|
|
204
|
-
console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err)
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Substitute with a function so `$&`/`$'` sequences in the value are not
|
|
209
|
-
// expanded as String.replace replacement patterns.
|
|
210
|
-
export function fillTemplate(tmpl: string, vars: Record<string, string>): string {
|
|
211
|
-
let out = tmpl
|
|
212
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
213
|
-
out = out.replaceAll(`{{ ${key} }}`, () => value)
|
|
214
|
-
}
|
|
215
|
-
return out
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function buildExtractionInput(sessionId: string, cwd: string, transcript: string): string {
|
|
219
|
-
return fillTemplate(readTemplate("stage_one_input.md"), {
|
|
220
|
-
session_id: sessionId,
|
|
221
|
-
session_cwd: cwd,
|
|
222
|
-
transcript,
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string {
|
|
227
|
-
return fillTemplate(readTemplate("consolidation.md"), {
|
|
228
|
-
memory_root: memoryRoot,
|
|
229
|
-
phase2_workspace_diff_file: diffFileName,
|
|
230
|
-
})
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function readTemplate(name: string): string {
|
|
234
|
-
return fs.readFileSync(path.join(import.meta.dirname, "templates", name), "utf8")
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
|
|
238
|
-
export function parseExtraction(raw: string): ExtractionResult | null {
|
|
239
|
-
const cleaned = raw.replace(/^```(?:json)?/gim, "").replace(/```$/gim, "").trim()
|
|
240
|
-
const start = cleaned.indexOf("{")
|
|
241
|
-
const end = cleaned.lastIndexOf("}")
|
|
242
|
-
if (start === -1 || end === -1 || end <= start) {
|
|
243
|
-
throw new Error("extraction response contained no JSON object")
|
|
244
|
-
}
|
|
245
|
-
const json = cleaned.slice(start, end + 1)
|
|
246
|
-
const obj = JSON.parse(json) as Partial<ExtractionResult>
|
|
247
|
-
if (typeof obj.raw_memory !== "string" || typeof obj.rollout_summary !== "string") {
|
|
248
|
-
throw new Error("extraction response missing required fields")
|
|
249
|
-
}
|
|
250
|
-
if (!obj.raw_memory.trim() && !obj.rollout_summary.trim()) {
|
|
251
|
-
return null
|
|
252
|
-
}
|
|
253
|
-
// Guard against the model echoing the format skeleton from the system prompt.
|
|
254
|
-
const templateArtifacts = [
|
|
255
|
-
"<success|partial|fail|uncertain>",
|
|
256
|
-
"<primary task signature>",
|
|
257
|
-
"<short quote or near-verbatim request>",
|
|
258
|
-
]
|
|
259
|
-
if (templateArtifacts.some((a) => obj.raw_memory!.includes(a))) {
|
|
260
|
-
throw new Error("extraction returned template placeholder text instead of actual content")
|
|
261
|
-
}
|
|
262
|
-
return {
|
|
263
|
-
raw_memory: obj.raw_memory,
|
|
264
|
-
rollout_summary: obj.rollout_summary,
|
|
265
|
-
rollout_slug: typeof obj.rollout_slug === "string" && obj.rollout_slug.trim() ? obj.rollout_slug : null,
|
|
266
|
-
}
|
|
267
|
-
}
|
package/src/path-guard.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import fs from "fs"
|
|
2
|
-
import path from "path"
|
|
3
|
-
import { memoryRoot } from "./paths.js"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Safe path resolution that cannot escape the memory root, mirroring codex
|
|
7
|
-
* ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
|
|
8
|
-
* - absolute paths and `..` components are rejected lexically
|
|
9
|
-
* - hidden (dot) components are invisible (reported as not found), so .git
|
|
10
|
-
* and other dotfiles are unreachable through the tools
|
|
11
|
-
* - every existing component is lstat-checked: symlinks are rejected, so a
|
|
12
|
-
* link placed inside the workspace cannot lead reads outside it
|
|
13
|
-
*/
|
|
14
|
-
export function safeResolveMemoryPath(rel: string): string {
|
|
15
|
-
const root = memoryRoot()
|
|
16
|
-
if (path.isAbsolute(rel)) {
|
|
17
|
-
throw new Error(`path escapes memory root: ${rel}`)
|
|
18
|
-
}
|
|
19
|
-
const parts = rel.split(/[\\/]+/).filter((p) => p.length > 0 && p !== ".")
|
|
20
|
-
let current = root
|
|
21
|
-
for (const part of parts) {
|
|
22
|
-
if (part === "..") {
|
|
23
|
-
throw new Error(`path escapes memory root: ${rel}`)
|
|
24
|
-
}
|
|
25
|
-
if (part.startsWith(".")) {
|
|
26
|
-
throw new Error(`not found: ${rel}`)
|
|
27
|
-
}
|
|
28
|
-
current = path.join(current, part)
|
|
29
|
-
let st: fs.Stats | null = null
|
|
30
|
-
try {
|
|
31
|
-
st = fs.lstatSync(current)
|
|
32
|
-
} catch {
|
|
33
|
-
// Component doesn't exist (yet): keep validating the rest lexically;
|
|
34
|
-
// the caller reports not-found / creates it under the checked prefix.
|
|
35
|
-
}
|
|
36
|
-
if (st?.isSymbolicLink()) {
|
|
37
|
-
throw new Error(`symlinks are not allowed in the memory workspace: ${rel}`)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
if (current !== root && !current.startsWith(root + path.sep)) {
|
|
41
|
-
throw new Error(`path escapes memory root: ${rel}`)
|
|
42
|
-
}
|
|
43
|
-
return current
|
|
44
|
-
}
|