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.
package/dist/src/llm.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface ExtractionResult {
5
5
  rollout_slug: string | null;
6
6
  }
7
7
  export declare function setPluginInput(input: PluginInput): void;
8
+ export declare function getPluginInput(): PluginInput | null;
8
9
  export declare function isMemorySubSession(sessionId: string): boolean;
9
10
  export interface ExtractOptions {
10
11
  cwd?: string;
package/dist/src/llm.js CHANGED
@@ -4,7 +4,7 @@ let inputRef = null;
4
4
  export function setPluginInput(input) {
5
5
  inputRef = input;
6
6
  }
7
- function getPluginInput() {
7
+ export function getPluginInput() {
8
8
  return inputRef;
9
9
  }
10
10
  // Sessions this plugin spawned for extraction/consolidation. The main
@@ -7,4 +7,13 @@
7
7
  * - every existing component is lstat-checked: symlinks are rejected, so a
8
8
  * link placed inside the workspace cannot lead reads outside it
9
9
  */
10
+ /**
11
+ * The memory root itself must not be a symlink: every scoped resolution and
12
+ * every workspace walk starts there, so a symlinked root would redirect ALL
13
+ * memory reads/writes elsewhere on disk. codex rejects a symlinked root when
14
+ * clearing (control.rs clear_memory_root_contents); the model-facing tools
15
+ * here extend that check to every memory operation. Returns the root path.
16
+ * A missing root is fine — callers create it as a real directory.
17
+ */
18
+ export declare function assertMemoryRootSafe(): string;
10
19
  export declare function safeResolveMemoryPath(rel: string): string;
@@ -10,8 +10,30 @@ import { memoryRoot } from "./paths.js";
10
10
  * - every existing component is lstat-checked: symlinks are rejected, so a
11
11
  * link placed inside the workspace cannot lead reads outside it
12
12
  */
13
- export function safeResolveMemoryPath(rel) {
13
+ /**
14
+ * The memory root itself must not be a symlink: every scoped resolution and
15
+ * every workspace walk starts there, so a symlinked root would redirect ALL
16
+ * memory reads/writes elsewhere on disk. codex rejects a symlinked root when
17
+ * clearing (control.rs clear_memory_root_contents); the model-facing tools
18
+ * here extend that check to every memory operation. Returns the root path.
19
+ * A missing root is fine — callers create it as a real directory.
20
+ */
21
+ export function assertMemoryRootSafe() {
14
22
  const root = memoryRoot();
23
+ let st = null;
24
+ try {
25
+ st = fs.lstatSync(root);
26
+ }
27
+ catch {
28
+ return root;
29
+ }
30
+ if (st.isSymbolicLink()) {
31
+ throw new Error(`memory root is a symlink; refusing memory operations: ${root}`);
32
+ }
33
+ return root;
34
+ }
35
+ export function safeResolveMemoryPath(rel) {
36
+ const root = assertMemoryRootSafe();
15
37
  if (path.isAbsolute(rel)) {
16
38
  throw new Error(`path escapes memory root: ${rel}`);
17
39
  }
@@ -1,4 +1,3 @@
1
1
  export declare function memoryRoot(): string;
2
2
  export declare function memoryDbPath(): string;
3
- export declare function opencodeDbPath(): string;
4
3
  export declare function memorySummaryPath(): string;
package/dist/src/paths.js CHANGED
@@ -15,9 +15,6 @@ export function memoryRoot() {
15
15
  export function memoryDbPath() {
16
16
  return path.join(dataRoot(), MEMORY_DB_NAME);
17
17
  }
18
- export function opencodeDbPath() {
19
- return path.join(dataRoot(), "opencode.db");
20
- }
21
18
  export function memorySummaryPath() {
22
19
  return path.join(memoryRoot(), "memory_summary.md");
23
20
  }
@@ -9,3 +9,4 @@ export interface Phase1Options {
9
9
  }
10
10
  export declare const DEFAULT_PHASE1_OPTIONS: Phase1Options;
11
11
  export declare function runPhase1(store: MemoryStore, opts?: Phase1Options): Promise<void>;
12
+ export declare function buildTranscript(sessionId: string): Promise<string>;
@@ -26,7 +26,7 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
26
26
  console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason);
27
27
  return;
28
28
  }
29
- const eligible = selectEligibleSessions(store, opts);
29
+ const eligible = await selectEligibleSessions(store, opts);
30
30
  if (eligible.length === 0)
31
31
  return;
32
32
  const claimed = store.claimStage1Jobs(eligible, opts.excludeSession, opts.maxClaimed);
@@ -38,7 +38,7 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
38
38
  try {
39
39
  const session = sessionById.get(sid);
40
40
  const sourceUpdatedAt = session?.updated_at ?? Date.now();
41
- const transcript = buildTranscript(sid);
41
+ const transcript = await buildTranscript(sid);
42
42
  if (!transcript.trim()) {
43
43
  store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt);
44
44
  return;
@@ -68,14 +68,19 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
68
68
  }
69
69
  });
70
70
  }
71
- function buildTranscript(sessionId) {
72
- const msgs = loadTranscript(sessionId);
71
+ export async function buildTranscript(sessionId) {
72
+ const msgs = await loadTranscript(sessionId);
73
73
  if (msgs.length === 0)
74
74
  return "";
75
75
  const lines = [];
76
76
  for (const m of msgs) {
77
77
  if (m.type === "system")
78
78
  continue;
79
+ // codex sanitize_response_item_for_memories drops developer-role messages
80
+ // entirely (injected instructions, not conversation). opencode 1.17 only
81
+ // stores user/assistant roles; this guards future role additions.
82
+ if (m.role === "developer")
83
+ continue;
79
84
  const role = m.role ?? m.type;
80
85
  const text = m.text ?? "";
81
86
  if (!text.trim())
@@ -6,6 +6,8 @@ export interface Phase2Options {
6
6
  consolidationModel?: string;
7
7
  }
8
8
  export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
9
+ /** True while THIS process runs a consolidation (memory_reset refuses then). */
10
+ export declare function isPhase2InFlight(): boolean;
9
11
  export declare function runPhase2(store: MemoryStore, opts?: Phase2Options): Promise<{
10
12
  status: string;
11
13
  }>;
@@ -10,6 +10,10 @@ export const DEFAULT_PHASE2_OPTIONS = {
10
10
  extensionRetentionDays: 7,
11
11
  };
12
12
  let phase2InFlight = false;
13
+ /** True while THIS process runs a consolidation (memory_reset refuses then). */
14
+ export function isPhase2InFlight() {
15
+ return phase2InFlight;
16
+ }
13
17
  export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
14
18
  if (phase2InFlight)
15
19
  return { status: "already_running" };
@@ -10,8 +10,11 @@ const REDACTIONS = [
10
10
  re: /-----BEGIN [A-Z]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z]+ PRIVATE KEY-----/g,
11
11
  replacement: "[REDACTED:private-key]",
12
12
  },
13
- { re: /(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token)\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
14
- { re: /(?:aws_secret_access_key|aws_access_key_id)\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
13
+ // Optional quotes around the KEY cover JSON/YAML forms like
14
+ // "password": "value" — codex's SECRET_ASSIGNMENT_REGEX misses those (it
15
+ // allows a quote only before the value); this is a deliberate superset.
16
+ { re: /["']?(password|passwd|pwd|secret|api[_-]?key|token|access[_-]?token)["']?\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
17
+ { re: /["']?(aws_secret_access_key|aws_access_key_id)["']?\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, replacement: "$1=[REDACTED]" },
15
18
  ];
16
19
  export function redact(text) {
17
20
  let out = text;
@@ -69,6 +69,11 @@ export declare class MemoryStore {
69
69
  * still back the consolidated artifacts.
70
70
  */
71
71
  markPhase2Succeeded(ownershipToken: string, selected?: Pick<Stage1Output, "session_id" | "source_updated_at">[]): void;
72
+ /** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
73
+ phase2LastSuccess(): {
74
+ finished_at: number | null;
75
+ last_success_watermark: number | null;
76
+ } | null;
72
77
  markPhase2Failed(ownershipToken: string, error: string): void;
73
78
  /**
74
79
  * Phase 2 input set, mirroring codex get_phase2_input_selection:
package/dist/src/store.js CHANGED
@@ -269,19 +269,35 @@ export class MemoryStore {
269
269
  // codex stores the completion watermark = max source_updated_at consumed;
270
270
  // the 6h cooldown is keyed on finished_at, not on this value.
271
271
  const watermark = selected.reduce((max, s) => Math.max(max, s.source_updated_at), 0);
272
- const res = this.db
273
- .prepare(`UPDATE memory_jobs SET status='done', finished_at=?, lease_until=NULL, last_error=NULL, retry_remaining=?,
274
- last_success_watermark=MAX(COALESCE(last_success_watermark, 0), ?), retry_at=NULL
275
- WHERE kind='memory_consolidate_global' AND job_key='global' AND ownership_token=? AND status='running'`)
276
- .run(nowSec(), DEFAULT_RETRY_REMAINING, watermark, ownershipToken);
277
- if (res.changes === 0)
278
- return;
279
- this.db.exec("UPDATE memory_stage1_outputs SET selected_for_phase2 = 0, selected_for_phase2_source_updated_at = NULL");
280
- const mark = this.db.prepare(`UPDATE memory_stage1_outputs
281
- SET selected_for_phase2 = 1, selected_for_phase2_source_updated_at = ?
282
- WHERE session_id = ? AND source_updated_at = ?`);
283
- for (const s of selected)
284
- mark.run(s.source_updated_at, s.session_id, s.source_updated_at);
272
+ // One transaction for the job row + the selected-input flags (codex
273
+ // mark_global_phase2_job_succeeded does the same): a crash between them
274
+ // must not leave a done job whose retention flags still describe the
275
+ // previous run pruning could then delete inputs backing the artifacts.
276
+ this.db.transaction(() => {
277
+ const res = this.db
278
+ .prepare(`UPDATE memory_jobs SET status='done', finished_at=?, lease_until=NULL, last_error=NULL, retry_remaining=?,
279
+ last_success_watermark=MAX(COALESCE(last_success_watermark, 0), ?), retry_at=NULL
280
+ WHERE kind='memory_consolidate_global' AND job_key='global' AND ownership_token=? AND status='running'`)
281
+ .run(nowSec(), DEFAULT_RETRY_REMAINING, watermark, ownershipToken);
282
+ if (res.changes === 0)
283
+ return;
284
+ this.db.exec("UPDATE memory_stage1_outputs SET selected_for_phase2 = 0, selected_for_phase2_source_updated_at = NULL");
285
+ const mark = this.db.prepare(`UPDATE memory_stage1_outputs
286
+ SET selected_for_phase2 = 1, selected_for_phase2_source_updated_at = ?
287
+ WHERE session_id = ? AND source_updated_at = ?`);
288
+ for (const s of selected)
289
+ mark.run(s.source_updated_at, s.session_id, s.source_updated_at);
290
+ }).immediate();
291
+ }
292
+ /** Last recorded phase-2 success info (memory_inspect). Null when phase 2 never succeeded. */
293
+ phase2LastSuccess() {
294
+ const row = this.db
295
+ .prepare(`SELECT finished_at, last_success_watermark FROM memory_jobs
296
+ WHERE kind='memory_consolidate_global' AND job_key='global'`)
297
+ .get();
298
+ if (!row || !row.last_success_watermark)
299
+ return null;
300
+ return row;
285
301
  }
286
302
  markPhase2Failed(ownershipToken, error) {
287
303
  const res = this.db
@@ -166,9 +166,10 @@ export function pruneExtensionResources(retentionDays) {
166
166
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
167
167
  for (const extName of fs.readdirSync(extensionsDir)) {
168
168
  const extDir = path.join(extensionsDir, extName);
169
+ // lstat: never prune through a symlinked extension dir.
169
170
  let extStat;
170
171
  try {
171
- extStat = fs.statSync(extDir);
172
+ extStat = fs.lstatSync(extDir);
172
173
  }
173
174
  catch {
174
175
  continue;
@@ -213,8 +214,12 @@ export function writeWorkspaceDiff(diff) {
213
214
  rendered += `- ${change.status} ${change.path}\n`;
214
215
  }
215
216
  let body = diff.unifiedDiff;
216
- if (body.length > WORKSPACE_DIFF_MAX_BYTES) {
217
- body = body.slice(0, WORKSPACE_DIFF_MAX_BYTES) + `\n[workspace diff truncated at ${WORKSPACE_DIFF_MAX_BYTES} bytes]\n`;
217
+ // The cap is in BYTES: .length counts UTF-16 code units and undercounts
218
+ // multibyte content. Cut on the byte buffer and drop a split trailing char.
219
+ if (Buffer.byteLength(body, "utf8") > WORKSPACE_DIFF_MAX_BYTES) {
220
+ body =
221
+ Buffer.from(body, "utf8").subarray(0, WORKSPACE_DIFF_MAX_BYTES).toString("utf8").replace(/\uFFFD+$/, "") +
222
+ `\n[workspace diff truncated at ${WORKSPACE_DIFF_MAX_BYTES} bytes]\n`;
218
223
  }
219
224
  rendered += "\n## Diff\n\n```diff\n" + body + (body.endsWith("\n") ? "" : "\n") + "```\n";
220
225
  }
@@ -5,32 +5,34 @@ import { memoryRoot, memorySummaryPath } from "../src/paths.js";
5
5
  import { MemoryStore } from "../src/store.js";
6
6
  import { invalidateCache } from "../src/source.js";
7
7
  import { estimateTokens } from "../src/token.js";
8
+ import { assertMemoryRootSafe } from "../src/path-guard.js";
9
+ import { isPhase2InFlight } from "../src/phase2.js";
8
10
  function isSymlinkedRoot() {
9
- const root = memoryRoot();
10
11
  try {
11
- return fs.lstatSync(root).isSymbolicLink();
12
+ assertMemoryRootSafe();
13
+ return false;
12
14
  }
13
15
  catch {
14
- return false;
16
+ return true;
15
17
  }
16
18
  }
17
19
  // Mirrors codex clear_memory_root_contents: deletes EVERY entry including
18
20
  // .git, so previously deleted/redacted memory content is not recoverable
19
- // from git history after a reset.
21
+ // from git history after a reset. Deletion errors PROPAGATE — codex bubbles
22
+ // every remove failure up, and a swallowed error here would report a
23
+ // successful reset while secrets/memories survive on disk. lstat semantics:
24
+ // a symlinked entry is unlinked itself, never followed.
20
25
  function wipeMemoriesDir() {
21
26
  const root = memoryRoot();
22
27
  if (!fs.existsSync(root))
23
28
  return;
24
29
  for (const entry of fs.readdirSync(root)) {
25
30
  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 { }
31
+ const st = fs.lstatSync(abs);
32
+ if (st.isDirectory())
33
+ fs.rmSync(abs, { recursive: true, force: true });
34
+ else
35
+ fs.unlinkSync(abs);
34
36
  }
35
37
  }
36
38
  function listMemoriesDir() {
@@ -44,14 +46,19 @@ function listMemoriesDir() {
44
46
  continue;
45
47
  const abs = path.join(dir, name);
46
48
  const rel = prefix ? `${prefix}/${name}` : name;
49
+ // lstat: report symlinks as entries but never walk THROUGH them —
50
+ // a link to a big/looping directory must not be followed.
47
51
  let stat;
48
52
  try {
49
- stat = fs.statSync(abs);
53
+ stat = fs.lstatSync(abs);
50
54
  }
51
55
  catch {
52
56
  continue;
53
57
  }
54
- if (stat.isDirectory()) {
58
+ if (stat.isSymbolicLink()) {
59
+ out.push(`${rel}@`);
60
+ }
61
+ else if (stat.isDirectory()) {
55
62
  out.push(`${rel}/`);
56
63
  walk(abs, rel);
57
64
  }
@@ -76,6 +83,15 @@ export const memory_reset = tool({
76
83
  if (isSymlinkedRoot()) {
77
84
  return { output: "Reset refused: memory root is a symlink. Remove it manually to be safe." };
78
85
  }
86
+ // A consolidation running in THIS process would recreate files right
87
+ // after the wipe (the sub-agent edits live artifacts and resets the git
88
+ // baseline). Refuse instead of racing it. Cross-process consolidators
89
+ // are still ownership-guarded DB-side (the wiped job rows make their
90
+ // final confirmation a no-op) but may leave stray files; same window
91
+ // codex has between CLI clear and a running daemon.
92
+ if (isPhase2InFlight()) {
93
+ return { output: "Reset refused: memory consolidation is currently running. Try again in a few minutes." };
94
+ }
79
95
  try {
80
96
  const store = new MemoryStore();
81
97
  store.clearMemoryData();
@@ -96,6 +112,8 @@ export const memory_inspect = tool({
96
112
  args: {},
97
113
  async execute() {
98
114
  try {
115
+ // Refuse to walk/report through a symlinked root (same rule as reset).
116
+ assertMemoryRootSafe();
99
117
  const store = new MemoryStore();
100
118
  const outputs = store.stage1Outputs();
101
119
  const summaryPath = memorySummaryPath();
@@ -107,8 +125,14 @@ export const memory_inspect = tool({
107
125
  summaryTokens = estimateTokens(text);
108
126
  }
109
127
  const listing = listMemoriesDir();
128
+ // The tool description promises the last Phase 2 success watermark.
129
+ const phase2 = store.phase2LastSuccess();
130
+ const watermark = phase2?.last_success_watermark ? new Date(phase2.last_success_watermark).toISOString() : "none";
131
+ const finishedAt = phase2?.finished_at ? new Date(phase2.finished_at * 1000).toISOString() : "none";
110
132
  const out = [
111
133
  `stage1_outputs: ${outputs.length}`,
134
+ `phase2_last_success_watermark: ${watermark}`,
135
+ `phase2_last_finished_at: ${finishedAt}`,
112
136
  `memory_summary_chars: ${summaryChars}`,
113
137
  `memory_summary_tokens_est: ${summaryTokens}`,
114
138
  `memories_dir_entries: ${listing.length}`,
@@ -120,6 +144,8 @@ export const memory_inspect = tool({
120
144
  output: out,
121
145
  metadata: {
122
146
  stage1_count: outputs.length,
147
+ phase2_last_success_watermark: phase2?.last_success_watermark ?? null,
148
+ phase2_last_finished_at: phase2?.finished_at ?? null,
123
149
  summary_chars: summaryChars,
124
150
  summary_tokens_est: summaryTokens,
125
151
  files: listing,
@@ -25,16 +25,32 @@ export declare const memory_list: {
25
25
  export declare const memory_search: {
26
26
  description: string;
27
27
  args: {
28
- query: import("zod").ZodOptional<import("zod").ZodString>;
28
+ queries: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
29
+ match_mode: import("zod").ZodDefault<import("zod").ZodEnum<{
30
+ any: "any";
31
+ all_on_same_line: "all_on_same_line";
32
+ all_within_lines: "all_within_lines";
33
+ }>>;
34
+ line_count: import("zod").ZodOptional<import("zod").ZodNumber>;
35
+ path: import("zod").ZodOptional<import("zod").ZodString>;
36
+ cursor: import("zod").ZodOptional<import("zod").ZodString>;
37
+ context_lines: import("zod").ZodDefault<import("zod").ZodNumber>;
29
38
  case_sensitive: import("zod").ZodDefault<import("zod").ZodBoolean>;
39
+ normalized: import("zod").ZodDefault<import("zod").ZodBoolean>;
30
40
  since: import("zod").ZodOptional<import("zod").ZodString>;
31
41
  until: import("zod").ZodOptional<import("zod").ZodString>;
32
- limit: import("zod").ZodDefault<import("zod").ZodNumber>;
42
+ max_results: import("zod").ZodDefault<import("zod").ZodNumber>;
33
43
  };
34
44
  execute(args: {
45
+ match_mode: "any" | "all_on_same_line" | "all_within_lines";
46
+ context_lines: number;
35
47
  case_sensitive: boolean;
36
- limit: number;
37
- query?: string | undefined;
48
+ normalized: boolean;
49
+ max_results: number;
50
+ queries?: string[] | undefined;
51
+ line_count?: number | undefined;
52
+ path?: string | undefined;
53
+ cursor?: string | undefined;
38
54
  since?: string | undefined;
39
55
  until?: string | undefined;
40
56
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;