@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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Respan Hook for Gemini CLI
3
+ *
4
+ * Handles streaming: Gemini fires AfterModel per chunk. We accumulate text
5
+ * and only send on the final chunk (text+STOP or empty after accumulated text).
6
+ *
7
+ * Handles tool calls: model calls a tool → turn ends with STOP → Gemini
8
+ * executes tool → new model turn. Detected via message count changes.
9
+ * BeforeTool/AfterTool hooks capture tool names, args, and output.
10
+ *
11
+ * Span tree per turn:
12
+ * Root: gemini-cli
13
+ * ├── gemini.chat (generation — model, tokens, messages)
14
+ * ├── Reasoning (if thinking tokens > 0)
15
+ * ├── Tool: Shell (if run_shell_command)
16
+ * └── Tool: File Read (if read_file)
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import { execFile } from 'node:child_process';
22
+ import { initLogging, log, debug, resolveCredentials, loadRespanConfig, acquireLock, addDefaultsToAll, resolveSpanFields, buildMetadata, resolveTracingIngestEndpoint, toOtlpPayload, nowISO, latencySeconds, truncate, } from './shared.js';
23
+ // ── Config ────────────────────────────────────────────────────────
24
+ const STATE_DIR = path.join(os.homedir(), '.gemini', 'state');
25
+ const LOG_FILE = path.join(STATE_DIR, 'respan_hook.log');
26
+ const LOCK_PATH = path.join(STATE_DIR, 'respan_hook.lock');
27
+ const DEBUG_MODE = (process.env.GEMINI_RESPAN_DEBUG ?? '').toLowerCase() === 'true';
28
+ const MAX_CHARS = parseInt(process.env.GEMINI_RESPAN_MAX_CHARS ?? '4000', 10) || 4000;
29
+ const SEND_DELAY = parseInt(process.env.GEMINI_RESPAN_SEND_DELAY ?? '10', 10) || 10;
30
+ initLogging(LOG_FILE, DEBUG_MODE);
31
+ // ── Tool display names ────────────────────────────────────────────
32
+ const TOOL_DISPLAY_NAMES = {
33
+ read_file: 'File Read',
34
+ read_many_files: 'File Read',
35
+ write_file: 'File Write',
36
+ list_directory: 'Directory List',
37
+ run_shell_command: 'Shell',
38
+ google_web_search: 'Web Search',
39
+ web_fetch: 'Web Fetch',
40
+ glob: 'Find Files',
41
+ grep_search: 'Search Text',
42
+ search_file_content: 'Search Text',
43
+ replace: 'File Edit',
44
+ save_memory: 'Memory',
45
+ write_todos: 'Todos',
46
+ get_internal_docs: 'Docs',
47
+ };
48
+ function toolDisplayName(name) {
49
+ return TOOL_DISPLAY_NAMES[name] ?? (name || 'Unknown');
50
+ }
51
+ function formatToolInput(toolName, args) {
52
+ if (!args)
53
+ return '';
54
+ const a = args;
55
+ if (toolName === 'run_shell_command' && typeof a === 'object') {
56
+ const cmd = String(a.command ?? '');
57
+ const dir = String(a.dir_path ?? '');
58
+ return truncate(dir ? `[${dir}] Command: ${cmd}` : `Command: ${cmd}`, MAX_CHARS);
59
+ }
60
+ if (['read_file', 'write_file'].includes(toolName) && typeof a === 'object')
61
+ return truncate(String(a.file_path ?? JSON.stringify(a)), MAX_CHARS);
62
+ if (toolName === 'read_many_files' && typeof a === 'object')
63
+ return truncate(String(a.include ?? JSON.stringify(a)), MAX_CHARS);
64
+ if (toolName === 'google_web_search' && typeof a === 'object')
65
+ return truncate(`Query: ${a.query ?? String(a)}`, MAX_CHARS);
66
+ if (['glob', 'grep_search', 'search_file_content'].includes(toolName) && typeof a === 'object')
67
+ return truncate(String(a.pattern ?? JSON.stringify(a)), MAX_CHARS);
68
+ if (toolName === 'replace' && typeof a === 'object') {
69
+ const fp = String(a.file_path ?? '');
70
+ const old = String(a.old_string ?? '');
71
+ if (fp && old)
72
+ return truncate(`${fp}: ${JSON.stringify(old)} → ...`, MAX_CHARS);
73
+ }
74
+ try {
75
+ return truncate(JSON.stringify(args, null, 2), MAX_CHARS);
76
+ }
77
+ catch { }
78
+ return truncate(String(args), MAX_CHARS);
79
+ }
80
+ // ── Stream state management ───────────────────────────────────────
81
+ function statePath(sessionId) {
82
+ const safeId = sessionId.replace(/[/\\]/g, '_').slice(0, 64);
83
+ return path.join(STATE_DIR, `respan_stream_${safeId}.json`);
84
+ }
85
+ function loadStreamState(sessionId) {
86
+ const p = statePath(sessionId);
87
+ if (fs.existsSync(p)) {
88
+ try {
89
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
90
+ }
91
+ catch { }
92
+ }
93
+ return { accumulated_text: '', last_tokens: 0, first_chunk_time: '' };
94
+ }
95
+ function saveStreamState(sessionId, state) {
96
+ const p = statePath(sessionId);
97
+ fs.mkdirSync(path.dirname(p), { recursive: true });
98
+ const tmp = p + '.tmp.' + process.pid;
99
+ try {
100
+ fs.writeFileSync(tmp, JSON.stringify(state));
101
+ fs.renameSync(tmp, p);
102
+ }
103
+ catch {
104
+ try {
105
+ fs.unlinkSync(tmp);
106
+ }
107
+ catch { }
108
+ fs.writeFileSync(p, JSON.stringify(state));
109
+ }
110
+ }
111
+ function clearStreamState(sessionId) {
112
+ try {
113
+ fs.unlinkSync(statePath(sessionId));
114
+ }
115
+ catch { }
116
+ }
117
+ // ── Message extraction ────────────────────────────────────────────
118
+ function extractMessages(hookData) {
119
+ const llmReq = (hookData.llm_request ?? {});
120
+ const messages = (llmReq.messages ?? []);
121
+ return messages.map((msg) => ({
122
+ role: String(msg.role ?? 'user') === 'model' ? 'assistant' : String(msg.role ?? 'user'),
123
+ content: truncate(String(msg.content ?? ''), MAX_CHARS),
124
+ }));
125
+ }
126
+ function detectModel(hookData) {
127
+ const override = process.env.RESPAN_GEMINI_MODEL;
128
+ if (override)
129
+ return override;
130
+ const llmReq = (hookData.llm_request ?? {});
131
+ return String(llmReq.model ?? '') || 'gemini-cli';
132
+ }
133
+ // ── Span construction ─────────────────────────────────────────────
134
+ function buildSpans(hookData, outputText, tokens, config, startTimeIso, toolTurns, toolDetails, thoughtsTokens) {
135
+ const spans = [];
136
+ const sessionId = String(hookData.session_id ?? '');
137
+ const model = detectModel(hookData);
138
+ const now = nowISO();
139
+ const endTime = String(hookData.timestamp ?? '') || now;
140
+ const beginTime = startTimeIso || endTime;
141
+ const lat = latencySeconds(beginTime, endTime);
142
+ const promptMessages = extractMessages(hookData);
143
+ const completionMessage = { role: 'assistant', content: truncate(outputText, MAX_CHARS) };
144
+ const { workflowName, spanName, customerId } = resolveSpanFields(config, {
145
+ workflowName: 'gemini-cli',
146
+ spanName: 'gemini-cli',
147
+ });
148
+ const safeId = sessionId.replace(/[/\\]/g, '_').slice(0, 50);
149
+ const traceUniqueId = `gcli_${safeId}`;
150
+ const rootSpanId = `gcli_${safeId}_root`;
151
+ const threadId = `gcli_${sessionId}`;
152
+ // LLM config
153
+ const llmReq = (hookData.llm_request ?? {});
154
+ const reqConfig = (llmReq.config ?? {});
155
+ // Metadata
156
+ const baseMeta = { source: 'gemini-cli' };
157
+ if (toolTurns > 0)
158
+ baseMeta.tool_turns = toolTurns;
159
+ if (thoughtsTokens > 0)
160
+ baseMeta.reasoning_tokens = thoughtsTokens;
161
+ const metadata = buildMetadata(config, baseMeta);
162
+ // Root span
163
+ spans.push({
164
+ trace_unique_id: traceUniqueId,
165
+ thread_identifier: threadId,
166
+ customer_identifier: customerId,
167
+ span_unique_id: rootSpanId,
168
+ span_name: spanName,
169
+ span_workflow_name: workflowName,
170
+ model,
171
+ provider_id: '',
172
+ span_path: '',
173
+ input: promptMessages.length ? JSON.stringify(promptMessages) : '',
174
+ output: JSON.stringify(completionMessage),
175
+ timestamp: endTime,
176
+ start_time: beginTime,
177
+ metadata,
178
+ ...(lat !== undefined ? { latency: lat } : {}),
179
+ });
180
+ // Generation child span
181
+ const genSpan = {
182
+ trace_unique_id: traceUniqueId,
183
+ span_unique_id: `gcli_${safeId}_gen`,
184
+ span_parent_id: rootSpanId,
185
+ span_name: 'gemini.chat',
186
+ span_workflow_name: workflowName,
187
+ span_path: 'gemini_chat',
188
+ model,
189
+ provider_id: 'google',
190
+ metadata: {},
191
+ input: promptMessages.length ? JSON.stringify(promptMessages) : '',
192
+ output: JSON.stringify(completionMessage),
193
+ timestamp: endTime,
194
+ start_time: beginTime,
195
+ prompt_tokens: tokens.prompt_tokens,
196
+ completion_tokens: tokens.completion_tokens,
197
+ total_tokens: tokens.total_tokens,
198
+ ...(lat !== undefined ? { latency: lat } : {}),
199
+ };
200
+ if (reqConfig.temperature != null)
201
+ genSpan.temperature = reqConfig.temperature;
202
+ if (reqConfig.maxOutputTokens != null)
203
+ genSpan.max_tokens = reqConfig.maxOutputTokens;
204
+ spans.push(genSpan);
205
+ // Reasoning span
206
+ if (thoughtsTokens > 0) {
207
+ spans.push({
208
+ trace_unique_id: traceUniqueId,
209
+ span_unique_id: `gcli_${safeId}_reasoning`,
210
+ span_parent_id: rootSpanId,
211
+ span_name: 'Reasoning',
212
+ span_workflow_name: workflowName,
213
+ span_path: 'reasoning',
214
+ provider_id: '',
215
+ metadata: { reasoning_tokens: thoughtsTokens },
216
+ input: '',
217
+ output: `[Reasoning: ${thoughtsTokens} tokens]`,
218
+ timestamp: endTime,
219
+ start_time: beginTime,
220
+ });
221
+ }
222
+ // Tool child spans
223
+ for (let i = 0; i < toolTurns; i++) {
224
+ const detail = toolDetails[i] ?? null;
225
+ const toolName = detail?.name ?? '';
226
+ const toolArgs = detail?.args ?? detail?.input ?? {};
227
+ const toolOutput = detail?.output ?? '';
228
+ const displayName = toolName ? toolDisplayName(toolName) : `Call ${i + 1}`;
229
+ const toolInputStr = toolName ? formatToolInput(toolName, toolArgs) : '';
230
+ const toolMeta = {};
231
+ if (toolName)
232
+ toolMeta.tool_name = toolName;
233
+ if (detail?.error)
234
+ toolMeta.error = detail.error;
235
+ const toolStart = detail?.start_time ?? beginTime;
236
+ const toolEnd = detail?.end_time ?? endTime;
237
+ const toolLat = latencySeconds(toolStart, toolEnd);
238
+ spans.push({
239
+ trace_unique_id: traceUniqueId,
240
+ span_unique_id: `gcli_${safeId}_tool_${i + 1}`,
241
+ span_parent_id: rootSpanId,
242
+ span_name: `Tool: ${displayName}`,
243
+ span_workflow_name: workflowName,
244
+ span_path: toolName ? `tool_${toolName}` : 'tool_call',
245
+ provider_id: '',
246
+ metadata: toolMeta,
247
+ input: toolInputStr,
248
+ output: truncate(toolOutput, MAX_CHARS),
249
+ timestamp: toolEnd,
250
+ start_time: toolStart,
251
+ ...(toolLat !== undefined ? { latency: toolLat } : {}),
252
+ });
253
+ }
254
+ return addDefaultsToAll(spans);
255
+ }
256
+ // ── Send spans (detached subprocess for Gemini CLI survival) ──────
257
+ function sendSpansDetached(spans, apiKey, baseUrl) {
258
+ const url = resolveTracingIngestEndpoint(baseUrl);
259
+ debug(`Sending ${spans.length} span(s) to ${url}: ${spans.map(s => s.span_name).join(', ')}`);
260
+ if (DEBUG_MODE) {
261
+ const debugFile = path.join(STATE_DIR, 'respan_last_payload.json');
262
+ fs.writeFileSync(debugFile, JSON.stringify(spans, null, 2));
263
+ }
264
+ // Convert to OTLP JSON and write to temp file for detached sender
265
+ const payloadFile = path.join(STATE_DIR, `respan_send_${process.pid}.json`);
266
+ fs.writeFileSync(payloadFile, JSON.stringify(toOtlpPayload(spans)));
267
+ const senderScript = `
268
+ const fs = require('fs');
269
+ const pf = ${JSON.stringify(payloadFile)};
270
+ try {
271
+ const data = fs.readFileSync(pf);
272
+ (async () => {
273
+ for (let i = 0; i < 3; i++) {
274
+ try {
275
+ const r = await fetch(${JSON.stringify(url)}, {
276
+ method: 'POST',
277
+ headers: {
278
+ 'Content-Type': 'application/json',
279
+ 'X-Respan-Dogfood': '1',
280
+ 'Authorization': 'Bearer ' + process.env.RESPAN_API_KEY,
281
+ },
282
+ body: data,
283
+ signal: AbortSignal.timeout(30000),
284
+ });
285
+ if (r.status < 500) break;
286
+ if (i < 2) await new Promise(r => setTimeout(r, 1000));
287
+ } catch(e) {
288
+ if (i < 2) await new Promise(r => setTimeout(r, 1000));
289
+ }
290
+ }
291
+ })().finally(() => { try { fs.unlinkSync(pf); } catch {} });
292
+ } catch(e) { try { fs.unlinkSync(pf); } catch {} }
293
+ `;
294
+ const env = { ...process.env, RESPAN_API_KEY: apiKey };
295
+ try {
296
+ const child = execFile('node', ['-e', senderScript], {
297
+ env,
298
+ stdio: 'ignore',
299
+ detached: true,
300
+ });
301
+ child.unref();
302
+ debug('Launched sender subprocess');
303
+ }
304
+ catch (e) {
305
+ log('ERROR', `Failed to launch sender: ${e}`);
306
+ try {
307
+ fs.unlinkSync(payloadFile);
308
+ }
309
+ catch { }
310
+ }
311
+ }
312
+ function launchDelayedSend(sessionId, sendVersion, spans, apiKey, baseUrl) {
313
+ // Convert to OTLP JSON before writing — detached sender posts raw bytes
314
+ const payloadFile = path.join(STATE_DIR, `respan_delayed_${process.pid}.json`);
315
+ fs.writeFileSync(payloadFile, JSON.stringify(toOtlpPayload(spans)));
316
+ const stateFile = statePath(sessionId);
317
+ const url = resolveTracingIngestEndpoint(baseUrl);
318
+ const script = `
319
+ const fs = require('fs');
320
+ setTimeout(async () => {
321
+ const sf = ${JSON.stringify(stateFile)};
322
+ const pf = ${JSON.stringify(payloadFile)};
323
+ try {
324
+ if (!fs.existsSync(sf)) { fs.unlinkSync(pf); process.exit(0); }
325
+ const state = JSON.parse(fs.readFileSync(sf, 'utf-8'));
326
+ if (state.send_version !== ${sendVersion}) { fs.unlinkSync(pf); process.exit(0); }
327
+ const data = fs.readFileSync(pf);
328
+ for (let i = 0; i < 3; i++) {
329
+ try {
330
+ const r = await fetch(${JSON.stringify(url)}, {
331
+ method: 'POST',
332
+ headers: {
333
+ 'Content-Type': 'application/json',
334
+ 'X-Respan-Dogfood': '1',
335
+ 'Authorization': 'Bearer ' + process.env.RESPAN_API_KEY,
336
+ },
337
+ body: data,
338
+ signal: AbortSignal.timeout(30000),
339
+ });
340
+ if (r.status < 500) break;
341
+ if (i < 2) await new Promise(r => setTimeout(r, 1000));
342
+ } catch(e) { if (i < 2) await new Promise(r => setTimeout(r, 1000)); }
343
+ }
344
+ try { fs.unlinkSync(sf); } catch {}
345
+ try { fs.unlinkSync(pf); } catch {}
346
+ } catch(e) { try { fs.unlinkSync(pf); } catch {} }
347
+ }, ${SEND_DELAY * 1000});
348
+ `;
349
+ const env = { ...process.env, RESPAN_API_KEY: apiKey };
350
+ try {
351
+ const child = execFile('node', ['-e', script], {
352
+ env,
353
+ stdio: 'ignore',
354
+ detached: true,
355
+ });
356
+ child.unref();
357
+ debug(`Launched delayed sender (version=${sendVersion}, delay=${SEND_DELAY}s)`);
358
+ }
359
+ catch (e) {
360
+ log('ERROR', `Failed to launch delayed sender: ${e}`);
361
+ try {
362
+ fs.unlinkSync(payloadFile);
363
+ }
364
+ catch { }
365
+ }
366
+ }
367
+ // ── BeforeTool / AfterTool handlers ──────────────────────────────
368
+ function processBeforeTool(hookData) {
369
+ const sessionId = String(hookData.session_id ?? 'unknown');
370
+ const toolName = String(hookData.tool_name ?? '');
371
+ const toolInput = hookData.tool_input ?? {};
372
+ debug(`BeforeTool: ${toolName}`);
373
+ const state = loadStreamState(sessionId);
374
+ const pending = state.pending_tools ?? [];
375
+ pending.push({ name: toolName, input: toolInput, start_time: nowISO() });
376
+ state.pending_tools = pending;
377
+ saveStreamState(sessionId, state);
378
+ process.stdout.write('{}\n');
379
+ }
380
+ function processAfterTool(hookData) {
381
+ const sessionId = String(hookData.session_id ?? 'unknown');
382
+ const toolName = String(hookData.tool_name ?? '');
383
+ const toolResponse = (hookData.tool_response ?? {});
384
+ const output = String(toolResponse.llmContent ?? '');
385
+ const error = toolResponse.error ? String(toolResponse.error) : undefined;
386
+ debug(`AfterTool: ${toolName}, output_len=${output.length}, error=${error}`);
387
+ const state = loadStreamState(sessionId);
388
+ const pending = state.pending_tools ?? [];
389
+ const completed = state.tool_details ?? [];
390
+ // Match last pending tool with this name
391
+ for (let i = pending.length - 1; i >= 0; i--) {
392
+ if (pending[i].name === toolName) {
393
+ const detail = pending.splice(i, 1)[0];
394
+ detail.output = output;
395
+ detail.end_time = nowISO();
396
+ if (error)
397
+ detail.error = error;
398
+ completed.push(detail);
399
+ break;
400
+ }
401
+ }
402
+ state.pending_tools = pending;
403
+ state.tool_details = completed;
404
+ saveStreamState(sessionId, state);
405
+ process.stdout.write('{}\n');
406
+ }
407
+ // ── AfterModel chunk processing ──────────────────────────────────
408
+ function processChunk(hookData) {
409
+ const sessionId = String(hookData.session_id ?? 'unknown');
410
+ const llmResp = (hookData.llm_response ?? {});
411
+ const chunkText = String(llmResp.text ?? '') || '';
412
+ const usage = (llmResp.usageMetadata ?? {});
413
+ const completionTokens = Number(usage.candidatesTokenCount ?? 0);
414
+ const thoughtsTokens = Number(usage.thoughtsTokenCount ?? 0);
415
+ // Check for finish signal and tool calls
416
+ const candidates = (llmResp.candidates ?? []);
417
+ let finishReason = '';
418
+ let hasToolCall = false;
419
+ const chunkToolDetails = [];
420
+ if (candidates.length > 0 && typeof candidates[0] === 'object') {
421
+ finishReason = String(candidates[0].finishReason ?? '');
422
+ const content = (candidates[0].content ?? {});
423
+ if (typeof content === 'object') {
424
+ for (const part of (content.parts ?? [])) {
425
+ if (typeof part !== 'object')
426
+ continue;
427
+ const fc = (part.functionCall ?? part.toolCall);
428
+ if (fc) {
429
+ hasToolCall = true;
430
+ if (typeof fc === 'object') {
431
+ chunkToolDetails.push({
432
+ name: String(fc.name ?? ''),
433
+ args: fc.args ?? {},
434
+ });
435
+ }
436
+ }
437
+ }
438
+ }
439
+ }
440
+ const messages = (hookData.llm_request?.messages ?? []);
441
+ const currentMsgCount = messages.length;
442
+ let state = loadStreamState(sessionId);
443
+ const isFinished = ['STOP', 'MAX_TOKENS', 'SAFETY'].includes(finishReason);
444
+ // Detect tool-call resumption via message count
445
+ const savedMsgCount = state.msg_count ?? 0;
446
+ let toolCallDetected = false;
447
+ if (savedMsgCount > 0 && currentMsgCount > savedMsgCount) {
448
+ const newMsgs = messages.slice(savedMsgCount);
449
+ const hasNewUserMsg = newMsgs.some((m) => m.role === 'user');
450
+ if (hasNewUserMsg) {
451
+ debug(`New user message detected (msgs ${savedMsgCount} → ${currentMsgCount}), starting fresh turn`);
452
+ clearStreamState(sessionId);
453
+ state = { accumulated_text: '', last_tokens: 0, first_chunk_time: '' };
454
+ }
455
+ else {
456
+ state.tool_turns = (state.tool_turns ?? 0) + 1;
457
+ state.send_version = (state.send_version ?? 0) + 1;
458
+ toolCallDetected = true;
459
+ debug(`Tool call detected via msg_count (${savedMsgCount} → ${currentMsgCount}), tool_turns=${state.tool_turns}`);
460
+ }
461
+ }
462
+ state.msg_count = currentMsgCount;
463
+ // Accumulate text
464
+ if (chunkText) {
465
+ if (!state.first_chunk_time)
466
+ state.first_chunk_time = nowISO();
467
+ state.accumulated_text += chunkText;
468
+ state.last_tokens = completionTokens || state.last_tokens;
469
+ if (thoughtsTokens > 0)
470
+ state.thoughts_tokens = thoughtsTokens;
471
+ saveStreamState(sessionId, state);
472
+ debug(`Accumulated chunk: +${chunkText.length} chars, total=${state.accumulated_text.length}`);
473
+ }
474
+ // Tool call in response parts
475
+ const isToolTurn = hasToolCall || ['TOOL_CALLS', 'FUNCTION_CALL', 'TOOL_USE'].includes(finishReason);
476
+ if (isToolTurn) {
477
+ state.tool_turns = (state.tool_turns ?? 0) + 1;
478
+ state.send_version = (state.send_version ?? 0) + 1;
479
+ if (chunkToolDetails.length) {
480
+ state.tool_details = [...(state.tool_details ?? []), ...chunkToolDetails];
481
+ }
482
+ saveStreamState(sessionId, state);
483
+ debug(`Tool call via response parts (finish=${finishReason}), tool_turns=${state.tool_turns}`);
484
+ process.stdout.write('{}\n');
485
+ return;
486
+ }
487
+ // Detect completion and send
488
+ const hasNewText = state.accumulated_text.length > (state.last_send_text_len ?? 0);
489
+ const shouldSend = ((!toolCallDetected || isFinished)
490
+ && hasNewText
491
+ && state.accumulated_text
492
+ && (!chunkText || isFinished));
493
+ process.stdout.write('{}\n');
494
+ if (!shouldSend) {
495
+ if (toolCallDetected)
496
+ saveStreamState(sessionId, state);
497
+ return;
498
+ }
499
+ const creds = resolveCredentials();
500
+ if (!creds) {
501
+ log('ERROR', 'No API key found. Run: respan auth login');
502
+ clearStreamState(sessionId);
503
+ return;
504
+ }
505
+ const finalPrompt = Number(usage.promptTokenCount ?? 0);
506
+ const finalCompletion = completionTokens || state.last_tokens;
507
+ const finalTotal = Number(usage.totalTokenCount ?? 0) || (finalPrompt + finalCompletion);
508
+ const tok = { prompt_tokens: finalPrompt, completion_tokens: finalCompletion, total_tokens: finalTotal };
509
+ const config = loadRespanConfig(path.join(os.homedir(), '.gemini', 'respan.json'));
510
+ const spans = buildSpans(hookData, state.accumulated_text, tok, config, state.first_chunk_time || undefined, state.tool_turns ?? 0, state.tool_details ?? [], state.thoughts_tokens ?? 0);
511
+ // Method b: text + STOP → send immediately
512
+ if (isFinished && chunkText) {
513
+ debug(`Immediate send (text+STOP, tool_turns=${state.tool_turns ?? 0}), ${state.accumulated_text.length} chars`);
514
+ sendSpansDetached(spans, creds.apiKey, creds.baseUrl);
515
+ clearStreamState(sessionId);
516
+ return;
517
+ }
518
+ // Method a: delayed send
519
+ state.send_version = (state.send_version ?? 0) + 1;
520
+ state.last_send_text_len = state.accumulated_text.length;
521
+ saveStreamState(sessionId, state);
522
+ debug(`Delayed send (version=${state.send_version}, delay=${SEND_DELAY}s), ${state.accumulated_text.length} chars`);
523
+ launchDelayedSend(sessionId, state.send_version, spans, creds.apiKey, creds.baseUrl);
524
+ }
525
+ // ── Main ──────────────────────────────────────────────────────────
526
+ function main() {
527
+ try {
528
+ const raw = fs.readFileSync(0, 'utf-8');
529
+ if (!raw.trim()) {
530
+ process.stdout.write('{}\n');
531
+ return;
532
+ }
533
+ const hookData = JSON.parse(raw);
534
+ const event = String(hookData.hook_event_name ?? '');
535
+ const unlock = acquireLock(LOCK_PATH);
536
+ try {
537
+ if (event === 'BeforeTool') {
538
+ processBeforeTool(hookData);
539
+ }
540
+ else if (event === 'AfterTool') {
541
+ processAfterTool(hookData);
542
+ }
543
+ else {
544
+ processChunk(hookData);
545
+ }
546
+ }
547
+ finally {
548
+ unlock?.();
549
+ }
550
+ }
551
+ catch (e) {
552
+ if (e instanceof SyntaxError) {
553
+ log('ERROR', `Invalid JSON from stdin: ${e}`);
554
+ }
555
+ else {
556
+ log('ERROR', `Hook error: ${e}`);
557
+ }
558
+ process.stdout.write('{}\n');
559
+ }
560
+ }
561
+ main();
@@ -0,0 +1,82 @@
1
+ export declare function resolveTracesEndpoint(baseUrl?: string): string;
2
+ export declare const resolveTracingIngestEndpoint: typeof resolveTracesEndpoint;
3
+ export interface RespanConfig {
4
+ fields: Record<string, string>;
5
+ properties: Record<string, string>;
6
+ }
7
+ export interface Credentials {
8
+ apiKey: string;
9
+ baseUrl: string;
10
+ }
11
+ export interface SpanData {
12
+ trace_unique_id: string;
13
+ span_unique_id: string;
14
+ span_parent_id?: string;
15
+ span_name: string;
16
+ span_workflow_name: string;
17
+ span_path?: string;
18
+ thread_identifier?: string;
19
+ customer_identifier?: string;
20
+ model?: string;
21
+ provider_id?: string;
22
+ input?: string;
23
+ output?: string;
24
+ timestamp: string;
25
+ start_time: string;
26
+ latency?: number;
27
+ metadata?: Record<string, unknown>;
28
+ prompt_tokens?: number;
29
+ completion_tokens?: number;
30
+ total_tokens?: number;
31
+ prompt_tokens_details?: Record<string, number>;
32
+ prompt_messages?: Array<Record<string, unknown>>;
33
+ completion_message?: Record<string, unknown> | null;
34
+ warnings?: string;
35
+ encoding_format?: string;
36
+ disable_fallback?: boolean;
37
+ respan_params?: Record<string, unknown>;
38
+ field_name?: string;
39
+ delimiter?: string;
40
+ disable_log?: boolean;
41
+ request_breakdown?: boolean;
42
+ }
43
+ export declare function initLogging(logFile: string, debug: boolean): void;
44
+ export declare function log(level: string, message: string): void;
45
+ export declare function debug(message: string): void;
46
+ export declare function resolveCredentials(): Credentials | null;
47
+ export declare function loadRespanConfig(configPath: string): RespanConfig;
48
+ export declare function loadState(statePath: string): Record<string, unknown>;
49
+ export declare function saveState(statePath: string, state: Record<string, unknown>): void;
50
+ /**
51
+ * Simple advisory file lock using mkdir (atomic on all platforms).
52
+ * Returns an unlock function, or null if lock couldn't be acquired.
53
+ */
54
+ export declare function acquireLock(lockPath: string, timeoutMs?: number): (() => void) | null;
55
+ export declare function nowISO(): string;
56
+ export declare function parseTimestamp(ts: string): Date | null;
57
+ export declare function latencySeconds(start: string, end: string): number | undefined;
58
+ export declare function truncate(text: string, maxChars?: number): string;
59
+ /**
60
+ * No-op — v1 platform defaults are no longer needed with OTLP v2 format.
61
+ * Kept for API compatibility with hook files.
62
+ */
63
+ export declare function addDefaults(span: SpanData): SpanData;
64
+ export declare function addDefaultsToAll(spans: SpanData[]): SpanData[];
65
+ /**
66
+ * Resolve config overrides for span fields. Env vars take precedence over config file.
67
+ */
68
+ export declare function resolveSpanFields(config: RespanConfig | null, defaults: {
69
+ workflowName: string;
70
+ spanName: string;
71
+ }): {
72
+ workflowName: string;
73
+ spanName: string;
74
+ customerId: string;
75
+ };
76
+ /**
77
+ * Build metadata from config properties + env overrides.
78
+ */
79
+ export declare function buildMetadata(config: RespanConfig | null, base?: Record<string, unknown>): Record<string, unknown>;
80
+ /** Convert SpanData[] to OTLP JSON payload for /v2/traces. */
81
+ export declare function toOtlpPayload(spans: SpanData[]): Record<string, unknown>;
82
+ export declare function sendSpans(spans: SpanData[], apiKey: string, baseUrl: string, context: string): Promise<void>;