opencode-codex-memory 0.1.2 → 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 -137
  39. package/src/citation.ts +0 -94
  40. package/src/db.ts +0 -84
  41. package/src/git-baseline.ts +0 -162
  42. package/src/index.ts +0 -366
  43. package/src/llm.ts +0 -266
  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 -101
  48. package/src/ratelimit.ts +0 -26
  49. package/src/redact.ts +0 -44
  50. package/src/source.ts +0 -62
  51. package/src/store.ts +0 -434
  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 -190
  58. package/tools/control.ts +0 -145
  59. package/tools/memory.ts +0 -318
@@ -0,0 +1,153 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { tool } from "@opencode-ai/plugin";
4
+ import { memoryRoot, memorySummaryPath } from "../src/paths.js";
5
+ import { MemoryStore } from "../src/store.js";
6
+ import { invalidateCache } from "../src/source.js";
7
+ import { estimateTokens } from "../src/token.js";
8
+ function isSymlinkedRoot() {
9
+ const root = memoryRoot();
10
+ try {
11
+ return fs.lstatSync(root).isSymbolicLink();
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ // Mirrors codex clear_memory_root_contents: deletes EVERY entry including
18
+ // .git, so previously deleted/redacted memory content is not recoverable
19
+ // from git history after a reset.
20
+ function wipeMemoriesDir() {
21
+ const root = memoryRoot();
22
+ if (!fs.existsSync(root))
23
+ return;
24
+ for (const entry of fs.readdirSync(root)) {
25
+ const abs = path.join(root, entry);
26
+ try {
27
+ const stat = fs.statSync(abs);
28
+ if (stat.isDirectory())
29
+ fs.rmSync(abs, { recursive: true, force: true });
30
+ else
31
+ fs.unlinkSync(abs);
32
+ }
33
+ catch { }
34
+ }
35
+ }
36
+ function listMemoriesDir() {
37
+ const root = memoryRoot();
38
+ if (!fs.existsSync(root))
39
+ return [];
40
+ const out = [];
41
+ const walk = (dir, prefix) => {
42
+ for (const name of fs.readdirSync(dir)) {
43
+ if (name === ".git")
44
+ continue;
45
+ const abs = path.join(dir, name);
46
+ const rel = prefix ? `${prefix}/${name}` : name;
47
+ let stat;
48
+ try {
49
+ stat = fs.statSync(abs);
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ if (stat.isDirectory()) {
55
+ out.push(`${rel}/`);
56
+ walk(abs, rel);
57
+ }
58
+ else {
59
+ out.push(rel);
60
+ }
61
+ }
62
+ };
63
+ walk(root, "");
64
+ return out;
65
+ }
66
+ export const memory_reset = tool({
67
+ description: "Reset all persistent memory. Wipes the plugin's extracted memories and jobs tables and the entire " +
68
+ "contents of the memories directory (including git history). Per-session memory modes are preserved, " +
69
+ "so disabled/polluted sessions stay excluded. Refuses to run if the memory root is a symlink.",
70
+ args: {
71
+ confirm: tool.schema.boolean().describe("Must be true to perform the reset."),
72
+ },
73
+ async execute(args) {
74
+ if (!args.confirm)
75
+ return { output: "Reset aborted: confirm=false." };
76
+ if (isSymlinkedRoot()) {
77
+ return { output: "Reset refused: memory root is a symlink. Remove it manually to be safe." };
78
+ }
79
+ try {
80
+ const store = new MemoryStore();
81
+ store.clearMemoryData();
82
+ wipeMemoriesDir();
83
+ // codex keeps its state DB pool open across resets (clear_memory_roots_contents
84
+ // only wipes directories); closing here would strand cached handles elsewhere.
85
+ invalidateCache();
86
+ return { output: "Memory reset complete. Extracted memories and jobs cleared, memories directory (incl. git history) wiped, cache invalidated. Per-session memory modes were preserved." };
87
+ }
88
+ catch (err) {
89
+ return { output: `memory_reset error: ${err.message}` };
90
+ }
91
+ },
92
+ });
93
+ export const memory_inspect = tool({
94
+ description: "Inspect the current memory state. Returns: stage1_outputs count, last Phase 2 success watermark, " +
95
+ "memory_summary token estimate, and a listing of the memories directory. Read-only.",
96
+ args: {},
97
+ async execute() {
98
+ try {
99
+ const store = new MemoryStore();
100
+ const outputs = store.stage1Outputs();
101
+ const summaryPath = memorySummaryPath();
102
+ let summaryChars = 0;
103
+ let summaryTokens = 0;
104
+ if (fs.existsSync(summaryPath)) {
105
+ const text = fs.readFileSync(summaryPath, "utf8");
106
+ summaryChars = text.length;
107
+ summaryTokens = estimateTokens(text);
108
+ }
109
+ const listing = listMemoriesDir();
110
+ const out = [
111
+ `stage1_outputs: ${outputs.length}`,
112
+ `memory_summary_chars: ${summaryChars}`,
113
+ `memory_summary_tokens_est: ${summaryTokens}`,
114
+ `memories_dir_entries: ${listing.length}`,
115
+ "",
116
+ "Files:",
117
+ listing.length > 0 ? listing.join("\n") : "(empty)",
118
+ ].join("\n");
119
+ return {
120
+ output: out,
121
+ metadata: {
122
+ stage1_count: outputs.length,
123
+ summary_chars: summaryChars,
124
+ summary_tokens_est: summaryTokens,
125
+ files: listing,
126
+ },
127
+ };
128
+ }
129
+ catch (err) {
130
+ return { output: `memory_inspect error: ${err.message}` };
131
+ }
132
+ },
133
+ });
134
+ export const memory_mode = tool({
135
+ description: "Set the memory mode for the current session. 'enabled' allows Phase 1 extraction. " +
136
+ "'disabled' excludes this session from extraction. 'polluted' marks it as having external context " +
137
+ "(websearch/webfetch) that should not be trusted for memory.",
138
+ args: {
139
+ mode: tool.schema.enum(["enabled", "disabled", "polluted"]).describe("The memory mode to set."),
140
+ sessionId: tool.schema.string().optional().describe("Session ID. Defaults to the current session."),
141
+ },
142
+ async execute(args, ctx) {
143
+ try {
144
+ const store = new MemoryStore();
145
+ const sid = args.sessionId ?? ctx.sessionID;
146
+ store.setMemoryMode(sid, args.mode);
147
+ return { output: `Memory mode for session ${sid} set to '${args.mode}'.`, metadata: { sessionId: sid, mode: args.mode } };
148
+ }
149
+ catch (err) {
150
+ return { output: `memory_mode error: ${err.message}` };
151
+ }
152
+ },
153
+ });
@@ -0,0 +1,52 @@
1
+ export declare const memory_read: {
2
+ description: string;
3
+ args: {
4
+ path: import("zod").ZodString;
5
+ line_offset: import("zod").ZodOptional<import("zod").ZodNumber>;
6
+ max_lines: import("zod").ZodOptional<import("zod").ZodNumber>;
7
+ };
8
+ execute(args: {
9
+ path: string;
10
+ line_offset?: number | undefined;
11
+ max_lines?: number | undefined;
12
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
13
+ };
14
+ export declare const memory_list: {
15
+ description: string;
16
+ args: {
17
+ path: import("zod").ZodDefault<import("zod").ZodString>;
18
+ max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
19
+ };
20
+ execute(args: {
21
+ path: string;
22
+ max_results: number;
23
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
24
+ };
25
+ export declare const memory_search: {
26
+ description: string;
27
+ args: {
28
+ query: import("zod").ZodOptional<import("zod").ZodString>;
29
+ case_sensitive: import("zod").ZodDefault<import("zod").ZodBoolean>;
30
+ since: import("zod").ZodOptional<import("zod").ZodString>;
31
+ until: import("zod").ZodOptional<import("zod").ZodString>;
32
+ limit: import("zod").ZodDefault<import("zod").ZodNumber>;
33
+ };
34
+ execute(args: {
35
+ case_sensitive: boolean;
36
+ limit: number;
37
+ query?: string | undefined;
38
+ since?: string | undefined;
39
+ until?: string | undefined;
40
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
41
+ };
42
+ export declare const memory_add_note: {
43
+ description: string;
44
+ args: {
45
+ note: import("zod").ZodString;
46
+ title: import("zod").ZodOptional<import("zod").ZodString>;
47
+ };
48
+ execute(args: {
49
+ note: string;
50
+ title?: string | undefined;
51
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
52
+ };
@@ -0,0 +1,322 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { safeResolveMemoryPath } from "../src/path-guard.js";
4
+ import { memoryRoot } from "../src/paths.js";
5
+ import { tool } from "@opencode-ai/plugin";
6
+ const MAX_READ_BYTES = 256 * 1024;
7
+ export const memory_read = tool({
8
+ description: "Read a file from the persistent memory workspace (MEMORY.md, rollout_summaries/*, skills/*, etc.). " +
9
+ "Paths are relative to the memory root and cannot escape it. Supports line_offset/max_lines for " +
10
+ "reading a window of a large file; output line numbers are 1-indexed.",
11
+ args: {
12
+ path: tool.schema.string().describe("Relative path inside the memory workspace (e.g. MEMORY.md, rollout_summaries/session-xyz.md)."),
13
+ line_offset: tool.schema.number().int().min(1).optional().describe("1-indexed line to start reading from."),
14
+ max_lines: tool.schema.number().int().min(1).optional().describe("Maximum number of lines to return."),
15
+ },
16
+ async execute(args, ctx) {
17
+ try {
18
+ const fullPath = safeResolveMemoryPath(args.path);
19
+ if (!fs.existsSync(fullPath)) {
20
+ return { output: `Not found: ${args.path}` };
21
+ }
22
+ const stat = fs.statSync(fullPath);
23
+ if (stat.isDirectory()) {
24
+ const entries = fs.readdirSync(fullPath);
25
+ return {
26
+ output: `Directory ${args.path}/\n` + entries.map((e) => `- ${e}`).join("\n") + "\n(use memory_list for sorted, typed listings)",
27
+ metadata: { kind: "directory", entries },
28
+ };
29
+ }
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
+ }
43
+ // Line windowing mirrors codex memories/read: 1-indexed offset, bounded
44
+ // line count, and the start line reported so file:line citations work.
45
+ const startLine = args.line_offset ?? 1;
46
+ let lines = text.split(/\r?\n/);
47
+ const totalLines = lines.length;
48
+ if (startLine > totalLines) {
49
+ return { output: `memory_read error: line_offset ${startLine} exceeds file length (${totalLines} lines).` };
50
+ }
51
+ lines = lines.slice(startLine - 1);
52
+ let lineTruncated = false;
53
+ if (args.max_lines !== undefined && lines.length > args.max_lines) {
54
+ lines = lines.slice(0, args.max_lines);
55
+ lineTruncated = true;
56
+ }
57
+ const body = lines.join("\n");
58
+ const notes = [];
59
+ if (lineTruncated)
60
+ notes.push(`[stopped after ${args.max_lines} lines; file has ${totalLines}]`);
61
+ if (byteTruncated)
62
+ 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
+ }
69
+ catch (err) {
70
+ return { output: `memory_read error: ${err.message}` };
71
+ }
72
+ },
73
+ });
74
+ /** Skip hidden entries and symlinks, mirroring codex local/list.rs + local/search.rs walkers. */
75
+ function visibleEntries(dir) {
76
+ let names;
77
+ try {
78
+ names = fs.readdirSync(dir);
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const out = [];
84
+ for (const name of names) {
85
+ if (name.startsWith("."))
86
+ continue;
87
+ let st;
88
+ try {
89
+ st = fs.lstatSync(path.join(dir, name));
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ if (st.isSymbolicLink())
95
+ continue;
96
+ out.push({ name, isDir: st.isDirectory() });
97
+ }
98
+ return out;
99
+ }
100
+ const LIST_MAX_RESULTS = 2000;
101
+ export const memory_list = tool({
102
+ description: "List the immediate entries of a directory in the persistent memory workspace, sorted by name, " +
103
+ "with entry types. Hidden files and symlinks are skipped. Use path '' (empty) for the memory root.",
104
+ args: {
105
+ path: tool.schema.string().default("").describe("Relative directory path inside the memory workspace ('' for the root)."),
106
+ max_results: tool.schema.number().int().min(1).max(LIST_MAX_RESULTS).default(LIST_MAX_RESULTS).describe("Maximum entries to return."),
107
+ },
108
+ async execute(args) {
109
+ try {
110
+ const fullPath = safeResolveMemoryPath(args.path || ".");
111
+ if (!fs.existsSync(fullPath))
112
+ return { output: `Not found: ${args.path}` };
113
+ if (!fs.statSync(fullPath).isDirectory())
114
+ return { output: `memory_list error: not a directory: ${args.path}` };
115
+ const entries = visibleEntries(fullPath).sort((a, b) => a.name.localeCompare(b.name));
116
+ const truncated = entries.length > args.max_results;
117
+ const shown = entries.slice(0, args.max_results);
118
+ const prefix = args.path ? `${args.path.replace(/\/+$/, "")}/` : "";
119
+ const listing = shown.map((e) => ({ path: `${prefix}${e.name}`, entry_type: e.isDir ? "directory" : "file" }));
120
+ if (listing.length === 0)
121
+ return { output: `Directory ${args.path || "."} is empty.` };
122
+ return {
123
+ output: listing.map((e) => `${e.entry_type === "directory" ? "d" : "f"} ${e.path}`).join("\n") +
124
+ (truncated ? `\n[truncated: ${entries.length - args.max_results} more entries]` : ""),
125
+ metadata: { path: args.path, entries: listing, truncated },
126
+ };
127
+ }
128
+ catch (err) {
129
+ return { output: `memory_list error: ${err.message}` };
130
+ }
131
+ },
132
+ });
133
+ // Time-anchored memory files carry their session/note timestamp as a filename
134
+ // prefix: 2026-07-03T05-11-22-<hash>-<slug>.md / 2026-07-03T05-11-22_<slug>.md
135
+ function fileTimestamp(name) {
136
+ const m = name.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
137
+ if (!m)
138
+ return null;
139
+ const ts = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
140
+ return Number.isNaN(ts) ? null : ts;
141
+ }
142
+ // Accepts YYYY-MM-DD (whole-day boundary) or a full ISO datetime.
143
+ function parseDateArg(value, endOfDay) {
144
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
145
+ const ts = Date.parse(`${value}T00:00:00Z`);
146
+ if (Number.isNaN(ts))
147
+ return null;
148
+ return endOfDay ? ts + 24 * 60 * 60 * 1000 - 1 : ts;
149
+ }
150
+ const ts = Date.parse(value);
151
+ return Number.isNaN(ts) ? null : ts;
152
+ }
153
+ // Walks every non-hidden, non-symlink file (codex searches all files, not an
154
+ // extension allowlist), in sorted order for deterministic results.
155
+ function collectSearchFiles(root) {
156
+ const files = [];
157
+ const walk = (dir, prefix) => {
158
+ const entries = visibleEntries(dir).sort((a, b) => a.name.localeCompare(b.name));
159
+ for (const { name, isDir } of entries) {
160
+ const abs = path.join(dir, name);
161
+ const rel = prefix ? `${prefix}/${name}` : name;
162
+ if (isDir) {
163
+ walk(abs, rel);
164
+ }
165
+ else {
166
+ files.push({ rel, abs, ts: fileTimestamp(name) });
167
+ }
168
+ }
169
+ };
170
+ walk(root, "");
171
+ return files;
172
+ }
173
+ function firstContentLine(content) {
174
+ for (const line of content.split(/\r?\n/)) {
175
+ const t = line.trim();
176
+ if (!t)
177
+ continue;
178
+ // Skip the metadata header lines of summary/note files.
179
+ if (/^(session_id|updated_at|cwd|usage_count|created|session):/i.test(t))
180
+ continue;
181
+ return t.slice(0, 160);
182
+ }
183
+ return "(empty)";
184
+ }
185
+ 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.",
191
+ args: {
192
+ query: tool.schema.string().min(1).optional().describe("Search query (substring match). Optional when since/until is set."),
193
+ case_sensitive: tool.schema.boolean().default(true).describe("Case-sensitive matching (default true, like codex memories/search)."),
194
+ since: tool.schema.string().optional().describe("Only time-anchored files at/after this time (YYYY-MM-DD or ISO datetime)."),
195
+ 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)."),
197
+ },
198
+ async execute(args, ctx) {
199
+ try {
200
+ const root = memoryRoot();
201
+ if (!fs.existsSync(root))
202
+ 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." };
205
+ }
206
+ const since = args.since ? parseDateArg(args.since, false) : null;
207
+ if (args.since && since === null)
208
+ return { output: `memory_search error: could not parse since="${args.since}".` };
209
+ const until = args.until ? parseDateArg(args.until, true) : null;
210
+ if (args.until && until === null)
211
+ return { output: `memory_search error: could not parse until="${args.until}".` };
212
+ let files = collectSearchFiles(root);
213
+ const timeFiltered = since !== null || until !== null;
214
+ if (timeFiltered) {
215
+ // Time filters only apply to time-anchored files; MEMORY.md etc. carry
216
+ // no single timestamp and are excluded from time-scoped recall.
217
+ files = files.filter((f) => f.ts !== null && (since === null || f.ts >= since) && (until === null || f.ts <= until));
218
+ files.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0));
219
+ }
220
+ const rangeLabel = timeFiltered ? ` in ${args.since ?? "..."}..${args.until ?? "..."}` : "";
221
+ if (!args.query) {
222
+ const listing = files.slice(0, args.limit).map((f) => {
223
+ let content = "";
224
+ try {
225
+ content = fs.readFileSync(f.abs, "utf8");
226
+ }
227
+ catch {
228
+ }
229
+ return `${new Date(f.ts).toISOString()} ${f.rel} — ${firstContentLine(content)}`;
230
+ });
231
+ if (listing.length === 0)
232
+ 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
+ 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.
243
+ for (const f of files) {
244
+ if (matches.length >= args.limit)
245
+ break;
246
+ let content;
247
+ try {
248
+ content = fs.readFileSync(f.abs, "utf8");
249
+ }
250
+ catch {
251
+ continue;
252
+ }
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
+ }
260
+ }
261
+ }
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
+ return {
268
+ output: `${matches.length} match(es) for "${args.query}"${rangeLabel}:\n${out}`,
269
+ metadata: { count: matches.length, query: args.query, since: args.since, until: args.until },
270
+ };
271
+ }
272
+ catch (err) {
273
+ return { output: `memory_search error: ${err.message}` };
274
+ }
275
+ },
276
+ });
277
+ const NOTES_DIR = "extensions/ad_hoc/notes";
278
+ export const memory_add_note = tool({
279
+ description: "Append a short ad-hoc note to the persistent memory workspace under extensions/ad_hoc/notes/. " +
280
+ "Used when the user asks to remember something for future sessions.",
281
+ args: {
282
+ note: tool.schema.string().min(1).max(4000).describe("The note text to persist."),
283
+ title: tool.schema.string().max(120).optional().describe("Optional short title for the note."),
284
+ },
285
+ async execute(args, ctx) {
286
+ try {
287
+ const root = memoryRoot();
288
+ const notesDir = path.join(root, NOTES_DIR);
289
+ fs.mkdirSync(notesDir, { recursive: true });
290
+ const ts = new Date().toISOString();
291
+ const slug = (args.title ?? `note-${ts}`)
292
+ .toLowerCase()
293
+ .replace(/[^a-z0-9]+/g, "-")
294
+ .replace(/^-+|-+$/g, "")
295
+ .slice(0, 60);
296
+ // Filename layout matches codex: <YYYY-MM-DDTHH-MM-SS>-<slug>.md.
297
+ const stem = `${ts.slice(0, 19).replace(/[:.]/g, "-")}-${slug}`;
298
+ const header = `# ${args.title ?? "Ad-hoc note"}\n\n- created: ${ts}\n- session: ${ctx.sessionID}\n\n`;
299
+ // Notes are append-only (codex create_new semantics): never overwrite an
300
+ // existing note; disambiguate on collision instead.
301
+ let file = path.join(notesDir, `${stem}.md`);
302
+ for (let i = 2;; i++) {
303
+ try {
304
+ fs.writeFileSync(file, header + args.note + "\n", { flag: "wx" });
305
+ break;
306
+ }
307
+ catch (err) {
308
+ if (err.code !== "EEXIST" || i > 20)
309
+ throw err;
310
+ file = path.join(notesDir, `${stem}-${i}.md`);
311
+ }
312
+ }
313
+ return {
314
+ output: `Note saved to ${path.relative(root, file)}`,
315
+ metadata: { file: path.relative(root, file), sessionID: ctx.sessionID },
316
+ };
317
+ }
318
+ catch (err) {
319
+ return { output: `memory_add_note error: ${err.message}` };
320
+ }
321
+ },
322
+ });
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
5
5
  "type": "module",
6
- "main": "src/index.ts",
6
+ "main": "./dist/src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/src/index.js",
10
+ "types": "./dist/src/index.d.ts"
11
+ },
12
+ "./tools/*": {
13
+ "import": "./dist/tools/*.js",
14
+ "types": "./dist/tools/*.d.ts"
15
+ }
16
+ },
7
17
  "files": [
8
- "src",
9
- "tools",
18
+ "dist",
10
19
  "opencode.json",
11
20
  "NOTICE"
12
21
  ],
@@ -20,11 +29,19 @@
20
29
  },
21
30
  "scripts": {
22
31
  "dev": "bun --watch src/index.ts",
23
- "build": "bun build src/index.ts --outdir dist --target bun",
32
+ "build": "tsc",
33
+ "prepublishOnly": "npm run build",
24
34
  "test": "bun test",
25
35
  "typecheck": "tsc --noEmit"
26
36
  },
27
- "keywords": ["opencode", "memory", "plugin", "ai", "persistent-context", "codex"],
37
+ "keywords": [
38
+ "opencode",
39
+ "memory",
40
+ "plugin",
41
+ "ai",
42
+ "persistent-context",
43
+ "codex"
44
+ ],
28
45
  "license": "Apache-2.0",
29
46
  "dependencies": {
30
47
  "@opencode-ai/plugin": "^1.17.13",