ctxcarry 0.3.0

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/README.md ADDED
@@ -0,0 +1,239 @@
1
+ <div align="center">
2
+
3
+ <p>
4
+ <img src="./assets/ctxcarry-ascii.svg" alt="ctxcarry - Portable project memory for AI coding agents" width="100%">
5
+ </p>
6
+
7
+ <h2>Move work between Claude Code, Codex, and future agents without losing repo state.</h2>
8
+
9
+ <p>
10
+ <img src="https://img.shields.io/badge/build-passing-22c55e?style=flat-square" />
11
+ <img src="https://img.shields.io/badge/version-v0.3.0-f97316?style=flat-square" />
12
+ <img src="https://img.shields.io/badge/npm-ctxcarry-c026d3?style=flat-square" />
13
+ <img src="https://img.shields.io/badge/node-%3E%3D20-3b82f6?style=flat-square" />
14
+ <img src="https://img.shields.io/badge/license-MIT-8b5cf6?style=flat-square" />
15
+ <img src="https://img.shields.io/badge/local--first-yes-111827?style=flat-square" />
16
+ </p>
17
+
18
+ <p>
19
+ <a href="#quickstart">Quickstart</a> ·
20
+ <a href="#commands">Commands</a> ·
21
+ <a href="#mcp-retrieval">MCP</a> ·
22
+ <a href="#benchmarks">Benchmarks</a> ·
23
+ <a href="./BENCHMARKS.md">Proof</a> ·
24
+ <a href="#development">Development</a>
25
+ </p>
26
+
27
+ <p>
28
+ <strong>AI agents / LLMs:</strong> use ctxcarry to capture repo state, compact session memory, and continue work across agents.
29
+ </p>
30
+
31
+ </div>
32
+
33
+ ---
34
+
35
+ # ctxcarry
36
+
37
+ **Portable project memory for AI coding agents.**
38
+
39
+ ctxcarry lets developers move long-running coding work between Claude Code, Codex, and future agents without losing the working state of a repo. It captures what happened in an agent session, distills the useful state, and generates the right handoff context for the next agent.
40
+
41
+ The core idea:
42
+
43
+ > Coding agents should not own your context. Your repo should.
44
+
45
+ ## Why
46
+
47
+ AI coding agents are powerful, but their context is trapped inside isolated sessions. If you start in Claude Code and then switch to Codex, the next agent usually does not know:
48
+
49
+ - what task was in progress
50
+ - which files changed
51
+ - what tests failed
52
+ - what decisions were made
53
+ - what constraints matter
54
+ - what the next step should be
55
+
56
+ ctxcarry stores that state locally in your repository and compiles compact agent-specific context files.
57
+
58
+ ## Quickstart
59
+
60
+ ```bash
61
+ npm install -g ctxcarry
62
+ ```
63
+
64
+ Inside a project:
65
+
66
+ ```bash
67
+ ctxcarry init
68
+ ctxcarry run claude
69
+ ctxcarry switch codex
70
+ codex
71
+ ```
72
+
73
+ That creates a Claude session, captures before/after git state, asks Claude to write a structured summary, compacts the result, updates `AGENTS.md`, and prepares Codex to continue.
74
+
75
+ ## What It Generates
76
+
77
+ ctxcarry stores source-of-truth state under `.ctxcarry/`:
78
+
79
+ ```text
80
+ .ctxcarry/
81
+ events.jsonl
82
+ commands.jsonl
83
+ state.json
84
+ state.md
85
+ learned.md
86
+ sessions/
87
+ ctxcarrys/
88
+ codex.md
89
+ claude.md
90
+ ```
91
+
92
+ Generated agent files:
93
+
94
+ - `AGENTS.md` for Codex
95
+ - `CLAUDE.md` for Claude Code
96
+ - `.ctxcarry/ctxcarrys/codex.md`
97
+ - `.ctxcarry/ctxcarrys/claude.md`
98
+
99
+ ## Commands
100
+
101
+ ```bash
102
+ ctxcarry init
103
+ ctxcarry run claude
104
+ ctxcarry switch codex
105
+ ctxcarry status
106
+ ```
107
+
108
+ Useful extras:
109
+
110
+ ```bash
111
+ ctxcarry capture
112
+ ctxcarry compact
113
+ ctxcarry compile --agent codex
114
+ ctxcarry compile --agent claude
115
+ handoff tokens
116
+ ```
117
+
118
+ Add explicit context when needed:
119
+
120
+ ```bash
121
+ ctxcarry note --type task --text "Fix Google OAuth redirect bug"
122
+ ctxcarry note --type decision --text "Do not rewrite the auth provider"
123
+ ctxcarry note --type constraint --text "Preserve existing email login behavior"
124
+ ctxcarry note --type failure --text "Production callback returns 400"
125
+ ctxcarry note --type next --text "Check redirect URI construction"
126
+ ```
127
+
128
+ ## Session Summaries
129
+
130
+ Before launching an agent, ctxcarry writes:
131
+
132
+ ```text
133
+ .ctxcarry/sessions/<session-id>/instructions.md
134
+ ```
135
+
136
+ The instruction asks the agent to write:
137
+
138
+ ```text
139
+ .ctxcarry/sessions/<session-id>/summary.md
140
+ ```
141
+
142
+ with strict headings:
143
+
144
+ ```md
145
+ ## Current Task
146
+ ## Files Changed
147
+ ## Decisions
148
+ ## Constraints
149
+ ## Failures
150
+ ## Commands Run
151
+ ## Next Step
152
+ ```
153
+
154
+ If the summary exists, ctxcarry parses it into structured memory. If it does not, ctxcarry falls back to git snapshots, changed files, branch, and diff summary.
155
+
156
+ ## MCP Retrieval
157
+
158
+ Serve local project memory to MCP-compatible clients:
159
+
160
+ ```bash
161
+ ctxcarry mcp serve
162
+ ```
163
+
164
+ Available tools:
165
+
166
+ - `get_current_task`
167
+ - `get_latest_ctxcarry`
168
+ - `get_relevant_session_events`
169
+ - `expand_session_artifact`
170
+ - `summarize_latest_failure`
171
+
172
+ ## Learning
173
+
174
+ Mine local sessions for durable guidance:
175
+
176
+ ```bash
177
+ ctxcarry learn
178
+ ctxcarry learn --apply
179
+ ```
180
+
181
+ `--apply` writes `.ctxcarry/learned.md` and updates `AGENTS.md` / `CLAUDE.md` with learned guidance.
182
+
183
+ ## Benchmarks
184
+
185
+ Run deterministic local fixture benchmarks:
186
+
187
+ ```bash
188
+ pnpm run bench
189
+ pnpm run bench -- --format json
190
+ pnpm run bench -- --out BENCHMARKS.md
191
+ ```
192
+
193
+ Current synthetic/local fixture headline:
194
+
195
+ | Metric | Value |
196
+ | --- | ---: |
197
+ | Total raw benchmark tokens | 210,371 |
198
+ | Total handoff tokens | 2,053 |
199
+ | Total saved tokens | 208,324 |
200
+ | Overall compression | 99.0% |
201
+ | Mean critical-fact recall | 100.0% |
202
+ | Mean budget recall | 100.0% |
203
+ | Contradictions found | 0 |
204
+ | Redaction checks | PASS |
205
+
206
+ These are deterministic synthetic/local fixture benchmarks, not production telemetry or real agent-execution results.
207
+
208
+ Run benchmarks on real local sessions:
209
+
210
+ ```bash
211
+ pnpm run bench:real -- --project /path/to/your/project
212
+ pnpm run bench:continuation -- --project /path/to/your/project
213
+ ```
214
+
215
+ Real-session benchmarks report observable local artifacts only. They do not fabricate task-success or recall metrics.
216
+
217
+ ## Development
218
+
219
+ ```bash
220
+ pnpm install
221
+ pnpm run build
222
+ pnpm test
223
+ pnpm run bench
224
+ ```
225
+
226
+ Link locally:
227
+
228
+ ```bash
229
+ pnpm link --global
230
+ ctxcarry --help
231
+ ```
232
+
233
+ ## Status
234
+
235
+ ctxcarry is early local-first developer tooling. The current focus is reliable Claude Code to Codex handoff, local session capture, compact project memory, and measurable benchmark quality.
236
+
237
+ ## License
238
+
239
+ MIT
@@ -0,0 +1,39 @@
1
+ <svg width="1400" height="300" viewBox="0 0 1400 300" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="ctxcarryGradient" x1="0" y1="0" x2="1" y2="0">
4
+ <stop offset="0%" stop-color="#22c55e"/>
5
+ <stop offset="35%" stop-color="#38bdf8"/>
6
+ <stop offset="70%" stop-color="#8b5cf6"/>
7
+ <stop offset="100%" stop-color="#f97316"/>
8
+ </linearGradient>
9
+ </defs>
10
+
11
+ <rect width="1400" height="300" rx="18" fill="#0d1117"/>
12
+
13
+ <text
14
+ x="220"
15
+ y="60"
16
+ font-family="Consolas, Monaco, 'Courier New', monospace"
17
+ font-size="24"
18
+ font-weight="800"
19
+ fill="url(#ctxcarryGradient)"
20
+ xml:space="preserve">
21
+ <tspan x="220" dy="0"> ██████╗████████╗██╗ ██╗ ██████╗ █████╗ ██████╗ ██████╗ ██╗ ██╗</tspan>
22
+ <tspan x="220" dy="34">██╔════╝╚══██╔══╝╚██╗██╔╝██╔════╝██╔══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝</tspan>
23
+ <tspan x="220" dy="34">██║ ██║ ╚███╔╝ ██║ ███████║██████╔╝██████╔╝ ╚████╔╝ </tspan>
24
+ <tspan x="220" dy="34">██║ ██║ ██╔██╗ ██║ ██╔══██║██╔══██╗██╔══██╗ ╚██╔╝ </tspan>
25
+ <tspan x="220" dy="34">╚██████╗ ██║ ██╔╝ ██╗╚██████╗██║ ██║██║ ██║██║ ██║ ██║ </tspan>
26
+ <tspan x="220" dy="34"> ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ </tspan>
27
+ </text>
28
+
29
+ <text
30
+ x="700"
31
+ y="265"
32
+ text-anchor="middle"
33
+ font-family="Consolas, Monaco, 'Courier New', monospace"
34
+ font-size="24"
35
+ font-weight="600"
36
+ fill="#e6edf3">
37
+ Portable project memory for AI coding agents
38
+ </text>
39
+ </svg>
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { redactText } from "./redact.js";
4
+ import { routeContent } from "./content-router.js";
5
+ export function archiveSessionArtifact(sessionDir, name, content) {
6
+ const rawDir = path.join(sessionDir, "raw");
7
+ fs.mkdirSync(rawDir, { recursive: true });
8
+ const artifactId = name.replace(/[^a-z0-9_.-]/gi, "-").toLowerCase();
9
+ const artifactPath = path.join(rawDir, artifactId);
10
+ const redacted = redactText(content);
11
+ fs.writeFileSync(artifactPath, redacted.endsWith("\n") ? redacted : `${redacted}\n`);
12
+ const routed = routeContent(name, redacted);
13
+ return {
14
+ artifactId,
15
+ path: artifactPath,
16
+ kind: routed.kind,
17
+ summary: routed.summary
18
+ };
19
+ }
@@ -0,0 +1,250 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { archiveSessionArtifact } from "./archive.js";
5
+ import { ctxcarryPath } from "./paths.js";
6
+ import { redactText } from "./redact.js";
7
+ import { appendEvent, readConfig } from "./store.js";
8
+ import { getGitSnapshot, summarizeSnapshot } from "./git.js";
9
+ export function captureSnapshot(agent) {
10
+ const snapshot = getGitSnapshot();
11
+ appendEvent({
12
+ type: "repo_snapshot",
13
+ agent,
14
+ branch: snapshot.branch,
15
+ isRepo: snapshot.isRepo,
16
+ status: snapshot.status,
17
+ changedFiles: snapshot.changedFiles,
18
+ diffStat: snapshot.diffStat,
19
+ summary: summarizeSnapshot(snapshot)
20
+ });
21
+ return snapshot;
22
+ }
23
+ export async function runAgent(agent) {
24
+ const config = readConfig();
25
+ const agentConfig = config.agents[agent];
26
+ if (!agentConfig?.enabled) {
27
+ throw new Error(`Agent "${agent}" is not enabled in ctxcarry.config.json.`);
28
+ }
29
+ const sessionId = createSessionId(agent);
30
+ const sessionDir = ctxcarryPath("sessions", sessionId);
31
+ fs.mkdirSync(sessionDir, { recursive: true });
32
+ const instructionsPath = path.join(sessionDir, "instructions.md");
33
+ const summaryPath = path.join(sessionDir, "summary.md");
34
+ writeSessionInstructions(agent, sessionId, instructionsPath, summaryPath);
35
+ const instructionArtifact = archiveSessionArtifact(sessionDir, "instructions.md", fs.readFileSync(instructionsPath, "utf8"));
36
+ const command = agentConfig.command ?? agent;
37
+ const startedAtMs = Date.now();
38
+ const startedAt = new Date(startedAtMs).toISOString();
39
+ const before = getGitSnapshot();
40
+ writeSnapshot(sessionDir, "before", before);
41
+ appendEvent({
42
+ type: "session_started",
43
+ agent,
44
+ sessionId,
45
+ sessionDir,
46
+ instructionsPath,
47
+ summaryPath,
48
+ command,
49
+ startedAt,
50
+ instructionArtifact,
51
+ branch: before.branch,
52
+ changedFiles: before.changedFiles,
53
+ diffStat: before.diffStat
54
+ });
55
+ const exitCode = await spawnInteractive(command, sessionDir, instructionsPath, summaryPath);
56
+ const endedAtMs = Date.now();
57
+ const endedAt = new Date(endedAtMs).toISOString();
58
+ const after = getGitSnapshot();
59
+ writeSnapshot(sessionDir, "after", after);
60
+ const beforeArtifact = archiveSessionArtifact(sessionDir, "before.json", JSON.stringify(before, null, 2));
61
+ const afterArtifact = archiveSessionArtifact(sessionDir, "after.json", JSON.stringify(after, null, 2));
62
+ const changedFiles = unique([...before.changedFiles, ...after.changedFiles]);
63
+ const newFiles = statusFiles(after.status, ["??", "A", "AM"]);
64
+ const deletedFiles = statusFiles(after.status, ["D", "AD"]);
65
+ const durationMs = endedAtMs - startedAtMs;
66
+ appendEvent({
67
+ type: "repo_snapshot",
68
+ agent,
69
+ sessionId,
70
+ branch: after.branch,
71
+ isRepo: after.isRepo,
72
+ status: after.status,
73
+ changedFiles,
74
+ newFiles,
75
+ deletedFiles,
76
+ diffStat: after.diffStat,
77
+ summary: summarizeSnapshot(after)
78
+ });
79
+ appendEvent({
80
+ type: "agent_session",
81
+ agent,
82
+ sessionId,
83
+ sessionDir,
84
+ instructionsPath,
85
+ summaryPath,
86
+ command,
87
+ startedAt,
88
+ endedAt,
89
+ durationMs,
90
+ exitCode,
91
+ branch: after.branch,
92
+ before,
93
+ after,
94
+ changedFiles,
95
+ newFiles,
96
+ deletedFiles,
97
+ diffSummary: after.diffStat
98
+ });
99
+ appendEvent({
100
+ type: "session_artifacts",
101
+ agent,
102
+ sessionId,
103
+ artifacts: [instructionArtifact, beforeArtifact, afterArtifact]
104
+ });
105
+ if (fs.existsSync(summaryPath)) {
106
+ const summaryArtifact = archiveSessionArtifact(sessionDir, "summary.md", fs.readFileSync(summaryPath, "utf8"));
107
+ appendEvent({
108
+ type: "session_artifact",
109
+ agent,
110
+ sessionId,
111
+ artifact: summaryArtifact
112
+ });
113
+ parseSessionSummary(agent, sessionId, summaryPath);
114
+ }
115
+ else {
116
+ appendEvent({
117
+ type: "session_fallback",
118
+ agent,
119
+ sessionId,
120
+ content: "No session summary was written. Falling back to git snapshots and changed files.",
121
+ branch: after.branch,
122
+ changedFiles,
123
+ newFiles,
124
+ deletedFiles,
125
+ diffSummary: after.diffStat
126
+ });
127
+ }
128
+ return { sessionId, sessionDir, exitCode, changedFiles };
129
+ }
130
+ function writeSessionInstructions(agent, sessionId, instructionsPath, summaryPath) {
131
+ const relativeSummaryPath = path.relative(process.cwd(), summaryPath);
132
+ const instructions = `# ctxcarry Session Instructions
133
+
134
+ At the end of this session, write a concise ctxcarry summary to:
135
+
136
+ ${relativeSummaryPath}
137
+
138
+ Use exactly these Markdown headings:
139
+
140
+ ## Current Task
141
+ ## Files Changed
142
+ ## Decisions
143
+ ## Constraints
144
+ ## Failures
145
+ ## Commands Run
146
+ ## Next Step
147
+
148
+ Include only durable state the next coding agent needs to continue.
149
+ `;
150
+ fs.writeFileSync(instructionsPath, instructions);
151
+ }
152
+ function parseSessionSummary(agent, sessionId, summaryPath) {
153
+ const summary = redactText(fs.readFileSync(summaryPath, "utf8"));
154
+ const sections = parseMarkdownSections(summary);
155
+ const task = firstContent(sections["Current Task"]);
156
+ if (task) {
157
+ appendEvent({ type: "note", agent, sessionId, noteType: "task", content: task });
158
+ }
159
+ for (const file of listContent(sections["Files Changed"])) {
160
+ appendEvent({ type: "file_changed", agent, sessionId, path: file, change_summary: `Changed during ${sessionId}` });
161
+ }
162
+ for (const decision of listContent(sections.Decisions)) {
163
+ appendEvent({ type: "note", agent, sessionId, noteType: "decision", content: decision });
164
+ }
165
+ for (const constraint of listContent(sections.Constraints)) {
166
+ appendEvent({ type: "note", agent, sessionId, noteType: "constraint", content: constraint });
167
+ }
168
+ for (const failure of listContent(sections.Failures)) {
169
+ appendEvent({ type: "note", agent, sessionId, noteType: "failure", content: failure });
170
+ }
171
+ for (const command of listContent(sections["Commands Run"])) {
172
+ appendEvent({ type: "command_run", agent, sessionId, command, status: "recorded", summary: `Recorded from ${sessionId}` });
173
+ }
174
+ const next = firstContent(sections["Next Step"]);
175
+ if (next) {
176
+ appendEvent({ type: "note", agent, sessionId, noteType: "next", content: next });
177
+ }
178
+ appendEvent({
179
+ type: "session_summary",
180
+ agent,
181
+ sessionId,
182
+ summaryPath,
183
+ content: summary
184
+ });
185
+ }
186
+ function parseMarkdownSections(markdown) {
187
+ const sections = {};
188
+ let current = null;
189
+ for (const line of markdown.split("\n")) {
190
+ const heading = line.match(/^##\s+(.+?)\s*$/);
191
+ if (heading) {
192
+ current = heading[1].trim();
193
+ sections[current] = "";
194
+ continue;
195
+ }
196
+ if (current) {
197
+ sections[current] += `${line}\n`;
198
+ }
199
+ }
200
+ return sections;
201
+ }
202
+ function firstContent(section = "") {
203
+ return listContent(section)[0] ?? section.trim();
204
+ }
205
+ function listContent(section = "") {
206
+ return section
207
+ .split("\n")
208
+ .map((line) => line.trim())
209
+ .map((line) => line.replace(/^[-*]\s+/, "").replace(/^\d+\.\s+/, ""))
210
+ .filter(Boolean);
211
+ }
212
+ function spawnInteractive(command, sessionDir, instructionsPath, summaryPath) {
213
+ const [bin, ...args] = splitCommand(command);
214
+ return new Promise((resolve, reject) => {
215
+ const child = spawn(bin, args, {
216
+ cwd: process.cwd(),
217
+ env: {
218
+ ...process.env,
219
+ CTXCARRY_SESSION_DIR: sessionDir,
220
+ CTXCARRY_SESSION_INSTRUCTIONS: instructionsPath,
221
+ CTXCARRY_SESSION_SUMMARY: summaryPath
222
+ },
223
+ stdio: "inherit",
224
+ shell: false
225
+ });
226
+ child.on("error", reject);
227
+ child.on("close", (code) => resolve(code ?? 0));
228
+ });
229
+ }
230
+ function writeSnapshot(sessionDir, name, snapshot) {
231
+ fs.writeFileSync(path.join(sessionDir, `${name}.json`), JSON.stringify(snapshot, null, 2) + "\n");
232
+ }
233
+ function createSessionId(agent) {
234
+ const safeAgent = agent.replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
235
+ return `${new Date().toISOString().replace(/[:.]/g, "-")}-${safeAgent}-${process.pid}`;
236
+ }
237
+ function statusFiles(status, codes) {
238
+ return unique(status
239
+ .filter((line) => codes.some((code) => line.trimStart().startsWith(code)))
240
+ .map((line) => line.slice(3).trim())
241
+ .filter((file) => !file.startsWith(".ctxcarry/"))
242
+ .filter(Boolean));
243
+ }
244
+ function splitCommand(command) {
245
+ const parts = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
246
+ return parts.map((part) => part.replace(/^["']|["']$/g, ""));
247
+ }
248
+ function unique(values) {
249
+ return [...new Set(values.filter(Boolean))].sort();
250
+ }