@kennykeni/agent-trace 0.1.2 → 0.1.3
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 +72 -14
- package/package.json +4 -2
- package/src/cli.ts +24 -3
- package/src/codex/index.ts +56 -0
- package/src/codex/ingestor.ts +427 -0
- package/src/codex/notify.ts +131 -0
- package/src/codex/stream.ts +25 -0
- package/src/core/git-utils.ts +91 -0
- package/src/core/ignore.ts +233 -0
- package/src/core/trace-hook.ts +85 -48
- package/src/core/trace-store.ts +86 -26
- package/src/extensions/diffs.ts +18 -14
- package/src/extensions/line-hashes.ts +1 -0
- package/src/install/codex.ts +52 -0
- package/src/install/config.ts +25 -0
- package/src/install/index.ts +8 -0
- package/src/providers/types.ts +1 -1
package/README.md
CHANGED
|
@@ -49,13 +49,16 @@ bunx @kennykeni/agent-trace status
|
|
|
49
49
|
|
|
50
50
|
## What `init` does
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Creates `.agent-trace/config.json` with default settings and configures the target repo's provider hooks:
|
|
53
53
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
54
|
+
| File | Purpose |
|
|
55
|
+
| --------------------------------------- | -------------------------------- |
|
|
56
|
+
| `.agent-trace/config.json` | Extensions and ignore settings |
|
|
57
|
+
| `.cursor/hooks.json` | Cursor hook registration |
|
|
58
|
+
| `.claude/settings.json` | Claude Code hook registration |
|
|
59
|
+
| `.opencode/plugins/agent-trace.ts` | OpenCode plugin registration |
|
|
60
|
+
|
|
61
|
+
Existing `config.json` files are never overwritten — only created when absent.
|
|
59
62
|
|
|
60
63
|
## How it works
|
|
61
64
|
|
|
@@ -72,20 +75,49 @@ Additional artifacts are written by extensions under `.agent-trace/`:
|
|
|
72
75
|
- `diffs/<provider>/<session>.patch` -- diff artifacts when available (`diffs` extension)
|
|
73
76
|
- `line-hashes/<provider>/<session>.jsonl` -- per-line content hashes (`line-hashes` extension)
|
|
74
77
|
|
|
75
|
-
##
|
|
76
|
-
|
|
77
|
-
Extensions are pluggable modules that run alongside the core trace pipeline. Four are built in: `raw-events`, `diffs`, `messages`, and `line-hashes`. All extensions are enabled by default.
|
|
78
|
+
## Configuration
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
`init` generates `.agent-trace/config.json` with these defaults:
|
|
80
81
|
|
|
81
82
|
```json
|
|
82
|
-
{
|
|
83
|
+
{
|
|
84
|
+
"extensions": ["diffs", "line-hashes", "raw-events", "messages"],
|
|
85
|
+
"useGitignore": true,
|
|
86
|
+
"useBuiltinSensitive": true,
|
|
87
|
+
"ignore": [],
|
|
88
|
+
"ignoreMode": "redact"
|
|
89
|
+
}
|
|
83
90
|
```
|
|
84
91
|
|
|
85
|
-
|
|
92
|
+
### Extensions
|
|
93
|
+
|
|
94
|
+
Extensions are pluggable modules that run alongside the core trace pipeline. Four are built in: `raw-events`, `diffs`, `messages`, and `line-hashes`.
|
|
95
|
+
|
|
86
96
|
- **`"extensions": ["diffs", "messages"]`** -- only listed extensions run
|
|
87
97
|
- **`"extensions": []`** -- no extensions run (only `traces.jsonl` is written)
|
|
88
|
-
|
|
98
|
+
|
|
99
|
+
### Sensitive file filtering
|
|
100
|
+
|
|
101
|
+
By default, agent-trace filters sensitive files to prevent secrets from leaking into trace artifacts. Filtering applies to `traces.jsonl`, diffs, line-hashes, and raw events.
|
|
102
|
+
|
|
103
|
+
| Field | Default | Description |
|
|
104
|
+
|-------|---------|-------------|
|
|
105
|
+
| `useGitignore` | `true` | Respect `.gitignore` patterns via `git check-ignore` |
|
|
106
|
+
| `useBuiltinSensitive` | `true` | Apply built-in sensitive file patterns |
|
|
107
|
+
| `ignore` | `[]` | Additional glob patterns to filter |
|
|
108
|
+
| `ignoreMode` | `"redact"` | `"redact"` keeps the trace entry with path but no content; `"skip"` drops the event entirely |
|
|
109
|
+
|
|
110
|
+
Built-in sensitive patterns match at any depth:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
.env .env.* *.pem *.key *.p12 *.pfx
|
|
114
|
+
id_rsa id_dsa id_ecdsa id_ed25519
|
|
115
|
+
*.kubeconfig credentials.*
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
When a file is **redacted**, the trace records the file path with empty ranges and `metadata.redacted: true`. Extensions see empty edits and produce no diff/hash artifacts. Raw events have sensitive fields (`old_string`, `new_string`, `content`, `before`, `after`, `originalFile`) replaced with `"[REDACTED]"`.
|
|
119
|
+
|
|
120
|
+
When a file is **skipped**, no trace entry, diff, hash, or raw event is written.
|
|
89
121
|
|
|
90
122
|
## Trace format
|
|
91
123
|
|
|
@@ -105,7 +137,33 @@ Schema source: [`schemas.ts`](./src/core/schemas.ts)
|
|
|
105
137
|
|
|
106
138
|
- **indexOf-based range attribution**: When the same text appears multiple times in a file, line-range attribution may point to the first occurrence rather than the actual edit location. Providers don't always supply line numbers, so `indexOf` is the best-effort fallback.
|
|
107
139
|
- **Bun-only**: The hook runtime and CLI require Bun. Node.js is not supported.
|
|
108
|
-
- **No VCS requirement**: Works without git. When git is available, traces include the current commit SHA. Without git, VCS info is omitted.
|
|
140
|
+
- **No VCS requirement**: Works without git. When git is available, traces include the current commit SHA. Without git, VCS info is omitted. `useGitignore` silently becomes a no-op in non-git repos.
|
|
141
|
+
- **Multi-file OpenCode events**: If any file in a `hook:tool.execute.after` payload is ignored, the entire raw event is redacted/skipped (conservative approach).
|
|
142
|
+
- **`.env.*` matches broadly**: `**/.env.*` matches `.env.example` and `.env.template` intentionally — these files sometimes contain real values.
|
|
143
|
+
|
|
144
|
+
## Provider quirks
|
|
145
|
+
|
|
146
|
+
### Cursor
|
|
147
|
+
|
|
148
|
+
- **Tab edits lack file content**: `afterTabFileEdit` events do not set `readContent`, so line-hashes for tab completions have no file context for position resolution.
|
|
149
|
+
- **Duration field ambiguity**: Shell events accept both `duration` and `duration_ms`. When both are present, `duration_ms` takes precedence.
|
|
150
|
+
|
|
151
|
+
### Claude Code
|
|
152
|
+
|
|
153
|
+
- **Model tracking limited to session start**: Claude Code only includes the `model` field in `SessionStart` hook payloads. Switching models mid-session via `/model` does not fire a hook event, so traces after a switch may reflect the original model. This is a Claude Code hook API limitation.
|
|
154
|
+
- **Only Write, Edit, and Bash traced**: Other tool uses (Read, Search, etc.) are not hooked and produce no trace events.
|
|
155
|
+
- **Write tool fallback**: When the `Write` tool payload has no `new_string`, falls back to `content`. When neither is present, an empty-edits trace is recorded.
|
|
156
|
+
|
|
157
|
+
### OpenCode
|
|
158
|
+
|
|
159
|
+
- **Two file-edit code paths**: `file.edited` events carry no diff data (`edits: []`, `diffs: false`). Only `hook:tool.execute.after` events include before/after content for diffs and line-hashes.
|
|
160
|
+
- **Flexible session ID extraction**: Session IDs can appear in five different payload locations depending on the event type. The adapter tries them all in priority order.
|
|
161
|
+
|
|
162
|
+
### Codex
|
|
163
|
+
|
|
164
|
+
- **Only `apply_patch` tool calls traced**: File changes from shell commands or other mechanisms are not detected. Same contract as other providers — only explicit tool-reported edits are traced.
|
|
165
|
+
- **Patch format stability**: `parsePatchInput` depends on Codex's `*** <Action> File:` patch grammar. If Codex changes the format, parsing fails silently.
|
|
166
|
+
- **`*** Move to:` (rename) blocks not parsed**: Rename operations in apply_patch are not traced. This is a rare edge case.
|
|
109
167
|
|
|
110
168
|
## Development
|
|
111
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kennykeni/agent-trace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-trace": "src/cli.ts"
|
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
"test": "bun test"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"diff": "^8.0.3",
|
|
25
26
|
"zod": "^3.24.0"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@biomejs/biome": "^2.3.14",
|
|
29
|
-
"@types/bun": "latest"
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"@types/diff": "^8.0.0"
|
|
30
32
|
}
|
|
31
33
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { runHook } from "./core/trace-hook";
|
|
6
7
|
import "./extensions";
|
|
@@ -21,21 +22,27 @@ Usage:
|
|
|
21
22
|
agent-trace <command> [options]
|
|
22
23
|
|
|
23
24
|
Commands:
|
|
24
|
-
init Initialize hooks for Cursor, Claude Code,
|
|
25
|
+
init Initialize hooks for Cursor, Claude Code, OpenCode, and Codex
|
|
25
26
|
hook Run the trace hook (reads JSON from stdin)
|
|
27
|
+
codex Codex subcommands (notify, ingest, exec)
|
|
26
28
|
status Show installed hook status
|
|
27
29
|
help Show this help message
|
|
28
30
|
|
|
29
31
|
Init options:
|
|
30
|
-
--providers <list> Comma-separated providers (cursor,claude,opencode) [default: all]
|
|
32
|
+
--providers <list> Comma-separated providers (cursor,claude,opencode,codex) [default: all]
|
|
31
33
|
--target-root <dir> Target project root [default: current directory]
|
|
32
34
|
--dry-run Preview changes without writing
|
|
33
35
|
--latest Use latest version instead of pinning to current
|
|
34
36
|
|
|
37
|
+
Codex subcommands:
|
|
38
|
+
codex notify '<json>' Handle Codex notify callback
|
|
39
|
+
codex ingest Read Codex JSONL from stdin
|
|
40
|
+
codex exec [args...] Wrap codex exec --json with tracing
|
|
41
|
+
|
|
35
42
|
Examples:
|
|
36
43
|
agent-trace init
|
|
37
44
|
agent-trace init --providers cursor
|
|
38
|
-
agent-trace init --providers
|
|
45
|
+
agent-trace init --providers codex
|
|
39
46
|
agent-trace init --target-root ~/my-project
|
|
40
47
|
agent-trace status`);
|
|
41
48
|
}
|
|
@@ -53,6 +60,12 @@ function checkHookConfig(
|
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
function codexConfigStatus(): "installed" | "not installed" {
|
|
64
|
+
const home = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
65
|
+
const configPath = join(home, "config.toml");
|
|
66
|
+
return checkHookConfig(configPath, "agent-trace");
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
function status(): void {
|
|
57
70
|
const root = getWorkspaceRoot();
|
|
58
71
|
|
|
@@ -69,6 +82,7 @@ function status(): void {
|
|
|
69
82
|
"agent-trace hook --provider claude",
|
|
70
83
|
);
|
|
71
84
|
const opencodeStatus = checkHookConfig(opencodePath, "agent-trace");
|
|
85
|
+
const codexStatus = codexConfigStatus();
|
|
72
86
|
const traceDir = join(root, ".agent-trace");
|
|
73
87
|
const hasTraces = existsSync(join(traceDir, "traces.jsonl"));
|
|
74
88
|
|
|
@@ -76,6 +90,7 @@ function status(): void {
|
|
|
76
90
|
console.log(`Cursor: ${cursorStatus}`);
|
|
77
91
|
console.log(`Claude: ${claudeStatus}`);
|
|
78
92
|
console.log(`OpenCode: ${opencodeStatus}`);
|
|
93
|
+
console.log(`Codex: ${codexStatus}`);
|
|
79
94
|
console.log(`Traces: ${hasTraces ? "present" : "none"}`);
|
|
80
95
|
}
|
|
81
96
|
|
|
@@ -99,6 +114,12 @@ switch (command) {
|
|
|
99
114
|
case "hook":
|
|
100
115
|
await runHook();
|
|
101
116
|
break;
|
|
117
|
+
case "codex": {
|
|
118
|
+
const { runCodexSubcommand } = await import("./codex");
|
|
119
|
+
const exitCode = await runCodexSubcommand(process.argv.slice(3));
|
|
120
|
+
process.exit(exitCode);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
102
123
|
case "status":
|
|
103
124
|
status();
|
|
104
125
|
break;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import "../extensions";
|
|
2
|
+
import { CodexTraceIngestor } from "./ingestor";
|
|
3
|
+
import { handleNotify } from "./notify";
|
|
4
|
+
import { streamLines } from "./stream";
|
|
5
|
+
|
|
6
|
+
async function ingestCodexJsonFromStdin(): Promise<number> {
|
|
7
|
+
const ingestor = new CodexTraceIngestor();
|
|
8
|
+
|
|
9
|
+
await streamLines(Bun.stdin.stream(), (line) => {
|
|
10
|
+
ingestor.processLine(line);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function runCodexExecWithTracing(args: string[]): Promise<number> {
|
|
17
|
+
const proc = Bun.spawn(["codex", "exec", "--json", ...args], {
|
|
18
|
+
stdin: "inherit",
|
|
19
|
+
stderr: "inherit",
|
|
20
|
+
stdout: "pipe",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ingestor = new CodexTraceIngestor();
|
|
24
|
+
|
|
25
|
+
await streamLines(proc.stdout, (line) => {
|
|
26
|
+
process.stdout.write(`${line}\n`);
|
|
27
|
+
ingestor.processLine(line);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const exitCode = await proc.exited;
|
|
31
|
+
return exitCode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runCodexSubcommand(args: string[]): Promise<number> {
|
|
35
|
+
const sub = args[0];
|
|
36
|
+
|
|
37
|
+
switch (sub) {
|
|
38
|
+
case "notify":
|
|
39
|
+
if (!args[1]) {
|
|
40
|
+
console.error("Usage: agent-trace codex notify '<json>'");
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
return handleNotify(args[1]);
|
|
44
|
+
|
|
45
|
+
case "ingest":
|
|
46
|
+
return ingestCodexJsonFromStdin();
|
|
47
|
+
|
|
48
|
+
case "exec":
|
|
49
|
+
return runCodexExecWithTracing(args.slice(1));
|
|
50
|
+
|
|
51
|
+
default:
|
|
52
|
+
console.error(`Unknown codex subcommand: ${sub ?? "(none)"}`);
|
|
53
|
+
console.error("Available: notify, ingest, exec");
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import type { IgnoreConfig } from "../core/ignore";
|
|
3
|
+
import { loadConfig } from "../core/ignore";
|
|
4
|
+
import { activeExtensions, dispatchTraceEvent } from "../core/trace-hook";
|
|
5
|
+
import { getWorkspaceRoot } from "../core/trace-store";
|
|
6
|
+
import type { FileEdit, TraceEvent } from "../core/types";
|
|
7
|
+
import { appendJsonl, sanitizeSessionId } from "../extensions/helpers";
|
|
8
|
+
|
|
9
|
+
const TOOL = { name: "codex-cli" } as const;
|
|
10
|
+
|
|
11
|
+
interface HunkLine {
|
|
12
|
+
type: "context" | "del" | "add";
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CONTEXT_LINES = 3;
|
|
17
|
+
const MERGE_GAP = 2 * CONTEXT_LINES;
|
|
18
|
+
|
|
19
|
+
export function clusterHunkLines(lines: HunkLine[]): FileEdit[] {
|
|
20
|
+
const changeIndices: number[] = [];
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i];
|
|
23
|
+
if (line && line.type !== "context") changeIndices.push(i);
|
|
24
|
+
}
|
|
25
|
+
if (changeIndices.length === 0) return [];
|
|
26
|
+
|
|
27
|
+
const regions: { start: number; end: number }[] = [];
|
|
28
|
+
const first = changeIndices[0];
|
|
29
|
+
if (first === undefined) return [];
|
|
30
|
+
let rStart = first;
|
|
31
|
+
let rEnd = rStart;
|
|
32
|
+
|
|
33
|
+
for (let i = 1; i < changeIndices.length; i++) {
|
|
34
|
+
const idx = changeIndices[i];
|
|
35
|
+
if (idx === undefined) continue;
|
|
36
|
+
if (idx === rEnd + 1) {
|
|
37
|
+
rEnd = idx;
|
|
38
|
+
} else {
|
|
39
|
+
let allContext = true;
|
|
40
|
+
for (let j = rEnd + 1; j < idx; j++) {
|
|
41
|
+
const jLine = lines[j];
|
|
42
|
+
if (jLine && jLine.type !== "context") {
|
|
43
|
+
allContext = false;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (allContext) {
|
|
48
|
+
regions.push({ start: rStart, end: rEnd });
|
|
49
|
+
rStart = idx;
|
|
50
|
+
rEnd = idx;
|
|
51
|
+
} else {
|
|
52
|
+
rEnd = idx;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
regions.push({ start: rStart, end: rEnd });
|
|
57
|
+
|
|
58
|
+
const firstRegion = regions[0];
|
|
59
|
+
if (!firstRegion) return [];
|
|
60
|
+
const merged: { start: number; end: number }[] = [firstRegion];
|
|
61
|
+
for (let i = 1; i < regions.length; i++) {
|
|
62
|
+
const prev = merged[merged.length - 1];
|
|
63
|
+
const curr = regions[i];
|
|
64
|
+
if (!prev || !curr) continue;
|
|
65
|
+
const gap = curr.start - prev.end - 1;
|
|
66
|
+
if (gap <= MERGE_GAP) {
|
|
67
|
+
prev.end = curr.end;
|
|
68
|
+
} else {
|
|
69
|
+
merged.push({ ...curr });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const edits: FileEdit[] = [];
|
|
74
|
+
for (const region of merged) {
|
|
75
|
+
const ctxStart = Math.max(0, region.start - CONTEXT_LINES);
|
|
76
|
+
const ctxEnd = Math.min(lines.length - 1, region.end + CONTEXT_LINES);
|
|
77
|
+
|
|
78
|
+
const oldParts: string[] = [];
|
|
79
|
+
const newParts: string[] = [];
|
|
80
|
+
for (let i = ctxStart; i <= ctxEnd; i++) {
|
|
81
|
+
const l = lines[i];
|
|
82
|
+
if (!l) continue;
|
|
83
|
+
if (l.type === "context") {
|
|
84
|
+
oldParts.push(l.text);
|
|
85
|
+
newParts.push(l.text);
|
|
86
|
+
} else if (l.type === "del") {
|
|
87
|
+
oldParts.push(l.text);
|
|
88
|
+
} else {
|
|
89
|
+
newParts.push(l.text);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
edits.push({
|
|
94
|
+
old_string: oldParts.join("\n"),
|
|
95
|
+
new_string: newParts.join("\n"),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return edits;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface IngestorState {
|
|
103
|
+
sessionId: string | undefined;
|
|
104
|
+
modelId: string | undefined;
|
|
105
|
+
turnIndex: number;
|
|
106
|
+
sessionStarted: boolean;
|
|
107
|
+
sessionEnded: boolean;
|
|
108
|
+
pendingUserPrompt: string | undefined;
|
|
109
|
+
lastAgentMessage: string | undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface RolloutLine {
|
|
113
|
+
timestamp?: string;
|
|
114
|
+
type: string;
|
|
115
|
+
payload: Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parsePatchInput(input: string): Map<string, FileEdit[]> {
|
|
119
|
+
const result = new Map<string, FileEdit[]>();
|
|
120
|
+
const filePattern = /^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+)$/;
|
|
121
|
+
const lines = input.split("\n");
|
|
122
|
+
let i = 0;
|
|
123
|
+
|
|
124
|
+
while (i < lines.length) {
|
|
125
|
+
const line = lines[i] ?? "";
|
|
126
|
+
const fileMatch = filePattern.exec(line);
|
|
127
|
+
if (!fileMatch) {
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const filePath = fileMatch[1]?.trim();
|
|
133
|
+
if (!filePath) {
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
i++;
|
|
139
|
+
const edits: FileEdit[] = [];
|
|
140
|
+
|
|
141
|
+
// Collect +lines between file header and first @@ (for Add File without hunk header)
|
|
142
|
+
const looseNewLines: string[] = [];
|
|
143
|
+
|
|
144
|
+
while (i < lines.length) {
|
|
145
|
+
const cur = lines[i] ?? "";
|
|
146
|
+
if (cur.startsWith("***")) break;
|
|
147
|
+
|
|
148
|
+
if (cur.startsWith("@@")) {
|
|
149
|
+
i++;
|
|
150
|
+
const hunkLines: HunkLine[] = [];
|
|
151
|
+
|
|
152
|
+
while (i < lines.length) {
|
|
153
|
+
const dl = lines[i] ?? "";
|
|
154
|
+
if (dl.startsWith("@@") || dl.startsWith("***")) break;
|
|
155
|
+
if (dl.startsWith("-")) {
|
|
156
|
+
hunkLines.push({ type: "del", text: dl.slice(1) });
|
|
157
|
+
} else if (dl.startsWith("+")) {
|
|
158
|
+
hunkLines.push({ type: "add", text: dl.slice(1) });
|
|
159
|
+
} else if (dl.startsWith(" ")) {
|
|
160
|
+
hunkLines.push({ type: "context", text: dl.slice(1) });
|
|
161
|
+
}
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
edits.push(...clusterHunkLines(hunkLines));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (cur.startsWith("+")) {
|
|
170
|
+
looseNewLines.push(cur.slice(1));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (looseNewLines.length > 0 && edits.length === 0) {
|
|
177
|
+
edits.push({
|
|
178
|
+
old_string: "",
|
|
179
|
+
new_string: looseNewLines.join("\n"),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const existing = result.get(filePath) ?? [];
|
|
184
|
+
result.set(filePath, [...existing, ...edits]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class CodexTraceIngestor {
|
|
191
|
+
sessionId: string | undefined;
|
|
192
|
+
modelId: string | undefined;
|
|
193
|
+
private transcriptPath: string | undefined;
|
|
194
|
+
private cachedIgnoreConfig: IgnoreConfig | undefined;
|
|
195
|
+
turnIndex = 0;
|
|
196
|
+
sessionStarted = false;
|
|
197
|
+
sessionEnded = false;
|
|
198
|
+
pendingUserPrompt: string | undefined;
|
|
199
|
+
lastAgentMessage: string | undefined;
|
|
200
|
+
|
|
201
|
+
constructor(transcriptPath?: string) {
|
|
202
|
+
this.transcriptPath = transcriptPath;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private getIgnoreConfig(): IgnoreConfig {
|
|
206
|
+
if (!this.cachedIgnoreConfig) {
|
|
207
|
+
this.cachedIgnoreConfig = loadConfig(getWorkspaceRoot()).ignore;
|
|
208
|
+
}
|
|
209
|
+
return this.cachedIgnoreConfig;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private emitTraceEvent(event: TraceEvent): void {
|
|
213
|
+
if (!this.sessionId) return;
|
|
214
|
+
const root = getWorkspaceRoot();
|
|
215
|
+
const config = loadConfig(root);
|
|
216
|
+
const extensions = activeExtensions(config.extensions);
|
|
217
|
+
dispatchTraceEvent(event, extensions, TOOL, this.getIgnoreConfig());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
restoreState(state: IngestorState): void {
|
|
221
|
+
this.sessionId = state.sessionId;
|
|
222
|
+
this.modelId = state.modelId;
|
|
223
|
+
this.turnIndex = state.turnIndex;
|
|
224
|
+
this.sessionStarted = state.sessionStarted;
|
|
225
|
+
this.sessionEnded = state.sessionEnded;
|
|
226
|
+
this.pendingUserPrompt = state.pendingUserPrompt;
|
|
227
|
+
this.lastAgentMessage = state.lastAgentMessage;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
snapshotState(): IngestorState {
|
|
231
|
+
return {
|
|
232
|
+
sessionId: this.sessionId,
|
|
233
|
+
modelId: this.modelId,
|
|
234
|
+
turnIndex: this.turnIndex,
|
|
235
|
+
sessionStarted: this.sessionStarted,
|
|
236
|
+
sessionEnded: this.sessionEnded,
|
|
237
|
+
pendingUserPrompt: this.pendingUserPrompt,
|
|
238
|
+
lastAgentMessage: this.lastAgentMessage,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
processLine(line: string): void {
|
|
243
|
+
let parsed: RolloutLine;
|
|
244
|
+
try {
|
|
245
|
+
parsed = JSON.parse(line) as RolloutLine;
|
|
246
|
+
} catch {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const outerType = parsed.type;
|
|
251
|
+
if (!outerType || !parsed.payload) return;
|
|
252
|
+
|
|
253
|
+
switch (outerType) {
|
|
254
|
+
case "session_meta":
|
|
255
|
+
this.onSessionMeta(parsed.payload);
|
|
256
|
+
break;
|
|
257
|
+
case "turn_context":
|
|
258
|
+
this.onTurnContext(parsed.payload);
|
|
259
|
+
break;
|
|
260
|
+
case "event_msg":
|
|
261
|
+
this.onEventMsg(parsed.payload);
|
|
262
|
+
break;
|
|
263
|
+
case "response_item":
|
|
264
|
+
this.onResponseItem(parsed.payload);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.appendRaw(parsed);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private appendRaw(event: RolloutLine): void {
|
|
272
|
+
if (!this.sessionId) return;
|
|
273
|
+
const root = getWorkspaceRoot();
|
|
274
|
+
const sid = sanitizeSessionId(this.sessionId);
|
|
275
|
+
const path = join(root, ".agent-trace", "raw", "codex", `${sid}.jsonl`);
|
|
276
|
+
appendJsonl(path, {
|
|
277
|
+
timestamp: new Date().toISOString(),
|
|
278
|
+
provider: "codex",
|
|
279
|
+
session_id: sid,
|
|
280
|
+
event,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private onSessionMeta(payload: Record<string, unknown>): void {
|
|
285
|
+
this.sessionId = (payload.id as string) ?? this.sessionId;
|
|
286
|
+
this.sessionStarted = true;
|
|
287
|
+
|
|
288
|
+
this.emitTraceEvent({
|
|
289
|
+
kind: "session_start",
|
|
290
|
+
provider: "codex",
|
|
291
|
+
sessionId: this.sessionId,
|
|
292
|
+
model: this.modelId,
|
|
293
|
+
meta: {
|
|
294
|
+
codex_session_id: this.sessionId,
|
|
295
|
+
cli_version: payload.cli_version,
|
|
296
|
+
model_provider: payload.model_provider,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private onTurnContext(payload: Record<string, unknown>): void {
|
|
302
|
+
const model = payload.model as string | undefined;
|
|
303
|
+
if (model) this.modelId = model;
|
|
304
|
+
this.turnIndex++;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private onEventMsg(payload: Record<string, unknown>): void {
|
|
308
|
+
const innerType = payload.type as string | undefined;
|
|
309
|
+
if (!innerType) return;
|
|
310
|
+
|
|
311
|
+
switch (innerType) {
|
|
312
|
+
case "user_message": {
|
|
313
|
+
const message = (payload.message as string) ?? undefined;
|
|
314
|
+
this.pendingUserPrompt = message;
|
|
315
|
+
if (message) {
|
|
316
|
+
this.emitTraceEvent({
|
|
317
|
+
kind: "message",
|
|
318
|
+
provider: "codex",
|
|
319
|
+
sessionId: this.sessionId,
|
|
320
|
+
role: "user",
|
|
321
|
+
content: message,
|
|
322
|
+
eventName: "user_message",
|
|
323
|
+
model: this.modelId,
|
|
324
|
+
meta: {
|
|
325
|
+
codex_session_id: this.sessionId,
|
|
326
|
+
turn_index: this.turnIndex,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "agent_message": {
|
|
333
|
+
const message = (payload.message as string) ?? undefined;
|
|
334
|
+
this.lastAgentMessage = message;
|
|
335
|
+
if (message) {
|
|
336
|
+
this.emitTraceEvent({
|
|
337
|
+
kind: "message",
|
|
338
|
+
provider: "codex",
|
|
339
|
+
sessionId: this.sessionId,
|
|
340
|
+
role: "assistant",
|
|
341
|
+
content: message,
|
|
342
|
+
eventName: "agent_message",
|
|
343
|
+
model: this.modelId,
|
|
344
|
+
meta: {
|
|
345
|
+
codex_session_id: this.sessionId,
|
|
346
|
+
turn_index: this.turnIndex,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private onResponseItem(payload: Record<string, unknown>): void {
|
|
356
|
+
const itemType = payload.type as string | undefined;
|
|
357
|
+
if (!itemType) return;
|
|
358
|
+
|
|
359
|
+
switch (itemType) {
|
|
360
|
+
case "custom_tool_call":
|
|
361
|
+
this.onToolCall(payload);
|
|
362
|
+
break;
|
|
363
|
+
case "function_call":
|
|
364
|
+
this.onFunctionCall(payload);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private onToolCall(payload: Record<string, unknown>): void {
|
|
370
|
+
const name = payload.name as string | undefined;
|
|
371
|
+
if (name !== "apply_patch") return;
|
|
372
|
+
|
|
373
|
+
const input = payload.input as string | undefined;
|
|
374
|
+
if (!input) return;
|
|
375
|
+
|
|
376
|
+
const parsed = parsePatchInput(input);
|
|
377
|
+
const root = getWorkspaceRoot();
|
|
378
|
+
|
|
379
|
+
for (const [filePath, edits] of parsed) {
|
|
380
|
+
this.emitTraceEvent({
|
|
381
|
+
kind: "file_edit",
|
|
382
|
+
provider: "codex",
|
|
383
|
+
sessionId: this.sessionId,
|
|
384
|
+
filePath: resolve(root, filePath),
|
|
385
|
+
edits,
|
|
386
|
+
model: this.modelId,
|
|
387
|
+
transcript: this.transcriptPath ?? undefined,
|
|
388
|
+
readContent: false,
|
|
389
|
+
eventName: "apply_patch",
|
|
390
|
+
meta: {
|
|
391
|
+
codex_session_id: this.sessionId,
|
|
392
|
+
turn_index: this.turnIndex,
|
|
393
|
+
source: "apply_patch",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private onFunctionCall(payload: Record<string, unknown>): void {
|
|
400
|
+
const name = payload.name as string | undefined;
|
|
401
|
+
if (name !== "exec_command") return;
|
|
402
|
+
|
|
403
|
+
let cmd: string | undefined;
|
|
404
|
+
const argsStr = payload.arguments as string | undefined;
|
|
405
|
+
if (argsStr) {
|
|
406
|
+
try {
|
|
407
|
+
const args = JSON.parse(argsStr) as Record<string, unknown>;
|
|
408
|
+
cmd = args.cmd as string | undefined;
|
|
409
|
+
} catch {
|
|
410
|
+
// ignore malformed arguments
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this.emitTraceEvent({
|
|
415
|
+
kind: "shell",
|
|
416
|
+
provider: "codex",
|
|
417
|
+
sessionId: this.sessionId,
|
|
418
|
+
model: this.modelId,
|
|
419
|
+
transcript: this.transcriptPath ?? undefined,
|
|
420
|
+
meta: {
|
|
421
|
+
codex_session_id: this.sessionId,
|
|
422
|
+
turn_index: this.turnIndex,
|
|
423
|
+
command: cmd,
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|