opencode-codex-memory 0.1.2

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/src/paths.ts ADDED
@@ -0,0 +1,29 @@
1
+ import path from "path"
2
+ import os from "os"
3
+
4
+ const MEMORY_DIR_NAME = "memories"
5
+ const MEMORY_DB_NAME = "memory.db"
6
+
7
+ const OVERRIDE_ENV = "OPENCODE_CODEX_MEMORY_TEST_ROOT"
8
+
9
+ function dataRoot(): string {
10
+ const override = process.env[OVERRIDE_ENV]
11
+ if (override) return override
12
+ return path.join(os.homedir(), ".local", "share", "opencode")
13
+ }
14
+
15
+ export function memoryRoot(): string {
16
+ return path.join(dataRoot(), MEMORY_DIR_NAME)
17
+ }
18
+
19
+ export function memoryDbPath(): string {
20
+ return path.join(dataRoot(), MEMORY_DB_NAME)
21
+ }
22
+
23
+ export function opencodeDbPath(): string {
24
+ return path.join(dataRoot(), "opencode.db")
25
+ }
26
+
27
+ export function memorySummaryPath(): string {
28
+ return path.join(memoryRoot(), "memory_summary.md")
29
+ }
package/src/phase1.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { MemoryStore, 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
+
8
+ export interface Phase1Options {
9
+ maxAgeDays: number
10
+ minIdleHours: number
11
+ // Max rollouts claimed per pass (codex max_rollouts_per_startup / max_claimed).
12
+ maxClaimed?: number
13
+ // Retention for stale stage-1 outputs (codex max_unused_days); pruning runs
14
+ // before the rate gate because it costs no tokens (codex start.rs).
15
+ maxUnusedDays?: number
16
+ excludeSession?: string
17
+ extractModel?: string
18
+ }
19
+
20
+ export const DEFAULT_PHASE1_OPTIONS: Phase1Options = {
21
+ maxAgeDays: 10,
22
+ minIdleHours: 6,
23
+ maxClaimed: 2,
24
+ maxUnusedDays: 30,
25
+ }
26
+
27
+ // codex serializes the full transcript and truncates at 70% of the model
28
+ // context window, falling back to 150k tokens; ~4 chars/token puts the
29
+ // char-estimate equivalent at 600k.
30
+ const TRANSCRIPT_MAX_CHARS = 600_000
31
+ // When truncating, keep the head and the tail: the start carries the user's
32
+ // framing, the end carries the final outcome and feedback.
33
+ const TRANSCRIPT_HEAD_CHARS = 360_000
34
+ const TRANSCRIPT_TAIL_CHARS = 240_000
35
+
36
+ export async function runPhase1(store: MemoryStore, opts: Phase1Options = DEFAULT_PHASE1_OPTIONS): Promise<void> {
37
+ store.pruneStage1Outputs(opts.maxUnusedDays ?? 30)
38
+ const rl = await checkRateLimit("phase1")
39
+ if (!rl.ok) {
40
+ console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason)
41
+ return
42
+ }
43
+ const eligible = selectEligibleSessions(store, opts)
44
+ if (eligible.length === 0) return
45
+ const claimed = store.claimStage1Jobs(eligible, opts.excludeSession, opts.maxClaimed)
46
+ if (claimed.length === 0) return
47
+ const sessionById = new Map(eligible.map((s) => [s.id, s]))
48
+
49
+ await runPool(claimed, STAGE1_CONCURRENCY, async (claim) => {
50
+ const sid = claim.sessionId
51
+ try {
52
+ const session = sessionById.get(sid)
53
+ const sourceUpdatedAt = session?.updated_at ?? Date.now()
54
+ const transcript = buildTranscript(sid)
55
+ if (!transcript.trim()) {
56
+ store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt)
57
+ return
58
+ }
59
+ const result = await extractViaSubagent(sid, transcript, {
60
+ cwd: session?.directory ?? undefined,
61
+ model: opts.extractModel,
62
+ })
63
+ if (!result) {
64
+ // Extractor judged the session not worth remembering.
65
+ store.markStage1SucceededNoOutput(sid, claim.ownershipToken, sourceUpdatedAt)
66
+ return
67
+ }
68
+ store.markStage1Succeeded(sid, claim.ownershipToken, {
69
+ session_id: sid,
70
+ source_updated_at: sourceUpdatedAt,
71
+ raw_memory: redact(result.raw_memory),
72
+ rollout_summary: redact(result.rollout_summary),
73
+ // codex redacts the slug too — it becomes a filename.
74
+ rollout_slug: result.rollout_slug ? redact(result.rollout_slug) : result.rollout_slug,
75
+ cwd: session?.directory ?? null,
76
+ generated_at: Date.now(),
77
+ })
78
+ } catch (err) {
79
+ store.markStage1Failed(sid, claim.ownershipToken, (err as Error).message)
80
+ }
81
+ })
82
+ }
83
+
84
+ function buildTranscript(sessionId: string): string {
85
+ const msgs = loadTranscript(sessionId)
86
+ if (msgs.length === 0) return ""
87
+ const lines: string[] = []
88
+ for (const m of msgs) {
89
+ if (m.type === "system") continue
90
+ const role = m.role ?? m.type
91
+ const text = m.text ?? ""
92
+ if (!text.trim()) continue
93
+ // Injected AGENTS.md/<skill> blocks in user content are excluded from
94
+ // extraction (codex is_memory_excluded_contextual_user_fragment).
95
+ if (role === "user" && isMemoryExcludedFragment(text)) continue
96
+ lines.push(`### ${role}\n${redact(stripCitations(text))}`)
97
+ }
98
+ const full = lines.join("\n\n")
99
+ if (full.length <= TRANSCRIPT_MAX_CHARS) return full
100
+ return (
101
+ full.slice(0, TRANSCRIPT_HEAD_CHARS) +
102
+ "\n\n[... transcript truncated ...]\n\n" +
103
+ full.slice(full.length - TRANSCRIPT_TAIL_CHARS)
104
+ )
105
+ }
106
+
107
+ async function runPool<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>): Promise<void> {
108
+ let cursor = 0
109
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
110
+ while (cursor < items.length) {
111
+ const i = cursor++
112
+ await fn(items[i])
113
+ }
114
+ })
115
+ await Promise.all(workers)
116
+ }
package/src/phase2.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { MemoryStore, PHASE2_COOLDOWN_MS } from "./store.js"
2
+ import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff } from "./workspace.js"
3
+ import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js"
4
+ import { consolidateViaSubagent } from "./llm.js"
5
+ import { invalidateCache } from "./source.js"
6
+ import { memoryRoot } from "./paths.js"
7
+ import { checkRateLimit } from "./ratelimit.js"
8
+
9
+ export interface Phase2Options {
10
+ maxRaw: number
11
+ maxUnusedDays: number
12
+ extensionRetentionDays: number
13
+ consolidationModel?: string
14
+ }
15
+
16
+ export const DEFAULT_PHASE2_OPTIONS: Phase2Options = {
17
+ maxRaw: 256,
18
+ maxUnusedDays: 30,
19
+ extensionRetentionDays: 7,
20
+ }
21
+
22
+ let phase2InFlight = false
23
+
24
+ export async function runPhase2(store: MemoryStore, opts: Phase2Options = DEFAULT_PHASE2_OPTIONS): Promise<{ status: string }> {
25
+ if (phase2InFlight) return { status: "already_running" }
26
+ phase2InFlight = true
27
+ try {
28
+ const rl = await checkRateLimit("phase2")
29
+ if (!rl.ok) return { status: "skipped_rate_limit" }
30
+
31
+ const claim = store.claimGlobalPhase2Job()
32
+ if (claim.type !== "claimed") return { status: claim.type }
33
+
34
+ try {
35
+ ensureLayout()
36
+
37
+ // Preserves an existing baseline (only initializes a missing one): the
38
+ // diff below must span last-successful-run -> now so user edits and
39
+ // ad-hoc notes added since then reach consolidation. Stale stage-1
40
+ // output pruning happens in phase 1, before the rate gate (codex
41
+ // start.rs ordering).
42
+ if (!await ensureBaseline()) {
43
+ store.markPhase2Failed(claim.ownershipToken, "git baseline failed")
44
+ return { status: "baseline_failed" }
45
+ }
46
+
47
+ const outputs = store.getPhase2InputSelection(opts.maxRaw, opts.maxUnusedDays)
48
+ rebuildRawMemories(outputs)
49
+ writeRolloutSummaries(outputs)
50
+ pruneExtensionResources(opts.extensionRetentionDays)
51
+
52
+ const diff = await captureWorkspaceDiff()
53
+ if (diff.changes.length === 0) {
54
+ store.markPhase2Succeeded(claim.ownershipToken, outputs)
55
+ return { status: "no_workspace_changes" }
56
+ }
57
+
58
+ writeWorkspaceDiff(diff)
59
+
60
+ let heartbeatLost = false
61
+ const heartbeat = setInterval(() => {
62
+ if (!store.heartbeatPhase2Job(claim.ownershipToken)) {
63
+ heartbeatLost = true
64
+ }
65
+ }, 90_000)
66
+
67
+ try {
68
+ await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, opts.consolidationModel)
69
+ } finally {
70
+ clearInterval(heartbeat)
71
+ }
72
+
73
+ // Final synchronous ownership confirmation before the destructive
74
+ // baseline reset (codex phase2.rs does the same): the periodic flag can
75
+ // be up to 90s stale, and a stale worker resetting the baseline would
76
+ // swallow the diff a re-claiming worker is about to consume. The
77
+ // heartbeat is token+status guarded, so it fails once ownership is lost;
78
+ // markPhase2Failed is equally guarded and becomes a no-op then.
79
+ if (heartbeatLost || !store.heartbeatPhase2Job(claim.ownershipToken)) {
80
+ store.markPhase2Failed(claim.ownershipToken, "ownership lost")
81
+ return { status: "heartbeat_lost" }
82
+ }
83
+
84
+ if (!await resetBaseline()) {
85
+ store.markPhase2Failed(claim.ownershipToken, "baseline reset failed")
86
+ return { status: "baseline_reset_failed" }
87
+ }
88
+
89
+ store.markPhase2Succeeded(claim.ownershipToken, outputs)
90
+ invalidateCache()
91
+ return { status: "succeeded" }
92
+ } catch (err) {
93
+ store.markPhase2Failed(claim.ownershipToken, (err as Error).message)
94
+ return { status: "failed" }
95
+ }
96
+ } finally {
97
+ phase2InFlight = false
98
+ }
99
+ }
100
+
101
+ export const PHASE2_COOLDOWN = PHASE2_COOLDOWN_MS
@@ -0,0 +1,26 @@
1
+ export interface RateLimitInfo {
2
+ ok: boolean
3
+ reason?: string
4
+ }
5
+
6
+ let lastPhase1 = 0
7
+ let lastPhase2 = 0
8
+
9
+ const MIN_PHASE1_INTERVAL_MS = 30_000
10
+ const MIN_PHASE2_INTERVAL_MS = 5 * 60 * 1000
11
+
12
+ export async function checkRateLimit(kind: "phase1" | "phase2" = "phase1"): Promise<RateLimitInfo> {
13
+ const now = Date.now()
14
+ if (kind === "phase1") {
15
+ if (now - lastPhase1 < MIN_PHASE1_INTERVAL_MS) {
16
+ return { ok: false, reason: "phase1 rate limit (30s)" }
17
+ }
18
+ lastPhase1 = now
19
+ } else {
20
+ if (now - lastPhase2 < MIN_PHASE2_INTERVAL_MS) {
21
+ return { ok: false, reason: "phase2 rate limit (5min)" }
22
+ }
23
+ lastPhase2 = now
24
+ }
25
+ return { ok: true }
26
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,44 @@
1
+ const REDACTIONS: { re: RegExp; replacement: string }[] = [
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
+
17
+ export function redact(text: string): string {
18
+ let out = text
19
+ for (const { re, replacement } of REDACTIONS) {
20
+ out = out.replace(re, replacement)
21
+ }
22
+ return out
23
+ }
24
+
25
+ function matchesMarkedFragment(text: string, startMarker: string, endMarker: string): boolean {
26
+ const trimmed = text.trim()
27
+ return (
28
+ trimmed.slice(0, startMarker.length).toLowerCase() === startMarker.toLowerCase() &&
29
+ trimmed.slice(-endMarker.length).toLowerCase() === endMarker.toLowerCase()
30
+ )
31
+ }
32
+
33
+ /**
34
+ * Mirrors codex is_memory_excluded_contextual_user_fragment (phase1.rs):
35
+ * injected AGENTS.md instruction blocks and <skill> payloads inside user
36
+ * content are contextual boilerplate, not conversation — they must not be
37
+ * mined for memories.
38
+ */
39
+ export function isMemoryExcludedFragment(text: string): boolean {
40
+ return (
41
+ matchesMarkedFragment(text, "# AGENTS.md instructions", "</INSTRUCTIONS>") ||
42
+ matchesMarkedFragment(text, "<skill>", "</skill>")
43
+ )
44
+ }
package/src/source.ts ADDED
@@ -0,0 +1,62 @@
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
+
7
+ const MEMORY_SUMMARY_TOKEN_LIMIT = 2500
8
+ const READ_PATH_TEMPLATE = "read_path.md"
9
+
10
+ interface CachedSummary {
11
+ content: string
12
+ mtime: number
13
+ }
14
+
15
+ let cached: CachedSummary | null = null
16
+
17
+ function readTemplate(): string {
18
+ const templatePath = path.join(import.meta.dirname, "templates", READ_PATH_TEMPLATE)
19
+ return fs.readFileSync(templatePath, "utf8")
20
+ }
21
+
22
+ function readMemorySummary(): string | null {
23
+ const summaryPath = memorySummaryPath()
24
+ if (!fs.existsSync(summaryPath)) return null
25
+
26
+ const stat = fs.statSync(summaryPath)
27
+ if (cached && cached.mtime === stat.mtimeMs) {
28
+ return cached.content
29
+ }
30
+
31
+ const raw = fs.readFileSync(summaryPath, "utf8").trim()
32
+ if (!raw) return null
33
+
34
+ const truncated = truncateToTokens(raw, MEMORY_SUMMARY_TOKEN_LIMIT)
35
+ cached = {
36
+ content: truncated,
37
+ mtime: stat.mtimeMs,
38
+ }
39
+ return truncated
40
+ }
41
+
42
+ export function invalidateCache(): void {
43
+ cached = null
44
+ }
45
+
46
+ export function buildMemorySystemPrompt(): string | null {
47
+ const summary = readMemorySummary()
48
+ if (!summary) return null
49
+
50
+ const template = readTemplate()
51
+ return fillTemplate(template, {
52
+ base_path: memoryRoot(),
53
+ memory_summary: summary,
54
+ })
55
+ }
56
+
57
+ export function ensureMemoryLayout(): void {
58
+ const root = memoryRoot()
59
+ if (!fs.existsSync(root)) {
60
+ fs.mkdirSync(root, { recursive: true })
61
+ }
62
+ }