@respan/cli 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/auth/login.js +2 -2
- package/dist/commands/integrate/claude-code.js +9 -28
- package/dist/commands/integrate/codex-cli.js +7 -27
- 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 +793 -0
- package/dist/hooks/codex-cli.d.ts +1 -0
- package/dist/hooks/codex-cli.js +469 -0
- package/dist/hooks/gemini-cli.cjs +826 -0
- package/dist/hooks/gemini-cli.d.ts +1 -0
- package/dist/hooks/gemini-cli.js +563 -0
- package/dist/hooks/shared.d.ts +82 -0
- package/dist/hooks/shared.js +461 -0
- package/dist/lib/integrate.d.ts +3 -3
- package/dist/lib/integrate.js +4 -8
- package/oclif.manifest.json +466 -466
- package/package.json +6 -3
- package/dist/assets/codex_hook.py +0 -897
- package/dist/assets/hook.py +0 -1052
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,563 @@
|
|
|
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
|
+
// Use first chunk timestamp to differentiate turns within the same session
|
|
150
|
+
const turnTs = beginTime.replace(/[^0-9]/g, '').slice(0, 14);
|
|
151
|
+
const traceUniqueId = `gcli_${safeId}_${turnTs}`;
|
|
152
|
+
const rootSpanId = `gcli_${safeId}_${turnTs}_root`;
|
|
153
|
+
const threadId = `gcli_${sessionId}`;
|
|
154
|
+
// LLM config
|
|
155
|
+
const llmReq = (hookData.llm_request ?? {});
|
|
156
|
+
const reqConfig = (llmReq.config ?? {});
|
|
157
|
+
// Metadata
|
|
158
|
+
const baseMeta = { source: 'gemini-cli' };
|
|
159
|
+
if (toolTurns > 0)
|
|
160
|
+
baseMeta.tool_turns = toolTurns;
|
|
161
|
+
if (thoughtsTokens > 0)
|
|
162
|
+
baseMeta.reasoning_tokens = thoughtsTokens;
|
|
163
|
+
const metadata = buildMetadata(config, baseMeta);
|
|
164
|
+
// Root span
|
|
165
|
+
spans.push({
|
|
166
|
+
trace_unique_id: traceUniqueId,
|
|
167
|
+
thread_identifier: threadId,
|
|
168
|
+
customer_identifier: customerId,
|
|
169
|
+
span_unique_id: rootSpanId,
|
|
170
|
+
span_name: spanName,
|
|
171
|
+
span_workflow_name: workflowName,
|
|
172
|
+
model,
|
|
173
|
+
provider_id: '',
|
|
174
|
+
span_path: '',
|
|
175
|
+
input: promptMessages.length ? JSON.stringify(promptMessages) : '',
|
|
176
|
+
output: truncate(outputText, MAX_CHARS),
|
|
177
|
+
timestamp: endTime,
|
|
178
|
+
start_time: beginTime,
|
|
179
|
+
metadata,
|
|
180
|
+
...(lat !== undefined ? { latency: lat } : {}),
|
|
181
|
+
});
|
|
182
|
+
// Generation child span
|
|
183
|
+
const genSpan = {
|
|
184
|
+
trace_unique_id: traceUniqueId,
|
|
185
|
+
span_unique_id: `gcli_${safeId}_${turnTs}_gen`,
|
|
186
|
+
span_parent_id: rootSpanId,
|
|
187
|
+
span_name: 'gemini.chat',
|
|
188
|
+
span_workflow_name: workflowName,
|
|
189
|
+
span_path: 'gemini_chat',
|
|
190
|
+
model,
|
|
191
|
+
provider_id: 'google',
|
|
192
|
+
metadata: {},
|
|
193
|
+
input: promptMessages.length ? JSON.stringify(promptMessages) : '',
|
|
194
|
+
output: truncate(outputText, MAX_CHARS),
|
|
195
|
+
timestamp: endTime,
|
|
196
|
+
start_time: beginTime,
|
|
197
|
+
prompt_tokens: tokens.prompt_tokens,
|
|
198
|
+
completion_tokens: tokens.completion_tokens,
|
|
199
|
+
total_tokens: tokens.total_tokens,
|
|
200
|
+
...(lat !== undefined ? { latency: lat } : {}),
|
|
201
|
+
};
|
|
202
|
+
if (reqConfig.temperature != null)
|
|
203
|
+
genSpan.temperature = reqConfig.temperature;
|
|
204
|
+
if (reqConfig.maxOutputTokens != null)
|
|
205
|
+
genSpan.max_tokens = reqConfig.maxOutputTokens;
|
|
206
|
+
spans.push(genSpan);
|
|
207
|
+
// Reasoning span
|
|
208
|
+
if (thoughtsTokens > 0) {
|
|
209
|
+
spans.push({
|
|
210
|
+
trace_unique_id: traceUniqueId,
|
|
211
|
+
span_unique_id: `gcli_${safeId}_${turnTs}_reasoning`,
|
|
212
|
+
span_parent_id: rootSpanId,
|
|
213
|
+
span_name: 'Reasoning',
|
|
214
|
+
span_workflow_name: workflowName,
|
|
215
|
+
span_path: 'reasoning',
|
|
216
|
+
provider_id: '',
|
|
217
|
+
metadata: { reasoning_tokens: thoughtsTokens },
|
|
218
|
+
input: '',
|
|
219
|
+
output: `[Reasoning: ${thoughtsTokens} tokens]`,
|
|
220
|
+
timestamp: endTime,
|
|
221
|
+
start_time: beginTime,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Tool child spans
|
|
225
|
+
for (let i = 0; i < toolTurns; i++) {
|
|
226
|
+
const detail = toolDetails[i] ?? null;
|
|
227
|
+
const toolName = detail?.name ?? '';
|
|
228
|
+
const toolArgs = detail?.args ?? detail?.input ?? {};
|
|
229
|
+
const toolOutput = detail?.output ?? '';
|
|
230
|
+
const displayName = toolName ? toolDisplayName(toolName) : `Call ${i + 1}`;
|
|
231
|
+
const toolInputStr = toolName ? formatToolInput(toolName, toolArgs) : '';
|
|
232
|
+
const toolMeta = {};
|
|
233
|
+
if (toolName)
|
|
234
|
+
toolMeta.tool_name = toolName;
|
|
235
|
+
if (detail?.error)
|
|
236
|
+
toolMeta.error = detail.error;
|
|
237
|
+
const toolStart = detail?.start_time ?? beginTime;
|
|
238
|
+
const toolEnd = detail?.end_time ?? endTime;
|
|
239
|
+
const toolLat = latencySeconds(toolStart, toolEnd);
|
|
240
|
+
spans.push({
|
|
241
|
+
trace_unique_id: traceUniqueId,
|
|
242
|
+
span_unique_id: `gcli_${safeId}_${turnTs}_tool_${i + 1}`,
|
|
243
|
+
span_parent_id: rootSpanId,
|
|
244
|
+
span_name: `Tool: ${displayName}`,
|
|
245
|
+
span_workflow_name: workflowName,
|
|
246
|
+
span_path: toolName ? `tool_${toolName}` : 'tool_call',
|
|
247
|
+
provider_id: '',
|
|
248
|
+
metadata: toolMeta,
|
|
249
|
+
input: toolInputStr,
|
|
250
|
+
output: truncate(toolOutput, MAX_CHARS),
|
|
251
|
+
timestamp: toolEnd,
|
|
252
|
+
start_time: toolStart,
|
|
253
|
+
...(toolLat !== undefined ? { latency: toolLat } : {}),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return addDefaultsToAll(spans);
|
|
257
|
+
}
|
|
258
|
+
// ── Send spans (detached subprocess for Gemini CLI survival) ──────
|
|
259
|
+
function sendSpansDetached(spans, apiKey, baseUrl) {
|
|
260
|
+
const url = resolveTracingIngestEndpoint(baseUrl);
|
|
261
|
+
debug(`Sending ${spans.length} span(s) to ${url}: ${spans.map(s => s.span_name).join(', ')}`);
|
|
262
|
+
if (DEBUG_MODE) {
|
|
263
|
+
const debugFile = path.join(STATE_DIR, 'respan_last_payload.json');
|
|
264
|
+
fs.writeFileSync(debugFile, JSON.stringify(spans, null, 2));
|
|
265
|
+
}
|
|
266
|
+
// Convert to OTLP JSON and write to temp file for detached sender
|
|
267
|
+
const payloadFile = path.join(STATE_DIR, `respan_send_${process.pid}.json`);
|
|
268
|
+
fs.writeFileSync(payloadFile, JSON.stringify(toOtlpPayload(spans)));
|
|
269
|
+
const senderScript = `
|
|
270
|
+
const fs = require('fs');
|
|
271
|
+
const pf = ${JSON.stringify(payloadFile)};
|
|
272
|
+
try {
|
|
273
|
+
const data = fs.readFileSync(pf);
|
|
274
|
+
(async () => {
|
|
275
|
+
for (let i = 0; i < 3; i++) {
|
|
276
|
+
try {
|
|
277
|
+
const r = await fetch(${JSON.stringify(url)}, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: {
|
|
280
|
+
'Content-Type': 'application/json',
|
|
281
|
+
'X-Respan-Dogfood': '1',
|
|
282
|
+
'Authorization': 'Bearer ' + process.env.RESPAN_API_KEY,
|
|
283
|
+
},
|
|
284
|
+
body: data,
|
|
285
|
+
signal: AbortSignal.timeout(30000),
|
|
286
|
+
});
|
|
287
|
+
if (r.status < 500) break;
|
|
288
|
+
if (i < 2) await new Promise(r => setTimeout(r, 1000));
|
|
289
|
+
} catch(e) {
|
|
290
|
+
if (i < 2) await new Promise(r => setTimeout(r, 1000));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
})().finally(() => { try { fs.unlinkSync(pf); } catch {} });
|
|
294
|
+
} catch(e) { try { fs.unlinkSync(pf); } catch {} }
|
|
295
|
+
`;
|
|
296
|
+
const env = { ...process.env, RESPAN_API_KEY: apiKey };
|
|
297
|
+
try {
|
|
298
|
+
const child = execFile('node', ['-e', senderScript], {
|
|
299
|
+
env,
|
|
300
|
+
stdio: 'ignore',
|
|
301
|
+
detached: true,
|
|
302
|
+
});
|
|
303
|
+
child.unref();
|
|
304
|
+
debug('Launched sender subprocess');
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
log('ERROR', `Failed to launch sender: ${e}`);
|
|
308
|
+
try {
|
|
309
|
+
fs.unlinkSync(payloadFile);
|
|
310
|
+
}
|
|
311
|
+
catch { }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function launchDelayedSend(sessionId, sendVersion, spans, apiKey, baseUrl) {
|
|
315
|
+
// Convert to OTLP JSON before writing — detached sender posts raw bytes
|
|
316
|
+
const payloadFile = path.join(STATE_DIR, `respan_delayed_${process.pid}.json`);
|
|
317
|
+
fs.writeFileSync(payloadFile, JSON.stringify(toOtlpPayload(spans)));
|
|
318
|
+
const stateFile = statePath(sessionId);
|
|
319
|
+
const url = resolveTracingIngestEndpoint(baseUrl);
|
|
320
|
+
const script = `
|
|
321
|
+
const fs = require('fs');
|
|
322
|
+
setTimeout(async () => {
|
|
323
|
+
const sf = ${JSON.stringify(stateFile)};
|
|
324
|
+
const pf = ${JSON.stringify(payloadFile)};
|
|
325
|
+
try {
|
|
326
|
+
if (!fs.existsSync(sf)) { fs.unlinkSync(pf); process.exit(0); }
|
|
327
|
+
const state = JSON.parse(fs.readFileSync(sf, 'utf-8'));
|
|
328
|
+
if (state.send_version !== ${sendVersion}) { fs.unlinkSync(pf); process.exit(0); }
|
|
329
|
+
const data = fs.readFileSync(pf);
|
|
330
|
+
for (let i = 0; i < 3; i++) {
|
|
331
|
+
try {
|
|
332
|
+
const r = await fetch(${JSON.stringify(url)}, {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers: {
|
|
335
|
+
'Content-Type': 'application/json',
|
|
336
|
+
'X-Respan-Dogfood': '1',
|
|
337
|
+
'Authorization': 'Bearer ' + process.env.RESPAN_API_KEY,
|
|
338
|
+
},
|
|
339
|
+
body: data,
|
|
340
|
+
signal: AbortSignal.timeout(30000),
|
|
341
|
+
});
|
|
342
|
+
if (r.status < 500) break;
|
|
343
|
+
if (i < 2) await new Promise(r => setTimeout(r, 1000));
|
|
344
|
+
} catch(e) { if (i < 2) await new Promise(r => setTimeout(r, 1000)); }
|
|
345
|
+
}
|
|
346
|
+
try { fs.unlinkSync(sf); } catch {}
|
|
347
|
+
try { fs.unlinkSync(pf); } catch {}
|
|
348
|
+
} catch(e) { try { fs.unlinkSync(pf); } catch {} }
|
|
349
|
+
}, ${SEND_DELAY * 1000});
|
|
350
|
+
`;
|
|
351
|
+
const env = { ...process.env, RESPAN_API_KEY: apiKey };
|
|
352
|
+
try {
|
|
353
|
+
const child = execFile('node', ['-e', script], {
|
|
354
|
+
env,
|
|
355
|
+
stdio: 'ignore',
|
|
356
|
+
detached: true,
|
|
357
|
+
});
|
|
358
|
+
child.unref();
|
|
359
|
+
debug(`Launched delayed sender (version=${sendVersion}, delay=${SEND_DELAY}s)`);
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
log('ERROR', `Failed to launch delayed sender: ${e}`);
|
|
363
|
+
try {
|
|
364
|
+
fs.unlinkSync(payloadFile);
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// ── BeforeTool / AfterTool handlers ──────────────────────────────
|
|
370
|
+
function processBeforeTool(hookData) {
|
|
371
|
+
const sessionId = String(hookData.session_id ?? 'unknown');
|
|
372
|
+
const toolName = String(hookData.tool_name ?? '');
|
|
373
|
+
const toolInput = hookData.tool_input ?? {};
|
|
374
|
+
debug(`BeforeTool: ${toolName}`);
|
|
375
|
+
const state = loadStreamState(sessionId);
|
|
376
|
+
const pending = state.pending_tools ?? [];
|
|
377
|
+
pending.push({ name: toolName, input: toolInput, start_time: nowISO() });
|
|
378
|
+
state.pending_tools = pending;
|
|
379
|
+
saveStreamState(sessionId, state);
|
|
380
|
+
process.stdout.write('{}\n');
|
|
381
|
+
}
|
|
382
|
+
function processAfterTool(hookData) {
|
|
383
|
+
const sessionId = String(hookData.session_id ?? 'unknown');
|
|
384
|
+
const toolName = String(hookData.tool_name ?? '');
|
|
385
|
+
const toolResponse = (hookData.tool_response ?? {});
|
|
386
|
+
const output = String(toolResponse.llmContent ?? '');
|
|
387
|
+
const error = toolResponse.error ? String(toolResponse.error) : undefined;
|
|
388
|
+
debug(`AfterTool: ${toolName}, output_len=${output.length}, error=${error}`);
|
|
389
|
+
const state = loadStreamState(sessionId);
|
|
390
|
+
const pending = state.pending_tools ?? [];
|
|
391
|
+
const completed = state.tool_details ?? [];
|
|
392
|
+
// Match last pending tool with this name
|
|
393
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
394
|
+
if (pending[i].name === toolName) {
|
|
395
|
+
const detail = pending.splice(i, 1)[0];
|
|
396
|
+
detail.output = output;
|
|
397
|
+
detail.end_time = nowISO();
|
|
398
|
+
if (error)
|
|
399
|
+
detail.error = error;
|
|
400
|
+
completed.push(detail);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
state.pending_tools = pending;
|
|
405
|
+
state.tool_details = completed;
|
|
406
|
+
saveStreamState(sessionId, state);
|
|
407
|
+
process.stdout.write('{}\n');
|
|
408
|
+
}
|
|
409
|
+
// ── AfterModel chunk processing ──────────────────────────────────
|
|
410
|
+
function processChunk(hookData) {
|
|
411
|
+
const sessionId = String(hookData.session_id ?? 'unknown');
|
|
412
|
+
const llmResp = (hookData.llm_response ?? {});
|
|
413
|
+
const chunkText = String(llmResp.text ?? '') || '';
|
|
414
|
+
const usage = (llmResp.usageMetadata ?? {});
|
|
415
|
+
const completionTokens = Number(usage.candidatesTokenCount ?? 0);
|
|
416
|
+
const thoughtsTokens = Number(usage.thoughtsTokenCount ?? 0);
|
|
417
|
+
// Check for finish signal and tool calls
|
|
418
|
+
const candidates = (llmResp.candidates ?? []);
|
|
419
|
+
let finishReason = '';
|
|
420
|
+
let hasToolCall = false;
|
|
421
|
+
const chunkToolDetails = [];
|
|
422
|
+
if (candidates.length > 0 && typeof candidates[0] === 'object') {
|
|
423
|
+
finishReason = String(candidates[0].finishReason ?? '');
|
|
424
|
+
const content = (candidates[0].content ?? {});
|
|
425
|
+
if (typeof content === 'object') {
|
|
426
|
+
for (const part of (content.parts ?? [])) {
|
|
427
|
+
if (typeof part !== 'object')
|
|
428
|
+
continue;
|
|
429
|
+
const fc = (part.functionCall ?? part.toolCall);
|
|
430
|
+
if (fc) {
|
|
431
|
+
hasToolCall = true;
|
|
432
|
+
if (typeof fc === 'object') {
|
|
433
|
+
chunkToolDetails.push({
|
|
434
|
+
name: String(fc.name ?? ''),
|
|
435
|
+
args: fc.args ?? {},
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const messages = (hookData.llm_request?.messages ?? []);
|
|
443
|
+
const currentMsgCount = messages.length;
|
|
444
|
+
let state = loadStreamState(sessionId);
|
|
445
|
+
const isFinished = ['STOP', 'MAX_TOKENS', 'SAFETY'].includes(finishReason);
|
|
446
|
+
// Detect tool-call resumption via message count
|
|
447
|
+
const savedMsgCount = state.msg_count ?? 0;
|
|
448
|
+
let toolCallDetected = false;
|
|
449
|
+
if (savedMsgCount > 0 && currentMsgCount > savedMsgCount) {
|
|
450
|
+
const newMsgs = messages.slice(savedMsgCount);
|
|
451
|
+
const hasNewUserMsg = newMsgs.some((m) => m.role === 'user');
|
|
452
|
+
if (hasNewUserMsg) {
|
|
453
|
+
debug(`New user message detected (msgs ${savedMsgCount} → ${currentMsgCount}), starting fresh turn`);
|
|
454
|
+
clearStreamState(sessionId);
|
|
455
|
+
state = { accumulated_text: '', last_tokens: 0, first_chunk_time: '' };
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
state.tool_turns = (state.tool_turns ?? 0) + 1;
|
|
459
|
+
state.send_version = (state.send_version ?? 0) + 1;
|
|
460
|
+
toolCallDetected = true;
|
|
461
|
+
debug(`Tool call detected via msg_count (${savedMsgCount} → ${currentMsgCount}), tool_turns=${state.tool_turns}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
state.msg_count = currentMsgCount;
|
|
465
|
+
// Accumulate text
|
|
466
|
+
if (chunkText) {
|
|
467
|
+
if (!state.first_chunk_time)
|
|
468
|
+
state.first_chunk_time = nowISO();
|
|
469
|
+
state.accumulated_text += chunkText;
|
|
470
|
+
state.last_tokens = completionTokens || state.last_tokens;
|
|
471
|
+
if (thoughtsTokens > 0)
|
|
472
|
+
state.thoughts_tokens = thoughtsTokens;
|
|
473
|
+
saveStreamState(sessionId, state);
|
|
474
|
+
debug(`Accumulated chunk: +${chunkText.length} chars, total=${state.accumulated_text.length}`);
|
|
475
|
+
}
|
|
476
|
+
// Tool call in response parts
|
|
477
|
+
const isToolTurn = hasToolCall || ['TOOL_CALLS', 'FUNCTION_CALL', 'TOOL_USE'].includes(finishReason);
|
|
478
|
+
if (isToolTurn) {
|
|
479
|
+
state.tool_turns = (state.tool_turns ?? 0) + 1;
|
|
480
|
+
state.send_version = (state.send_version ?? 0) + 1;
|
|
481
|
+
if (chunkToolDetails.length) {
|
|
482
|
+
state.tool_details = [...(state.tool_details ?? []), ...chunkToolDetails];
|
|
483
|
+
}
|
|
484
|
+
saveStreamState(sessionId, state);
|
|
485
|
+
debug(`Tool call via response parts (finish=${finishReason}), tool_turns=${state.tool_turns}`);
|
|
486
|
+
process.stdout.write('{}\n');
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Detect completion and send
|
|
490
|
+
const hasNewText = state.accumulated_text.length > (state.last_send_text_len ?? 0);
|
|
491
|
+
const shouldSend = ((!toolCallDetected || isFinished)
|
|
492
|
+
&& hasNewText
|
|
493
|
+
&& state.accumulated_text
|
|
494
|
+
&& (!chunkText || isFinished));
|
|
495
|
+
process.stdout.write('{}\n');
|
|
496
|
+
if (!shouldSend) {
|
|
497
|
+
if (toolCallDetected)
|
|
498
|
+
saveStreamState(sessionId, state);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const creds = resolveCredentials();
|
|
502
|
+
if (!creds) {
|
|
503
|
+
log('ERROR', 'No API key found. Run: respan auth login');
|
|
504
|
+
clearStreamState(sessionId);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const finalPrompt = Number(usage.promptTokenCount ?? 0);
|
|
508
|
+
const finalCompletion = completionTokens || state.last_tokens;
|
|
509
|
+
const finalTotal = Number(usage.totalTokenCount ?? 0) || (finalPrompt + finalCompletion);
|
|
510
|
+
const tok = { prompt_tokens: finalPrompt, completion_tokens: finalCompletion, total_tokens: finalTotal };
|
|
511
|
+
const config = loadRespanConfig(path.join(os.homedir(), '.gemini', 'respan.json'));
|
|
512
|
+
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);
|
|
513
|
+
// Method b: text + STOP → send immediately
|
|
514
|
+
if (isFinished && chunkText) {
|
|
515
|
+
debug(`Immediate send (text+STOP, tool_turns=${state.tool_turns ?? 0}), ${state.accumulated_text.length} chars`);
|
|
516
|
+
sendSpansDetached(spans, creds.apiKey, creds.baseUrl);
|
|
517
|
+
clearStreamState(sessionId);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// Method a: delayed send
|
|
521
|
+
state.send_version = (state.send_version ?? 0) + 1;
|
|
522
|
+
state.last_send_text_len = state.accumulated_text.length;
|
|
523
|
+
saveStreamState(sessionId, state);
|
|
524
|
+
debug(`Delayed send (version=${state.send_version}, delay=${SEND_DELAY}s), ${state.accumulated_text.length} chars`);
|
|
525
|
+
launchDelayedSend(sessionId, state.send_version, spans, creds.apiKey, creds.baseUrl);
|
|
526
|
+
}
|
|
527
|
+
// ── Main ──────────────────────────────────────────────────────────
|
|
528
|
+
function main() {
|
|
529
|
+
try {
|
|
530
|
+
const raw = fs.readFileSync(0, 'utf-8');
|
|
531
|
+
if (!raw.trim()) {
|
|
532
|
+
process.stdout.write('{}\n');
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const hookData = JSON.parse(raw);
|
|
536
|
+
const event = String(hookData.hook_event_name ?? '');
|
|
537
|
+
const unlock = acquireLock(LOCK_PATH);
|
|
538
|
+
try {
|
|
539
|
+
if (event === 'BeforeTool') {
|
|
540
|
+
processBeforeTool(hookData);
|
|
541
|
+
}
|
|
542
|
+
else if (event === 'AfterTool') {
|
|
543
|
+
processAfterTool(hookData);
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
processChunk(hookData);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
unlock?.();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
if (e instanceof SyntaxError) {
|
|
555
|
+
log('ERROR', `Invalid JSON from stdin: ${e}`);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
log('ERROR', `Hook error: ${e}`);
|
|
559
|
+
}
|
|
560
|
+
process.stdout.write('{}\n');
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
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>;
|