opencode-codex-memory 0.1.3 → 0.1.6

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 (60) hide show
  1. package/dist/opencode.json +37 -0
  2. package/dist/src/capture.d.ts +19 -0
  3. package/dist/src/capture.js +120 -0
  4. package/dist/src/citation.d.ts +14 -0
  5. package/dist/src/citation.js +81 -0
  6. package/dist/src/db.d.ts +3 -0
  7. package/dist/src/db.js +78 -0
  8. package/dist/src/git-baseline.d.ts +24 -0
  9. package/dist/src/git-baseline.js +150 -0
  10. package/dist/src/index.d.ts +163 -0
  11. package/dist/src/index.js +365 -0
  12. package/dist/src/llm.d.ts +19 -0
  13. package/dist/src/llm.js +251 -0
  14. package/dist/src/path-guard.d.ts +10 -0
  15. package/dist/src/path-guard.js +44 -0
  16. package/dist/src/paths.d.ts +4 -0
  17. package/dist/src/paths.js +23 -0
  18. package/dist/src/phase1.d.ts +11 -0
  19. package/dist/src/phase1.js +104 -0
  20. package/dist/src/phase2.d.ts +11 -0
  21. package/dist/src/phase2.js +83 -0
  22. package/dist/src/ratelimit.d.ts +5 -0
  23. package/dist/src/ratelimit.js +20 -0
  24. package/dist/src/redact.d.ts +8 -0
  25. package/dist/src/redact.js +37 -0
  26. package/dist/src/source.d.ts +3 -0
  27. package/dist/src/source.js +46 -0
  28. package/dist/src/store.d.ts +96 -0
  29. package/dist/src/store.js +346 -0
  30. package/dist/src/token.d.ts +8 -0
  31. package/dist/src/token.js +19 -0
  32. package/dist/src/workspace.d.ts +8 -0
  33. package/dist/src/workspace.js +194 -0
  34. package/dist/tools/control.d.ts +29 -0
  35. package/dist/tools/control.js +153 -0
  36. package/dist/tools/memory.d.ts +52 -0
  37. package/dist/tools/memory.js +322 -0
  38. package/package.json +24 -6
  39. package/src/capture.ts +0 -135
  40. package/src/citation.ts +0 -94
  41. package/src/db.ts +0 -80
  42. package/src/git-baseline.ts +0 -162
  43. package/src/index.ts +0 -366
  44. package/src/llm.ts +0 -267
  45. package/src/path-guard.ts +0 -44
  46. package/src/paths.ts +0 -29
  47. package/src/phase1.ts +0 -116
  48. package/src/phase2.ts +0 -99
  49. package/src/ratelimit.ts +0 -26
  50. package/src/redact.ts +0 -44
  51. package/src/source.ts +0 -59
  52. package/src/store.ts +0 -430
  53. package/src/token.ts +0 -21
  54. package/src/workspace.ts +0 -181
  55. package/tools/control.ts +0 -145
  56. package/tools/memory.ts +0 -318
  57. /package/{src → dist/src}/templates/consolidation.md +0 -0
  58. /package/{src → dist/src}/templates/read_path.md +0 -0
  59. /package/{src → dist/src}/templates/stage_one_input.md +0 -0
  60. /package/{src → dist/src}/templates/stage_one_system.md +0 -0
@@ -0,0 +1,251 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ let inputRef = null;
4
+ export function setPluginInput(input) {
5
+ inputRef = input;
6
+ }
7
+ function getPluginInput() {
8
+ return inputRef;
9
+ }
10
+ // Sessions this plugin spawned for extraction/consolidation. The main
11
+ // hooks skip these so the plugin never injects memory into (or memorizes) its
12
+ // own sub-agents.
13
+ const activeSubSessions = new Set();
14
+ export function isMemorySubSession(sessionId) {
15
+ return activeSubSessions.has(sessionId);
16
+ }
17
+ async function createSession(agent, title) {
18
+ const input = getPluginInput();
19
+ if (!input)
20
+ throw new Error("plugin input not initialized");
21
+ const res = await input.client.session.create({
22
+ body: { title: title ?? `codex-memory-${agent}` },
23
+ });
24
+ if (!res.data)
25
+ throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
26
+ const body = res.data;
27
+ const id = body.id;
28
+ if (!id)
29
+ throw new Error(`session create returned no id: ${JSON.stringify(body)}`);
30
+ activeSubSessions.add(id);
31
+ return id;
32
+ }
33
+ /**
34
+ * opencode's config carries the same split codex expresses with provider
35
+ * model preferences: `small_model` for cheap background work (codex:
36
+ * memory_extraction_preferred_model = gpt-5.4-mini) and `model` for capable
37
+ * work (codex: memory_consolidation_preferred_model = gpt-5.4). Cached per
38
+ * plugin instance — opencode reloads plugins on config change.
39
+ */
40
+ let configModels = null;
41
+ async function getConfigModels() {
42
+ if (configModels)
43
+ return configModels;
44
+ const input = getPluginInput();
45
+ if (!input)
46
+ return {};
47
+ try {
48
+ const res = await input.client.config.get();
49
+ const cfg = res?.data;
50
+ configModels = { model: cfg?.model, smallModel: cfg?.small_model };
51
+ }
52
+ catch {
53
+ // Config endpoint unavailable: leave models unset so the sub-agent runs
54
+ // on the session default, the previous behavior.
55
+ configModels = {};
56
+ }
57
+ return configModels;
58
+ }
59
+ // extract_model / consolidation model strings are "providerID/modelID".
60
+ function parseModelRef(ref) {
61
+ const slash = ref.indexOf("/");
62
+ if (slash <= 0 || slash === ref.length - 1)
63
+ return null;
64
+ return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
65
+ }
66
+ async function promptSession(sessionId, prompt, agent, opts = {}) {
67
+ const timeoutMs = opts.timeoutMs ?? 300_000;
68
+ const input = getPluginInput();
69
+ if (!input)
70
+ throw new Error("plugin input not initialized");
71
+ const model = opts.model ? parseModelRef(opts.model) : null;
72
+ const promptPromise = input.client.session.prompt({
73
+ path: { id: sessionId },
74
+ body: {
75
+ agent,
76
+ ...(opts.system ? { system: opts.system } : {}),
77
+ ...(model ? { model } : {}),
78
+ parts: [{ type: "text", text: prompt }],
79
+ },
80
+ });
81
+ let timer;
82
+ try {
83
+ const res = await Promise.race([
84
+ promptPromise,
85
+ new Promise((_, reject) => {
86
+ timer = setTimeout(() => reject(new Error(`sub-agent prompt timed out after ${timeoutMs}ms`)), timeoutMs);
87
+ }),
88
+ ]);
89
+ if (!res.data)
90
+ throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
91
+ return extractAssistantText(res.data);
92
+ }
93
+ finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ function extractAssistantText(body) {
98
+ if (!body)
99
+ return "";
100
+ if (typeof body === "string")
101
+ return body;
102
+ if (Array.isArray(body))
103
+ return body.map(extractAssistantText).join("\n");
104
+ if (typeof body.text === "string")
105
+ return body.text;
106
+ if (body.parts && Array.isArray(body.parts))
107
+ return body.parts.map((p) => p?.text ?? "").filter(Boolean).join("\n");
108
+ if (body.messages && Array.isArray(body.messages)) {
109
+ return body.messages
110
+ .filter((m) => m?.info?.role === "assistant")
111
+ .flatMap((m) => (m.parts ?? []).map((p) => p?.text ?? ""))
112
+ .filter(Boolean)
113
+ .join("\n");
114
+ }
115
+ if (body.output && typeof body.output === "string")
116
+ return body.output;
117
+ return JSON.stringify(body);
118
+ }
119
+ /** Returns null when the extractor reported a no-op (nothing worth remembering). */
120
+ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
121
+ const agent = "memorize-extract";
122
+ const subId = await createSession(agent, `codex-memory-extract-${sessionId}`);
123
+ try {
124
+ const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
125
+ // extract_model option > opencode small_model > session default.
126
+ const model = opts.model ?? (await getConfigModels()).smallModel;
127
+ const raw = await promptSession(subId, prompt, agent, {
128
+ timeoutMs: 180_000,
129
+ system: readTemplate("stage_one_system.md"),
130
+ model,
131
+ });
132
+ return parseExtraction(raw);
133
+ }
134
+ finally {
135
+ void deleteSession(subId).catch(() => { });
136
+ }
137
+ }
138
+ // codex runs the consolidation agent under a 1h job lease with heartbeats;
139
+ // its INIT pass is explicitly allowed to run long ("do not be lazy"). A short
140
+ // timeout here would fail the job after the workspace was already synced.
141
+ const CONSOLIDATION_TIMEOUT_MS = 3600_000;
142
+ export async function consolidateViaSubagent(memoryRoot, diffFileName, model) {
143
+ const agent = "memorize";
144
+ const subId = await createSession(agent, "codex-memory-consolidate");
145
+ try {
146
+ const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
147
+ // consolidation_model option > opencode model (main) > session default.
148
+ const resolved = model ?? (await getConfigModels()).model;
149
+ await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS });
150
+ }
151
+ finally {
152
+ void deleteSession(subId).catch(() => { });
153
+ }
154
+ }
155
+ // Must exceed the longest legitimate sub-session lifetime (consolidation may
156
+ // run up to CONSOLIDATION_TIMEOUT_MS = 60min), or a second opencode instance /
157
+ // plugin reload would delete a working sub-session mid-run.
158
+ export async function cleanupOldSubSessions(maxAgeMinutes = 90) {
159
+ const input = getPluginInput();
160
+ if (!input)
161
+ return;
162
+ try {
163
+ const res = await input.client.session.list();
164
+ if (!res.data)
165
+ return;
166
+ const list = res.data;
167
+ const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
168
+ for (const s of list) {
169
+ if (s.title && s.title.startsWith("codex-memory-")) {
170
+ const created = s.time?.created ?? 0;
171
+ if (created && created < cutoff) {
172
+ await deleteSession(s.id);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ // best effort only
179
+ }
180
+ }
181
+ async function deleteSession(id) {
182
+ activeSubSessions.delete(id);
183
+ const input = getPluginInput();
184
+ if (!input)
185
+ return;
186
+ try {
187
+ const res = await input.client.session.delete({ path: { id } });
188
+ if (res.error) {
189
+ console.warn(`[opencode-codex-memory] failed to delete sub-session ${id}: ${JSON.stringify(res.error)}`);
190
+ }
191
+ }
192
+ catch (err) {
193
+ console.warn(`[opencode-codex-memory] error deleting sub-session ${id}:`, err);
194
+ }
195
+ }
196
+ // Substitute with a function so `$&`/`$'` sequences in the value are not
197
+ // expanded as String.replace replacement patterns.
198
+ export function fillTemplate(tmpl, vars) {
199
+ let out = tmpl;
200
+ for (const [key, value] of Object.entries(vars)) {
201
+ out = out.replaceAll(`{{ ${key} }}`, () => value);
202
+ }
203
+ return out;
204
+ }
205
+ function buildExtractionInput(sessionId, cwd, transcript) {
206
+ return fillTemplate(readTemplate("stage_one_input.md"), {
207
+ session_id: sessionId,
208
+ session_cwd: cwd,
209
+ transcript,
210
+ });
211
+ }
212
+ function buildConsolidationPrompt(memoryRoot, diffFileName) {
213
+ return fillTemplate(readTemplate("consolidation.md"), {
214
+ memory_root: memoryRoot,
215
+ phase2_workspace_diff_file: diffFileName,
216
+ });
217
+ }
218
+ function readTemplate(name) {
219
+ return fs.readFileSync(path.join(import.meta.dirname, "templates", name), "utf8");
220
+ }
221
+ /** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
222
+ export function parseExtraction(raw) {
223
+ const cleaned = raw.replace(/^```(?:json)?/gim, "").replace(/```$/gim, "").trim();
224
+ const start = cleaned.indexOf("{");
225
+ const end = cleaned.lastIndexOf("}");
226
+ if (start === -1 || end === -1 || end <= start) {
227
+ throw new Error("extraction response contained no JSON object");
228
+ }
229
+ const json = cleaned.slice(start, end + 1);
230
+ const obj = JSON.parse(json);
231
+ if (typeof obj.raw_memory !== "string" || typeof obj.rollout_summary !== "string") {
232
+ throw new Error("extraction response missing required fields");
233
+ }
234
+ if (!obj.raw_memory.trim() && !obj.rollout_summary.trim()) {
235
+ return null;
236
+ }
237
+ // Guard against the model echoing the format skeleton from the system prompt.
238
+ const templateArtifacts = [
239
+ "<success|partial|fail|uncertain>",
240
+ "<primary task signature>",
241
+ "<short quote or near-verbatim request>",
242
+ ];
243
+ if (templateArtifacts.some((a) => obj.raw_memory.includes(a))) {
244
+ throw new Error("extraction returned template placeholder text instead of actual content");
245
+ }
246
+ return {
247
+ raw_memory: obj.raw_memory,
248
+ rollout_summary: obj.rollout_summary,
249
+ rollout_slug: typeof obj.rollout_slug === "string" && obj.rollout_slug.trim() ? obj.rollout_slug : null,
250
+ };
251
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Safe path resolution that cannot escape the memory root, mirroring codex
3
+ * ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
4
+ * - absolute paths and `..` components are rejected lexically
5
+ * - hidden (dot) components are invisible (reported as not found), so .git
6
+ * and other dotfiles are unreachable through the tools
7
+ * - every existing component is lstat-checked: symlinks are rejected, so a
8
+ * link placed inside the workspace cannot lead reads outside it
9
+ */
10
+ export declare function safeResolveMemoryPath(rel: string): string;
@@ -0,0 +1,44 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { memoryRoot } from "./paths.js";
4
+ /**
5
+ * Safe path resolution that cannot escape the memory root, mirroring codex
6
+ * ext/memories/src/local/path.rs + local.rs resolve_scoped_path:
7
+ * - absolute paths and `..` components are rejected lexically
8
+ * - hidden (dot) components are invisible (reported as not found), so .git
9
+ * and other dotfiles are unreachable through the tools
10
+ * - every existing component is lstat-checked: symlinks are rejected, so a
11
+ * link placed inside the workspace cannot lead reads outside it
12
+ */
13
+ export function safeResolveMemoryPath(rel) {
14
+ const root = memoryRoot();
15
+ if (path.isAbsolute(rel)) {
16
+ throw new Error(`path escapes memory root: ${rel}`);
17
+ }
18
+ const parts = rel.split(/[\\/]+/).filter((p) => p.length > 0 && p !== ".");
19
+ let current = root;
20
+ for (const part of parts) {
21
+ if (part === "..") {
22
+ throw new Error(`path escapes memory root: ${rel}`);
23
+ }
24
+ if (part.startsWith(".")) {
25
+ throw new Error(`not found: ${rel}`);
26
+ }
27
+ current = path.join(current, part);
28
+ let st = null;
29
+ try {
30
+ st = fs.lstatSync(current);
31
+ }
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
+ }
@@ -0,0 +1,4 @@
1
+ export declare function memoryRoot(): string;
2
+ export declare function memoryDbPath(): string;
3
+ export declare function opencodeDbPath(): string;
4
+ export declare function memorySummaryPath(): string;
@@ -0,0 +1,23 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ const MEMORY_DIR_NAME = "memories";
4
+ const MEMORY_DB_NAME = "memory.db";
5
+ const OVERRIDE_ENV = "OPENCODE_CODEX_MEMORY_TEST_ROOT";
6
+ function dataRoot() {
7
+ const override = process.env[OVERRIDE_ENV];
8
+ if (override)
9
+ return override;
10
+ return path.join(os.homedir(), ".local", "share", "opencode");
11
+ }
12
+ export function memoryRoot() {
13
+ return path.join(dataRoot(), MEMORY_DIR_NAME);
14
+ }
15
+ export function memoryDbPath() {
16
+ return path.join(dataRoot(), MEMORY_DB_NAME);
17
+ }
18
+ export function opencodeDbPath() {
19
+ return path.join(dataRoot(), "opencode.db");
20
+ }
21
+ export function memorySummaryPath() {
22
+ return path.join(memoryRoot(), "memory_summary.md");
23
+ }
@@ -0,0 +1,11 @@
1
+ import { MemoryStore } from "./store.js";
2
+ export interface Phase1Options {
3
+ maxAgeDays: number;
4
+ minIdleHours: number;
5
+ maxClaimed?: number;
6
+ maxUnusedDays?: number;
7
+ excludeSession?: string;
8
+ extractModel?: string;
9
+ }
10
+ export declare const DEFAULT_PHASE1_OPTIONS: Phase1Options;
11
+ export declare function runPhase1(store: MemoryStore, opts?: Phase1Options): Promise<void>;
@@ -0,0 +1,104 @@
1
+ import { STAGE1_CONCURRENCY } from "./store.js";
2
+ import { loadTranscript, selectEligibleSessions } from "./capture.js";
3
+ import { redact, isMemoryExcludedFragment } from "./redact.js";
4
+ import { stripCitations } from "./citation.js";
5
+ import { extractViaSubagent } from "./llm.js";
6
+ import { checkRateLimit } from "./ratelimit.js";
7
+ export const DEFAULT_PHASE1_OPTIONS = {
8
+ maxAgeDays: 10,
9
+ minIdleHours: 6,
10
+ maxClaimed: 2,
11
+ maxUnusedDays: 30,
12
+ };
13
+ // codex serializes the full transcript and truncates at 70% of the model
14
+ // context window, falling back to 150k tokens; ~4 chars/token puts the
15
+ // char-estimate equivalent at 600k.
16
+ const TRANSCRIPT_MAX_CHARS = 600_000;
17
+ // When truncating, keep the head and the tail: the start carries the user's
18
+ // framing, the end carries the final outcome and feedback.
19
+ const TRANSCRIPT_HEAD_CHARS = 360_000;
20
+ const TRANSCRIPT_TAIL_CHARS = 240_000;
21
+ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS) {
22
+ store.pruneStage1Outputs(opts.maxUnusedDays ?? 30);
23
+ const rl = await checkRateLimit("phase1");
24
+ if (!rl.ok) {
25
+ console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason);
26
+ return;
27
+ }
28
+ const eligible = selectEligibleSessions(store, opts);
29
+ if (eligible.length === 0)
30
+ return;
31
+ const claimed = store.claimStage1Jobs(eligible, opts.excludeSession, opts.maxClaimed);
32
+ if (claimed.length === 0)
33
+ return;
34
+ const sessionById = new Map(eligible.map((s) => [s.id, s]));
35
+ await runPool(claimed, STAGE1_CONCURRENCY, async (claim) => {
36
+ const sid = claim.sessionId;
37
+ try {
38
+ const session = sessionById.get(sid);
39
+ const sourceUpdatedAt = session?.updated_at ?? Date.now();
40
+ const transcript = buildTranscript(sid);
41
+ if (!transcript.trim()) {
42
+ store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt);
43
+ return;
44
+ }
45
+ const result = await extractViaSubagent(sid, transcript, {
46
+ cwd: session?.directory ?? undefined,
47
+ model: opts.extractModel,
48
+ });
49
+ if (!result) {
50
+ // Extractor judged the session not worth remembering.
51
+ store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt);
52
+ return;
53
+ }
54
+ store.markStage1Succeeded(sid, claim.ownershipToken, {
55
+ session_id: sid,
56
+ source_updated_at: sourceUpdatedAt,
57
+ raw_memory: redact(result.raw_memory),
58
+ rollout_summary: redact(result.rollout_summary),
59
+ // codex redacts the slug too — it becomes a filename.
60
+ rollout_slug: result.rollout_slug ? redact(result.rollout_slug) : result.rollout_slug,
61
+ cwd: session?.directory ?? null,
62
+ generated_at: Date.now(),
63
+ });
64
+ }
65
+ catch (err) {
66
+ store.markStage1Failed(sid, claim.ownershipToken, err.message);
67
+ }
68
+ });
69
+ }
70
+ function buildTranscript(sessionId) {
71
+ const msgs = loadTranscript(sessionId);
72
+ if (msgs.length === 0)
73
+ return "";
74
+ const lines = [];
75
+ for (const m of msgs) {
76
+ if (m.type === "system")
77
+ continue;
78
+ const role = m.role ?? m.type;
79
+ const text = m.text ?? "";
80
+ if (!text.trim())
81
+ continue;
82
+ // Injected AGENTS.md/<skill> blocks in user content are excluded from
83
+ // extraction (codex is_memory_excluded_contextual_user_fragment).
84
+ if (role === "user" && isMemoryExcludedFragment(text))
85
+ continue;
86
+ lines.push(`### ${role}\n${redact(stripCitations(text))}`);
87
+ }
88
+ const full = lines.join("\n\n");
89
+ if (full.length <= TRANSCRIPT_MAX_CHARS)
90
+ return full;
91
+ return (full.slice(0, TRANSCRIPT_HEAD_CHARS) +
92
+ "\n\n[... transcript truncated ...]\n\n" +
93
+ full.slice(full.length - TRANSCRIPT_TAIL_CHARS));
94
+ }
95
+ async function runPool(items, concurrency, fn) {
96
+ let cursor = 0;
97
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
98
+ while (cursor < items.length) {
99
+ const i = cursor++;
100
+ await fn(items[i]);
101
+ }
102
+ });
103
+ await Promise.all(workers);
104
+ }
@@ -0,0 +1,11 @@
1
+ import { MemoryStore } from "./store.js";
2
+ export interface Phase2Options {
3
+ maxRaw: number;
4
+ maxUnusedDays: number;
5
+ extensionRetentionDays: number;
6
+ consolidationModel?: string;
7
+ }
8
+ export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
9
+ export declare function runPhase2(store: MemoryStore, opts?: Phase2Options): Promise<{
10
+ status: string;
11
+ }>;
@@ -0,0 +1,83 @@
1
+ import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff } from "./workspace.js";
2
+ import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js";
3
+ import { consolidateViaSubagent } from "./llm.js";
4
+ import { invalidateCache } from "./source.js";
5
+ import { memoryRoot } from "./paths.js";
6
+ import { checkRateLimit } from "./ratelimit.js";
7
+ export const DEFAULT_PHASE2_OPTIONS = {
8
+ maxRaw: 256,
9
+ maxUnusedDays: 30,
10
+ extensionRetentionDays: 7,
11
+ };
12
+ let phase2InFlight = false;
13
+ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
14
+ if (phase2InFlight)
15
+ return { status: "already_running" };
16
+ phase2InFlight = true;
17
+ try {
18
+ const rl = await checkRateLimit("phase2");
19
+ if (!rl.ok)
20
+ return { status: "skipped_rate_limit" };
21
+ const claim = store.claimGlobalPhase2Job();
22
+ if (claim.type !== "claimed")
23
+ return { status: claim.type };
24
+ try {
25
+ ensureLayout();
26
+ // Preserves an existing baseline (only initializes a missing one): the
27
+ // diff below must span last-successful-run -> now so user edits and
28
+ // ad-hoc notes added since then reach consolidation. Stale stage-1
29
+ // output pruning happens in phase 1, before the rate gate (codex
30
+ // start.rs ordering).
31
+ if (!await ensureBaseline()) {
32
+ store.markPhase2Failed(claim.ownershipToken, "git baseline failed");
33
+ return { status: "baseline_failed" };
34
+ }
35
+ const outputs = store.getPhase2InputSelection(opts.maxRaw, opts.maxUnusedDays);
36
+ rebuildRawMemories(outputs);
37
+ writeRolloutSummaries(outputs);
38
+ pruneExtensionResources(opts.extensionRetentionDays);
39
+ const diff = await captureWorkspaceDiff();
40
+ if (diff.changes.length === 0) {
41
+ store.markPhase2Succeeded(claim.ownershipToken, outputs);
42
+ return { status: "no_workspace_changes" };
43
+ }
44
+ writeWorkspaceDiff(diff);
45
+ let heartbeatLost = false;
46
+ const heartbeat = setInterval(() => {
47
+ if (!store.heartbeatPhase2Job(claim.ownershipToken)) {
48
+ heartbeatLost = true;
49
+ }
50
+ }, 90_000);
51
+ try {
52
+ await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel);
53
+ }
54
+ finally {
55
+ clearInterval(heartbeat);
56
+ }
57
+ // Final synchronous ownership confirmation before the destructive
58
+ // baseline reset (codex phase2.rs does the same): the periodic flag can
59
+ // be up to 90s stale, and a stale worker resetting the baseline would
60
+ // swallow the diff a re-claiming worker is about to consume. The
61
+ // heartbeat is token+status guarded, so it fails once ownership is lost;
62
+ // markPhase2Failed is equally guarded and becomes a no-op then.
63
+ if (heartbeatLost || !store.heartbeatPhase2Job(claim.ownershipToken)) {
64
+ store.markPhase2Failed(claim.ownershipToken, "ownership lost");
65
+ return { status: "heartbeat_lost" };
66
+ }
67
+ if (!await resetBaseline()) {
68
+ store.markPhase2Failed(claim.ownershipToken, "baseline reset failed");
69
+ return { status: "baseline_reset_failed" };
70
+ }
71
+ store.markPhase2Succeeded(claim.ownershipToken, outputs);
72
+ invalidateCache();
73
+ return { status: "succeeded" };
74
+ }
75
+ catch (err) {
76
+ store.markPhase2Failed(claim.ownershipToken, err.message);
77
+ return { status: "failed" };
78
+ }
79
+ }
80
+ finally {
81
+ phase2InFlight = false;
82
+ }
83
+ }
@@ -0,0 +1,5 @@
1
+ export interface RateLimitInfo {
2
+ ok: boolean;
3
+ reason?: string;
4
+ }
5
+ export declare function checkRateLimit(kind?: "phase1" | "phase2"): Promise<RateLimitInfo>;
@@ -0,0 +1,20 @@
1
+ let lastPhase1 = 0;
2
+ let lastPhase2 = 0;
3
+ const MIN_PHASE1_INTERVAL_MS = 30_000;
4
+ const MIN_PHASE2_INTERVAL_MS = 5 * 60 * 1000;
5
+ export async function checkRateLimit(kind = "phase1") {
6
+ const now = Date.now();
7
+ if (kind === "phase1") {
8
+ if (now - lastPhase1 < MIN_PHASE1_INTERVAL_MS) {
9
+ return { ok: false, reason: "phase1 rate limit (30s)" };
10
+ }
11
+ lastPhase1 = now;
12
+ }
13
+ else {
14
+ if (now - lastPhase2 < MIN_PHASE2_INTERVAL_MS) {
15
+ return { ok: false, reason: "phase2 rate limit (5min)" };
16
+ }
17
+ lastPhase2 = now;
18
+ }
19
+ return { ok: true };
20
+ }
@@ -0,0 +1,8 @@
1
+ export declare function redact(text: string): string;
2
+ /**
3
+ * Mirrors codex is_memory_excluded_contextual_user_fragment (phase1.rs):
4
+ * injected AGENTS.md instruction blocks and <skill> payloads inside user
5
+ * content are contextual boilerplate, not conversation — they must not be
6
+ * mined for memories.
7
+ */
8
+ export declare function isMemoryExcludedFragment(text: string): boolean;
@@ -0,0 +1,37 @@
1
+ const REDACTIONS = [
2
+ { re: /sk-ant-[A-Za-z0-9_\-]{20,}/g, replacement: "[REDACTED:anthropic-key]" },
3
+ { re: /sk-[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:openai-key]" },
4
+ { re: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws-key]" },
5
+ { re: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github-token]" },
6
+ { re: /xox[baprs]-[A-Za-z0-9\-]{10,}/g, replacement: "[REDACTED:slack-token]" },
7
+ // Case-insensitive with a 16-char floor, matching codex's sanitizer.
8
+ { re: /bearer\s+[A-Za-z0-9\-\._~+\/=]{16,}/gi, replacement: "Bearer [REDACTED]" },
9
+ {
10
+ re: /-----BEGIN [A-Z]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z]+ PRIVATE KEY-----/g,
11
+ replacement: "[REDACTED:private-key]",
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]" },
15
+ ];
16
+ export function redact(text) {
17
+ let out = text;
18
+ for (const { re, replacement } of REDACTIONS) {
19
+ out = out.replace(re, replacement);
20
+ }
21
+ return out;
22
+ }
23
+ function matchesMarkedFragment(text, startMarker, endMarker) {
24
+ const trimmed = text.trim();
25
+ return (trimmed.slice(0, startMarker.length).toLowerCase() === startMarker.toLowerCase() &&
26
+ trimmed.slice(-endMarker.length).toLowerCase() === endMarker.toLowerCase());
27
+ }
28
+ /**
29
+ * Mirrors codex is_memory_excluded_contextual_user_fragment (phase1.rs):
30
+ * injected AGENTS.md instruction blocks and <skill> payloads inside user
31
+ * content are contextual boilerplate, not conversation — they must not be
32
+ * mined for memories.
33
+ */
34
+ export function isMemoryExcludedFragment(text) {
35
+ return (matchesMarkedFragment(text, "# AGENTS.md instructions", "</INSTRUCTIONS>") ||
36
+ matchesMarkedFragment(text, "<skill>", "</skill>"));
37
+ }
@@ -0,0 +1,3 @@
1
+ export declare function invalidateCache(): void;
2
+ export declare function buildMemorySystemPrompt(): string | null;
3
+ export declare function ensureMemoryLayout(): void;
@@ -0,0 +1,46 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { memorySummaryPath, memoryRoot } from "./paths.js";
4
+ import { truncateToTokens } from "./token.js";
5
+ import { fillTemplate } from "./llm.js";
6
+ const MEMORY_SUMMARY_TOKEN_LIMIT = 2500;
7
+ const READ_PATH_TEMPLATE = "read_path.md";
8
+ let cached = null;
9
+ function readTemplate() {
10
+ const templatePath = path.join(import.meta.dirname, "templates", READ_PATH_TEMPLATE);
11
+ return fs.readFileSync(templatePath, "utf8");
12
+ }
13
+ function readMemorySummary() {
14
+ const summaryPath = memorySummaryPath();
15
+ if (!fs.existsSync(summaryPath))
16
+ return null;
17
+ const stat = fs.statSync(summaryPath);
18
+ if (cached && cached.mtime === stat.mtimeMs) {
19
+ return cached.content;
20
+ }
21
+ const raw = fs.readFileSync(summaryPath, "utf8").trim();
22
+ if (!raw)
23
+ return null;
24
+ const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT);
25
+ cached = {
26
+ content: truncated,
27
+ mtime: stat.mtimeMs,
28
+ };
29
+ return truncated;
30
+ }
31
+ export function invalidateCache() {
32
+ cached = null;
33
+ }
34
+ export function buildMemorySystemPrompt() {
35
+ const summary = readMemorySummary();
36
+ if (!summary)
37
+ return null;
38
+ const template = readTemplate();
39
+ return fillTemplate(template, {
40
+ base_path: memoryRoot(),
41
+ memory_summary: summary,
42
+ });
43
+ }
44
+ export function ensureMemoryLayout() {
45
+ fs.mkdirSync(memoryRoot(), { recursive: true });
46
+ }