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.
@@ -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,2 @@
1
+ import type { TraceEvent } from "iteration-trace-contracts";
2
+ export declare function redactTraceEvent(event: TraceEvent): Promise<TraceEvent>;
@@ -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 { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
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 ("claudeHome" in config)
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
- await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), collectorFetch, controller.signal);
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)