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.
Files changed (59) hide show
  1. package/dist/src/capture.d.ts +19 -0
  2. package/dist/src/capture.js +120 -0
  3. package/dist/src/citation.d.ts +14 -0
  4. package/dist/src/citation.js +81 -0
  5. package/dist/src/db.d.ts +3 -0
  6. package/dist/src/db.js +78 -0
  7. package/dist/src/git-baseline.d.ts +24 -0
  8. package/dist/src/git-baseline.js +150 -0
  9. package/dist/src/index.d.ts +163 -0
  10. package/dist/src/index.js +365 -0
  11. package/dist/src/llm.d.ts +19 -0
  12. package/dist/src/llm.js +251 -0
  13. package/dist/src/path-guard.d.ts +10 -0
  14. package/dist/src/path-guard.js +44 -0
  15. package/dist/src/paths.d.ts +4 -0
  16. package/dist/src/paths.js +23 -0
  17. package/dist/src/phase1.d.ts +11 -0
  18. package/dist/src/phase1.js +104 -0
  19. package/dist/src/phase2.d.ts +11 -0
  20. package/dist/src/phase2.js +83 -0
  21. package/dist/src/ratelimit.d.ts +5 -0
  22. package/dist/src/ratelimit.js +20 -0
  23. package/dist/src/redact.d.ts +8 -0
  24. package/dist/src/redact.js +37 -0
  25. package/dist/src/source.d.ts +3 -0
  26. package/dist/src/source.js +46 -0
  27. package/dist/src/store.d.ts +96 -0
  28. package/dist/src/store.js +346 -0
  29. package/dist/src/token.d.ts +8 -0
  30. package/dist/src/token.js +19 -0
  31. package/dist/src/workspace.d.ts +8 -0
  32. package/dist/src/workspace.js +194 -0
  33. package/dist/tools/control.d.ts +29 -0
  34. package/dist/tools/control.js +153 -0
  35. package/dist/tools/memory.d.ts +52 -0
  36. package/dist/tools/memory.js +322 -0
  37. package/package.json +23 -6
  38. package/src/capture.ts +0 -135
  39. package/src/citation.ts +0 -94
  40. package/src/db.ts +0 -80
  41. package/src/git-baseline.ts +0 -162
  42. package/src/index.ts +0 -366
  43. package/src/llm.ts +0 -267
  44. package/src/path-guard.ts +0 -44
  45. package/src/paths.ts +0 -29
  46. package/src/phase1.ts +0 -116
  47. package/src/phase2.ts +0 -99
  48. package/src/ratelimit.ts +0 -26
  49. package/src/redact.ts +0 -44
  50. package/src/source.ts +0 -59
  51. package/src/store.ts +0 -430
  52. package/src/templates/consolidation.md +0 -448
  53. package/src/templates/read_path.md +0 -104
  54. package/src/templates/stage_one_input.md +0 -11
  55. package/src/templates/stage_one_system.md +0 -333
  56. package/src/token.ts +0 -21
  57. package/src/workspace.ts +0 -181
  58. package/tools/control.ts +0 -145
  59. 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
- }