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/tools/memory.ts DELETED
@@ -1,318 +0,0 @@
1
- import fs from "fs"
2
- import path from "path"
3
- import { safeResolveMemoryPath } from "@/path-guard"
4
- import { memoryRoot } from "@/paths"
5
- import { tool } from "@opencode-ai/plugin"
6
-
7
- const MAX_READ_BYTES = 256 * 1024
8
-
9
- export const memory_read = tool({
10
- description:
11
- "Read a file from the persistent memory workspace (MEMORY.md, rollout_summaries/*, skills/*, etc.). " +
12
- "Paths are relative to the memory root and cannot escape it. Supports line_offset/max_lines for " +
13
- "reading a window of a large file; output line numbers are 1-indexed.",
14
- args: {
15
- path: tool.schema.string().describe("Relative path inside the memory workspace (e.g. MEMORY.md, rollout_summaries/session-xyz.md)."),
16
- line_offset: tool.schema.number().int().min(1).optional().describe("1-indexed line to start reading from."),
17
- max_lines: tool.schema.number().int().min(1).optional().describe("Maximum number of lines to return."),
18
- },
19
- async execute(args, ctx) {
20
- try {
21
- const fullPath = safeResolveMemoryPath(args.path)
22
- if (!fs.existsSync(fullPath)) {
23
- return { output: `Not found: ${args.path}` }
24
- }
25
- const stat = fs.statSync(fullPath)
26
- if (stat.isDirectory()) {
27
- const entries = fs.readdirSync(fullPath)
28
- return {
29
- output: `Directory ${args.path}/\n` + entries.map((e) => `- ${e}`).join("\n") + "\n(use memory_list for sorted, typed listings)",
30
- metadata: { kind: "directory", entries },
31
- }
32
- }
33
- const fd = fs.openSync(fullPath, "r")
34
- let text: string
35
- let byteTruncated: boolean
36
- try {
37
- const size = Math.min(stat.size, MAX_READ_BYTES)
38
- const buf = Buffer.alloc(size)
39
- fs.readSync(fd, buf, 0, size, 0)
40
- text = buf.toString("utf8")
41
- byteTruncated = stat.size > MAX_READ_BYTES
42
- } finally {
43
- fs.closeSync(fd)
44
- }
45
- // Line windowing mirrors codex memories/read: 1-indexed offset, bounded
46
- // line count, and the start line reported so file:line citations work.
47
- const startLine = args.line_offset ?? 1
48
- let lines = text.split(/\r?\n/)
49
- const totalLines = lines.length
50
- if (startLine > totalLines) {
51
- return { output: `memory_read error: line_offset ${startLine} exceeds file length (${totalLines} lines).` }
52
- }
53
- lines = lines.slice(startLine - 1)
54
- let lineTruncated = false
55
- if (args.max_lines !== undefined && lines.length > args.max_lines) {
56
- lines = lines.slice(0, args.max_lines)
57
- lineTruncated = true
58
- }
59
- const body = lines.join("\n")
60
- const notes: string[] = []
61
- if (lineTruncated) notes.push(`[stopped after ${args.max_lines} lines; file has ${totalLines}]`)
62
- if (byteTruncated) notes.push(`[truncated: ${stat.size - MAX_READ_BYTES} bytes omitted]`)
63
- const header = startLine > 1 ? `[starting at line ${startLine}]\n` : ""
64
- return {
65
- output: header + body + (notes.length ? "\n\n" + notes.join("\n") : ""),
66
- metadata: { path: args.path, bytes: stat.size, start_line_number: startLine, truncated: byteTruncated || lineTruncated },
67
- }
68
- } catch (err) {
69
- return { output: `memory_read error: ${(err as Error).message}` }
70
- }
71
- },
72
- })
73
-
74
- /** Skip hidden entries and symlinks, mirroring codex local/list.rs + local/search.rs walkers. */
75
- function visibleEntries(dir: string): { name: string; isDir: boolean }[] {
76
- let names: string[]
77
- try {
78
- names = fs.readdirSync(dir)
79
- } catch {
80
- return []
81
- }
82
- const out: { name: string; isDir: boolean }[] = []
83
- for (const name of names) {
84
- if (name.startsWith(".")) continue
85
- let st: fs.Stats
86
- try {
87
- st = fs.lstatSync(path.join(dir, name))
88
- } catch {
89
- continue
90
- }
91
- if (st.isSymbolicLink()) continue
92
- out.push({ name, isDir: st.isDirectory() })
93
- }
94
- return out
95
- }
96
-
97
- const LIST_MAX_RESULTS = 2000
98
-
99
- export const memory_list = tool({
100
- description:
101
- "List the immediate entries of a directory in the persistent memory workspace, sorted by name, " +
102
- "with entry types. Hidden files and symlinks are skipped. Use path '' (empty) for the memory root.",
103
- args: {
104
- path: tool.schema.string().default("").describe("Relative directory path inside the memory workspace ('' for the root)."),
105
- max_results: tool.schema.number().int().min(1).max(LIST_MAX_RESULTS).default(LIST_MAX_RESULTS).describe("Maximum entries to return."),
106
- },
107
- async execute(args) {
108
- try {
109
- const fullPath = safeResolveMemoryPath(args.path || ".")
110
- if (!fs.existsSync(fullPath)) return { output: `Not found: ${args.path}` }
111
- if (!fs.statSync(fullPath).isDirectory()) return { output: `memory_list error: not a directory: ${args.path}` }
112
- const entries = visibleEntries(fullPath).sort((a, b) => a.name.localeCompare(b.name))
113
- const truncated = entries.length > args.max_results
114
- const shown = entries.slice(0, args.max_results)
115
- const prefix = args.path ? `${args.path.replace(/\/+$/, "")}/` : ""
116
- const listing = shown.map((e) => ({ path: `${prefix}${e.name}`, entry_type: e.isDir ? "directory" : "file" }))
117
- if (listing.length === 0) return { output: `Directory ${args.path || "."} is empty.` }
118
- return {
119
- output:
120
- listing.map((e) => `${e.entry_type === "directory" ? "d" : "f"} ${e.path}`).join("\n") +
121
- (truncated ? `\n[truncated: ${entries.length - args.max_results} more entries]` : ""),
122
- metadata: { path: args.path, entries: listing, truncated },
123
- }
124
- } catch (err) {
125
- return { output: `memory_list error: ${(err as Error).message}` }
126
- }
127
- },
128
- })
129
-
130
- // Time-anchored memory files carry their session/note timestamp as a filename
131
- // prefix: 2026-07-03T05-11-22-<hash>-<slug>.md / 2026-07-03T05-11-22_<slug>.md
132
- function fileTimestamp(name: string): number | null {
133
- const m = name.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/)
134
- if (!m) return null
135
- const ts = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`)
136
- return Number.isNaN(ts) ? null : ts
137
- }
138
-
139
- // Accepts YYYY-MM-DD (whole-day boundary) or a full ISO datetime.
140
- function parseDateArg(value: string, endOfDay: boolean): number | null {
141
- if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
142
- const ts = Date.parse(`${value}T00:00:00Z`)
143
- if (Number.isNaN(ts)) return null
144
- return endOfDay ? ts + 24 * 60 * 60 * 1000 - 1 : ts
145
- }
146
- const ts = Date.parse(value)
147
- return Number.isNaN(ts) ? null : ts
148
- }
149
-
150
- interface CandidateFile {
151
- rel: string
152
- abs: string
153
- ts: number | null
154
- }
155
-
156
- // Walks every non-hidden, non-symlink file (codex searches all files, not an
157
- // extension allowlist), in sorted order for deterministic results.
158
- function collectSearchFiles(root: string): CandidateFile[] {
159
- const files: CandidateFile[] = []
160
- const walk = (dir: string, prefix: string) => {
161
- const entries = visibleEntries(dir).sort((a, b) => a.name.localeCompare(b.name))
162
- for (const { name, isDir } of entries) {
163
- const abs = path.join(dir, name)
164
- const rel = prefix ? `${prefix}/${name}` : name
165
- if (isDir) {
166
- walk(abs, rel)
167
- } else {
168
- files.push({ rel, abs, ts: fileTimestamp(name) })
169
- }
170
- }
171
- }
172
- walk(root, "")
173
- return files
174
- }
175
-
176
- function firstContentLine(content: string): string {
177
- for (const line of content.split(/\r?\n/)) {
178
- const t = line.trim()
179
- if (!t) continue
180
- // Skip the metadata header lines of summary/note files.
181
- if (/^(session_id|updated_at|cwd|usage_count|created|session):/i.test(t)) continue
182
- return t.slice(0, 160)
183
- }
184
- return "(empty)"
185
- }
186
-
187
- export const memory_search = tool({
188
- description:
189
- "Search across the persistent memory workspace (MEMORY.md, rollout_summaries/*, skills/*). " +
190
- "Returns matching lines with file paths. Optional since/until restrict the search to " +
191
- "time-anchored files (rollout summaries, ad-hoc notes) from that period — useful to recall " +
192
- "what the user was working on around a given time. With since/until and no query, returns a " +
193
- "chronological listing of that period's sessions/notes.",
194
- args: {
195
- query: tool.schema.string().min(1).optional().describe("Search query (substring match). Optional when since/until is set."),
196
- case_sensitive: tool.schema.boolean().default(true).describe("Case-sensitive matching (default true, like codex memories/search)."),
197
- since: tool.schema.string().optional().describe("Only time-anchored files at/after this time (YYYY-MM-DD or ISO datetime)."),
198
- until: tool.schema.string().optional().describe("Only time-anchored files at/before this time (YYYY-MM-DD or ISO datetime; whole day for date-only)."),
199
- limit: tool.schema.number().int().min(1).max(200).default(200).describe("Max matches to return (default/max 200, like codex)."),
200
- },
201
- async execute(args, ctx) {
202
- try {
203
- const root = memoryRoot()
204
- if (!fs.existsSync(root)) return { output: "Memory workspace is empty." }
205
- if (!args.query && !args.since && !args.until) {
206
- return { output: "memory_search error: provide a query and/or since/until." }
207
- }
208
- const since = args.since ? parseDateArg(args.since, false) : null
209
- if (args.since && since === null) return { output: `memory_search error: could not parse since="${args.since}".` }
210
- const until = args.until ? parseDateArg(args.until, true) : null
211
- if (args.until && until === null) return { output: `memory_search error: could not parse until="${args.until}".` }
212
-
213
- let files = collectSearchFiles(root)
214
- const timeFiltered = since !== null || until !== null
215
- if (timeFiltered) {
216
- // Time filters only apply to time-anchored files; MEMORY.md etc. carry
217
- // no single timestamp and are excluded from time-scoped recall.
218
- files = files.filter((f) => f.ts !== null && (since === null || f.ts >= since) && (until === null || f.ts <= until))
219
- files.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0))
220
- }
221
- const rangeLabel = timeFiltered ? ` in ${args.since ?? "..."}..${args.until ?? "..."}` : ""
222
-
223
- if (!args.query) {
224
- const listing = files.slice(0, args.limit).map((f) => {
225
- let content = ""
226
- try {
227
- content = fs.readFileSync(f.abs, "utf8")
228
- } catch {
229
- }
230
- return `${new Date(f.ts!).toISOString()} ${f.rel} — ${firstContentLine(content)}`
231
- })
232
- if (listing.length === 0) return { output: `No time-anchored memory files${rangeLabel}.` }
233
- return {
234
- output: `${listing.length} memory file(s)${rangeLabel}:\n${listing.join("\n")}`,
235
- metadata: { count: listing.length, since: args.since, until: args.until },
236
- }
237
- }
238
-
239
- const caseSensitive = args.case_sensitive ?? true
240
- const q = caseSensitive ? args.query : args.query.toLowerCase()
241
- const matches: { file: string; line: number; text: string }[] = []
242
- // Files are walked in sorted order, so results are ordered by
243
- // (path, line) like codex's search response.
244
- for (const f of files) {
245
- if (matches.length >= args.limit) break
246
- let content: string
247
- try {
248
- content = fs.readFileSync(f.abs, "utf8")
249
- } catch {
250
- continue
251
- }
252
- for (const [i, line] of content.split(/\r?\n/).entries()) {
253
- if (matches.length >= args.limit) break
254
- const haystack = caseSensitive ? line : line.toLowerCase()
255
- if (haystack.includes(q)) {
256
- matches.push({ file: f.rel, line: i + 1, text: line.slice(0, 240) })
257
- }
258
- }
259
- }
260
- if (matches.length === 0) return { output: `No matches for "${args.query}"${rangeLabel}.` }
261
- const out = matches
262
- .map((m) => `${m.file}:${m.line}: ${m.text}`)
263
- .join("\n")
264
- return {
265
- output: `${matches.length} match(es) for "${args.query}"${rangeLabel}:\n${out}`,
266
- metadata: { count: matches.length, query: args.query, since: args.since, until: args.until },
267
- }
268
- } catch (err) {
269
- return { output: `memory_search error: ${(err as Error).message}` }
270
- }
271
- },
272
- })
273
-
274
- const NOTES_DIR = "extensions/ad_hoc/notes"
275
-
276
- export const memory_add_note = tool({
277
- description:
278
- "Append a short ad-hoc note to the persistent memory workspace under extensions/ad_hoc/notes/. " +
279
- "Used when the user asks to remember something for future sessions.",
280
- args: {
281
- note: tool.schema.string().min(1).max(4000).describe("The note text to persist."),
282
- title: tool.schema.string().max(120).optional().describe("Optional short title for the note."),
283
- },
284
- async execute(args, ctx) {
285
- try {
286
- const root = memoryRoot()
287
- const notesDir = path.join(root, NOTES_DIR)
288
- fs.mkdirSync(notesDir, { recursive: true })
289
- const ts = new Date().toISOString()
290
- const slug = (args.title ?? `note-${ts}`)
291
- .toLowerCase()
292
- .replace(/[^a-z0-9]+/g, "-")
293
- .replace(/^-+|-+$/g, "")
294
- .slice(0, 60)
295
- // Filename layout matches codex: <YYYY-MM-DDTHH-MM-SS>-<slug>.md.
296
- const stem = `${ts.slice(0, 19).replace(/[:.]/g, "-")}-${slug}`
297
- const header = `# ${args.title ?? "Ad-hoc note"}\n\n- created: ${ts}\n- session: ${ctx.sessionID}\n\n`
298
- // Notes are append-only (codex create_new semantics): never overwrite an
299
- // existing note; disambiguate on collision instead.
300
- let file = path.join(notesDir, `${stem}.md`)
301
- for (let i = 2; ; i++) {
302
- try {
303
- fs.writeFileSync(file, header + args.note + "\n", { flag: "wx" })
304
- break
305
- } catch (err) {
306
- if ((err as NodeJS.ErrnoException).code !== "EEXIST" || i > 20) throw err
307
- file = path.join(notesDir, `${stem}-${i}.md`)
308
- }
309
- }
310
- return {
311
- output: `Note saved to ${path.relative(root, file)}`,
312
- metadata: { file: path.relative(root, file), sessionID: ctx.sessionID },
313
- }
314
- } catch (err) {
315
- return { output: `memory_add_note error: ${(err as Error).message}` }
316
- }
317
- },
318
- })