hammadev 0.1.0-alpha.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/LICENSE +15 -0
- package/README.md +226 -0
- package/dist/adapters/claude/discover.js +91 -0
- package/dist/adapters/claude/index.js +18 -0
- package/dist/adapters/claude/parse.js +130 -0
- package/dist/adapters/claude/paths.js +26 -0
- package/dist/adapters/claude/resolve.js +34 -0
- package/dist/adapters/claude/shape.js +86 -0
- package/dist/adapters/codex/discover.js +27 -0
- package/dist/adapters/codex/index.js +18 -0
- package/dist/adapters/codex/paths.js +21 -0
- package/dist/adapters/codex/resolve.js +59 -0
- package/dist/adapters/codex/rollout.js +214 -0
- package/dist/cli.js +248 -0
- package/dist/core/doctor.js +187 -0
- package/dist/core/handoff.js +530 -0
- package/dist/core/history.js +117 -0
- package/dist/core/project-status.js +167 -0
- package/dist/core/redact.js +21 -0
- package/dist/core/schema.js +1 -0
- package/dist/core/state.js +444 -0
- package/dist/session-loader.js +54 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xayrullonematov
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
|
15
|
+
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# HammaDev
|
|
2
|
+
|
|
3
|
+
**Persistent memory and handoff layer for AI coding agents.**
|
|
4
|
+
|
|
5
|
+
HammaDev reads a Codex CLI or Claude Code session and produces a compact,
|
|
6
|
+
structured handoff package another supported agent can pick up and continue
|
|
7
|
+
from — with no shared cloud service and no changes to the source agent's files.
|
|
8
|
+
|
|
9
|
+
> **Status:** v0.1-alpha. Local-only CLI. Codex ↔ Claude handoff.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The problem
|
|
14
|
+
|
|
15
|
+
Each AI coding CLI keeps its own conversation history in its own format.
|
|
16
|
+
When you switch agents mid-task — because you hit a context limit, want a
|
|
17
|
+
second opinion, or one tool is better at the next step — the new agent has
|
|
18
|
+
no idea what has already been tried, decided, or ruled out.
|
|
19
|
+
|
|
20
|
+
You either:
|
|
21
|
+
|
|
22
|
+
- Re-paste large chunks of the previous transcript by hand, or
|
|
23
|
+
- Give a vague "continue from before" prompt and watch the new agent
|
|
24
|
+
redo work that's already done.
|
|
25
|
+
|
|
26
|
+
HammaDev is a small tool that reads the source agent's native session file,
|
|
27
|
+
extracts what actually matters (goal, task ledger, verification signals,
|
|
28
|
+
current repo state, known risks), redacts obvious secrets, and writes a
|
|
29
|
+
short `handoff.md` plus supporting artifacts to a local `.hamma/` directory
|
|
30
|
+
in your project. The next agent reads that file and continues.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Current alpha capabilities
|
|
35
|
+
|
|
36
|
+
- **Source adapter — Codex CLI.** Discovers rollouts under
|
|
37
|
+
`~/.codex/sessions/**/rollout-*.jsonl` and parses them into a normalized
|
|
38
|
+
`HammaSession` model.
|
|
39
|
+
- **Source adapter — Claude Code.** Discovers Claude JSONL sessions and conservatively parses visible user/assistant text while excluding internal, system, thinking, and tool records.
|
|
40
|
+
- **Handoff generator — targets `claude` and `codex`.** Produces a `.hamma/tasks/<id>/`
|
|
41
|
+
directory containing:
|
|
42
|
+
- `handoff.md` — a size-guarded (~15 KB target, 20 KB hard cap) markdown
|
|
43
|
+
brief with next action, current state, completed vs. remaining tasks,
|
|
44
|
+
verification signals, `git status --short` / `git diff --stat`, and
|
|
45
|
+
known risks.
|
|
46
|
+
- `state.json` — the structured task state the markdown was rendered from.
|
|
47
|
+
- `session.json` — the full normalized session (for archival / debugging).
|
|
48
|
+
- `timeline.md` — an importance-filtered chronological view.
|
|
49
|
+
- `commands.md` — bucketed summary of shell/tool invocations observed.
|
|
50
|
+
- `redaction-report.md` — count and warnings from secret redaction.
|
|
51
|
+
- **Secret redaction.** Regex-based scrub of common API-key shapes
|
|
52
|
+
(OpenAI, Anthropic, GitHub, Google, Slack, generic `api_key=...`).
|
|
53
|
+
- **Non-destructive.** Never writes to the source agent's session files.
|
|
54
|
+
Optionally appends `.hamma/` to your project's `.gitignore`.
|
|
55
|
+
- **Project status.** Reports local Git state, handoff history, session counts,
|
|
56
|
+
and `.hamma/` ignore coverage without printing transcripts.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Demo flow
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 1. See what Codex sessions exist on this machine
|
|
64
|
+
pnpm dev list codex
|
|
65
|
+
|
|
66
|
+
# 2. Inspect the most recent Codex session (summarized JSON)
|
|
67
|
+
pnpm dev inspect codex:last --summary
|
|
68
|
+
|
|
69
|
+
# 2b. Inspect a specific session by conversationId (exact or unique prefix)
|
|
70
|
+
pnpm dev inspect codex:019f18df-4e55-73a1-91d9-83551639edbf --summary
|
|
71
|
+
|
|
72
|
+
# 2c. Inspect a rollout file directly by path
|
|
73
|
+
pnpm dev inspect /home/you/.codex/sessions/2026/06/30/rollout-2026-06-30T14-22-05-019f18df-4e55-73a1-91d9-83551639edbf.jsonl --summary
|
|
74
|
+
|
|
75
|
+
# 3. Create a handoff package for Claude Code to pick up
|
|
76
|
+
pnpm dev handoff codex:last --to claude
|
|
77
|
+
|
|
78
|
+
# 3b. Same, but selecting a specific session
|
|
79
|
+
pnpm dev handoff codex:019f18df-4e55-73a1-91d9-83551639edbf --to claude
|
|
80
|
+
|
|
81
|
+
# 4. Inspect the most recent Claude session
|
|
82
|
+
pnpm dev inspect claude:last --summary
|
|
83
|
+
|
|
84
|
+
# 5. Hand the Claude session to Codex (last, exact ID, or unique prefix)
|
|
85
|
+
pnpm dev handoff claude:last --to codex
|
|
86
|
+
pnpm dev handoff claude:aaaaaaaa-1111-4aaa-8aaa-aaaaaaaaaaaa --to codex
|
|
87
|
+
|
|
88
|
+
# 6. List this project's local handoffs (newest first)
|
|
89
|
+
pnpm dev log
|
|
90
|
+
|
|
91
|
+
# 6b. List handoffs from another project
|
|
92
|
+
pnpm dev log --project /path/to/project
|
|
93
|
+
|
|
94
|
+
# 7. Print the latest or a specific handoff brief
|
|
95
|
+
pnpm dev show latest
|
|
96
|
+
pnpm dev show <task-id>
|
|
97
|
+
|
|
98
|
+
# 8. Show an overview for this project or another project
|
|
99
|
+
pnpm dev status
|
|
100
|
+
pnpm dev status --project /path/to/project
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Handoffs are written under the source session's project directory as either
|
|
104
|
+
`.hamma/tasks/<timestamp>-codex-to-claude/` or
|
|
105
|
+
`.hamma/tasks/<timestamp>-claude-to-codex/`. The CLI then prints a suggested
|
|
106
|
+
command, for example:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
codex "Read .hamma/tasks/<id>/handoff.md and continue the task from the current repo state."
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Install / dev setup
|
|
115
|
+
|
|
116
|
+
Requirements: Node.js 20+ and [pnpm](https://pnpm.io/) 10+.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/<you>/hammadev.git
|
|
120
|
+
cd hammadev
|
|
121
|
+
pnpm install
|
|
122
|
+
|
|
123
|
+
# Run the CLI directly via tsx (no build step needed for dev)
|
|
124
|
+
pnpm dev --help
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Typecheck:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pnpm typecheck
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Build the compiled JS (`dist/`):
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
pnpm build
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Commands
|
|
142
|
+
|
|
143
|
+
| Command | Purpose |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `hamma doctor` | Preflight check: Node version, `git` availability, Codex session presence, `projectPath` detection, and `.gitignore` safety. Exits non-zero on any failure. |
|
|
146
|
+
| `hamma status [--project <path>]` | Show a read-only overview for the current or selected project: Git state, handoff count/latest route, Codex and Claude session counts, and whether `.hamma/` is ignored. |
|
|
147
|
+
| `hamma list codex` | List Codex sessions found on this machine (newest first). |
|
|
148
|
+
| `hamma list claude` | List candidate Claude Code session files found under `~/.claude`, `~/.config/claude`, and `~/.local/share/claude`. Claude files are never modified. |
|
|
149
|
+
| `hamma inspect claude:last --shape` (also `claude:<sessionId>`) | **Experimental / read-only shape probe.** Reads a Claude Code `.jsonl` line-by-line and prints only structural stats — file size, line counts, top-level key frequency, `type`/role tallies, per-type field shapes, and any `cwd`/`projectPath` values. **No message text, prompt text, tool inputs, tool outputs, or file contents are ever printed.** Used to design the Claude parser without leaking session content. |
|
|
150
|
+
| `hamma inspect claude:<target> [--summary]` | **Experimental conservative parser (v0.1).** Normalizes a Claude session into `HammaSession`. Only visible user/assistant text messages are included — `system`, `permission-mode`, `mode`, `file-history-snapshot`, `ai-title`, `last-prompt`, and `attachment` records are ignored, and assistant `thinking`/`tool_use` and user `tool_result` blocks are dropped. All emitted message content passes through the same secret redaction used for Codex. No Claude files are modified. |
|
|
151
|
+
| `hamma inspect <target> [--summary]` | Print the normalized session as JSON. `<target>` accepts `codex:last`, `codex:<conversationId>`, `claude:last`, `claude:<sessionId>` (exact or unique prefix), a Codex rollout path, or an absolute UUID-named Claude session path. |
|
|
152
|
+
| `hamma handoff codex:<target> --to claude [--no-gitignore]` | Generate a Codex → Claude handoff under the source project's `.hamma/tasks/`. |
|
|
153
|
+
| `hamma handoff claude:<target> --to codex [--no-gitignore]` | Generate a Claude → Codex handoff under the Claude session's `projectPath`. The conservative parser excludes Claude internal/system/tool/thinking records. |
|
|
154
|
+
| `hamma log [--project <path>]` | List local handoffs newest first for the current directory, or for the selected project. Shows task ID, agents, created time, `handoff.md` path, and the continue-from-here line when present. |
|
|
155
|
+
| `hamma show latest` | Print the newest local `handoff.md` from the current directory. |
|
|
156
|
+
| `hamma show <task-id>` | Print one local `handoff.md` by task ID from the current directory. |
|
|
157
|
+
|
|
158
|
+
In dev, invoke via `pnpm dev <command>`. The `bin` entry is `hamma`, so once
|
|
159
|
+
published/linked it can be invoked directly.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Generated files
|
|
164
|
+
|
|
165
|
+
Handoff artifacts are written under the *source project's* directory (the
|
|
166
|
+
project Codex or Claude was working in), not this repo:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
<project>/.hamma/tasks/<ISO-timestamp>-<source>-to-<target>/
|
|
170
|
+
├── handoff.md # short markdown brief for the target agent
|
|
171
|
+
├── state.json # structured task state
|
|
172
|
+
├── session.json # full normalized session
|
|
173
|
+
├── timeline.md # importance-filtered chronological view
|
|
174
|
+
├── commands.md # bucketed shell/tool command summary
|
|
175
|
+
└── redaction-report.md # secret-redaction summary
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
By default, HammaDev appends `.hamma/` to that project's `.gitignore` so
|
|
179
|
+
handoff artifacts stay local. Pass `--no-gitignore` to skip.
|
|
180
|
+
|
|
181
|
+
`hamma status`, `hamma log`, and `hamma show` are read-only. Status reads only
|
|
182
|
+
project/Git metadata plus handoff or state metadata; these commands do not
|
|
183
|
+
print `session.json` or raw transcript data.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Security model
|
|
188
|
+
|
|
189
|
+
- **Local only.** All parsing, redaction, and file writes happen on your
|
|
190
|
+
machine. There is no network call, no backend, no telemetry.
|
|
191
|
+
- **Read-only against source sessions.** HammaDev never modifies Codex rollout
|
|
192
|
+
files or Claude session JSONL files.
|
|
193
|
+
- **Best-effort secret redaction.** Common API-key patterns (OpenAI,
|
|
194
|
+
Anthropic, GitHub, Google, Slack, generic `key/token/secret/password = …`)
|
|
195
|
+
are replaced with `[REDACTED_SECRET]` in the emitted artifacts, and
|
|
196
|
+
counted in `redaction-report.md`. Redaction is regex-based and is *not*
|
|
197
|
+
a substitute for reviewing the handoff before sharing it.
|
|
198
|
+
- **System / developer prompts omitted** from the handoff brief; the full
|
|
199
|
+
transcript still lives in `session.json` for local inspection.
|
|
200
|
+
- **`.hamma/` is treated as local scratch.** It is gitignored in this repo
|
|
201
|
+
and auto-added to the source project's `.gitignore` on handoff.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Roadmap
|
|
206
|
+
|
|
207
|
+
Near-term:
|
|
208
|
+
|
|
209
|
+
- Additional source adapters: Gemini CLI, opencode, Antigravity.
|
|
210
|
+
- Richer task-ledger extraction (fewer parser warnings, better dedup).
|
|
211
|
+
- More history filters and handoff retention controls.
|
|
212
|
+
|
|
213
|
+
Later:
|
|
214
|
+
|
|
215
|
+
- Optional backend for cross-machine handoff and team-shared memory.
|
|
216
|
+
- Durable storage layer (CockroachDB) for multi-user deployments.
|
|
217
|
+
- Editor / IDE integrations.
|
|
218
|
+
|
|
219
|
+
Backend and CockroachDB are intentionally **not** part of the alpha — the
|
|
220
|
+
current design is a local CLI you can audit end-to-end.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
ISC. See `package.json`.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import fg from "fast-glob";
|
|
5
|
+
import { candidateClaudeHomes, claudeProjectsGlobs, sessionIdFromFilename } from "./paths.js";
|
|
6
|
+
const PEEK_MAX_LINES = 8;
|
|
7
|
+
async function pathExists(p) {
|
|
8
|
+
try {
|
|
9
|
+
await fsp.access(p);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function peekMetadata(filePath) {
|
|
17
|
+
const result = {};
|
|
18
|
+
let stream;
|
|
19
|
+
try {
|
|
20
|
+
stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
21
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
22
|
+
let seen = 0;
|
|
23
|
+
for await (const line of rl) {
|
|
24
|
+
seen += 1;
|
|
25
|
+
if (!line.trim())
|
|
26
|
+
continue;
|
|
27
|
+
let obj;
|
|
28
|
+
try {
|
|
29
|
+
obj = JSON.parse(line);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
if (seen >= PEEK_MAX_LINES)
|
|
33
|
+
break;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!result.sessionId && typeof obj?.sessionId === "string") {
|
|
37
|
+
result.sessionId = obj.sessionId;
|
|
38
|
+
}
|
|
39
|
+
if (!result.projectPathHint && typeof obj?.cwd === "string") {
|
|
40
|
+
result.projectPathHint = obj.cwd;
|
|
41
|
+
}
|
|
42
|
+
if (result.sessionId && result.projectPathHint)
|
|
43
|
+
break;
|
|
44
|
+
if (seen >= PEEK_MAX_LINES)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Peek is best-effort; ignore read errors.
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
stream?.destroy();
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
export async function discoverClaudeSessions(claudeHomes = candidateClaudeHomes()) {
|
|
57
|
+
const refs = [];
|
|
58
|
+
for (const home of claudeHomes) {
|
|
59
|
+
if (!(await pathExists(home)))
|
|
60
|
+
continue;
|
|
61
|
+
const patterns = claudeProjectsGlobs(home);
|
|
62
|
+
const files = await fg(patterns, { onlyFiles: true, dot: true });
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = await fsp.stat(file);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!stat.isFile())
|
|
72
|
+
continue;
|
|
73
|
+
const { sessionId: peekedId, projectPathHint } = await peekMetadata(file);
|
|
74
|
+
const sessionId = peekedId ?? sessionIdFromFilename(file);
|
|
75
|
+
refs.push({
|
|
76
|
+
sourceCli: "claude",
|
|
77
|
+
path: file,
|
|
78
|
+
sizeBytes: stat.size,
|
|
79
|
+
lastUpdatedAt: stat.mtime.toISOString(),
|
|
80
|
+
sessionId,
|
|
81
|
+
projectPathHint,
|
|
82
|
+
claudeHome: home
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return refs.sort((a, b) => {
|
|
87
|
+
const at = new Date(a.lastUpdatedAt).getTime();
|
|
88
|
+
const bt = new Date(b.lastUpdatedAt).getTime();
|
|
89
|
+
return bt - at;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { discoverClaudeSessions } from "./discover.js";
|
|
2
|
+
import { resolveClaudeTarget } from "./resolve.js";
|
|
3
|
+
import { inspectClaudeShape } from "./shape.js";
|
|
4
|
+
import { parseClaudeSession } from "./parse.js";
|
|
5
|
+
export const ClaudeAdapter = {
|
|
6
|
+
async list(claudeHomes) {
|
|
7
|
+
return discoverClaudeSessions(claudeHomes);
|
|
8
|
+
},
|
|
9
|
+
async resolve(target, claudeHomes) {
|
|
10
|
+
return resolveClaudeTarget(target, { claudeHomes });
|
|
11
|
+
},
|
|
12
|
+
async inspectShape(filePath) {
|
|
13
|
+
return inspectClaudeShape(filePath);
|
|
14
|
+
},
|
|
15
|
+
async inspect(filePath) {
|
|
16
|
+
return parseClaudeSession(filePath);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { redactText } from "../../core/redact.js";
|
|
5
|
+
import { sessionIdFromFilename } from "./paths.js";
|
|
6
|
+
const IGNORED_TOP_TYPES = new Set([
|
|
7
|
+
"system",
|
|
8
|
+
"permission-mode",
|
|
9
|
+
"mode",
|
|
10
|
+
"file-history-snapshot",
|
|
11
|
+
"ai-title",
|
|
12
|
+
"last-prompt",
|
|
13
|
+
"attachment"
|
|
14
|
+
]);
|
|
15
|
+
function extractText(message) {
|
|
16
|
+
if (!message)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (typeof message.content === "string") {
|
|
19
|
+
return message.content;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(message.content)) {
|
|
22
|
+
const parts = [];
|
|
23
|
+
for (const block of message.content) {
|
|
24
|
+
if (!block || typeof block !== "object")
|
|
25
|
+
continue;
|
|
26
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
27
|
+
parts.push(block.text);
|
|
28
|
+
}
|
|
29
|
+
// Deliberately skip: thinking, tool_use, tool_result, image, etc.
|
|
30
|
+
}
|
|
31
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
function redactInto(session, text) {
|
|
36
|
+
const r = redactText(text);
|
|
37
|
+
session.security.redactionCount += r.count;
|
|
38
|
+
if (r.count > 0)
|
|
39
|
+
session.security.redacted = true;
|
|
40
|
+
return r.text;
|
|
41
|
+
}
|
|
42
|
+
export async function parseClaudeSession(sessionPath) {
|
|
43
|
+
try {
|
|
44
|
+
await fsp.access(sessionPath, fs.constants.R_OK);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`Claude session file is missing or not readable: ${sessionPath}`);
|
|
48
|
+
}
|
|
49
|
+
const session = {
|
|
50
|
+
meta: {
|
|
51
|
+
sourceCli: "claude",
|
|
52
|
+
sourceSessionId: sessionIdFromFilename(sessionPath) ?? "",
|
|
53
|
+
sourcePath: sessionPath
|
|
54
|
+
},
|
|
55
|
+
messages: [],
|
|
56
|
+
shellCommands: [],
|
|
57
|
+
parserWarnings: [],
|
|
58
|
+
security: {
|
|
59
|
+
redacted: false,
|
|
60
|
+
redactionCount: 0,
|
|
61
|
+
warnings: []
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const stream = fs.createReadStream(sessionPath, { encoding: "utf8" });
|
|
65
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
66
|
+
let earliestTs;
|
|
67
|
+
let latestTs;
|
|
68
|
+
for await (const line of rl) {
|
|
69
|
+
if (!line.trim())
|
|
70
|
+
continue;
|
|
71
|
+
let obj;
|
|
72
|
+
try {
|
|
73
|
+
obj = JSON.parse(line);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
session.parserWarnings.push("Skipped malformed JSONL line.");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
80
|
+
session.parserWarnings.push("Skipped non-object JSONL line.");
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const type = typeof obj.type === "string" ? obj.type : "";
|
|
84
|
+
if (IGNORED_TOP_TYPES.has(type))
|
|
85
|
+
continue;
|
|
86
|
+
// Seed session id / project path from any record that carries them.
|
|
87
|
+
if (!session.meta.sourceSessionId && typeof obj.sessionId === "string") {
|
|
88
|
+
session.meta.sourceSessionId = obj.sessionId;
|
|
89
|
+
}
|
|
90
|
+
if (!session.meta.projectPath && typeof obj.cwd === "string") {
|
|
91
|
+
session.meta.projectPath = obj.cwd;
|
|
92
|
+
}
|
|
93
|
+
if (!session.meta.projectPath && typeof obj.projectPath === "string") {
|
|
94
|
+
session.meta.projectPath = obj.projectPath;
|
|
95
|
+
}
|
|
96
|
+
const timestamp = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
97
|
+
if (timestamp) {
|
|
98
|
+
if (!earliestTs || timestamp < earliestTs)
|
|
99
|
+
earliestTs = timestamp;
|
|
100
|
+
if (!latestTs || timestamp > latestTs)
|
|
101
|
+
latestTs = timestamp;
|
|
102
|
+
}
|
|
103
|
+
if (type === "user" && obj.message?.role === "user") {
|
|
104
|
+
const raw = extractText(obj.message);
|
|
105
|
+
if (raw && raw.trim().length > 0) {
|
|
106
|
+
session.messages.push({
|
|
107
|
+
role: "user",
|
|
108
|
+
content: redactInto(session, raw),
|
|
109
|
+
timestamp
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (type === "assistant" && obj.message?.role === "assistant") {
|
|
115
|
+
const raw = extractText(obj.message);
|
|
116
|
+
if (raw && raw.trim().length > 0) {
|
|
117
|
+
session.messages.push({
|
|
118
|
+
role: "assistant",
|
|
119
|
+
content: redactInto(session, raw),
|
|
120
|
+
timestamp
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (earliestTs)
|
|
127
|
+
session.meta.startedAt = earliestTs;
|
|
128
|
+
session.meta.lastUpdatedAt = latestTs ?? new Date().toISOString();
|
|
129
|
+
return session;
|
|
130
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function defaultClaudeHome() {
|
|
4
|
+
return path.join(os.homedir(), ".claude");
|
|
5
|
+
}
|
|
6
|
+
export function candidateClaudeHomes() {
|
|
7
|
+
const home = os.homedir();
|
|
8
|
+
return [
|
|
9
|
+
path.join(home, ".claude"),
|
|
10
|
+
path.join(home, ".config", "claude"),
|
|
11
|
+
path.join(home, ".local", "share", "claude")
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
export function claudeProjectsGlobs(claudeHome) {
|
|
15
|
+
return [
|
|
16
|
+
path.join(claudeHome, "projects", "**", "*.jsonl"),
|
|
17
|
+
path.join(claudeHome, "sessions", "**", "*.jsonl"),
|
|
18
|
+
path.join(claudeHome, "history", "**", "*.jsonl")
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
22
|
+
export function sessionIdFromFilename(filePath) {
|
|
23
|
+
const base = path.basename(filePath, path.extname(filePath));
|
|
24
|
+
const match = base.match(UUID_RE);
|
|
25
|
+
return match ? match[0] : undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { discoverClaudeSessions } from "./discover.js";
|
|
2
|
+
const CLAUDE_PREFIX = "claude:";
|
|
3
|
+
export async function resolveClaudeTarget(target, options = {}) {
|
|
4
|
+
if (!target.startsWith(CLAUDE_PREFIX)) {
|
|
5
|
+
throw new Error(`Invalid Claude target '${target}'. Expected 'claude:last' or 'claude:<sessionId>'.`);
|
|
6
|
+
}
|
|
7
|
+
const rest = target.slice(CLAUDE_PREFIX.length);
|
|
8
|
+
if (!rest) {
|
|
9
|
+
throw new Error(`Invalid Claude target '${target}'. Expected 'claude:last' or 'claude:<sessionId>'.`);
|
|
10
|
+
}
|
|
11
|
+
const sessions = await discoverClaudeSessions(options.claudeHomes);
|
|
12
|
+
if (sessions.length === 0) {
|
|
13
|
+
throw new Error("No Claude Code session files found. Looked under ~/.claude, ~/.config/claude, and ~/.local/share/claude.");
|
|
14
|
+
}
|
|
15
|
+
if (rest === "last")
|
|
16
|
+
return sessions[0].path;
|
|
17
|
+
return resolveBySessionId(rest, sessions);
|
|
18
|
+
}
|
|
19
|
+
function resolveBySessionId(id, sessions) {
|
|
20
|
+
const withIds = sessions.filter((s) => !!s.sessionId);
|
|
21
|
+
const exact = withIds.find((s) => s.sessionId === id);
|
|
22
|
+
if (exact)
|
|
23
|
+
return exact.path;
|
|
24
|
+
const prefixMatches = withIds.filter((s) => s.sessionId.startsWith(id));
|
|
25
|
+
if (prefixMatches.length === 1)
|
|
26
|
+
return prefixMatches[0].path;
|
|
27
|
+
if (prefixMatches.length > 1) {
|
|
28
|
+
const list = prefixMatches
|
|
29
|
+
.map((s) => ` - ${s.sessionId} (${s.path})`)
|
|
30
|
+
.join("\n");
|
|
31
|
+
throw new Error(`Ambiguous Claude sessionId prefix '${id}'. Matches ${prefixMatches.length} sessions:\n${list}`);
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`No Claude session found with sessionId matching '${id}'.`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
function jsType(v) {
|
|
5
|
+
if (v === null)
|
|
6
|
+
return "null";
|
|
7
|
+
if (Array.isArray(v))
|
|
8
|
+
return "array";
|
|
9
|
+
return typeof v;
|
|
10
|
+
}
|
|
11
|
+
function mergeType(existing, next) {
|
|
12
|
+
if (!existing)
|
|
13
|
+
return next;
|
|
14
|
+
if (existing === next)
|
|
15
|
+
return existing;
|
|
16
|
+
const parts = new Set(existing.split("|"));
|
|
17
|
+
parts.add(next);
|
|
18
|
+
return Array.from(parts).sort().join("|");
|
|
19
|
+
}
|
|
20
|
+
export async function inspectClaudeShape(filePath) {
|
|
21
|
+
const stat = await fsp.stat(filePath);
|
|
22
|
+
const report = {
|
|
23
|
+
path: filePath,
|
|
24
|
+
sizeBytes: stat.size,
|
|
25
|
+
totalLines: 0,
|
|
26
|
+
parsedLines: 0,
|
|
27
|
+
malformedLines: 0,
|
|
28
|
+
topLevelKeyFrequency: {},
|
|
29
|
+
typeCounts: {},
|
|
30
|
+
roleCounts: {},
|
|
31
|
+
shapeByType: {},
|
|
32
|
+
cwdValues: [],
|
|
33
|
+
projectPathValues: []
|
|
34
|
+
};
|
|
35
|
+
const cwdSet = new Set();
|
|
36
|
+
const projectPathSet = new Set();
|
|
37
|
+
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
38
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
39
|
+
for await (const line of rl) {
|
|
40
|
+
if (!line.trim())
|
|
41
|
+
continue;
|
|
42
|
+
report.totalLines += 1;
|
|
43
|
+
let obj;
|
|
44
|
+
try {
|
|
45
|
+
obj = JSON.parse(line);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
report.malformedLines += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
52
|
+
report.malformedLines += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
report.parsedLines += 1;
|
|
56
|
+
const typeLabel = typeof obj.type === "string" && obj.type.length > 0
|
|
57
|
+
? obj.type
|
|
58
|
+
: "<no-type>";
|
|
59
|
+
report.typeCounts[typeLabel] = (report.typeCounts[typeLabel] ?? 0) + 1;
|
|
60
|
+
const role = typeof obj?.message?.role === "string"
|
|
61
|
+
? obj.message.role
|
|
62
|
+
: typeof obj?.role === "string"
|
|
63
|
+
? obj.role
|
|
64
|
+
: undefined;
|
|
65
|
+
if (role) {
|
|
66
|
+
report.roleCounts[role] = (report.roleCounts[role] ?? 0) + 1;
|
|
67
|
+
}
|
|
68
|
+
for (const key of Object.keys(obj)) {
|
|
69
|
+
report.topLevelKeyFrequency[key] =
|
|
70
|
+
(report.topLevelKeyFrequency[key] ?? 0) + 1;
|
|
71
|
+
}
|
|
72
|
+
const shape = (report.shapeByType[typeLabel] ??= {});
|
|
73
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
74
|
+
shape[k] = mergeType(shape[k], jsType(v));
|
|
75
|
+
}
|
|
76
|
+
if (typeof obj.cwd === "string" && obj.cwd.length > 0) {
|
|
77
|
+
cwdSet.add(obj.cwd);
|
|
78
|
+
}
|
|
79
|
+
if (typeof obj.projectPath === "string" && obj.projectPath.length > 0) {
|
|
80
|
+
projectPathSet.add(obj.projectPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
report.cwdValues = Array.from(cwdSet).sort();
|
|
84
|
+
report.projectPathValues = Array.from(projectPathSet).sort();
|
|
85
|
+
return report;
|
|
86
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { codexSessionsGlob, defaultCodexHome, parseRolloutFilename } from "./paths.js";
|
|
4
|
+
export async function discoverCodexSessions(codexHome = defaultCodexHome()) {
|
|
5
|
+
const pattern = codexSessionsGlob(codexHome);
|
|
6
|
+
const files = await fg(pattern, { onlyFiles: true, dot: true });
|
|
7
|
+
const sessions = [];
|
|
8
|
+
for (const file of files) {
|
|
9
|
+
const parsed = parseRolloutFilename(file);
|
|
10
|
+
if (!parsed)
|
|
11
|
+
continue;
|
|
12
|
+
const stat = await fs.stat(file);
|
|
13
|
+
sessions.push({
|
|
14
|
+
sourceCli: "codex",
|
|
15
|
+
conversationId: parsed.conversationId,
|
|
16
|
+
path: file,
|
|
17
|
+
startedAt: parsed.startedAt,
|
|
18
|
+
lastUpdatedAt: stat.mtime.toISOString(),
|
|
19
|
+
sizeBytes: stat.size
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return sessions.sort((a, b) => {
|
|
23
|
+
const at = a.lastUpdatedAt ? new Date(a.lastUpdatedAt).getTime() : 0;
|
|
24
|
+
const bt = b.lastUpdatedAt ? new Date(b.lastUpdatedAt).getTime() : 0;
|
|
25
|
+
return bt - at;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { discoverCodexSessions } from "./discover.js";
|
|
2
|
+
import { parseCodexRollout } from "./rollout.js";
|
|
3
|
+
import { resolveCodexTarget } from "./resolve.js";
|
|
4
|
+
export const CodexAdapter = {
|
|
5
|
+
async list() {
|
|
6
|
+
return discoverCodexSessions();
|
|
7
|
+
},
|
|
8
|
+
async latest() {
|
|
9
|
+
const sessions = await discoverCodexSessions();
|
|
10
|
+
return sessions[0] ?? null;
|
|
11
|
+
},
|
|
12
|
+
async resolve(target, codexHome) {
|
|
13
|
+
return resolveCodexTarget(target, { codexHome });
|
|
14
|
+
},
|
|
15
|
+
async inspect(sessionPath) {
|
|
16
|
+
return parseCodexRollout(sessionPath);
|
|
17
|
+
}
|
|
18
|
+
};
|