opencode-codex-memory 0.1.9 → 0.2.1

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.
@@ -1,7 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { safeResolveMemoryPath } from "../src/path-guard.js";
4
- import { memoryRoot } from "../src/paths.js";
3
+ import { safeResolveMemoryPath, assertMemoryRootSafe } from "../src/path-guard.js";
5
4
  import { tool } from "@opencode-ai/plugin";
6
5
  const MAX_READ_BYTES = 256 * 1024;
7
6
  export const memory_read = tool({
@@ -27,19 +26,10 @@ export const memory_read = tool({
27
26
  metadata: { kind: "directory", entries },
28
27
  };
29
28
  }
30
- const fd = fs.openSync(fullPath, "r");
31
- let text;
32
- let byteTruncated;
33
- try {
34
- const size = Math.min(stat.size, MAX_READ_BYTES);
35
- const buf = Buffer.alloc(size);
36
- fs.readSync(fd, buf, 0, size, 0);
37
- text = buf.toString("utf8");
38
- byteTruncated = stat.size > MAX_READ_BYTES;
39
- }
40
- finally {
41
- fs.closeSync(fd);
42
- }
29
+ // Read the whole file and apply the line window FIRST; the byte cap
30
+ // applies to the WINDOWED output. Capping the raw read used to make
31
+ // lines beyond the first 256 KiB unreachable regardless of line_offset.
32
+ const text = fs.readFileSync(fullPath, "utf8");
43
33
  // Line windowing mirrors codex memories/read: 1-indexed offset, bounded
44
34
  // line count, and the start line reported so file:line citations work.
45
35
  const startLine = args.line_offset ?? 1;
@@ -54,12 +44,18 @@ export const memory_read = tool({
54
44
  lines = lines.slice(0, args.max_lines);
55
45
  lineTruncated = true;
56
46
  }
57
- const body = lines.join("\n");
47
+ let body = lines.join("\n");
48
+ let byteTruncated = false;
49
+ if (Buffer.byteLength(body, "utf8") > MAX_READ_BYTES) {
50
+ byteTruncated = true;
51
+ // Byte-accurate cut; drop a possibly split trailing multibyte char.
52
+ body = Buffer.from(body, "utf8").subarray(0, MAX_READ_BYTES).toString("utf8").replace(/\uFFFD+$/, "");
53
+ }
58
54
  const notes = [];
59
55
  if (lineTruncated)
60
56
  notes.push(`[stopped after ${args.max_lines} lines; file has ${totalLines}]`);
61
57
  if (byteTruncated)
62
- notes.push(`[truncated: ${stat.size - MAX_READ_BYTES} bytes omitted]`);
58
+ notes.push(`[output truncated at ${MAX_READ_BYTES} bytes; use line_offset to page]`);
63
59
  const header = startLine > 1 ? `[starting at line ${startLine}]\n` : "";
64
60
  return {
65
61
  output: header + body + (notes.length ? "\n\n" + notes.join("\n") : ""),
@@ -152,22 +148,22 @@ function parseDateArg(value, endOfDay) {
152
148
  }
153
149
  // Walks every non-hidden, non-symlink file (codex searches all files, not an
154
150
  // extension allowlist), in sorted order for deterministic results.
155
- function collectSearchFiles(root) {
151
+ function collectSearchFiles(start, prefix) {
156
152
  const files = [];
157
- const walk = (dir, prefix) => {
153
+ const walk = (dir, rel) => {
158
154
  const entries = visibleEntries(dir).sort((a, b) => a.name.localeCompare(b.name));
159
155
  for (const { name, isDir } of entries) {
160
156
  const abs = path.join(dir, name);
161
- const rel = prefix ? `${prefix}/${name}` : name;
157
+ const relPath = rel ? `${rel}/${name}` : name;
162
158
  if (isDir) {
163
- walk(abs, rel);
159
+ walk(abs, relPath);
164
160
  }
165
161
  else {
166
- files.push({ rel, abs, ts: fileTimestamp(name) });
162
+ files.push({ rel: relPath, abs, ts: fileTimestamp(name) });
167
163
  }
168
164
  }
169
165
  };
170
- walk(root, "");
166
+ walk(start, prefix);
171
167
  return files;
172
168
  }
173
169
  function firstContentLine(content) {
@@ -182,26 +178,118 @@ function firstContentLine(content) {
182
178
  }
183
179
  return "(empty)";
184
180
  }
181
+ // --- search engine, ported from codex ext/memories/src/local/search.rs ---
182
+ const SEARCH_MAX_RESULTS = 200; // codex MAX_SEARCH_RESULTS (= default)
183
+ // codex SearchComparison::prepare: lowercase when case-insensitive; when
184
+ // normalized, keep ONLY alphanumeric characters (Unicode) so "blue-green",
185
+ // "blue green" and "bluegreen" compare equal.
186
+ function prepareComparable(value, caseSensitive, normalized) {
187
+ let v = caseSensitive ? value : value.toLowerCase();
188
+ if (normalized)
189
+ v = v.replace(/[^\p{L}\p{N}]/gu, "");
190
+ return v;
191
+ }
192
+ /**
193
+ * Per-file matching, all three codex modes. Window mode extends from every
194
+ * line matching at least one query until all queries are covered (bounded by
195
+ * lineCount), then drops windows that strictly contain another window so only
196
+ * minimal windows are reported.
197
+ */
198
+ function searchFileContent(file, lines, queries, preparedQueries, mode, lineCount, contextLines, caseSensitive, normalized, out) {
199
+ const lineFlags = lines.map((line) => {
200
+ const prepared = prepareComparable(line, caseSensitive, normalized);
201
+ return preparedQueries.map((q) => prepared.includes(q));
202
+ });
203
+ const matchedQueries = (flags) => queries.filter((_, i) => flags[i]);
204
+ const push = (start, end, flags) => {
205
+ const contentStart = Math.max(0, start - contextLines);
206
+ const contentEnd = Math.min(lines.length, end + contextLines + 1);
207
+ out.push({
208
+ path: file.rel,
209
+ match_line_number: start + 1,
210
+ content_start_line_number: contentStart + 1,
211
+ content: lines.slice(contentStart, contentEnd).join("\n"),
212
+ matched_queries: matchedQueries(flags),
213
+ });
214
+ };
215
+ if (mode === "any" || mode === "all_on_same_line") {
216
+ for (let i = 0; i < lines.length; i++) {
217
+ const flags = lineFlags[i];
218
+ const hit = mode === "any" ? flags.some(Boolean) : flags.every(Boolean);
219
+ if (hit)
220
+ push(i, i, flags);
221
+ }
222
+ return;
223
+ }
224
+ // all_within_lines
225
+ const windows = [];
226
+ for (let start = 0; start < lines.length; start++) {
227
+ if (!lineFlags[start].some(Boolean))
228
+ continue;
229
+ const lastAllowed = Math.min(start + lineCount - 1, lines.length - 1);
230
+ const flags = new Array(preparedQueries.length).fill(false);
231
+ for (let end = start; end <= lastAllowed; end++) {
232
+ for (let q = 0; q < flags.length; q++)
233
+ flags[q] = flags[q] || lineFlags[end][q];
234
+ if (flags.every(Boolean)) {
235
+ windows.push({ start, end, flags });
236
+ break;
237
+ }
238
+ }
239
+ }
240
+ for (let i = 0; i < windows.length; i++) {
241
+ const w = windows[i];
242
+ const containsAnother = windows.some((o, j) => i !== j && w.start <= o.start && w.end >= o.end && (w.start !== o.start || w.end !== o.end));
243
+ if (containsAnother)
244
+ continue;
245
+ push(w.start, w.end, w.flags);
246
+ }
247
+ }
248
+ function renderMatch(m) {
249
+ const header = `${m.path}:${m.match_line_number}`;
250
+ if (!m.content.includes("\n") && m.content_start_line_number === m.match_line_number) {
251
+ return `${header}: ${m.content}`;
252
+ }
253
+ return `${header} (content from line ${m.content_start_line_number}):\n${m.content}`;
254
+ }
185
255
  export const memory_search = tool({
186
- description: "Search across the persistent memory workspace (MEMORY.md, rollout_summaries/*, skills/*). " +
187
- "Returns matching lines with file paths. Optional since/until restrict the search to " +
188
- "time-anchored files (rollout summaries, ad-hoc notes) from that period useful to recall " +
189
- "what the user was working on around a given time. With since/until and no query, returns a " +
190
- "chronological listing of that period's sessions/notes.",
256
+ description: "Search the persistent memory workspace (MEMORY.md, rollout_summaries/*, skills/*) for substring " +
257
+ "matches. Supports multiple queries with match_mode: 'any' (a line matching any query), " +
258
+ "'all_on_same_line' (a line containing every query), or 'all_within_lines' (all queries within a " +
259
+ "window of line_count lines). Optional path scoping, context lines, and cursor pagination. " +
260
+ "Optional since/until restrict the search to time-anchored files (rollout summaries, ad-hoc " +
261
+ "notes) from that period — useful to recall what the user was working on around a given time. " +
262
+ "With since/until and no queries, returns a chronological listing of that period's sessions/notes.",
191
263
  args: {
192
- query: tool.schema.string().min(1).optional().describe("Search query (substring match). Optional when since/until is set."),
264
+ queries: tool.schema.array(tool.schema.string()).optional().describe("Search substrings (at least one, non-empty after trim). Optional only when since/until is set."),
265
+ match_mode: tool.schema.enum(["any", "all_on_same_line", "all_within_lines"]).default("any").describe("How multiple queries combine (default any)."),
266
+ line_count: tool.schema.number().int().min(1).optional().describe("Window size in lines for all_within_lines (required for that mode)."),
267
+ path: tool.schema.string().optional().describe("Restrict the search to a file or directory relative to the memory root."),
268
+ cursor: tool.schema.string().optional().describe("Pagination cursor from a previous response's next_cursor."),
269
+ context_lines: tool.schema.number().int().min(0).default(0).describe("Extra lines of context around each match."),
193
270
  case_sensitive: tool.schema.boolean().default(true).describe("Case-sensitive matching (default true, like codex memories/search)."),
271
+ normalized: tool.schema.boolean().default(false).describe("Compare only alphanumeric characters, ignoring separators (blue-green == bluegreen); combine with case_sensitive=false to also ignore case."),
194
272
  since: tool.schema.string().optional().describe("Only time-anchored files at/after this time (YYYY-MM-DD or ISO datetime)."),
195
273
  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)."),
196
- limit: tool.schema.number().int().min(1).max(200).default(200).describe("Max matches to return (default/max 200, like codex)."),
274
+ max_results: tool.schema.number().int().min(1).max(SEARCH_MAX_RESULTS).default(SEARCH_MAX_RESULTS).describe("Max matches per page (default/max 200, like codex)."),
197
275
  },
198
276
  async execute(args, ctx) {
199
277
  try {
200
- const root = memoryRoot();
278
+ // Walks from the root directly (no per-path resolution), so the root
279
+ // symlink check must run here explicitly.
280
+ const root = assertMemoryRootSafe();
201
281
  if (!fs.existsSync(root))
202
282
  return { output: "Memory workspace is empty." };
203
- if (!args.query && !args.since && !args.until) {
204
- return { output: "memory_search error: provide a query and/or since/until." };
283
+ const queries = (args.queries ?? []).map((q) => q.trim());
284
+ if (args.queries && (queries.length === 0 || queries.some((q) => q.length === 0))) {
285
+ return { output: "memory_search error: queries must be non-empty strings." };
286
+ }
287
+ if (queries.length === 0 && !args.since && !args.until) {
288
+ return { output: "memory_search error: provide queries and/or since/until." };
289
+ }
290
+ const mode = (args.match_mode ?? "any");
291
+ if (mode === "all_within_lines" && !(typeof args.line_count === "number" && args.line_count >= 1)) {
292
+ return { output: "memory_search error: all_within_lines requires line_count >= 1." };
205
293
  }
206
294
  const since = args.since ? parseDateArg(args.since, false) : null;
207
295
  if (args.since && since === null)
@@ -209,7 +297,23 @@ export const memory_search = tool({
209
297
  const until = args.until ? parseDateArg(args.until, true) : null;
210
298
  if (args.until && until === null)
211
299
  return { output: `memory_search error: could not parse until="${args.until}".` };
212
- let files = collectSearchFiles(root);
300
+ // Path scoping: a file searches just that file, a directory is walked.
301
+ let files;
302
+ if (args.path) {
303
+ const start = safeResolveMemoryPath(args.path);
304
+ let st;
305
+ try {
306
+ st = fs.statSync(start);
307
+ }
308
+ catch {
309
+ return { output: `Not found: ${args.path}` };
310
+ }
311
+ const rel = args.path.replace(/\/+$/, "");
312
+ files = st.isFile() ? [{ rel, abs: start, ts: fileTimestamp(path.basename(start)) }] : collectSearchFiles(start, rel);
313
+ }
314
+ else {
315
+ files = collectSearchFiles(root, "");
316
+ }
213
317
  const timeFiltered = since !== null || until !== null;
214
318
  if (timeFiltered) {
215
319
  // Time filters only apply to time-anchored files; MEMORY.md etc. carry
@@ -218,8 +322,8 @@ export const memory_search = tool({
218
322
  files.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0));
219
323
  }
220
324
  const rangeLabel = timeFiltered ? ` in ${args.since ?? "..."}..${args.until ?? "..."}` : "";
221
- if (!args.query) {
222
- const listing = files.slice(0, args.limit).map((f) => {
325
+ if (queries.length === 0) {
326
+ const listing = files.slice(0, args.max_results).map((f) => {
223
327
  let content = "";
224
328
  try {
225
329
  content = fs.readFileSync(f.abs, "utf8");
@@ -236,13 +340,14 @@ export const memory_search = tool({
236
340
  };
237
341
  }
238
342
  const caseSensitive = args.case_sensitive ?? true;
239
- const q = caseSensitive ? args.query : args.query.toLowerCase();
240
- const matches = [];
241
- // Files are walked in sorted order, so results are ordered by
242
- // (path, line) like codex's search response.
343
+ const normalized = args.normalized ?? false;
344
+ const preparedQueries = queries.map((q) => prepareComparable(q, caseSensitive, normalized));
345
+ if (preparedQueries.some((q) => q.length === 0)) {
346
+ return { output: "memory_search error: a query is empty after normalization." };
347
+ }
348
+ // codex: collect ALL matches, sort by (path, line), then page by cursor.
349
+ const all = [];
243
350
  for (const f of files) {
244
- if (matches.length >= args.limit)
245
- break;
246
351
  let content;
247
352
  try {
248
353
  content = fs.readFileSync(f.abs, "utf8");
@@ -250,26 +355,42 @@ export const memory_search = tool({
250
355
  catch {
251
356
  continue;
252
357
  }
253
- for (const [i, line] of content.split(/\r?\n/).entries()) {
254
- if (matches.length >= args.limit)
255
- break;
256
- const haystack = caseSensitive ? line : line.toLowerCase();
257
- if (haystack.includes(q)) {
258
- matches.push({ file: f.rel, line: i + 1, text: line.slice(0, 240) });
259
- }
358
+ if (content.includes("\u0000"))
359
+ continue; // binary, like codex's InvalidData skip
360
+ searchFileContent(f, content.split(/\r?\n/), queries, preparedQueries, mode, args.line_count ?? 1, args.context_lines ?? 0, caseSensitive, normalized, all);
361
+ }
362
+ all.sort((a, b) => a.path.localeCompare(b.path) || a.match_line_number - b.match_line_number);
363
+ let startIndex = 0;
364
+ if (args.cursor !== undefined) {
365
+ startIndex = Number.parseInt(args.cursor, 10);
366
+ if (!Number.isInteger(startIndex) || startIndex < 0 || String(startIndex) !== args.cursor.trim()) {
367
+ return { output: `memory_search error: invalid cursor "${args.cursor}" (must be a non-negative integer).` };
368
+ }
369
+ if (startIndex > all.length) {
370
+ return { output: `memory_search error: cursor ${startIndex} exceeds result count ${all.length}.` };
260
371
  }
261
372
  }
262
- if (matches.length === 0)
263
- return { output: `No matches for "${args.query}"${rangeLabel}.` };
264
- const out = matches
265
- .map((m) => `${m.file}:${m.line}: ${m.text}`)
266
- .join("\n");
267
- // codex signals a capped result set (truncated/next_cursor); without an
268
- // indicator the model cannot tell "exactly N" from "stopped at N".
269
- const capped = matches.length >= args.limit;
373
+ const endIndex = Math.min(startIndex + (args.max_results ?? SEARCH_MAX_RESULTS), all.length);
374
+ const pageMatches = all.slice(startIndex, endIndex);
375
+ const nextCursor = endIndex < all.length ? String(endIndex) : null;
376
+ const truncated = nextCursor !== null;
377
+ const label = queries.map((q) => `"${q}"`).join(", ");
378
+ if (all.length === 0)
379
+ return { output: `No matches for ${label}${rangeLabel}.` };
270
380
  return {
271
- output: `${matches.length} match(es) for "${args.query}"${rangeLabel}${capped ? " (result limit reached; more may exist)" : ""}:\n${out}`,
272
- metadata: { count: matches.length, query: args.query, since: args.since, until: args.until, truncated: capped },
381
+ output: `${pageMatches.length} of ${all.length} match(es) for ${label}${rangeLabel}` +
382
+ `${truncated ? ` (more available; pass cursor=${nextCursor})` : ""}:\n` +
383
+ pageMatches.map(renderMatch).join("\n"),
384
+ metadata: {
385
+ queries,
386
+ match_mode: mode === "all_within_lines" ? { type: mode, line_count: args.line_count } : { type: mode },
387
+ path: args.path,
388
+ matches: pageMatches,
389
+ next_cursor: nextCursor,
390
+ truncated,
391
+ since: args.since,
392
+ until: args.until,
393
+ },
273
394
  };
274
395
  }
275
396
  catch (err) {
@@ -287,7 +408,8 @@ export const memory_add_note = tool({
287
408
  },
288
409
  async execute(args, ctx) {
289
410
  try {
290
- const root = memoryRoot();
411
+ // Writes under the root without per-path resolution; check the root.
412
+ const root = assertMemoryRootSafe();
291
413
  const notesDir = path.join(root, NOTES_DIR);
292
414
  fs.mkdirSync(notesDir, { recursive: true });
293
415
  const ts = new Date().toISOString();
package/opencode.json CHANGED
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "memorize-extract": {
22
22
  "mode": "subagent",
23
- "prompt": "You are a memory extraction agent. Read the session transcript and extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
23
+ "prompt": "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
24
24
  "permission": {
25
25
  "*": "deny",
26
26
  "bash": "deny",
@@ -28,11 +28,11 @@
28
28
  "websearch": "deny",
29
29
  "task": "deny",
30
30
  "todowrite": "deny",
31
- "read": "allow",
31
+ "read": "deny",
32
32
  "write": "deny",
33
33
  "edit": "deny",
34
- "glob": "allow",
35
- "grep": "allow"
34
+ "glob": "deny",
35
+ "grep": "deny"
36
36
  }
37
37
  }
38
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
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",
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "license": "Apache-2.0",
47
47
  "dependencies": {
48
- "@opencode-ai/plugin": "^1.17.13",
48
+ "@opencode-ai/plugin": "^1.18.0",
49
49
  "diff": "^9.0.0",
50
50
  "isomorphic-git": "^1.38.6"
51
51
  },