iteration-cli 0.7.3 → 0.8.1
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 +6 -2
- package/dist/auto-connect.d.ts +1 -0
- package/dist/auto-connect.js +18 -4
- package/dist/clerk-device-auth.js +1 -1
- package/dist/cli.js +63 -33
- package/dist/collector.js +32 -7
- package/dist/cursor-hook-entry.d.ts +1 -0
- package/dist/cursor-hook-entry.js +157 -0
- package/dist/cursor-hooks.d.ts +7 -0
- package/dist/cursor-hooks.js +163 -0
- package/dist/cursor-transcript.d.ts +8 -0
- package/dist/cursor-transcript.js +294 -0
- package/dist/redact-trace.d.ts +2 -0
- package/dist/redact-trace.js +182 -0
- package/dist/service.js +9 -3
- package/dist/status-view.d.ts +1 -0
- package/dist/status-view.js +6 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +125 -3
- package/dist/turn-summary.js +3 -1
- package/package.json +4 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { ensurePrivateDirectory } from "./storage.js";
|
|
6
|
+
export const CURSOR_HOOKS = [
|
|
7
|
+
"sessionStart",
|
|
8
|
+
"beforeSubmitPrompt",
|
|
9
|
+
"preToolUse",
|
|
10
|
+
"postToolUse",
|
|
11
|
+
"postToolUseFailure",
|
|
12
|
+
"afterAgentThought",
|
|
13
|
+
"afterAgentResponse",
|
|
14
|
+
"preCompact",
|
|
15
|
+
"stop",
|
|
16
|
+
"subagentStart",
|
|
17
|
+
"subagentStop",
|
|
18
|
+
"sessionEnd",
|
|
19
|
+
];
|
|
20
|
+
export function cursorHookDirectory(dataDirectory) {
|
|
21
|
+
return path.join(dataDirectory, "cursor-hooks");
|
|
22
|
+
}
|
|
23
|
+
export function cursorHookFailurePath(dataDirectory) {
|
|
24
|
+
return path.join(dataDirectory, "cursor-hook-failure.json");
|
|
25
|
+
}
|
|
26
|
+
export async function cursorHookFailureAtMs(dataDirectory) {
|
|
27
|
+
let contents;
|
|
28
|
+
try {
|
|
29
|
+
contents = await fs.readFile(cursorHookFailurePath(dataDirectory), "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
33
|
+
return null;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
let decoded;
|
|
37
|
+
try {
|
|
38
|
+
decoded = JSON.parse(contents);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new Error("Cursor hook failure status is invalid.", { cause: error });
|
|
42
|
+
}
|
|
43
|
+
const parsed = z
|
|
44
|
+
.object({ atMs: z.number().int().min(0) })
|
|
45
|
+
.strict()
|
|
46
|
+
.safeParse(decoded);
|
|
47
|
+
if (!parsed.success)
|
|
48
|
+
throw new Error("Cursor hook failure status is invalid.");
|
|
49
|
+
return parsed.data.atMs;
|
|
50
|
+
}
|
|
51
|
+
function shellQuote(value) {
|
|
52
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
53
|
+
}
|
|
54
|
+
function hookCommand(hook, dataDirectory) {
|
|
55
|
+
const entry = fileURLToPath(new URL("./cursor-hook-entry.js", import.meta.url));
|
|
56
|
+
return `${shellQuote(process.execPath)} ${shellQuote(entry)} ${shellQuote(hook)} ${shellQuote(dataDirectory)}`;
|
|
57
|
+
}
|
|
58
|
+
const hookEntrySchema = z.object({ command: z.string().optional() }).loose();
|
|
59
|
+
const hookFileSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
version: z.literal(1).optional(),
|
|
62
|
+
hooks: z.record(z.string(), z.array(hookEntrySchema)).optional(),
|
|
63
|
+
})
|
|
64
|
+
.loose();
|
|
65
|
+
async function readHooks(filename) {
|
|
66
|
+
let contents;
|
|
67
|
+
let mode = 0o600;
|
|
68
|
+
try {
|
|
69
|
+
contents = await fs.readFile(filename, "utf8");
|
|
70
|
+
mode = (await fs.stat(filename)).mode & 0o777;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
74
|
+
return { file: {}, mode };
|
|
75
|
+
throw new Error(`Could not read Cursor hooks at ${filename}.`, {
|
|
76
|
+
cause: error,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(contents);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new Error(`Cursor hooks at ${filename} are not valid JSON.`, {
|
|
85
|
+
cause: error,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const checked = hookFileSchema.safeParse(parsed);
|
|
89
|
+
if (!checked.success)
|
|
90
|
+
throw new Error(`Cursor hooks at ${filename} have an unexpected shape.`);
|
|
91
|
+
return { file: checked.data, mode };
|
|
92
|
+
}
|
|
93
|
+
async function writeHooks(filename, file, mode) {
|
|
94
|
+
await ensurePrivateDirectory(path.dirname(filename));
|
|
95
|
+
const target = await fs.realpath(filename).catch((error) => {
|
|
96
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
97
|
+
return filename;
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
const temporary = `${target}.iteration-${process.pid}.tmp`;
|
|
101
|
+
const handle = await fs.open(temporary, "w", mode);
|
|
102
|
+
try {
|
|
103
|
+
await handle.writeFile(`${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
104
|
+
await handle.sync();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await handle.close();
|
|
108
|
+
}
|
|
109
|
+
await fs.rename(temporary, target);
|
|
110
|
+
}
|
|
111
|
+
function entries(value) {
|
|
112
|
+
return value ?? [];
|
|
113
|
+
}
|
|
114
|
+
export async function installCursorHooks(cursorHome, dataDirectory) {
|
|
115
|
+
await ensurePrivateDirectory(cursorHookDirectory(dataDirectory));
|
|
116
|
+
const filename = path.join(cursorHome, "hooks.json");
|
|
117
|
+
const { file, mode } = await readHooks(filename);
|
|
118
|
+
const hooks = { ...file.hooks };
|
|
119
|
+
for (const hook of CURSOR_HOOKS) {
|
|
120
|
+
const command = hookCommand(hook, dataDirectory);
|
|
121
|
+
const existing = entries(hooks[hook]);
|
|
122
|
+
if (!existing.some((entry) => entry.command === command))
|
|
123
|
+
hooks[hook] = [...existing, { command }];
|
|
124
|
+
}
|
|
125
|
+
await writeHooks(filename, { ...file, version: 1, hooks }, mode);
|
|
126
|
+
}
|
|
127
|
+
export async function uninstallCursorHooks(cursorHome, dataDirectory) {
|
|
128
|
+
const filename = path.join(cursorHome, "hooks.json");
|
|
129
|
+
const { file, mode } = await readHooks(filename);
|
|
130
|
+
const hooks = { ...file.hooks };
|
|
131
|
+
let changed = false;
|
|
132
|
+
for (const hook of CURSOR_HOOKS) {
|
|
133
|
+
const existing = entries(hooks[hook]);
|
|
134
|
+
const remaining = existing.filter((entry) => entry.command !== hookCommand(hook, dataDirectory));
|
|
135
|
+
if (remaining.length !== existing.length) {
|
|
136
|
+
changed = true;
|
|
137
|
+
if (remaining.length === 0)
|
|
138
|
+
delete hooks[hook];
|
|
139
|
+
else
|
|
140
|
+
hooks[hook] = remaining;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (changed)
|
|
144
|
+
await writeHooks(filename, { ...file, hooks }, mode);
|
|
145
|
+
}
|
|
146
|
+
export async function cursorHookFiles(dataDirectory) {
|
|
147
|
+
const directory = cursorHookDirectory(dataDirectory);
|
|
148
|
+
let names;
|
|
149
|
+
try {
|
|
150
|
+
names = await fs.readdir(directory);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
154
|
+
return [];
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
if (names.length > 10_000)
|
|
158
|
+
throw new Error("Cursor hook queue exceeds the scan limit.");
|
|
159
|
+
return names
|
|
160
|
+
.filter((name) => /^[0-9a-f-]+\.jsonl$/i.test(name))
|
|
161
|
+
.sort()
|
|
162
|
+
.map((name) => path.join(directory, name));
|
|
163
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type TraceEvent } from "iteration-trace-contracts";
|
|
2
|
+
type ParseResult = Readonly<{
|
|
3
|
+
events: readonly TraceEvent[];
|
|
4
|
+
gaps: readonly string[];
|
|
5
|
+
}>;
|
|
6
|
+
export declare function parseCursorTranscriptLine(line: string, filename: string, byteOffset: number): ParseResult;
|
|
7
|
+
export declare function parseCursorHookLine(line: string, filename: string): ParseResult;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { parseTraceEventBatch, } from "iteration-trace-contracts";
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
function record(value) {
|
|
7
|
+
const parsed = z.record(z.string(), z.json()).safeParse(value);
|
|
8
|
+
return parsed.success ? parsed.data : null;
|
|
9
|
+
}
|
|
10
|
+
function string(value) {
|
|
11
|
+
const parsed = z.string().safeParse(value);
|
|
12
|
+
return parsed.success ? parsed.data : null;
|
|
13
|
+
}
|
|
14
|
+
function nonnegativeInteger(value) {
|
|
15
|
+
const parsed = z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(0)
|
|
19
|
+
.max(Number.MAX_SAFE_INTEGER)
|
|
20
|
+
.safeParse(value);
|
|
21
|
+
return parsed.success ? parsed.data : null;
|
|
22
|
+
}
|
|
23
|
+
function event(conversationId, filename, byteOffset, index, eventType, payload, callId = null, occurredAt = null) {
|
|
24
|
+
const hash = createHash("sha256")
|
|
25
|
+
.update(`${filename}:${byteOffset}:${index}:${eventType}`)
|
|
26
|
+
.digest("hex");
|
|
27
|
+
return {
|
|
28
|
+
eventId: `${String(index).padStart(4, "0")}:${hash}`,
|
|
29
|
+
sessionId: `cursor:${conversationId}`,
|
|
30
|
+
sequence: byteOffset * 1000 + index,
|
|
31
|
+
callId,
|
|
32
|
+
projectId: null,
|
|
33
|
+
occurredAt,
|
|
34
|
+
eventType,
|
|
35
|
+
payload,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function parseCursorTranscriptLine(line, filename, byteOffset) {
|
|
39
|
+
const conversationId = path.basename(filename, ".jsonl");
|
|
40
|
+
if (!UUID.test(conversationId) ||
|
|
41
|
+
path.basename(path.dirname(filename)) !== conversationId)
|
|
42
|
+
return { events: [], gaps: ["invalid_cursor_transcript_path"] };
|
|
43
|
+
let decoded;
|
|
44
|
+
try {
|
|
45
|
+
decoded = JSON.parse(line);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { events: [], gaps: ["malformed_jsonl_record"] };
|
|
49
|
+
}
|
|
50
|
+
const source = record(decoded);
|
|
51
|
+
if (source === null)
|
|
52
|
+
return { events: [], gaps: ["record_is_not_an_object"] };
|
|
53
|
+
const events = [];
|
|
54
|
+
const gaps = [];
|
|
55
|
+
if (byteOffset === 0) {
|
|
56
|
+
events.push({
|
|
57
|
+
...event(conversationId, filename, 0, 0, "session_meta", {
|
|
58
|
+
id: conversationId,
|
|
59
|
+
transcript_path: filename,
|
|
60
|
+
}),
|
|
61
|
+
run: {
|
|
62
|
+
kind: "identity",
|
|
63
|
+
id: conversationId,
|
|
64
|
+
parentId: null,
|
|
65
|
+
ownStart: true,
|
|
66
|
+
startOrdinal: null,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const role = string(source.role);
|
|
71
|
+
if (role === "user" || role === "assistant") {
|
|
72
|
+
const message = record(source.message);
|
|
73
|
+
if (message === null || !Array.isArray(message.content))
|
|
74
|
+
gaps.push("cursor_message_without_content");
|
|
75
|
+
else {
|
|
76
|
+
for (const [index, value] of message.content.entries()) {
|
|
77
|
+
const block = record(value);
|
|
78
|
+
if (block === null) {
|
|
79
|
+
gaps.push("invalid_cursor_content_block");
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const type = string(block.type);
|
|
83
|
+
const eventType = type === "tool_use"
|
|
84
|
+
? "tool_call"
|
|
85
|
+
: type === "thinking"
|
|
86
|
+
? "reasoning"
|
|
87
|
+
: type === "text"
|
|
88
|
+
? role === "user"
|
|
89
|
+
? "user_message"
|
|
90
|
+
: "assistant_message"
|
|
91
|
+
: "source_record";
|
|
92
|
+
events.push(event(conversationId, filename, byteOffset, index + 1, eventType, {
|
|
93
|
+
text: string(block.text) ?? string(block.thinking),
|
|
94
|
+
name: string(block.name),
|
|
95
|
+
input: block.input ?? null,
|
|
96
|
+
sourceType: type,
|
|
97
|
+
record: source,
|
|
98
|
+
}, string(block.id)));
|
|
99
|
+
if (type !== "text" && type !== "tool_use" && type !== "thinking")
|
|
100
|
+
gaps.push(`unsupported_cursor_content_block:${type ?? "unknown"}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (source.type === "turn_ended") {
|
|
105
|
+
events.push(event(conversationId, filename, byteOffset, 1, source.status === "success" ? "turn_complete" : "turn_aborted", { record: source }));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
gaps.push(`unsupported_cursor_record:${string(source.type) ?? "unknown"}`);
|
|
109
|
+
events.push(event(conversationId, filename, byteOffset, 1, "source_record", {
|
|
110
|
+
sourceType: string(source.type),
|
|
111
|
+
record: source,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
return validated(events, gaps);
|
|
115
|
+
}
|
|
116
|
+
export function parseCursorHookLine(line, filename) {
|
|
117
|
+
let decoded;
|
|
118
|
+
try {
|
|
119
|
+
decoded = JSON.parse(line);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { events: [], gaps: ["malformed_cursor_hook_record"] };
|
|
123
|
+
}
|
|
124
|
+
const wrapper = record(decoded);
|
|
125
|
+
const input = wrapper === null ? null : record(wrapper.input);
|
|
126
|
+
const conversationId = input === null ? null : string(input.conversation_id);
|
|
127
|
+
const hook = wrapper === null ? null : string(wrapper.hook);
|
|
128
|
+
const capturedAt = wrapper === null ? null : string(wrapper.capturedAt);
|
|
129
|
+
if (wrapper?.kind === "subagent_transcript_chunk") {
|
|
130
|
+
const subagent = record(wrapper.subagent);
|
|
131
|
+
const part = nonnegativeInteger(subagent?.part);
|
|
132
|
+
const totalParts = nonnegativeInteger(subagent?.totalParts);
|
|
133
|
+
if (conversationId === null ||
|
|
134
|
+
!UUID.test(conversationId) ||
|
|
135
|
+
capturedAt === null ||
|
|
136
|
+
Number.isNaN(Date.parse(capturedAt)) ||
|
|
137
|
+
subagent === null ||
|
|
138
|
+
part === null ||
|
|
139
|
+
totalParts === null ||
|
|
140
|
+
part < 1 ||
|
|
141
|
+
part > totalParts ||
|
|
142
|
+
string(subagent.base64) === null)
|
|
143
|
+
return { events: [], gaps: ["invalid_cursor_subagent_transcript_chunk"] };
|
|
144
|
+
const transcriptBytes = nonnegativeInteger(wrapper.transcriptBytes) ?? 0;
|
|
145
|
+
const candidate = event(conversationId, filename, transcriptBytes, part, "source_record", {
|
|
146
|
+
sourceType: "cursor_subagent_transcript",
|
|
147
|
+
text: string(subagent.text),
|
|
148
|
+
base64: string(subagent.base64),
|
|
149
|
+
path: string(subagent.path),
|
|
150
|
+
part,
|
|
151
|
+
totalParts,
|
|
152
|
+
parent_conversation_id: string(input?.parent_conversation_id),
|
|
153
|
+
subagent_id: string(input?.subagent_id),
|
|
154
|
+
}, null, capturedAt);
|
|
155
|
+
const hash = candidate.eventId.split(":").at(-1);
|
|
156
|
+
return validated([
|
|
157
|
+
{
|
|
158
|
+
...candidate,
|
|
159
|
+
sequence: transcriptBytes * 1000 + 500,
|
|
160
|
+
eventId: `${String(Date.parse(capturedAt)).padStart(13, "0")}:${String(2000 + part).padStart(4, "0")}:${hash}`,
|
|
161
|
+
},
|
|
162
|
+
], []);
|
|
163
|
+
}
|
|
164
|
+
if (input === null ||
|
|
165
|
+
conversationId === null ||
|
|
166
|
+
!UUID.test(conversationId) ||
|
|
167
|
+
hook === null ||
|
|
168
|
+
capturedAt === null ||
|
|
169
|
+
Number.isNaN(Date.parse(capturedAt)))
|
|
170
|
+
return { events: [], gaps: ["invalid_cursor_hook_record"] };
|
|
171
|
+
const transcriptBytes = wrapper?.transcriptBytes;
|
|
172
|
+
const sequence = nonnegativeInteger(transcriptBytes) ?? 0;
|
|
173
|
+
const events = [];
|
|
174
|
+
if (hook === "beforeSubmitPrompt" || hook === "sessionStart") {
|
|
175
|
+
const roots = Array.isArray(input.workspace_roots)
|
|
176
|
+
? input.workspace_roots
|
|
177
|
+
: [];
|
|
178
|
+
events.push({
|
|
179
|
+
...event(conversationId, filename, sequence, 0, "session_meta", {
|
|
180
|
+
id: conversationId,
|
|
181
|
+
cwd: string(roots[0]),
|
|
182
|
+
version: string(input.cursor_version),
|
|
183
|
+
model: string(input.model),
|
|
184
|
+
transcript_path: string(input.transcript_path),
|
|
185
|
+
}, null, capturedAt),
|
|
186
|
+
run: {
|
|
187
|
+
kind: "identity",
|
|
188
|
+
id: conversationId,
|
|
189
|
+
parentId: null,
|
|
190
|
+
ownStart: true,
|
|
191
|
+
startOrdinal: null,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const rawText = JSON.stringify(input);
|
|
196
|
+
if (Buffer.byteLength(rawText, "utf8") <= 500_000) {
|
|
197
|
+
events.push(event(conversationId, filename, sequence, 1, "source_record", {
|
|
198
|
+
sourceType: `cursor_hook:${hook}`,
|
|
199
|
+
record: input,
|
|
200
|
+
}, null, capturedAt));
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const chunks = splitText(rawText);
|
|
204
|
+
for (const [index, text] of chunks.entries())
|
|
205
|
+
events.push(event(conversationId, filename, sequence, index + 1, "source_record", {
|
|
206
|
+
sourceType: `cursor_hook:${hook}`,
|
|
207
|
+
text,
|
|
208
|
+
part: index + 1,
|
|
209
|
+
totalParts: chunks.length,
|
|
210
|
+
}, null, capturedAt));
|
|
211
|
+
}
|
|
212
|
+
const next = events.length + 1;
|
|
213
|
+
if (hook === "preToolUse") {
|
|
214
|
+
events.push(event(conversationId, filename, sequence, next, "tool_call", {
|
|
215
|
+
name: string(input.tool_name),
|
|
216
|
+
input: input.tool_input ?? null,
|
|
217
|
+
}, string(input.tool_use_id), capturedAt));
|
|
218
|
+
}
|
|
219
|
+
if (hook === "postToolUse" || hook === "postToolUseFailure") {
|
|
220
|
+
const output = string(input.tool_output) ??
|
|
221
|
+
string(input.error_message) ??
|
|
222
|
+
string(input.error) ??
|
|
223
|
+
(input.tool_output === undefined && input.error === undefined
|
|
224
|
+
? null
|
|
225
|
+
: JSON.stringify(input.tool_output ?? input.error));
|
|
226
|
+
const chunks = output === null ? [null] : splitText(output);
|
|
227
|
+
for (const [index, text] of chunks.entries())
|
|
228
|
+
events.push(event(conversationId, filename, sequence, next + index, "tool_result", {
|
|
229
|
+
text,
|
|
230
|
+
name: string(input.tool_name),
|
|
231
|
+
part: index + 1,
|
|
232
|
+
totalParts: chunks.length,
|
|
233
|
+
}, string(input.tool_use_id), capturedAt));
|
|
234
|
+
}
|
|
235
|
+
else if (hook === "afterAgentThought") {
|
|
236
|
+
events.push(event(conversationId, filename, sequence, next, "reasoning", {
|
|
237
|
+
text: string(input.text) ?? string(input.thought),
|
|
238
|
+
}, null, capturedAt));
|
|
239
|
+
}
|
|
240
|
+
else if (hook === "afterAgentResponse" &&
|
|
241
|
+
(input.usage !== undefined ||
|
|
242
|
+
input.input_tokens !== undefined ||
|
|
243
|
+
input.output_tokens !== undefined)) {
|
|
244
|
+
events.push(event(conversationId, filename, sequence, next, "token_usage_record", {
|
|
245
|
+
model: string(input.model),
|
|
246
|
+
usage: input.usage ?? {
|
|
247
|
+
input_tokens: input.input_tokens ?? null,
|
|
248
|
+
output_tokens: input.output_tokens ?? null,
|
|
249
|
+
cache_read_tokens: input.cache_read_tokens ?? null,
|
|
250
|
+
cache_write_tokens: input.cache_write_tokens ?? null,
|
|
251
|
+
},
|
|
252
|
+
}, null, capturedAt));
|
|
253
|
+
}
|
|
254
|
+
const capturedAtMs = Date.parse(capturedAt);
|
|
255
|
+
const ordered = events.map((candidate, index) => {
|
|
256
|
+
const digest = candidate.eventId.split(":").at(-1);
|
|
257
|
+
const thought = candidate.eventType === "reasoning"
|
|
258
|
+
? createHash("sha256")
|
|
259
|
+
.update(`${conversationId}:${string(input.generation_id)?.slice(0, 36) ?? ""}:${candidate.payload.text}`)
|
|
260
|
+
.digest("hex")
|
|
261
|
+
: null;
|
|
262
|
+
return {
|
|
263
|
+
...candidate,
|
|
264
|
+
sequence: sequence * 1000 + 500,
|
|
265
|
+
eventId: thought === null
|
|
266
|
+
? `${String(capturedAtMs).padStart(13, "0")}:${String(index).padStart(4, "0")}:${digest}`
|
|
267
|
+
: `thought:${thought}`,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
return validated(ordered, []);
|
|
271
|
+
}
|
|
272
|
+
function splitText(value) {
|
|
273
|
+
if (value.length === 0)
|
|
274
|
+
return [""];
|
|
275
|
+
const chunks = [];
|
|
276
|
+
for (let start = 0; start < value.length;) {
|
|
277
|
+
let end = Math.min(start + 250_000, value.length);
|
|
278
|
+
if (end < value.length && /[\uD800-\uDBFF]/u.test(value[end - 1]))
|
|
279
|
+
end -= 1;
|
|
280
|
+
chunks.push(value.slice(start, end));
|
|
281
|
+
start = end;
|
|
282
|
+
}
|
|
283
|
+
return chunks;
|
|
284
|
+
}
|
|
285
|
+
function validated(events, gaps) {
|
|
286
|
+
const valid = [];
|
|
287
|
+
for (const candidate of events) {
|
|
288
|
+
if (parseTraceEventBatch({ events: [candidate] }).success)
|
|
289
|
+
valid.push(candidate);
|
|
290
|
+
else
|
|
291
|
+
gaps.push(`invalid_normalized_event:${candidate.eventType}`);
|
|
292
|
+
}
|
|
293
|
+
return { events: valid, gaps };
|
|
294
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { lintSource } from "@secretlint/core";
|
|
2
|
+
import { rules as recommendedRules } from "@secretlint/secretlint-rule-preset-recommend";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
const REDACTED = "[REDACTED]";
|
|
5
|
+
const secretlintConfig = {
|
|
6
|
+
rules: recommendedRules
|
|
7
|
+
.filter((rule) => rule.meta.id !== "@secretlint/secretlint-rule-filter-comments")
|
|
8
|
+
.map((rule) => ({ id: rule.meta.id, rule })),
|
|
9
|
+
};
|
|
10
|
+
const assignment = /\b([A-Za-z][A-Za-z0-9_-]{0,79})["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"'`,;}&]+))/g;
|
|
11
|
+
const authorization = /\b(?:authorization|proxy-authorization)["']?\s*:\s*["']?(?:Bearer|Basic)\s+([A-Za-z0-9._~+/-]+={0,2})/gi;
|
|
12
|
+
const privateKey = /-----BEGIN ((?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY)-----[\s\S]*?-----END \1-----/g;
|
|
13
|
+
function normalizedKey(key) {
|
|
14
|
+
return key
|
|
15
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
16
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
function sensitiveKey(key) {
|
|
20
|
+
const normalized = normalizedKey(key);
|
|
21
|
+
if (/^(?:next_public|public|vite|react_app)_/.test(normalized))
|
|
22
|
+
return false;
|
|
23
|
+
return (/(?:^|_)(?:secret|password|passwd|credential)(?:_|$)/.test(normalized) ||
|
|
24
|
+
/(?:^|_)(?:api_key|private_key|database_url|db_url|dsn|connection_string|authorization|proxy_authorization|auth_header|cookie|set_cookie|token)$/.test(normalized));
|
|
25
|
+
}
|
|
26
|
+
function placeholder(value) {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
return (trimmed === REDACTED ||
|
|
29
|
+
/^(?:<[^>]+>|\$\{[^}]+\}|\*+|redacted|placeholder|example|changeme|undefined|null)$/i.test(trimmed) ||
|
|
30
|
+
/^(?:process\.env|env)\.[A-Za-z0-9_]+$/.test(trimmed));
|
|
31
|
+
}
|
|
32
|
+
function addRange(ranges, start, end, length) {
|
|
33
|
+
if (!Number.isSafeInteger(start) ||
|
|
34
|
+
!Number.isSafeInteger(end) ||
|
|
35
|
+
start < 0 ||
|
|
36
|
+
end > length ||
|
|
37
|
+
start >= end) {
|
|
38
|
+
throw new Error("Local trace secret scanner returned an invalid location.");
|
|
39
|
+
}
|
|
40
|
+
ranges.push({ start, end });
|
|
41
|
+
}
|
|
42
|
+
function textRanges(value) {
|
|
43
|
+
const ranges = [];
|
|
44
|
+
for (const match of value.matchAll(assignment)) {
|
|
45
|
+
const key = match[1];
|
|
46
|
+
const candidate = match[2] ?? match[3] ?? match[4];
|
|
47
|
+
if (key === undefined ||
|
|
48
|
+
candidate === undefined ||
|
|
49
|
+
candidate.length === 0 ||
|
|
50
|
+
!sensitiveKey(key) ||
|
|
51
|
+
placeholder(candidate) ||
|
|
52
|
+
((normalizedKey(key) === "authorization" ||
|
|
53
|
+
normalizedKey(key) === "proxy_authorization") &&
|
|
54
|
+
/^(?:Bearer|Basic)$/i.test(candidate)))
|
|
55
|
+
continue;
|
|
56
|
+
const start = match.index + match[0].lastIndexOf(candidate);
|
|
57
|
+
addRange(ranges, start, start + candidate.length, value.length);
|
|
58
|
+
}
|
|
59
|
+
for (const match of value.matchAll(authorization)) {
|
|
60
|
+
const candidate = match[1];
|
|
61
|
+
if (candidate === undefined || placeholder(candidate))
|
|
62
|
+
continue;
|
|
63
|
+
const start = match.index + match[0].lastIndexOf(candidate);
|
|
64
|
+
addRange(ranges, start, start + candidate.length, value.length);
|
|
65
|
+
}
|
|
66
|
+
for (const match of value.matchAll(privateKey)) {
|
|
67
|
+
addRange(ranges, match.index, match.index + match[0].length, value.length);
|
|
68
|
+
}
|
|
69
|
+
return ranges;
|
|
70
|
+
}
|
|
71
|
+
function replaceRanges(value, ranges) {
|
|
72
|
+
if (ranges.length === 0)
|
|
73
|
+
return value;
|
|
74
|
+
ranges.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
75
|
+
const merged = [];
|
|
76
|
+
for (const range of ranges) {
|
|
77
|
+
const previous = merged.at(-1);
|
|
78
|
+
if (previous !== undefined && range.start <= previous.end) {
|
|
79
|
+
merged[merged.length - 1] = {
|
|
80
|
+
start: previous.start,
|
|
81
|
+
end: Math.max(previous.end, range.end),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
merged.push(range);
|
|
86
|
+
}
|
|
87
|
+
let result = value;
|
|
88
|
+
for (let index = merged.length - 1; index >= 0; index -= 1) {
|
|
89
|
+
const range = merged[index];
|
|
90
|
+
if (range === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
result = `${result.slice(0, range.start)}${REDACTED}${result.slice(range.end)}`;
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
async function redactText(value) {
|
|
97
|
+
if (value.length === 0 || value === REDACTED)
|
|
98
|
+
return value;
|
|
99
|
+
const ranges = textRanges(value);
|
|
100
|
+
let result;
|
|
101
|
+
try {
|
|
102
|
+
result = await lintSource({
|
|
103
|
+
source: {
|
|
104
|
+
contentType: "text",
|
|
105
|
+
content: value,
|
|
106
|
+
filePath: "trace.txt",
|
|
107
|
+
ext: "txt",
|
|
108
|
+
},
|
|
109
|
+
options: {
|
|
110
|
+
maskSecrets: true,
|
|
111
|
+
noPhysicFilePath: true,
|
|
112
|
+
config: secretlintConfig,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error("Local trace secret scanning failed; the event was not queued.");
|
|
118
|
+
}
|
|
119
|
+
for (const message of result.messages) {
|
|
120
|
+
addRange(ranges, message.range[0], message.range[1], value.length);
|
|
121
|
+
}
|
|
122
|
+
return replaceRanges(value, ranges);
|
|
123
|
+
}
|
|
124
|
+
async function redactValue(value, cache, force) {
|
|
125
|
+
const parsedText = z.string().safeParse(value);
|
|
126
|
+
if (parsedText.success) {
|
|
127
|
+
const text = parsedText.data;
|
|
128
|
+
if (force)
|
|
129
|
+
return placeholder(text) ? text : REDACTED;
|
|
130
|
+
let result = cache.get(text);
|
|
131
|
+
if (result === undefined) {
|
|
132
|
+
result = redactText(text);
|
|
133
|
+
cache.set(text, result);
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
if (value === null || value === true || value === false)
|
|
138
|
+
return value;
|
|
139
|
+
const parsedNumber = z.number().safeParse(value);
|
|
140
|
+
if (parsedNumber.success)
|
|
141
|
+
return force ? REDACTED : parsedNumber.data;
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
const items = await Promise.all(value.map((item) => redactValue(item, cache, force)));
|
|
144
|
+
return items.every((item, index) => item === value[index]) ? value : items;
|
|
145
|
+
}
|
|
146
|
+
const record = value;
|
|
147
|
+
const entries = await Promise.all(Object.entries(record).map(async ([key, item]) => [
|
|
148
|
+
key,
|
|
149
|
+
await redactValue(item, cache, force || sensitiveKey(key)),
|
|
150
|
+
]));
|
|
151
|
+
return entries.every(([key, item]) => item === record[key])
|
|
152
|
+
? value
|
|
153
|
+
: Object.fromEntries(entries);
|
|
154
|
+
}
|
|
155
|
+
export async function redactTraceEvent(event) {
|
|
156
|
+
const cache = new Map();
|
|
157
|
+
if (event.payload.sourceType === "cursor_subagent_transcript") {
|
|
158
|
+
const parsed = z.string().safeParse(event.payload.base64);
|
|
159
|
+
if (!parsed.success)
|
|
160
|
+
throw new Error("Cursor transcript has invalid encoded content.");
|
|
161
|
+
const bytes = Buffer.from(parsed.data, "base64");
|
|
162
|
+
if (bytes.toString("base64") !== parsed.data)
|
|
163
|
+
throw new Error("Cursor transcript has invalid encoded content.");
|
|
164
|
+
const raw = bytes.toString("latin1");
|
|
165
|
+
const redacted = await redactText(raw);
|
|
166
|
+
const { base64: _encoded, ...remaining } = event.payload;
|
|
167
|
+
const payload = await redactValue(remaining, cache, false);
|
|
168
|
+
return {
|
|
169
|
+
...event,
|
|
170
|
+
payload: {
|
|
171
|
+
...payload,
|
|
172
|
+
base64: redacted === raw
|
|
173
|
+
? parsed.data
|
|
174
|
+
: Buffer.from(redacted, "latin1").toString("base64"),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const payload = await redactValue(event.payload, cache, false);
|
|
179
|
+
return payload === event.payload
|
|
180
|
+
? event
|
|
181
|
+
: { ...event, payload: payload };
|
|
182
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -5,7 +5,8 @@ import { connectNewDetectedSources, loadMachineConsent, } from "./auto-connect.j
|
|
|
5
5
|
import { collectorFetch } from "./collector-fetch.js";
|
|
6
6
|
import { collectorTick, TraceUploadError } from "./collector.js";
|
|
7
7
|
import { refreshTurnSummaries } from "./turn-summary.js";
|
|
8
|
-
import {
|
|
8
|
+
import { installCursorHooks } from "./cursor-hooks.js";
|
|
9
|
+
import { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureCursorHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
|
|
9
10
|
function errorCode(error) {
|
|
10
11
|
if (error instanceof Error && "code" in error)
|
|
11
12
|
return String(error.code);
|
|
@@ -125,7 +126,11 @@ export async function runCollector(directory, expectedSource) {
|
|
|
125
126
|
message: "Local collector status could not be read; collection continues.",
|
|
126
127
|
}));
|
|
127
128
|
}
|
|
128
|
-
if ("
|
|
129
|
+
if ("cursorHome" in config) {
|
|
130
|
+
await ensureCursorHistoryBackfill(config.cursorHome, paths);
|
|
131
|
+
await installCursorHooks(config.cursorHome, config.dataDirectory);
|
|
132
|
+
}
|
|
133
|
+
else if ("claudeHome" in config)
|
|
129
134
|
await ensureClaudeHistoryBackfill(config.claudeHome, paths);
|
|
130
135
|
else
|
|
131
136
|
await ensureHistoryBackfill(config.codexHome, paths);
|
|
@@ -208,7 +213,8 @@ export async function runCollector(directory, expectedSource) {
|
|
|
208
213
|
retryDelayMs = Math.min(retryDelayMs * 2, 60_000);
|
|
209
214
|
}
|
|
210
215
|
try {
|
|
211
|
-
|
|
216
|
+
if (source !== "cursor")
|
|
217
|
+
await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), collectorFetch, controller.signal);
|
|
212
218
|
}
|
|
213
219
|
catch (error) {
|
|
214
220
|
if (controller.signal.aborted)
|