@respan/cli 0.5.2 → 0.6.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/dist/commands/auth/login.js +2 -2
- package/dist/commands/integrate/claude-code.js +9 -28
- package/dist/commands/integrate/codex-cli.d.ts +8 -0
- package/dist/commands/integrate/codex-cli.js +123 -64
- package/dist/commands/integrate/gemini-cli.js +99 -53
- package/dist/hooks/claude-code.cjs +951 -0
- package/dist/hooks/claude-code.d.ts +1 -0
- package/dist/hooks/claude-code.js +641 -0
- package/dist/hooks/codex-cli.cjs +782 -0
- package/dist/hooks/codex-cli.d.ts +1 -0
- package/dist/hooks/codex-cli.js +457 -0
- package/dist/hooks/gemini-cli.cjs +825 -0
- package/dist/hooks/gemini-cli.d.ts +1 -0
- package/dist/hooks/gemini-cli.js +561 -0
- package/dist/hooks/shared.d.ts +82 -0
- package/dist/hooks/shared.js +461 -0
- package/dist/lib/integrate.d.ts +5 -1
- package/dist/lib/integrate.js +6 -2
- package/oclif.manifest.json +815 -814
- package/package.json +7 -3
- package/dist/assets/hook.py +0 -1052
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
|
|
25
|
+
// src/hooks/claude-code.ts
|
|
26
|
+
var fs2 = __toESM(require("node:fs"), 1);
|
|
27
|
+
var os2 = __toESM(require("node:os"), 1);
|
|
28
|
+
var path2 = __toESM(require("node:path"), 1);
|
|
29
|
+
|
|
30
|
+
// src/hooks/shared.ts
|
|
31
|
+
var fs = __toESM(require("node:fs"), 1);
|
|
32
|
+
var os = __toESM(require("node:os"), 1);
|
|
33
|
+
var path = __toESM(require("node:path"), 1);
|
|
34
|
+
function resolveTracesEndpoint(baseUrl) {
|
|
35
|
+
const DEFAULT = "https://api.respan.ai/api/v2/traces";
|
|
36
|
+
if (!baseUrl) return DEFAULT;
|
|
37
|
+
const normalized = baseUrl.replace(/\/+$/, "");
|
|
38
|
+
if (normalized.endsWith("/api")) return `${normalized}/v2/traces`;
|
|
39
|
+
return `${normalized}/api/v2/traces`;
|
|
40
|
+
}
|
|
41
|
+
var _logFile = null;
|
|
42
|
+
var _debug = false;
|
|
43
|
+
function initLogging(logFile, debug2) {
|
|
44
|
+
_logFile = logFile;
|
|
45
|
+
_debug = debug2;
|
|
46
|
+
const dir = path.dirname(logFile);
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
function log(level, message) {
|
|
50
|
+
if (!_logFile) return;
|
|
51
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
|
52
|
+
fs.appendFileSync(_logFile, `${ts} [${level}] ${message}
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
function debug(message) {
|
|
56
|
+
if (_debug) log("DEBUG", message);
|
|
57
|
+
}
|
|
58
|
+
var DEFAULT_BASE_URL = "https://api.respan.ai/api";
|
|
59
|
+
function resolveCredentials() {
|
|
60
|
+
let apiKey = process.env.RESPAN_API_KEY ?? "";
|
|
61
|
+
let baseUrl = process.env.RESPAN_BASE_URL ?? DEFAULT_BASE_URL;
|
|
62
|
+
if (!apiKey) {
|
|
63
|
+
const credsFile = path.join(os.homedir(), ".respan", "credentials.json");
|
|
64
|
+
if (fs.existsSync(credsFile)) {
|
|
65
|
+
try {
|
|
66
|
+
const creds = JSON.parse(fs.readFileSync(credsFile, "utf-8"));
|
|
67
|
+
const configFile = path.join(os.homedir(), ".respan", "config.json");
|
|
68
|
+
let profile = "default";
|
|
69
|
+
if (fs.existsSync(configFile)) {
|
|
70
|
+
const cfg = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
71
|
+
profile = cfg.activeProfile ?? "default";
|
|
72
|
+
}
|
|
73
|
+
const cred = creds[profile] ?? {};
|
|
74
|
+
apiKey = cred.apiKey ?? cred.accessToken ?? "";
|
|
75
|
+
if (!baseUrl || baseUrl === DEFAULT_BASE_URL) {
|
|
76
|
+
baseUrl = cred.baseUrl ?? baseUrl;
|
|
77
|
+
}
|
|
78
|
+
if (baseUrl && !baseUrl.replace(/\/+$/, "").endsWith("/api")) {
|
|
79
|
+
baseUrl = baseUrl.replace(/\/+$/, "") + "/api";
|
|
80
|
+
}
|
|
81
|
+
if (apiKey) {
|
|
82
|
+
debug(`Using API key from credentials.json (profile: ${profile})`);
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
debug(`Failed to read credentials.json: ${e}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!apiKey) return null;
|
|
90
|
+
return { apiKey, baseUrl };
|
|
91
|
+
}
|
|
92
|
+
var KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
93
|
+
"customer_id",
|
|
94
|
+
"span_name",
|
|
95
|
+
"workflow_name",
|
|
96
|
+
"base_url",
|
|
97
|
+
"project_id"
|
|
98
|
+
]);
|
|
99
|
+
function loadRespanConfig(configPath) {
|
|
100
|
+
if (!fs.existsSync(configPath)) {
|
|
101
|
+
return { fields: {}, properties: {} };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
105
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
106
|
+
return { fields: {}, properties: {} };
|
|
107
|
+
}
|
|
108
|
+
const fields = {};
|
|
109
|
+
const properties = {};
|
|
110
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
111
|
+
if (KNOWN_CONFIG_KEYS.has(k)) {
|
|
112
|
+
fields[k] = String(v);
|
|
113
|
+
} else {
|
|
114
|
+
properties[k] = String(v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { fields, properties };
|
|
118
|
+
} catch (e) {
|
|
119
|
+
debug(`Failed to load config from ${configPath}: ${e}`);
|
|
120
|
+
return { fields: {}, properties: {} };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function loadState(statePath) {
|
|
124
|
+
if (!fs.existsSync(statePath)) return {};
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(fs.readFileSync(statePath, "utf-8"));
|
|
127
|
+
} catch {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function saveState(statePath, state) {
|
|
132
|
+
const dir = path.dirname(statePath);
|
|
133
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
134
|
+
const tmpPath = statePath + ".tmp." + process.pid;
|
|
135
|
+
try {
|
|
136
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
|
|
137
|
+
fs.renameSync(tmpPath, statePath);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
try {
|
|
140
|
+
fs.unlinkSync(tmpPath);
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function acquireLock(lockPath, timeoutMs = 5e3) {
|
|
147
|
+
const deadline = Date.now() + timeoutMs;
|
|
148
|
+
while (Date.now() < deadline) {
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(lockPath);
|
|
151
|
+
return () => {
|
|
152
|
+
try {
|
|
153
|
+
fs.rmdirSync(lockPath);
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
const waitMs = Math.min(100, deadline - Date.now());
|
|
159
|
+
if (waitMs > 0) {
|
|
160
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
debug("Could not acquire lock within timeout, proceeding without lock");
|
|
165
|
+
return () => {
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function nowISO() {
|
|
169
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
170
|
+
}
|
|
171
|
+
function parseTimestamp(ts) {
|
|
172
|
+
try {
|
|
173
|
+
const d = new Date(ts);
|
|
174
|
+
return isNaN(d.getTime()) ? null : d;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function latencySeconds(start, end) {
|
|
180
|
+
const s = parseTimestamp(start);
|
|
181
|
+
const e = parseTimestamp(end);
|
|
182
|
+
if (s && e) return (e.getTime() - s.getTime()) / 1e3;
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
function truncate(text, maxChars = 4e3) {
|
|
186
|
+
if (text.length <= maxChars) return text;
|
|
187
|
+
return text.slice(0, maxChars) + "\n... (truncated)";
|
|
188
|
+
}
|
|
189
|
+
function addDefaultsToAll(spans) {
|
|
190
|
+
return spans;
|
|
191
|
+
}
|
|
192
|
+
function resolveSpanFields(config, defaults) {
|
|
193
|
+
const fields = config?.fields ?? {};
|
|
194
|
+
return {
|
|
195
|
+
workflowName: process.env.RESPAN_WORKFLOW_NAME ?? fields.workflow_name ?? defaults.workflowName,
|
|
196
|
+
spanName: process.env.RESPAN_SPAN_NAME ?? fields.span_name ?? defaults.spanName,
|
|
197
|
+
customerId: process.env.RESPAN_CUSTOMER_ID ?? fields.customer_id ?? ""
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function buildMetadata(config, base = {}) {
|
|
201
|
+
const metadata = { ...base };
|
|
202
|
+
if (config?.properties) {
|
|
203
|
+
Object.assign(metadata, config.properties);
|
|
204
|
+
}
|
|
205
|
+
const envMetadata = process.env.RESPAN_METADATA;
|
|
206
|
+
if (envMetadata) {
|
|
207
|
+
try {
|
|
208
|
+
const extra = JSON.parse(envMetadata);
|
|
209
|
+
if (typeof extra === "object" && extra !== null) {
|
|
210
|
+
Object.assign(metadata, extra);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return metadata;
|
|
216
|
+
}
|
|
217
|
+
function toOtlpValue(value) {
|
|
218
|
+
if (value === null || value === void 0) return null;
|
|
219
|
+
if (typeof value === "string") return { stringValue: value };
|
|
220
|
+
if (typeof value === "number") {
|
|
221
|
+
return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
222
|
+
}
|
|
223
|
+
if (typeof value === "boolean") return { boolValue: value };
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
const values = value.map(toOtlpValue).filter(Boolean);
|
|
226
|
+
return { arrayValue: { values } };
|
|
227
|
+
}
|
|
228
|
+
if (typeof value === "object") {
|
|
229
|
+
const values = Object.entries(value).map(([k, v]) => {
|
|
230
|
+
const converted = toOtlpValue(v);
|
|
231
|
+
return converted ? { key: k, value: converted } : null;
|
|
232
|
+
}).filter(Boolean);
|
|
233
|
+
return { kvlistValue: { values } };
|
|
234
|
+
}
|
|
235
|
+
return { stringValue: String(value) };
|
|
236
|
+
}
|
|
237
|
+
function toOtlpAttributes(attrs) {
|
|
238
|
+
const result = [];
|
|
239
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
240
|
+
if (value === null || value === void 0) continue;
|
|
241
|
+
const converted = toOtlpValue(value);
|
|
242
|
+
if (converted) result.push({ key, value: converted });
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
function isoToNanos(iso) {
|
|
247
|
+
const d = new Date(iso);
|
|
248
|
+
if (isNaN(d.getTime())) return "0";
|
|
249
|
+
return String(BigInt(d.getTime()) * 1000000n);
|
|
250
|
+
}
|
|
251
|
+
function stringToTraceId(s) {
|
|
252
|
+
let hash = 0;
|
|
253
|
+
for (let i = 0; i < s.length; i++) {
|
|
254
|
+
hash = (hash << 5) - hash + s.charCodeAt(i) | 0;
|
|
255
|
+
}
|
|
256
|
+
let hash2 = 0;
|
|
257
|
+
for (let i = s.length - 1; i >= 0; i--) {
|
|
258
|
+
hash2 = (hash2 << 7) - hash2 + s.charCodeAt(i) | 0;
|
|
259
|
+
}
|
|
260
|
+
const hex1 = (hash >>> 0).toString(16).padStart(8, "0");
|
|
261
|
+
const hex2 = (hash2 >>> 0).toString(16).padStart(8, "0");
|
|
262
|
+
const hex3 = (s.length * 2654435761 >>> 0).toString(16).padStart(8, "0");
|
|
263
|
+
const hex4 = ((hash ^ hash2) >>> 0).toString(16).padStart(8, "0");
|
|
264
|
+
return (hex1 + hex2 + hex3 + hex4).slice(0, 32);
|
|
265
|
+
}
|
|
266
|
+
function stringToSpanId(s) {
|
|
267
|
+
let hash = 0;
|
|
268
|
+
for (let i = 0; i < s.length; i++) {
|
|
269
|
+
hash = (hash << 5) - hash + s.charCodeAt(i) | 0;
|
|
270
|
+
}
|
|
271
|
+
const hex1 = (hash >>> 0).toString(16).padStart(8, "0");
|
|
272
|
+
let hash2 = 0;
|
|
273
|
+
for (let i = s.length - 1; i >= 0; i--) {
|
|
274
|
+
hash2 = (hash2 << 3) - hash2 + s.charCodeAt(i) | 0;
|
|
275
|
+
}
|
|
276
|
+
const hex2 = (hash2 >>> 0).toString(16).padStart(8, "0");
|
|
277
|
+
return (hex1 + hex2).slice(0, 16);
|
|
278
|
+
}
|
|
279
|
+
function toOtlpPayload(spans) {
|
|
280
|
+
const otlpSpans = spans.map((span) => {
|
|
281
|
+
const attrs = {};
|
|
282
|
+
if (span.thread_identifier) attrs["respan.threads.thread_identifier"] = span.thread_identifier;
|
|
283
|
+
if (span.customer_identifier) attrs["respan.customer_params.customer_identifier"] = span.customer_identifier;
|
|
284
|
+
if (span.span_workflow_name) attrs["traceloop.workflow.name"] = span.span_workflow_name;
|
|
285
|
+
if (span.span_path) attrs["traceloop.entity.path"] = span.span_path;
|
|
286
|
+
const isRoot = !span.span_parent_id;
|
|
287
|
+
const isLlm = span.span_name.includes(".chat");
|
|
288
|
+
const isTool = span.span_name.startsWith("Tool:");
|
|
289
|
+
const isThinking = span.span_name.startsWith("Thinking") || span.span_name === "Reasoning";
|
|
290
|
+
if (isLlm) {
|
|
291
|
+
attrs["traceloop.span.kind"] = "task";
|
|
292
|
+
attrs["llm.request.type"] = "chat";
|
|
293
|
+
} else if (isTool) {
|
|
294
|
+
attrs["traceloop.span.kind"] = "tool";
|
|
295
|
+
} else if (isRoot) {
|
|
296
|
+
attrs["traceloop.span.kind"] = "workflow";
|
|
297
|
+
} else if (isThinking) {
|
|
298
|
+
attrs["traceloop.span.kind"] = "task";
|
|
299
|
+
}
|
|
300
|
+
if (span.model) attrs["gen_ai.request.model"] = span.model;
|
|
301
|
+
if (span.provider_id) attrs["gen_ai.system"] = span.provider_id;
|
|
302
|
+
if (span.prompt_tokens !== void 0) attrs["gen_ai.usage.prompt_tokens"] = span.prompt_tokens;
|
|
303
|
+
if (span.completion_tokens !== void 0) attrs["gen_ai.usage.completion_tokens"] = span.completion_tokens;
|
|
304
|
+
if (span.total_tokens !== void 0) attrs["llm.usage.total_tokens"] = span.total_tokens;
|
|
305
|
+
if (span.input) attrs["traceloop.entity.input"] = span.input;
|
|
306
|
+
if (span.output) attrs["traceloop.entity.output"] = span.output;
|
|
307
|
+
if (span.metadata && Object.keys(span.metadata).length > 0) {
|
|
308
|
+
attrs["respan.metadata"] = JSON.stringify(span.metadata);
|
|
309
|
+
}
|
|
310
|
+
attrs["respan.entity.log_method"] = "ts_tracing";
|
|
311
|
+
const startNanos = isoToNanos(span.start_time);
|
|
312
|
+
let endNanos = isoToNanos(span.timestamp);
|
|
313
|
+
if (startNanos === endNanos && span.latency && span.latency > 0) {
|
|
314
|
+
const startMs = new Date(span.start_time).getTime();
|
|
315
|
+
endNanos = String(BigInt(Math.round(startMs + span.latency * 1e3)) * 1000000n);
|
|
316
|
+
}
|
|
317
|
+
const otlpSpan = {
|
|
318
|
+
traceId: stringToTraceId(span.trace_unique_id),
|
|
319
|
+
spanId: stringToSpanId(span.span_unique_id),
|
|
320
|
+
name: span.span_name,
|
|
321
|
+
kind: isLlm ? 3 : 1,
|
|
322
|
+
// 3=CLIENT (LLM calls), 1=INTERNAL
|
|
323
|
+
startTimeUnixNano: startNanos,
|
|
324
|
+
endTimeUnixNano: endNanos,
|
|
325
|
+
attributes: toOtlpAttributes(attrs),
|
|
326
|
+
status: { code: 1 }
|
|
327
|
+
// STATUS_CODE_OK
|
|
328
|
+
};
|
|
329
|
+
if (span.span_parent_id) {
|
|
330
|
+
otlpSpan.parentSpanId = stringToSpanId(span.span_parent_id);
|
|
331
|
+
}
|
|
332
|
+
return otlpSpan;
|
|
333
|
+
});
|
|
334
|
+
return {
|
|
335
|
+
resourceSpans: [{
|
|
336
|
+
resource: {
|
|
337
|
+
attributes: toOtlpAttributes({
|
|
338
|
+
"service.name": "respan-cli-hooks"
|
|
339
|
+
})
|
|
340
|
+
},
|
|
341
|
+
scopeSpans: [{
|
|
342
|
+
scope: { name: "respan-cli-hooks", version: "0.5.3" },
|
|
343
|
+
spans: otlpSpans
|
|
344
|
+
}]
|
|
345
|
+
}]
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async function sendSpans(spans, apiKey, baseUrl, context) {
|
|
349
|
+
const url = resolveTracesEndpoint(baseUrl);
|
|
350
|
+
const headers = {
|
|
351
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
352
|
+
"Content-Type": "application/json",
|
|
353
|
+
"X-Respan-Dogfood": "1"
|
|
354
|
+
// Anti-recursion: prevent trace loops on ingest
|
|
355
|
+
};
|
|
356
|
+
const payload = toOtlpPayload(spans);
|
|
357
|
+
const body = JSON.stringify(payload);
|
|
358
|
+
const spanNames = spans.map((s) => s.span_name);
|
|
359
|
+
debug(`Sending ${spans.length} spans (${body.length} bytes) to ${url} for ${context}: ${spanNames.join(", ")}`);
|
|
360
|
+
if (_debug) {
|
|
361
|
+
const debugDir = _logFile ? path.dirname(_logFile) : os.tmpdir();
|
|
362
|
+
const debugFile = path.join(debugDir, `respan_spans_${context.replace(/\s+/g, "_")}.json`);
|
|
363
|
+
fs.writeFileSync(debugFile, body);
|
|
364
|
+
debug(`Dumped OTLP payload to ${debugFile}`);
|
|
365
|
+
}
|
|
366
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
367
|
+
try {
|
|
368
|
+
const controller = new AbortController();
|
|
369
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
370
|
+
const response = await fetch(url, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers,
|
|
373
|
+
body,
|
|
374
|
+
signal: controller.signal
|
|
375
|
+
});
|
|
376
|
+
clearTimeout(timeout);
|
|
377
|
+
if (response.status < 400) {
|
|
378
|
+
const text = await response.text();
|
|
379
|
+
debug(`Sent ${spans.length} spans for ${context} (attempt ${attempt + 1}): ${text.slice(0, 300)}`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (response.status < 500) {
|
|
383
|
+
const text = await response.text();
|
|
384
|
+
log("ERROR", `Spans rejected for ${context}: HTTP ${response.status} - ${text.slice(0, 200)}`);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
debug(`Server error for ${context} (attempt ${attempt + 1}), retrying...`);
|
|
388
|
+
await sleep(1e3);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
if (attempt < 2) {
|
|
391
|
+
await sleep(1e3);
|
|
392
|
+
} else {
|
|
393
|
+
log("ERROR", `Failed to send spans for ${context}: ${e}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
log("ERROR", `Failed to send ${spans.length} spans for ${context} after 3 attempts`);
|
|
398
|
+
}
|
|
399
|
+
function sleep(ms) {
|
|
400
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/hooks/claude-code.ts
|
|
404
|
+
var STATE_DIR = path2.join(os2.homedir(), ".claude", "state");
|
|
405
|
+
var LOG_FILE = path2.join(STATE_DIR, "respan_hook.log");
|
|
406
|
+
var STATE_FILE = path2.join(STATE_DIR, "respan_state.json");
|
|
407
|
+
var LOCK_PATH = path2.join(STATE_DIR, "respan_hook.lock");
|
|
408
|
+
var DEBUG_MODE = (process.env.CC_RESPAN_DEBUG ?? "").toLowerCase() === "true";
|
|
409
|
+
var MAX_CHARS = parseInt(process.env.CC_RESPAN_MAX_CHARS ?? "4000", 10) || 4e3;
|
|
410
|
+
initLogging(LOG_FILE, DEBUG_MODE);
|
|
411
|
+
function getContent(msg) {
|
|
412
|
+
if (msg.message && typeof msg.message === "object") {
|
|
413
|
+
return msg.message.content;
|
|
414
|
+
}
|
|
415
|
+
return msg.content;
|
|
416
|
+
}
|
|
417
|
+
function isToolResult(msg) {
|
|
418
|
+
const content = getContent(msg);
|
|
419
|
+
if (Array.isArray(content)) {
|
|
420
|
+
return content.some(
|
|
421
|
+
(item) => typeof item === "object" && item !== null && item.type === "tool_result"
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
function getToolCalls(msg) {
|
|
427
|
+
const content = getContent(msg);
|
|
428
|
+
if (Array.isArray(content)) {
|
|
429
|
+
return content.filter(
|
|
430
|
+
(item) => typeof item === "object" && item !== null && item.type === "tool_use"
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return [];
|
|
434
|
+
}
|
|
435
|
+
function getTextContent(msg) {
|
|
436
|
+
const content = getContent(msg);
|
|
437
|
+
if (typeof content === "string") return content;
|
|
438
|
+
if (Array.isArray(content)) {
|
|
439
|
+
return content.map((item) => {
|
|
440
|
+
if (typeof item === "string") return item;
|
|
441
|
+
if (typeof item === "object" && item !== null) {
|
|
442
|
+
if (item.type === "text") return String(item.text ?? "");
|
|
443
|
+
}
|
|
444
|
+
return "";
|
|
445
|
+
}).filter(Boolean).join("\n");
|
|
446
|
+
}
|
|
447
|
+
return "";
|
|
448
|
+
}
|
|
449
|
+
function mergeAssistantParts(parts) {
|
|
450
|
+
if (parts.length === 0) return {};
|
|
451
|
+
const merged = [];
|
|
452
|
+
for (const part of parts) {
|
|
453
|
+
const content = getContent(part);
|
|
454
|
+
if (Array.isArray(content)) merged.push(...content);
|
|
455
|
+
else if (content) merged.push({ type: "text", text: String(content) });
|
|
456
|
+
}
|
|
457
|
+
const result = { ...parts[0] };
|
|
458
|
+
if (result.message && typeof result.message === "object") {
|
|
459
|
+
result.message = { ...result.message, content: merged };
|
|
460
|
+
} else {
|
|
461
|
+
result.content = merged;
|
|
462
|
+
}
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
function formatToolInput(toolName, toolInput) {
|
|
466
|
+
if (!toolInput) return "";
|
|
467
|
+
const input = toolInput;
|
|
468
|
+
if (["Write", "Edit", "MultiEdit"].includes(toolName) && typeof input === "object") {
|
|
469
|
+
const filePath = input.file_path ?? input.path ?? "";
|
|
470
|
+
const content = String(input.content ?? "");
|
|
471
|
+
let result = `File: ${filePath}
|
|
472
|
+
`;
|
|
473
|
+
if (content) {
|
|
474
|
+
const preview = content.length > 2e3 ? content.slice(0, 2e3) + "..." : content;
|
|
475
|
+
result += `Content:
|
|
476
|
+
${preview}`;
|
|
477
|
+
}
|
|
478
|
+
return truncate(result, MAX_CHARS);
|
|
479
|
+
}
|
|
480
|
+
if (toolName === "Read" && typeof input === "object") {
|
|
481
|
+
return `File: ${input.file_path ?? input.path ?? ""}`;
|
|
482
|
+
}
|
|
483
|
+
if (["Bash", "Shell"].includes(toolName) && typeof input === "object") {
|
|
484
|
+
return `Command: ${input.command ?? ""}`;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
return truncate(JSON.stringify(toolInput, null, 2), MAX_CHARS);
|
|
488
|
+
} catch {
|
|
489
|
+
return truncate(String(toolInput), MAX_CHARS);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function formatToolOutput(toolName, toolOutput) {
|
|
493
|
+
if (!toolOutput) return "";
|
|
494
|
+
if (typeof toolOutput === "string") return truncate(toolOutput, MAX_CHARS);
|
|
495
|
+
if (Array.isArray(toolOutput)) {
|
|
496
|
+
const parts = [];
|
|
497
|
+
let total = 0;
|
|
498
|
+
for (const item of toolOutput) {
|
|
499
|
+
if (typeof item === "object" && item !== null) {
|
|
500
|
+
const obj = item;
|
|
501
|
+
if (obj.type === "text") {
|
|
502
|
+
const text = String(obj.text ?? "");
|
|
503
|
+
if (total + text.length > MAX_CHARS) {
|
|
504
|
+
const remaining = MAX_CHARS - total;
|
|
505
|
+
if (remaining > 100) parts.push(text.slice(0, remaining) + "... (truncated)");
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
parts.push(text);
|
|
509
|
+
total += text.length;
|
|
510
|
+
} else if (obj.type === "image") {
|
|
511
|
+
parts.push("[Image output]");
|
|
512
|
+
}
|
|
513
|
+
} else if (typeof item === "string") {
|
|
514
|
+
if (total + item.length > MAX_CHARS) break;
|
|
515
|
+
parts.push(item);
|
|
516
|
+
total += item.length;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return parts.join("\n");
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
return truncate(JSON.stringify(toolOutput, null, 2), MAX_CHARS);
|
|
523
|
+
} catch {
|
|
524
|
+
return truncate(String(toolOutput), MAX_CHARS);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function createSpans(sessionId, turnNum, userMsg, assistantMsgs, toolResults, config) {
|
|
528
|
+
const spans = [];
|
|
529
|
+
const userText = getTextContent(userMsg);
|
|
530
|
+
const userTimestamp = String(userMsg.timestamp ?? "");
|
|
531
|
+
const textParts = assistantMsgs.map(getTextContent).filter(Boolean);
|
|
532
|
+
const finalOutput = textParts.join("\n");
|
|
533
|
+
let model = "claude";
|
|
534
|
+
let usage = null;
|
|
535
|
+
let requestId;
|
|
536
|
+
let stopReason;
|
|
537
|
+
let firstAssistantTs;
|
|
538
|
+
let lastAssistantTs;
|
|
539
|
+
for (const aMsg of assistantMsgs) {
|
|
540
|
+
if (typeof aMsg !== "object" || !aMsg.message) continue;
|
|
541
|
+
const msgObj = aMsg.message;
|
|
542
|
+
model = String(msgObj.model ?? model);
|
|
543
|
+
requestId = String(aMsg.requestId ?? requestId ?? "");
|
|
544
|
+
stopReason = String(msgObj.stop_reason ?? stopReason ?? "");
|
|
545
|
+
const ts = String(aMsg.timestamp ?? "");
|
|
546
|
+
if (ts) {
|
|
547
|
+
if (!firstAssistantTs) firstAssistantTs = ts;
|
|
548
|
+
lastAssistantTs = ts;
|
|
549
|
+
}
|
|
550
|
+
const msgUsage = msgObj.usage;
|
|
551
|
+
if (msgUsage) {
|
|
552
|
+
if (!usage) {
|
|
553
|
+
usage = { ...msgUsage };
|
|
554
|
+
} else {
|
|
555
|
+
for (const key of ["input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"]) {
|
|
556
|
+
if (key in msgUsage) {
|
|
557
|
+
usage[key] = (usage[key] ?? 0) + Number(msgUsage[key]);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (msgUsage.service_tier) usage.service_tier = msgUsage.service_tier;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const now = nowISO();
|
|
565
|
+
const startTimeStr = userTimestamp || firstAssistantTs || now;
|
|
566
|
+
const timestampStr = lastAssistantTs || firstAssistantTs || now;
|
|
567
|
+
const lat = latencySeconds(startTimeStr, timestampStr);
|
|
568
|
+
const promptMessages = [];
|
|
569
|
+
if (userText) promptMessages.push({ role: "user", content: userText });
|
|
570
|
+
const completionMessage = finalOutput ? { role: "assistant", content: finalOutput } : null;
|
|
571
|
+
const { workflowName, spanName, customerId } = resolveSpanFields(config, {
|
|
572
|
+
workflowName: "claude-code",
|
|
573
|
+
spanName: "claude-code"
|
|
574
|
+
});
|
|
575
|
+
const traceUniqueId = `${sessionId}_turn_${turnNum}`;
|
|
576
|
+
const threadId = `claudecode_${sessionId}`;
|
|
577
|
+
const metadata = buildMetadata(config, { claude_code_turn: turnNum });
|
|
578
|
+
if (requestId) metadata.request_id = requestId;
|
|
579
|
+
if (stopReason) metadata.stop_reason = stopReason;
|
|
580
|
+
const usageFields = {};
|
|
581
|
+
if (usage) {
|
|
582
|
+
const pt = Number(usage.input_tokens ?? 0);
|
|
583
|
+
const ct = Number(usage.output_tokens ?? 0);
|
|
584
|
+
usageFields.prompt_tokens = pt;
|
|
585
|
+
usageFields.completion_tokens = ct;
|
|
586
|
+
if (pt + ct > 0) usageFields.total_tokens = pt + ct;
|
|
587
|
+
const cacheCreation = Number(usage.cache_creation_input_tokens ?? 0);
|
|
588
|
+
const cacheRead = Number(usage.cache_read_input_tokens ?? 0);
|
|
589
|
+
if (cacheCreation > 0) usageFields.prompt_tokens_details = { cache_creation_tokens: cacheCreation };
|
|
590
|
+
if (cacheRead > 0) {
|
|
591
|
+
usageFields.prompt_tokens_details = {
|
|
592
|
+
...usageFields.prompt_tokens_details,
|
|
593
|
+
cached_tokens: cacheRead
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (usage.service_tier) metadata.service_tier = String(usage.service_tier);
|
|
597
|
+
}
|
|
598
|
+
const rootSpanId = `claudecode_${traceUniqueId}_root`;
|
|
599
|
+
spans.push({
|
|
600
|
+
trace_unique_id: traceUniqueId,
|
|
601
|
+
thread_identifier: threadId,
|
|
602
|
+
customer_identifier: customerId,
|
|
603
|
+
span_unique_id: rootSpanId,
|
|
604
|
+
span_name: spanName,
|
|
605
|
+
span_workflow_name: workflowName,
|
|
606
|
+
model,
|
|
607
|
+
provider_id: "",
|
|
608
|
+
span_path: "",
|
|
609
|
+
input: promptMessages.length ? JSON.stringify(promptMessages) : "",
|
|
610
|
+
output: completionMessage ? JSON.stringify(completionMessage) : "",
|
|
611
|
+
timestamp: timestampStr,
|
|
612
|
+
start_time: startTimeStr,
|
|
613
|
+
metadata,
|
|
614
|
+
...lat !== void 0 ? { latency: lat } : {}
|
|
615
|
+
});
|
|
616
|
+
const genStart = firstAssistantTs || startTimeStr;
|
|
617
|
+
const genEnd = lastAssistantTs || timestampStr;
|
|
618
|
+
const genLat = latencySeconds(genStart, genEnd);
|
|
619
|
+
spans.push({
|
|
620
|
+
trace_unique_id: traceUniqueId,
|
|
621
|
+
span_unique_id: `claudecode_${traceUniqueId}_gen`,
|
|
622
|
+
span_parent_id: rootSpanId,
|
|
623
|
+
span_name: "claude.chat",
|
|
624
|
+
span_workflow_name: workflowName,
|
|
625
|
+
span_path: "claude_chat",
|
|
626
|
+
model,
|
|
627
|
+
provider_id: "anthropic",
|
|
628
|
+
metadata: {},
|
|
629
|
+
input: promptMessages.length ? JSON.stringify(promptMessages) : "",
|
|
630
|
+
output: completionMessage ? JSON.stringify(completionMessage) : "",
|
|
631
|
+
prompt_messages: promptMessages,
|
|
632
|
+
completion_message: completionMessage,
|
|
633
|
+
timestamp: genEnd,
|
|
634
|
+
start_time: genStart,
|
|
635
|
+
...genLat !== void 0 ? { latency: genLat } : {},
|
|
636
|
+
...usageFields
|
|
637
|
+
});
|
|
638
|
+
let thinkingNum = 0;
|
|
639
|
+
for (const aMsg of assistantMsgs) {
|
|
640
|
+
if (typeof aMsg !== "object" || !aMsg.message) continue;
|
|
641
|
+
const content = aMsg.message.content;
|
|
642
|
+
if (!Array.isArray(content)) continue;
|
|
643
|
+
for (const item of content) {
|
|
644
|
+
if (typeof item === "object" && item !== null && item.type === "thinking") {
|
|
645
|
+
const thinkingText = String(item.thinking ?? "");
|
|
646
|
+
if (!thinkingText) continue;
|
|
647
|
+
thinkingNum++;
|
|
648
|
+
const thinkingTs = String(aMsg.timestamp ?? timestampStr);
|
|
649
|
+
spans.push({
|
|
650
|
+
trace_unique_id: traceUniqueId,
|
|
651
|
+
span_unique_id: `claudecode_${traceUniqueId}_thinking_${thinkingNum}`,
|
|
652
|
+
span_parent_id: rootSpanId,
|
|
653
|
+
span_name: `Thinking ${thinkingNum}`,
|
|
654
|
+
span_workflow_name: workflowName,
|
|
655
|
+
span_path: "thinking",
|
|
656
|
+
provider_id: "",
|
|
657
|
+
metadata: {},
|
|
658
|
+
input: "",
|
|
659
|
+
output: thinkingText,
|
|
660
|
+
timestamp: thinkingTs,
|
|
661
|
+
start_time: thinkingTs
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const toolCallMap = /* @__PURE__ */ new Map();
|
|
667
|
+
for (const aMsg of assistantMsgs) {
|
|
668
|
+
for (const tc of getToolCalls(aMsg)) {
|
|
669
|
+
const id = String(tc.id ?? "");
|
|
670
|
+
toolCallMap.set(id, {
|
|
671
|
+
name: tc.name ?? "unknown",
|
|
672
|
+
input: tc.input,
|
|
673
|
+
id,
|
|
674
|
+
timestamp: aMsg.timestamp
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const tr of toolResults) {
|
|
679
|
+
const trContent = getContent(tr);
|
|
680
|
+
const trMeta = {};
|
|
681
|
+
if (typeof tr === "object" && tr.toolUseResult && typeof tr.toolUseResult === "object") {
|
|
682
|
+
const tur = tr.toolUseResult;
|
|
683
|
+
for (const [src, dst] of [["durationMs", "duration_ms"], ["numFiles", "num_files"], ["filenames", "filenames"], ["truncated", "truncated"]]) {
|
|
684
|
+
if (src in tur) trMeta[dst] = tur[src];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (Array.isArray(trContent)) {
|
|
688
|
+
for (const item of trContent) {
|
|
689
|
+
if (typeof item === "object" && item !== null && item.type === "tool_result") {
|
|
690
|
+
const toolUseId = String(item.tool_use_id ?? "");
|
|
691
|
+
const existing = toolCallMap.get(toolUseId);
|
|
692
|
+
if (existing) {
|
|
693
|
+
existing.output = item.content;
|
|
694
|
+
existing.result_metadata = trMeta;
|
|
695
|
+
existing.result_timestamp = tr.timestamp;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
let toolNum = 0;
|
|
702
|
+
for (const [, td] of toolCallMap) {
|
|
703
|
+
toolNum++;
|
|
704
|
+
const toolTs = String(td.result_timestamp ?? td.timestamp ?? timestampStr);
|
|
705
|
+
const toolStart = String(td.timestamp ?? startTimeStr);
|
|
706
|
+
const toolLat = latencySeconds(toolStart, toolTs);
|
|
707
|
+
const durationMs = td.result_metadata?.duration_ms;
|
|
708
|
+
spans.push({
|
|
709
|
+
trace_unique_id: traceUniqueId,
|
|
710
|
+
span_unique_id: `claudecode_${traceUniqueId}_tool_${toolNum}`,
|
|
711
|
+
span_parent_id: rootSpanId,
|
|
712
|
+
span_name: `Tool: ${td.name}`,
|
|
713
|
+
span_workflow_name: workflowName,
|
|
714
|
+
span_path: `tool_${String(td.name).toLowerCase()}`,
|
|
715
|
+
provider_id: "",
|
|
716
|
+
metadata: td.result_metadata ?? {},
|
|
717
|
+
input: formatToolInput(String(td.name), td.input),
|
|
718
|
+
output: formatToolOutput(String(td.name), td.output),
|
|
719
|
+
timestamp: toolTs,
|
|
720
|
+
start_time: toolStart,
|
|
721
|
+
...durationMs ? { latency: Number(durationMs) / 1e3 } : toolLat !== void 0 ? { latency: toolLat } : {}
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return addDefaultsToAll(spans);
|
|
725
|
+
}
|
|
726
|
+
function processTranscript(sessionId, transcriptFile, state, apiKey, baseUrl, config) {
|
|
727
|
+
const sessionState = state[sessionId] ?? {};
|
|
728
|
+
const lastLine = Number(sessionState.last_line ?? 0);
|
|
729
|
+
const turnCount = Number(sessionState.turn_count ?? 0);
|
|
730
|
+
const content = fs2.readFileSync(transcriptFile, "utf-8");
|
|
731
|
+
const lines = content.trim().split("\n");
|
|
732
|
+
const totalLines = lines.length;
|
|
733
|
+
if (lastLine >= totalLines) {
|
|
734
|
+
debug(`No new lines to process (last: ${lastLine}, total: ${totalLines})`);
|
|
735
|
+
return { turnsProcessed: 0, lastCommittedLine: lastLine };
|
|
736
|
+
}
|
|
737
|
+
const newMessages = [];
|
|
738
|
+
for (let i = lastLine; i < totalLines; i++) {
|
|
739
|
+
try {
|
|
740
|
+
if (lines[i].trim()) {
|
|
741
|
+
const msg = JSON.parse(lines[i]);
|
|
742
|
+
newMessages.push({ ...msg, _lineIdx: i });
|
|
743
|
+
}
|
|
744
|
+
} catch {
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (newMessages.length === 0) return { turnsProcessed: 0, lastCommittedLine: lastLine };
|
|
748
|
+
debug(`Processing ${newMessages.length} new messages`);
|
|
749
|
+
let turnsProcessed = 0;
|
|
750
|
+
let lastCommittedLine = lastLine;
|
|
751
|
+
let currentUser = null;
|
|
752
|
+
let currentUserLine = lastLine;
|
|
753
|
+
let currentAssistants = [];
|
|
754
|
+
let currentAssistantParts = [];
|
|
755
|
+
let currentMsgId = null;
|
|
756
|
+
let currentToolResults = [];
|
|
757
|
+
const commitTurn = () => {
|
|
758
|
+
turnsProcessed++;
|
|
759
|
+
const turnNum = turnCount + turnsProcessed;
|
|
760
|
+
const spans = createSpans(sessionId, turnNum, currentUser, currentAssistants, currentToolResults, config);
|
|
761
|
+
sendSpans(spans, apiKey, baseUrl, `turn_${turnNum}`);
|
|
762
|
+
lastCommittedLine = totalLines;
|
|
763
|
+
};
|
|
764
|
+
for (const msg of newMessages) {
|
|
765
|
+
const lineIdx = msg._lineIdx;
|
|
766
|
+
delete msg._lineIdx;
|
|
767
|
+
const role = String(msg.type ?? msg.message?.role ?? "");
|
|
768
|
+
if (role === "user") {
|
|
769
|
+
if (isToolResult(msg)) {
|
|
770
|
+
currentToolResults.push(msg);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (currentMsgId && currentAssistantParts.length) {
|
|
774
|
+
currentAssistants.push(mergeAssistantParts(currentAssistantParts));
|
|
775
|
+
currentAssistantParts = [];
|
|
776
|
+
currentMsgId = null;
|
|
777
|
+
}
|
|
778
|
+
if (currentUser && currentAssistants.length) {
|
|
779
|
+
commitTurn();
|
|
780
|
+
lastCommittedLine = lineIdx;
|
|
781
|
+
}
|
|
782
|
+
currentUser = msg;
|
|
783
|
+
currentUserLine = lineIdx;
|
|
784
|
+
currentAssistants = [];
|
|
785
|
+
currentAssistantParts = [];
|
|
786
|
+
currentMsgId = null;
|
|
787
|
+
currentToolResults = [];
|
|
788
|
+
} else if (role === "assistant") {
|
|
789
|
+
let msgId = null;
|
|
790
|
+
if (typeof msg === "object" && msg.message) {
|
|
791
|
+
msgId = String(msg.message.id ?? "") || null;
|
|
792
|
+
}
|
|
793
|
+
if (!msgId) {
|
|
794
|
+
currentAssistantParts.push(msg);
|
|
795
|
+
} else if (msgId === currentMsgId) {
|
|
796
|
+
currentAssistantParts.push(msg);
|
|
797
|
+
} else {
|
|
798
|
+
if (currentMsgId && currentAssistantParts.length) {
|
|
799
|
+
currentAssistants.push(mergeAssistantParts(currentAssistantParts));
|
|
800
|
+
}
|
|
801
|
+
currentMsgId = msgId;
|
|
802
|
+
currentAssistantParts = [msg];
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (currentMsgId && currentAssistantParts.length) {
|
|
807
|
+
currentAssistants.push(mergeAssistantParts(currentAssistantParts));
|
|
808
|
+
}
|
|
809
|
+
if (currentUser && currentAssistants.length) {
|
|
810
|
+
const hasText = currentAssistants.some((m) => getTextContent(m));
|
|
811
|
+
if (hasText) {
|
|
812
|
+
commitTurn();
|
|
813
|
+
lastCommittedLine = totalLines;
|
|
814
|
+
} else {
|
|
815
|
+
lastCommittedLine = currentUserLine;
|
|
816
|
+
debug("Turn has assistant msgs but no text output yet, will retry");
|
|
817
|
+
}
|
|
818
|
+
} else {
|
|
819
|
+
if (currentUser) {
|
|
820
|
+
lastCommittedLine = currentUserLine;
|
|
821
|
+
debug(`Incomplete turn at line ${currentUserLine}, will retry next run`);
|
|
822
|
+
} else if (lastCommittedLine === lastLine) {
|
|
823
|
+
lastCommittedLine = totalLines;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return { turnsProcessed, lastCommittedLine };
|
|
827
|
+
}
|
|
828
|
+
function readStdinPayload() {
|
|
829
|
+
if (process.stdin.isTTY) return null;
|
|
830
|
+
try {
|
|
831
|
+
const raw = fs2.readFileSync(0, "utf-8");
|
|
832
|
+
if (!raw.trim()) return null;
|
|
833
|
+
const payload = JSON.parse(raw);
|
|
834
|
+
const sessionId = String(payload.session_id ?? "");
|
|
835
|
+
const transcriptPath = String(payload.transcript_path ?? "");
|
|
836
|
+
if (!sessionId || !transcriptPath) return null;
|
|
837
|
+
if (!fs2.existsSync(transcriptPath)) return null;
|
|
838
|
+
debug(`Got transcript from stdin: session=${sessionId}, path=${transcriptPath}`);
|
|
839
|
+
return { sessionId, transcriptPath };
|
|
840
|
+
} catch {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function findLatestTranscript() {
|
|
845
|
+
const projectsDir = path2.join(os2.homedir(), ".claude", "projects");
|
|
846
|
+
if (!fs2.existsSync(projectsDir)) return null;
|
|
847
|
+
let latestFile = null;
|
|
848
|
+
let latestMtime = 0;
|
|
849
|
+
for (const projEntry of fs2.readdirSync(projectsDir)) {
|
|
850
|
+
const projDir = path2.join(projectsDir, projEntry);
|
|
851
|
+
if (!fs2.statSync(projDir).isDirectory()) continue;
|
|
852
|
+
for (const file of fs2.readdirSync(projDir)) {
|
|
853
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
854
|
+
const full = path2.join(projDir, file);
|
|
855
|
+
const mtime = fs2.statSync(full).mtimeMs;
|
|
856
|
+
if (mtime > latestMtime) {
|
|
857
|
+
latestMtime = mtime;
|
|
858
|
+
latestFile = full;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (!latestFile) return null;
|
|
863
|
+
try {
|
|
864
|
+
const firstLine = fs2.readFileSync(latestFile, "utf-8").split("\n")[0];
|
|
865
|
+
if (!firstLine) return null;
|
|
866
|
+
const firstMsg = JSON.parse(firstLine);
|
|
867
|
+
const sessionId = String(firstMsg.sessionId ?? path2.basename(latestFile, ".jsonl"));
|
|
868
|
+
return { sessionId, transcriptPath: latestFile };
|
|
869
|
+
} catch {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function main() {
|
|
874
|
+
const scriptStart = Date.now();
|
|
875
|
+
debug("Hook started");
|
|
876
|
+
if ((process.env.TRACE_TO_RESPAN ?? "").toLowerCase() !== "true") {
|
|
877
|
+
debug("Tracing disabled (TRACE_TO_RESPAN != true)");
|
|
878
|
+
process.exit(0);
|
|
879
|
+
}
|
|
880
|
+
const creds = resolveCredentials();
|
|
881
|
+
if (!creds) {
|
|
882
|
+
log("ERROR", "No API key found. Run: respan auth login");
|
|
883
|
+
process.exit(0);
|
|
884
|
+
}
|
|
885
|
+
const payload = readStdinPayload() ?? findLatestTranscript();
|
|
886
|
+
if (!payload) {
|
|
887
|
+
debug("No transcript file found");
|
|
888
|
+
process.exit(0);
|
|
889
|
+
}
|
|
890
|
+
const { sessionId, transcriptPath } = payload;
|
|
891
|
+
debug(`Processing session: ${sessionId}`);
|
|
892
|
+
let config = null;
|
|
893
|
+
try {
|
|
894
|
+
const content = fs2.readFileSync(transcriptPath, "utf-8");
|
|
895
|
+
const lines = content.split("\n");
|
|
896
|
+
let cwd = "";
|
|
897
|
+
for (const line of lines.slice(0, 5)) {
|
|
898
|
+
if (!line.trim()) continue;
|
|
899
|
+
try {
|
|
900
|
+
const msg = JSON.parse(line);
|
|
901
|
+
if (msg.cwd) {
|
|
902
|
+
cwd = String(msg.cwd);
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
} catch {
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (cwd) {
|
|
909
|
+
config = loadRespanConfig(path2.join(cwd, ".claude", "respan.json"));
|
|
910
|
+
debug(`Loaded respan.json config from ${cwd}`);
|
|
911
|
+
}
|
|
912
|
+
} catch (e) {
|
|
913
|
+
debug(`Failed to load config: ${e}`);
|
|
914
|
+
}
|
|
915
|
+
const maxAttempts = 3;
|
|
916
|
+
let turns = 0;
|
|
917
|
+
try {
|
|
918
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
919
|
+
const unlock = acquireLock(LOCK_PATH);
|
|
920
|
+
try {
|
|
921
|
+
const state = loadState(STATE_FILE);
|
|
922
|
+
const result = processTranscript(sessionId, transcriptPath, state, creds.apiKey, creds.baseUrl, config);
|
|
923
|
+
turns = result.turnsProcessed;
|
|
924
|
+
state[sessionId] = {
|
|
925
|
+
last_line: result.lastCommittedLine,
|
|
926
|
+
turn_count: Number(state[sessionId]?.turn_count ?? 0) + turns,
|
|
927
|
+
updated: nowISO()
|
|
928
|
+
};
|
|
929
|
+
saveState(STATE_FILE, state);
|
|
930
|
+
} finally {
|
|
931
|
+
unlock?.();
|
|
932
|
+
}
|
|
933
|
+
if (turns > 0) break;
|
|
934
|
+
if (attempt < maxAttempts - 1) {
|
|
935
|
+
const delay = 500 * (attempt + 1);
|
|
936
|
+
debug(`No turns processed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay}ms...`);
|
|
937
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const duration = (Date.now() - scriptStart) / 1e3;
|
|
941
|
+
log("INFO", `Processed ${turns} turns in ${duration.toFixed(1)}s`);
|
|
942
|
+
if (duration > 180) log("WARN", `Hook took ${duration.toFixed(1)}s (>3min)`);
|
|
943
|
+
} catch (e) {
|
|
944
|
+
log("ERROR", `Failed to process transcript: ${e}`);
|
|
945
|
+
if (DEBUG_MODE) debug(String(e.stack ?? e));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
main().catch((e) => {
|
|
949
|
+
log("ERROR", `Hook crashed: ${e}`);
|
|
950
|
+
process.exit(1);
|
|
951
|
+
});
|