@kennykeni/agent-trace 0.1.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 +124 -0
- package/package.json +37 -0
- package/src/cli.ts +119 -0
- package/src/core/schemas.ts +138 -0
- package/src/core/trace-hook.ts +230 -0
- package/src/core/trace-store.ts +170 -0
- package/src/core/types.ts +93 -0
- package/src/core/utils.ts +85 -0
- package/src/extensions/diffs.ts +89 -0
- package/src/extensions/helpers.ts +19 -0
- package/src/extensions/index.ts +16 -0
- package/src/extensions/line-hashes.ts +67 -0
- package/src/extensions/messages.ts +48 -0
- package/src/extensions/raw-events.ts +30 -0
- package/src/install/args.ts +71 -0
- package/src/install/claude.ts +78 -0
- package/src/install/cursor.ts +50 -0
- package/src/install/index.ts +49 -0
- package/src/install/opencode.ts +18 -0
- package/src/install/templates/opencode-plugin.ts +207 -0
- package/src/install/types.ts +14 -0
- package/src/install/utils.ts +78 -0
- package/src/providers/claude.ts +130 -0
- package/src/providers/cursor.ts +127 -0
- package/src/providers/index.ts +19 -0
- package/src/providers/opencode.ts +239 -0
- package/src/providers/types.ts +6 -0
- package/src/providers/utils.ts +27 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
ContributorType,
|
|
6
|
+
Conversation,
|
|
7
|
+
Range,
|
|
8
|
+
TraceRecord,
|
|
9
|
+
Vcs,
|
|
10
|
+
} from "./schemas";
|
|
11
|
+
import { SPEC_VERSION } from "./schemas";
|
|
12
|
+
import type { FileEdit, RangePosition } from "./types";
|
|
13
|
+
import { ensureDir, normalizeNewlines, resolvePosition } from "./utils";
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
ContributorType,
|
|
17
|
+
Conversation,
|
|
18
|
+
Range,
|
|
19
|
+
TraceRecord,
|
|
20
|
+
Vcs,
|
|
21
|
+
VcsType,
|
|
22
|
+
} from "./schemas";
|
|
23
|
+
|
|
24
|
+
export type { FileEdit, RangePosition } from "./types";
|
|
25
|
+
|
|
26
|
+
function contentHash(text: string): string {
|
|
27
|
+
const h = Bun.hash.murmur32v3(text, 0) >>> 0;
|
|
28
|
+
return `murmur3:${h.toString(16).padStart(8, "0")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TRACE_PATH = ".agent-trace/traces.jsonl";
|
|
32
|
+
|
|
33
|
+
let cachedRoot: string | undefined;
|
|
34
|
+
|
|
35
|
+
export function getWorkspaceRoot(): string {
|
|
36
|
+
if (cachedRoot) return cachedRoot;
|
|
37
|
+
if (process.env.AGENT_TRACE_WORKSPACE_ROOT) {
|
|
38
|
+
cachedRoot = process.env.AGENT_TRACE_WORKSPACE_ROOT;
|
|
39
|
+
return cachedRoot;
|
|
40
|
+
}
|
|
41
|
+
if (process.env.CURSOR_PROJECT_DIR) {
|
|
42
|
+
cachedRoot = process.env.CURSOR_PROJECT_DIR;
|
|
43
|
+
return cachedRoot;
|
|
44
|
+
}
|
|
45
|
+
if (process.env.CLAUDE_PROJECT_DIR) {
|
|
46
|
+
cachedRoot = process.env.CLAUDE_PROJECT_DIR;
|
|
47
|
+
return cachedRoot;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
cachedRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
53
|
+
}).trim();
|
|
54
|
+
} catch {
|
|
55
|
+
cachedRoot = process.cwd();
|
|
56
|
+
}
|
|
57
|
+
return cachedRoot;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getVcsInfo(cwd: string): Vcs | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const revision = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
66
|
+
}).trim();
|
|
67
|
+
return { type: "git", revision };
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toRelativePath(
|
|
74
|
+
filePath: string,
|
|
75
|
+
root: string,
|
|
76
|
+
): string | undefined {
|
|
77
|
+
if (!isAbsolute(filePath)) {
|
|
78
|
+
const resolved = resolve(root, filePath);
|
|
79
|
+
const rel = relative(resolve(root), resolved);
|
|
80
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || rel === "")
|
|
81
|
+
return undefined;
|
|
82
|
+
return rel;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rootResolved = resolve(root);
|
|
86
|
+
const fileResolved = resolve(filePath);
|
|
87
|
+
const rel = relative(rootResolved, fileResolved);
|
|
88
|
+
|
|
89
|
+
// Outside-root paths are not repo-relative and should not be traced.
|
|
90
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A file path equal to root is not a valid file entry.
|
|
95
|
+
if (rel === "") return undefined;
|
|
96
|
+
|
|
97
|
+
return rel;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function computeRangePositions(
|
|
101
|
+
edits: FileEdit[],
|
|
102
|
+
fileContent?: string,
|
|
103
|
+
): RangePosition[] {
|
|
104
|
+
return edits
|
|
105
|
+
.filter((e) => e.new_string)
|
|
106
|
+
.map((edit) => {
|
|
107
|
+
const pos = resolvePosition(edit, fileContent);
|
|
108
|
+
return { ...pos, content_hash: contentHash(edit.new_string) };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createTrace(
|
|
113
|
+
type: ContributorType,
|
|
114
|
+
filePath: string,
|
|
115
|
+
opts: {
|
|
116
|
+
model?: string;
|
|
117
|
+
rangePositions?: RangePosition[];
|
|
118
|
+
transcript?: string | null;
|
|
119
|
+
tool?: { name: string; version?: string };
|
|
120
|
+
metadata?: Record<string, unknown>;
|
|
121
|
+
} = {},
|
|
122
|
+
): TraceRecord | undefined {
|
|
123
|
+
const root = getWorkspaceRoot();
|
|
124
|
+
const relativePath = toRelativePath(filePath, root);
|
|
125
|
+
if (!relativePath) return undefined;
|
|
126
|
+
const conversationUrl = opts.transcript
|
|
127
|
+
? `file://${opts.transcript}`
|
|
128
|
+
: undefined;
|
|
129
|
+
|
|
130
|
+
const ranges: Range[] = opts.rangePositions?.length
|
|
131
|
+
? opts.rangePositions.map((pos) => ({ ...pos }))
|
|
132
|
+
: [];
|
|
133
|
+
|
|
134
|
+
const conversation: Conversation = {
|
|
135
|
+
url: conversationUrl,
|
|
136
|
+
contributor: { type, model_id: opts.model },
|
|
137
|
+
ranges,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
version: SPEC_VERSION,
|
|
142
|
+
id: crypto.randomUUID(),
|
|
143
|
+
timestamp: new Date().toISOString(),
|
|
144
|
+
vcs: getVcsInfo(root),
|
|
145
|
+
tool: opts.tool,
|
|
146
|
+
files: [
|
|
147
|
+
{
|
|
148
|
+
path: relativePath,
|
|
149
|
+
conversations: [conversation],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
metadata: opts.metadata,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function appendTrace(trace: TraceRecord): void {
|
|
157
|
+
const root = getWorkspaceRoot();
|
|
158
|
+
const filePath = join(root, TRACE_PATH);
|
|
159
|
+
const dir = join(root, ".agent-trace");
|
|
160
|
+
ensureDir(dir);
|
|
161
|
+
appendFileSync(filePath, `${JSON.stringify(trace)}\n`, "utf-8");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function tryReadFile(path: string): string | undefined {
|
|
165
|
+
try {
|
|
166
|
+
return readFileSync(path, "utf-8");
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface FileEdit {
|
|
2
|
+
old_string: string;
|
|
3
|
+
new_string: string;
|
|
4
|
+
range?: {
|
|
5
|
+
start_line_number: number;
|
|
6
|
+
end_line_number: number;
|
|
7
|
+
start_column: number;
|
|
8
|
+
end_column: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RangePosition {
|
|
13
|
+
start_line: number;
|
|
14
|
+
end_line: number;
|
|
15
|
+
content_hash?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HookInput {
|
|
19
|
+
hook_event_name: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
session_id?: string;
|
|
22
|
+
conversation_id?: string;
|
|
23
|
+
generation_id?: string;
|
|
24
|
+
transcript_path?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TraceEvent =
|
|
28
|
+
| {
|
|
29
|
+
kind: "file_edit";
|
|
30
|
+
provider: string;
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
filePath: string;
|
|
33
|
+
edits: FileEdit[];
|
|
34
|
+
model?: string;
|
|
35
|
+
transcript?: string | null;
|
|
36
|
+
readContent?: boolean;
|
|
37
|
+
diffs?: boolean;
|
|
38
|
+
eventName: string;
|
|
39
|
+
tool?: { name: string; version?: string };
|
|
40
|
+
meta: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
kind: "shell";
|
|
44
|
+
provider: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
transcript?: string | null;
|
|
48
|
+
tool?: { name: string; version?: string };
|
|
49
|
+
meta: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
kind: "session_start";
|
|
53
|
+
provider: string;
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
tool?: { name: string; version?: string };
|
|
57
|
+
meta: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
kind: "session_end";
|
|
61
|
+
provider: string;
|
|
62
|
+
sessionId?: string;
|
|
63
|
+
model?: string;
|
|
64
|
+
tool?: { name: string; version?: string };
|
|
65
|
+
meta: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
kind: "message";
|
|
69
|
+
provider: string;
|
|
70
|
+
sessionId?: string;
|
|
71
|
+
role: "user" | "assistant" | "system";
|
|
72
|
+
content: string;
|
|
73
|
+
eventName: string;
|
|
74
|
+
model?: string;
|
|
75
|
+
tool?: { name: string; version?: string };
|
|
76
|
+
meta: Record<string, unknown>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export interface ProviderAdapter {
|
|
80
|
+
adapt(input: HookInput): TraceEvent | TraceEvent[] | undefined;
|
|
81
|
+
sessionIdFor(input: HookInput): string | undefined;
|
|
82
|
+
toolInfo?(): { name: string; version?: string };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Extension {
|
|
86
|
+
name: string;
|
|
87
|
+
onRawInput?(
|
|
88
|
+
provider: string,
|
|
89
|
+
sessionId: string | undefined,
|
|
90
|
+
input: HookInput,
|
|
91
|
+
): void;
|
|
92
|
+
onTraceEvent?(event: TraceEvent): void;
|
|
93
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { FileEdit } from "./types";
|
|
4
|
+
|
|
5
|
+
export function ensureDir(path: string): void {
|
|
6
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ensureParent(path: string): void {
|
|
10
|
+
ensureDir(dirname(path));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeNewlines(s: string): string {
|
|
14
|
+
return s.replace(/\r\n/g, "\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolvePosition(
|
|
18
|
+
edit: FileEdit,
|
|
19
|
+
fileContent?: string,
|
|
20
|
+
): { start_line: number; end_line: number } {
|
|
21
|
+
const lineCount = normalizeNewlines(edit.new_string).split("\n").length;
|
|
22
|
+
|
|
23
|
+
if (edit.range) {
|
|
24
|
+
return {
|
|
25
|
+
start_line: edit.range.start_line_number,
|
|
26
|
+
end_line: edit.range.end_line_number,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (fileContent) {
|
|
31
|
+
const idx = fileContent.indexOf(edit.new_string);
|
|
32
|
+
if (idx !== -1) {
|
|
33
|
+
const startLine = fileContent.substring(0, idx).split("\n").length;
|
|
34
|
+
return { start_line: startLine, end_line: startLine + lineCount - 1 };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { start_line: 1, end_line: lineCount };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function textFromUnknown(value: unknown): string | undefined {
|
|
42
|
+
if (typeof value === "string") return value;
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
const texts = value
|
|
45
|
+
.map((part) => textFromUnknown(part))
|
|
46
|
+
.filter((part): part is string => Boolean(part));
|
|
47
|
+
if (texts.length > 0) return texts.join("\n");
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (value && typeof value === "object") {
|
|
51
|
+
return stringFromObjectContent(value as Record<string, unknown>);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringFromObjectContent(
|
|
57
|
+
value: Record<string, unknown>,
|
|
58
|
+
): string | undefined {
|
|
59
|
+
const direct = value.text;
|
|
60
|
+
if (typeof direct === "string" && direct.trim()) return direct;
|
|
61
|
+
|
|
62
|
+
const content = value.content;
|
|
63
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
64
|
+
|
|
65
|
+
if (Array.isArray(content)) {
|
|
66
|
+
const texts = content
|
|
67
|
+
.map((part) => textFromUnknown(part))
|
|
68
|
+
.filter((part): part is string => Boolean(part));
|
|
69
|
+
if (texts.length > 0) return texts.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function maybeString(value: unknown): string | undefined {
|
|
76
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function safeRecord(
|
|
80
|
+
value: unknown,
|
|
81
|
+
): Record<string, unknown> | undefined {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
83
|
+
return undefined;
|
|
84
|
+
return value as Record<string, unknown>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { registerExtension } from "../core/trace-hook";
|
|
4
|
+
import { getWorkspaceRoot } from "../core/trace-store";
|
|
5
|
+
import { normalizeNewlines } from "../core/utils";
|
|
6
|
+
import { ensureParent, nowIso, sanitizeSessionId } from "./helpers";
|
|
7
|
+
|
|
8
|
+
const TRACE_ROOT_DIR = ".agent-trace";
|
|
9
|
+
|
|
10
|
+
function normalizeLines(input: string): string[] {
|
|
11
|
+
if (!input) return [];
|
|
12
|
+
return normalizeNewlines(input).split("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createPatchFromStrings(
|
|
16
|
+
filePath: string,
|
|
17
|
+
oldText: string | undefined,
|
|
18
|
+
newText: string | undefined,
|
|
19
|
+
): string | undefined {
|
|
20
|
+
if ((oldText ?? "") === (newText ?? "")) return undefined;
|
|
21
|
+
|
|
22
|
+
const oldExists = oldText !== undefined;
|
|
23
|
+
const newExists = newText !== undefined;
|
|
24
|
+
const oldNorm = oldText ?? "";
|
|
25
|
+
const newNorm = newText ?? "";
|
|
26
|
+
const oldLines = normalizeLines(oldNorm);
|
|
27
|
+
const newLines = normalizeLines(newNorm);
|
|
28
|
+
|
|
29
|
+
const fromFile = oldExists ? `a/${filePath}` : "/dev/null";
|
|
30
|
+
const toFile = newExists ? `b/${filePath}` : "/dev/null";
|
|
31
|
+
const oldStart = oldLines.length === 0 ? 0 : 1;
|
|
32
|
+
const newStart = newLines.length === 0 ? 0 : 1;
|
|
33
|
+
|
|
34
|
+
const lines: string[] = [
|
|
35
|
+
`diff --git a/${filePath} b/${filePath}`,
|
|
36
|
+
`--- ${fromFile}`,
|
|
37
|
+
`+++ ${toFile}`,
|
|
38
|
+
`@@ -${oldStart},${oldLines.length} +${newStart},${newLines.length} @@`,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
for (const line of oldLines) lines.push(`-${line}`);
|
|
42
|
+
for (const line of newLines) lines.push(`+${line}`);
|
|
43
|
+
|
|
44
|
+
return `${lines.join("\n")}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function appendDiffArtifact(
|
|
48
|
+
provider: string,
|
|
49
|
+
sessionId: string | undefined,
|
|
50
|
+
filePath: string,
|
|
51
|
+
eventName: string,
|
|
52
|
+
diff: string,
|
|
53
|
+
root = getWorkspaceRoot(),
|
|
54
|
+
): string {
|
|
55
|
+
const sid = sanitizeSessionId(sessionId);
|
|
56
|
+
const path = join(root, TRACE_ROOT_DIR, "diffs", provider, `${sid}.patch`);
|
|
57
|
+
ensureParent(path);
|
|
58
|
+
const section = [
|
|
59
|
+
`# event=${eventName} file=${filePath} timestamp=${nowIso()}`,
|
|
60
|
+
diff.trimEnd(),
|
|
61
|
+
"",
|
|
62
|
+
].join("\n");
|
|
63
|
+
appendFileSync(path, section, "utf-8");
|
|
64
|
+
return `file://${path}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
registerExtension({
|
|
68
|
+
name: "diffs",
|
|
69
|
+
onTraceEvent(event) {
|
|
70
|
+
if (event.kind !== "file_edit") return;
|
|
71
|
+
if (event.diffs === false) return;
|
|
72
|
+
for (const edit of event.edits) {
|
|
73
|
+
const diff = createPatchFromStrings(
|
|
74
|
+
event.filePath,
|
|
75
|
+
edit.old_string,
|
|
76
|
+
edit.new_string,
|
|
77
|
+
);
|
|
78
|
+
if (diff) {
|
|
79
|
+
appendDiffArtifact(
|
|
80
|
+
event.provider,
|
|
81
|
+
event.sessionId,
|
|
82
|
+
event.filePath,
|
|
83
|
+
event.eventName,
|
|
84
|
+
diff,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { ensureParent } from "../core/utils";
|
|
3
|
+
|
|
4
|
+
export { ensureDir, ensureParent } from "../core/utils";
|
|
5
|
+
|
|
6
|
+
export function appendJsonl(path: string, value: unknown): void {
|
|
7
|
+
ensureParent(path);
|
|
8
|
+
appendFileSync(path, `${JSON.stringify(value)}\n`, "utf-8");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function nowIso(): string {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sanitizeSessionId(sessionId?: string | null): string {
|
|
16
|
+
const raw = (sessionId ?? "unknown").trim();
|
|
17
|
+
if (!raw) return "unknown";
|
|
18
|
+
return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "./raw-events";
|
|
2
|
+
import "./diffs";
|
|
3
|
+
import "./messages";
|
|
4
|
+
import "./line-hashes";
|
|
5
|
+
|
|
6
|
+
export { appendDiffArtifact, createPatchFromStrings } from "./diffs";
|
|
7
|
+
export {
|
|
8
|
+
appendJsonl,
|
|
9
|
+
ensureDir,
|
|
10
|
+
ensureParent,
|
|
11
|
+
nowIso,
|
|
12
|
+
sanitizeSessionId,
|
|
13
|
+
} from "./helpers";
|
|
14
|
+
export { appendLineHashes } from "./line-hashes";
|
|
15
|
+
export { appendMessage, type MessageRecord } from "./messages";
|
|
16
|
+
export { appendRawEvent } from "./raw-events";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { registerExtension } from "../core/trace-hook";
|
|
3
|
+
import { getWorkspaceRoot, tryReadFile } from "../core/trace-store";
|
|
4
|
+
import type { FileEdit } from "../core/types";
|
|
5
|
+
import { normalizeNewlines, resolvePosition } from "../core/utils";
|
|
6
|
+
import { appendJsonl, nowIso, sanitizeSessionId } from "./helpers";
|
|
7
|
+
|
|
8
|
+
const TRACE_ROOT_DIR = ".agent-trace";
|
|
9
|
+
|
|
10
|
+
function lineHash(line: string): string {
|
|
11
|
+
const h = Bun.hash.murmur32v3(line, 0) >>> 0;
|
|
12
|
+
return `murmur3:${h.toString(16).padStart(8, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function appendLineHashes(
|
|
16
|
+
provider: string,
|
|
17
|
+
sessionId: string | undefined,
|
|
18
|
+
filePath: string,
|
|
19
|
+
eventName: string,
|
|
20
|
+
edits: FileEdit[],
|
|
21
|
+
fileContent?: string,
|
|
22
|
+
root = getWorkspaceRoot(),
|
|
23
|
+
): void {
|
|
24
|
+
const sid = sanitizeSessionId(sessionId);
|
|
25
|
+
const path = join(
|
|
26
|
+
root,
|
|
27
|
+
TRACE_ROOT_DIR,
|
|
28
|
+
"line-hashes",
|
|
29
|
+
provider,
|
|
30
|
+
`${sid}.jsonl`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
for (const edit of edits) {
|
|
34
|
+
if (!edit.new_string) continue;
|
|
35
|
+
|
|
36
|
+
const lines = normalizeNewlines(edit.new_string).split("\n");
|
|
37
|
+
const hashes = lines.map(lineHash);
|
|
38
|
+
const pos = resolvePosition(edit, fileContent);
|
|
39
|
+
|
|
40
|
+
appendJsonl(path, {
|
|
41
|
+
timestamp: nowIso(),
|
|
42
|
+
file: filePath,
|
|
43
|
+
event: eventName,
|
|
44
|
+
start_line: pos.start_line,
|
|
45
|
+
end_line: pos.end_line,
|
|
46
|
+
hashes,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
registerExtension({
|
|
52
|
+
name: "line-hashes",
|
|
53
|
+
onTraceEvent(event) {
|
|
54
|
+
if (event.kind !== "file_edit") return;
|
|
55
|
+
const fileContent = event.readContent
|
|
56
|
+
? tryReadFile(event.filePath)
|
|
57
|
+
: undefined;
|
|
58
|
+
appendLineHashes(
|
|
59
|
+
event.provider,
|
|
60
|
+
event.sessionId,
|
|
61
|
+
event.filePath,
|
|
62
|
+
event.eventName,
|
|
63
|
+
event.edits,
|
|
64
|
+
fileContent,
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { registerExtension } from "../core/trace-hook";
|
|
3
|
+
import { getWorkspaceRoot } from "../core/trace-store";
|
|
4
|
+
import { appendJsonl, nowIso, sanitizeSessionId } from "./helpers";
|
|
5
|
+
|
|
6
|
+
const TRACE_ROOT_DIR = ".agent-trace";
|
|
7
|
+
|
|
8
|
+
export interface MessageRecord {
|
|
9
|
+
role: "user" | "assistant" | "system";
|
|
10
|
+
content: string;
|
|
11
|
+
event: string;
|
|
12
|
+
model_id?: string;
|
|
13
|
+
metadata?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function appendMessage(
|
|
17
|
+
provider: string,
|
|
18
|
+
sessionId: string | undefined,
|
|
19
|
+
message: MessageRecord,
|
|
20
|
+
root = getWorkspaceRoot(),
|
|
21
|
+
): string {
|
|
22
|
+
const sid = sanitizeSessionId(sessionId);
|
|
23
|
+
const path = join(root, TRACE_ROOT_DIR, "messages", provider, `${sid}.jsonl`);
|
|
24
|
+
appendJsonl(path, {
|
|
25
|
+
id: crypto.randomUUID(),
|
|
26
|
+
timestamp: nowIso(),
|
|
27
|
+
provider,
|
|
28
|
+
session_id: sid,
|
|
29
|
+
...message,
|
|
30
|
+
});
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
registerExtension({
|
|
35
|
+
name: "messages",
|
|
36
|
+
onTraceEvent(event) {
|
|
37
|
+
if (event.kind !== "message") return;
|
|
38
|
+
const metadata =
|
|
39
|
+
Object.keys(event.meta).length > 0 ? event.meta : undefined;
|
|
40
|
+
appendMessage(event.provider, event.sessionId, {
|
|
41
|
+
role: event.role,
|
|
42
|
+
content: event.content,
|
|
43
|
+
event: event.eventName,
|
|
44
|
+
model_id: event.model,
|
|
45
|
+
metadata,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { registerExtension } from "../core/trace-hook";
|
|
3
|
+
import { getWorkspaceRoot } from "../core/trace-store";
|
|
4
|
+
import { appendJsonl, nowIso, sanitizeSessionId } from "./helpers";
|
|
5
|
+
|
|
6
|
+
const TRACE_ROOT_DIR = ".agent-trace";
|
|
7
|
+
|
|
8
|
+
export function appendRawEvent(
|
|
9
|
+
provider: string,
|
|
10
|
+
sessionId: string | undefined,
|
|
11
|
+
event: unknown,
|
|
12
|
+
root = getWorkspaceRoot(),
|
|
13
|
+
): string {
|
|
14
|
+
const sid = sanitizeSessionId(sessionId);
|
|
15
|
+
const path = join(root, TRACE_ROOT_DIR, "raw", provider, `${sid}.jsonl`);
|
|
16
|
+
appendJsonl(path, {
|
|
17
|
+
timestamp: nowIso(),
|
|
18
|
+
provider,
|
|
19
|
+
session_id: sid,
|
|
20
|
+
event,
|
|
21
|
+
});
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
registerExtension({
|
|
26
|
+
name: "raw-events",
|
|
27
|
+
onRawInput(provider, sessionId, input) {
|
|
28
|
+
appendRawEvent(provider, sessionId, input);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { parseArgs as nodeParseArgs } from "node:util";
|
|
3
|
+
import { getWorkspaceRoot } from "../core/trace-store";
|
|
4
|
+
import { isProvider, PROVIDERS, type Provider } from "../providers/types";
|
|
5
|
+
import type { InstallOptions } from "./types";
|
|
6
|
+
|
|
7
|
+
export class InstallError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "InstallError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizePath(path: string): string {
|
|
15
|
+
return resolve(path);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseProviders(raw?: string): Provider[] {
|
|
19
|
+
if (!raw) return [...PROVIDERS];
|
|
20
|
+
const values = raw
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((x) => x.trim().toLowerCase())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
const invalid = values.filter((value) => !isProvider(value));
|
|
25
|
+
if (invalid.length > 0) {
|
|
26
|
+
throw new InstallError(
|
|
27
|
+
`Invalid providers: ${invalid.join(", ")}. Valid values: ${PROVIDERS.join(", ")}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return values as Provider[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseArgs(argv: string[]): InstallOptions {
|
|
34
|
+
let values: {
|
|
35
|
+
providers?: string;
|
|
36
|
+
"target-root"?: string[];
|
|
37
|
+
"dry-run"?: boolean;
|
|
38
|
+
latest?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
({ values } = nodeParseArgs({
|
|
43
|
+
args: argv,
|
|
44
|
+
options: {
|
|
45
|
+
providers: { type: "string" },
|
|
46
|
+
"target-root": { type: "string", multiple: true },
|
|
47
|
+
"dry-run": { type: "boolean" },
|
|
48
|
+
latest: { type: "boolean" },
|
|
49
|
+
},
|
|
50
|
+
strict: true,
|
|
51
|
+
}));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof TypeError) {
|
|
54
|
+
throw new InstallError(err.message);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const providers = parseProviders(values.providers);
|
|
60
|
+
const dryRun = values["dry-run"] ?? false;
|
|
61
|
+
const pinVersion = !(values.latest ?? false);
|
|
62
|
+
|
|
63
|
+
const targetRoots = values["target-root"]?.map(normalizePath) ?? [];
|
|
64
|
+
if (targetRoots.length === 0) {
|
|
65
|
+
targetRoots.push(getWorkspaceRoot());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dedupedTargets = [...new Set(targetRoots.map(normalizePath))];
|
|
69
|
+
|
|
70
|
+
return { providers, dryRun, pinVersion, targetRoots: dedupedTargets };
|
|
71
|
+
}
|