@neatlogs/claude-code 0.1.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/.claude-plugin/plugin.json +6 -0
- package/dist/chunk-RAUPOAL6.js +47 -0
- package/dist/cli.js +1005 -0
- package/dist/index.cjs +1121 -0
- package/dist/index.d.cts +93 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +1080 -0
- package/dist/setup-4EA4K7RF.js +73 -0
- package/hooks/hooks.json +17 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
// src/hook-handler.ts
|
|
2
|
+
import { appendFileSync as appendFileSync2 } from "fs";
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "fs";
|
|
6
|
+
import { homedir, userInfo } from "os";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
var CONFIG_PATH = join(homedir(), ".config", "neatlogs", "config.json");
|
|
9
|
+
function loadConfig() {
|
|
10
|
+
let file = {};
|
|
11
|
+
try {
|
|
12
|
+
if (existsSync(CONFIG_PATH)) {
|
|
13
|
+
file = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
14
|
+
}
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.warn(`[neatlogs/claude-code] Failed to read config: ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
const systemUser = (() => {
|
|
19
|
+
try {
|
|
20
|
+
return userInfo().username;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.warn(`[neatlogs/claude-code] Could not resolve OS username: ${err.message}`);
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
return {
|
|
27
|
+
apiKey: process.env["NEATLOGS_API_KEY"] ?? file.api_key ?? "",
|
|
28
|
+
endpoint: process.env["NEATLOGS_ENDPOINT"] ?? file.endpoint ?? "https://staging-cloud.neatlogs.com",
|
|
29
|
+
userId: process.env["NEATLOGS_USER_ID"] ?? file.user_id ?? systemUser,
|
|
30
|
+
debug: process.env["NEATLOGS_DEBUG"] === "true" ? true : file.debug ?? false
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function getConfigPath() {
|
|
34
|
+
return CONFIG_PATH;
|
|
35
|
+
}
|
|
36
|
+
function updateConfig(patch) {
|
|
37
|
+
const dir = dirname(CONFIG_PATH);
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
let existing = {};
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(CONFIG_PATH)) {
|
|
42
|
+
existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn(`[neatlogs/claude-code] Failed to read existing config: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ ...existing, ...patch }, null, 2) + "\n", "utf-8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/trace-shipper.ts
|
|
51
|
+
import protobuf from "protobufjs";
|
|
52
|
+
|
|
53
|
+
// src/package-info.ts
|
|
54
|
+
var PACKAGE_NAME = "@neatlogs/claude-code";
|
|
55
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
56
|
+
|
|
57
|
+
// src/trace-shipper.ts
|
|
58
|
+
var OTLP_PROTO_JSON = {
|
|
59
|
+
nested: {
|
|
60
|
+
opentelemetry: { nested: { proto: { nested: {
|
|
61
|
+
common: { nested: { v1: { nested: {
|
|
62
|
+
AnyValue: {
|
|
63
|
+
oneofs: { value: { oneof: ["stringValue", "boolValue", "intValue", "doubleValue", "arrayValue", "kvlistValue", "bytesValue"] } },
|
|
64
|
+
fields: {
|
|
65
|
+
stringValue: { type: "string", id: 1 },
|
|
66
|
+
boolValue: { type: "bool", id: 2 },
|
|
67
|
+
intValue: { type: "int64", id: 3 },
|
|
68
|
+
doubleValue: { type: "double", id: 4 },
|
|
69
|
+
arrayValue: { type: "ArrayValue", id: 5 },
|
|
70
|
+
kvlistValue: { type: "KeyValueList", id: 6 },
|
|
71
|
+
bytesValue: { type: "bytes", id: 7 }
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
ArrayValue: { fields: { values: { rule: "repeated", type: "AnyValue", id: 1 } } },
|
|
75
|
+
KeyValueList: { fields: { values: { rule: "repeated", type: "KeyValue", id: 1 } } },
|
|
76
|
+
KeyValue: { fields: { key: { type: "string", id: 1 }, value: { type: "AnyValue", id: 2 } } },
|
|
77
|
+
InstrumentationScope: { fields: { name: { type: "string", id: 1 }, version: { type: "string", id: 2 } } }
|
|
78
|
+
} } } },
|
|
79
|
+
resource: { nested: { v1: { nested: {
|
|
80
|
+
Resource: { fields: { attributes: { rule: "repeated", type: "opentelemetry.proto.common.v1.KeyValue", id: 1 } } }
|
|
81
|
+
} } } },
|
|
82
|
+
trace: { nested: { v1: { nested: {
|
|
83
|
+
ResourceSpans: { fields: {
|
|
84
|
+
resource: { type: "opentelemetry.proto.resource.v1.Resource", id: 1 },
|
|
85
|
+
scopeSpans: { rule: "repeated", type: "ScopeSpans", id: 2 }
|
|
86
|
+
} },
|
|
87
|
+
ScopeSpans: { fields: {
|
|
88
|
+
scope: { type: "opentelemetry.proto.common.v1.InstrumentationScope", id: 1 },
|
|
89
|
+
spans: { rule: "repeated", type: "Span", id: 2 }
|
|
90
|
+
} },
|
|
91
|
+
Span: { fields: {
|
|
92
|
+
traceId: { type: "bytes", id: 1 },
|
|
93
|
+
spanId: { type: "bytes", id: 2 },
|
|
94
|
+
traceState: { type: "string", id: 3 },
|
|
95
|
+
parentSpanId: { type: "bytes", id: 4 },
|
|
96
|
+
name: { type: "string", id: 5 },
|
|
97
|
+
kind: { type: "SpanKind", id: 6 },
|
|
98
|
+
startTimeUnixNano: { type: "fixed64", id: 7 },
|
|
99
|
+
endTimeUnixNano: { type: "fixed64", id: 8 },
|
|
100
|
+
attributes: { rule: "repeated", type: "opentelemetry.proto.common.v1.KeyValue", id: 9 },
|
|
101
|
+
droppedAttributesCount: { type: "uint32", id: 10 },
|
|
102
|
+
events: { rule: "repeated", type: "SpanEvent", id: 11 },
|
|
103
|
+
droppedEventsCount: { type: "uint32", id: 12 },
|
|
104
|
+
links: { rule: "repeated", type: "SpanLink", id: 13 },
|
|
105
|
+
droppedLinksCount: { type: "uint32", id: 14 },
|
|
106
|
+
status: { type: "Status", id: 15 }
|
|
107
|
+
} },
|
|
108
|
+
SpanEvent: { fields: {
|
|
109
|
+
timeUnixNano: { type: "fixed64", id: 1 },
|
|
110
|
+
name: { type: "string", id: 2 },
|
|
111
|
+
attributes: { rule: "repeated", type: "opentelemetry.proto.common.v1.KeyValue", id: 3 }
|
|
112
|
+
} },
|
|
113
|
+
SpanLink: { fields: {
|
|
114
|
+
traceId: { type: "bytes", id: 1 },
|
|
115
|
+
spanId: { type: "bytes", id: 2 },
|
|
116
|
+
traceState: { type: "string", id: 3 },
|
|
117
|
+
attributes: { rule: "repeated", type: "opentelemetry.proto.common.v1.KeyValue", id: 4 }
|
|
118
|
+
} },
|
|
119
|
+
Status: { fields: { message: { type: "string", id: 2 }, code: { type: "StatusCode", id: 3 } } },
|
|
120
|
+
StatusCode: { values: { STATUS_CODE_UNSET: 0, STATUS_CODE_OK: 1, STATUS_CODE_ERROR: 2 } },
|
|
121
|
+
SpanKind: { values: {
|
|
122
|
+
SPAN_KIND_UNSPECIFIED: 0,
|
|
123
|
+
SPAN_KIND_INTERNAL: 1,
|
|
124
|
+
SPAN_KIND_SERVER: 2,
|
|
125
|
+
SPAN_KIND_CLIENT: 3,
|
|
126
|
+
SPAN_KIND_PRODUCER: 4,
|
|
127
|
+
SPAN_KIND_CONSUMER: 5
|
|
128
|
+
} }
|
|
129
|
+
} } } },
|
|
130
|
+
collector: { nested: { trace: { nested: { v1: { nested: {
|
|
131
|
+
ExportTraceServiceRequest: { fields: { resourceSpans: { rule: "repeated", type: "opentelemetry.proto.trace.v1.ResourceSpans", id: 1 } } }
|
|
132
|
+
} } } } } }
|
|
133
|
+
} } } }
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var protoRoot = protobuf.Root.fromJSON(OTLP_PROTO_JSON);
|
|
137
|
+
var ExportTraceServiceRequest = protoRoot.lookupType(
|
|
138
|
+
"opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"
|
|
139
|
+
);
|
|
140
|
+
var SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 };
|
|
141
|
+
function randomBytes(length) {
|
|
142
|
+
const out = new Uint8Array(length);
|
|
143
|
+
const crypto = globalThis.crypto;
|
|
144
|
+
if (crypto && typeof crypto.getRandomValues === "function") {
|
|
145
|
+
crypto.getRandomValues(out);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < out.length; i++) out[i] = Math.floor(Math.random() * 256);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
function generateTraceId() {
|
|
152
|
+
return randomBytes(16);
|
|
153
|
+
}
|
|
154
|
+
function generateSpanId() {
|
|
155
|
+
return randomBytes(8);
|
|
156
|
+
}
|
|
157
|
+
function bytesToHex(bytes) {
|
|
158
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
159
|
+
}
|
|
160
|
+
function hexToBytes(hex) {
|
|
161
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
162
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
163
|
+
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
164
|
+
}
|
|
165
|
+
return bytes;
|
|
166
|
+
}
|
|
167
|
+
function nowNanoString() {
|
|
168
|
+
return (BigInt(Date.now()) * 1000000n).toString();
|
|
169
|
+
}
|
|
170
|
+
function msToNanoString(ms) {
|
|
171
|
+
return (BigInt(ms) * 1000000n).toString();
|
|
172
|
+
}
|
|
173
|
+
function attrString(key, value) {
|
|
174
|
+
if (value === void 0) return void 0;
|
|
175
|
+
return { key, value: { stringValue: value } };
|
|
176
|
+
}
|
|
177
|
+
function attrInt(key, value) {
|
|
178
|
+
if (value === void 0 || !Number.isFinite(value)) return void 0;
|
|
179
|
+
return { key, value: { intValue: String(Math.trunc(value)) } };
|
|
180
|
+
}
|
|
181
|
+
var TraceShipper = class {
|
|
182
|
+
apiKey;
|
|
183
|
+
endpoint;
|
|
184
|
+
debug;
|
|
185
|
+
maxRetries;
|
|
186
|
+
workflowName;
|
|
187
|
+
queue = [];
|
|
188
|
+
prefix = "[neatlogs/claude-code]";
|
|
189
|
+
constructor(opts) {
|
|
190
|
+
this.apiKey = opts.apiKey;
|
|
191
|
+
this.endpoint = opts.endpoint.endsWith("/") ? opts.endpoint.slice(0, -1) : opts.endpoint;
|
|
192
|
+
this.debug = opts.debug;
|
|
193
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
194
|
+
this.workflowName = opts.workflowName || "";
|
|
195
|
+
}
|
|
196
|
+
enqueue(span) {
|
|
197
|
+
this.queue.push(span);
|
|
198
|
+
}
|
|
199
|
+
async flush() {
|
|
200
|
+
if (this.queue.length === 0) return;
|
|
201
|
+
if (!this.apiKey) {
|
|
202
|
+
if (this.debug) console.warn(`${this.prefix} No API key \u2014 skipping export`);
|
|
203
|
+
this.queue = [];
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const spans = this.queue.splice(0);
|
|
207
|
+
const payload = this.buildProtobuf(spans);
|
|
208
|
+
const url = `${this.endpoint}/v1/traces`;
|
|
209
|
+
if (this.debug) {
|
|
210
|
+
console.log(`${this.prefix} Shipping ${spans.length} spans to ${url}`);
|
|
211
|
+
}
|
|
212
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
const resp = await fetch(url, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: {
|
|
217
|
+
"Content-Type": "application/x-protobuf",
|
|
218
|
+
"x-api-key": this.apiKey
|
|
219
|
+
},
|
|
220
|
+
body: payload
|
|
221
|
+
});
|
|
222
|
+
if (resp.ok) {
|
|
223
|
+
if (this.debug) console.log(`${this.prefix} Shipped ${spans.length} spans`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (resp.status === 401) {
|
|
227
|
+
console.warn(`${this.prefix} Invalid API key (401) \u2014 dropping ${spans.length} spans`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (resp.status < 500 && resp.status !== 429) {
|
|
231
|
+
const text = await resp.text().catch(() => "");
|
|
232
|
+
console.warn(`${this.prefix} Server returned ${resp.status}: ${text} \u2014 dropping spans`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (this.debug) {
|
|
236
|
+
console.warn(`${this.prefix} Attempt ${attempt}/${this.maxRetries} failed: HTTP ${resp.status}`);
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (this.debug) {
|
|
240
|
+
console.warn(`${this.prefix} Attempt ${attempt}/${this.maxRetries} error: ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (attempt < this.maxRetries) {
|
|
244
|
+
await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
console.warn(`${this.prefix} Failed to ship ${spans.length} spans after ${this.maxRetries} attempts`);
|
|
248
|
+
}
|
|
249
|
+
async shutdown() {
|
|
250
|
+
await this.flush();
|
|
251
|
+
}
|
|
252
|
+
buildProtobuf(spans) {
|
|
253
|
+
const protoSpans = spans.map((span) => ({
|
|
254
|
+
traceId: span.traceId,
|
|
255
|
+
spanId: span.spanId,
|
|
256
|
+
parentSpanId: span.parentSpanId || void 0,
|
|
257
|
+
name: span.name,
|
|
258
|
+
kind: span.kind,
|
|
259
|
+
startTimeUnixNano: this.nanoStringToLong(span.startTimeUnixNano),
|
|
260
|
+
endTimeUnixNano: this.nanoStringToLong(span.endTimeUnixNano),
|
|
261
|
+
attributes: span.attributes.map((a) => ({
|
|
262
|
+
key: a.key,
|
|
263
|
+
value: a.value.intValue !== void 0 ? { intValue: this.nanoStringToLong(a.value.intValue) } : a.value
|
|
264
|
+
})),
|
|
265
|
+
status: span.status ? { code: span.status.code, message: span.status.message } : void 0
|
|
266
|
+
}));
|
|
267
|
+
const resourceAttributes = [
|
|
268
|
+
{ key: "service.name", value: { stringValue: "neatlogs.claude-code" } },
|
|
269
|
+
{ key: "service.version", value: { stringValue: PACKAGE_VERSION } }
|
|
270
|
+
];
|
|
271
|
+
if (this.workflowName) {
|
|
272
|
+
resourceAttributes.push({ key: "neatlogs.workflow_name", value: { stringValue: this.workflowName } });
|
|
273
|
+
}
|
|
274
|
+
const message = {
|
|
275
|
+
resourceSpans: [{
|
|
276
|
+
resource: { attributes: resourceAttributes },
|
|
277
|
+
scopeSpans: [{
|
|
278
|
+
scope: { name: PACKAGE_NAME, version: PACKAGE_VERSION },
|
|
279
|
+
spans: protoSpans
|
|
280
|
+
}]
|
|
281
|
+
}]
|
|
282
|
+
};
|
|
283
|
+
const errMsg = ExportTraceServiceRequest.verify(message);
|
|
284
|
+
if (errMsg && this.debug) {
|
|
285
|
+
console.warn(`${this.prefix} Proto verification warning: ${errMsg}`);
|
|
286
|
+
}
|
|
287
|
+
return ExportTraceServiceRequest.encode(
|
|
288
|
+
ExportTraceServiceRequest.fromObject(message)
|
|
289
|
+
).finish();
|
|
290
|
+
}
|
|
291
|
+
nanoStringToLong(nanoStr) {
|
|
292
|
+
const big = BigInt(nanoStr);
|
|
293
|
+
const low = Number(big & 0xFFFFFFFFn);
|
|
294
|
+
const high = Number(big >> 32n & 0xFFFFFFFFn);
|
|
295
|
+
return { low, high, unsigned: true };
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// src/event-mapper.ts
|
|
300
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync } from "fs";
|
|
301
|
+
|
|
302
|
+
// src/state.ts
|
|
303
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync } from "fs";
|
|
304
|
+
import { tmpdir } from "os";
|
|
305
|
+
import { join as join2 } from "path";
|
|
306
|
+
var STATE_DIR = join2(tmpdir(), "neatlogs-claude-code");
|
|
307
|
+
function ensureStateDir() {
|
|
308
|
+
mkdirSync2(STATE_DIR, { recursive: true });
|
|
309
|
+
}
|
|
310
|
+
function safeKey(key) {
|
|
311
|
+
return key.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
312
|
+
}
|
|
313
|
+
function statePath(key) {
|
|
314
|
+
const sanitized = safeKey(key);
|
|
315
|
+
const full = join2(STATE_DIR, sanitized);
|
|
316
|
+
if (!full.startsWith(STATE_DIR)) {
|
|
317
|
+
throw new Error("Path traversal detected");
|
|
318
|
+
}
|
|
319
|
+
return full;
|
|
320
|
+
}
|
|
321
|
+
function writeState(key, value) {
|
|
322
|
+
ensureStateDir();
|
|
323
|
+
writeFileSync2(statePath(key), value, "utf-8");
|
|
324
|
+
}
|
|
325
|
+
function readState(key) {
|
|
326
|
+
const filePath = statePath(key);
|
|
327
|
+
if (!existsSync2(filePath)) return void 0;
|
|
328
|
+
return readFileSync2(filePath, "utf-8");
|
|
329
|
+
}
|
|
330
|
+
function deleteState(key) {
|
|
331
|
+
const filePath = statePath(key);
|
|
332
|
+
if (existsSync2(filePath)) {
|
|
333
|
+
unlinkSync(filePath);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function listStateFiles(prefix) {
|
|
337
|
+
if (!existsSync2(STATE_DIR)) return [];
|
|
338
|
+
return readdirSync(STATE_DIR).filter((f) => f.startsWith(safeKey(prefix)));
|
|
339
|
+
}
|
|
340
|
+
function cleanupStateFiles(prefix) {
|
|
341
|
+
for (const file of listStateFiles(prefix)) {
|
|
342
|
+
unlinkSync(join2(STATE_DIR, file));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/transcript.ts
|
|
347
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
348
|
+
function isToolResultContentBlock(value) {
|
|
349
|
+
return Boolean(
|
|
350
|
+
value && typeof value === "object" && "type" in value && value.type === "tool_result"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
function isTopLevelUserPrompt(entry) {
|
|
354
|
+
if (entry.type !== "user") return false;
|
|
355
|
+
const content = entry.message?.content;
|
|
356
|
+
if (typeof content === "string") return true;
|
|
357
|
+
if (!Array.isArray(content)) return false;
|
|
358
|
+
return content.some((block) => !isToolResultContentBlock(block));
|
|
359
|
+
}
|
|
360
|
+
function isToolResultUserEntry(entry) {
|
|
361
|
+
if (entry.type !== "user") return false;
|
|
362
|
+
const content = entry.message?.content;
|
|
363
|
+
if (!Array.isArray(content)) return false;
|
|
364
|
+
return content.length > 0 && content.every((b) => isToolResultContentBlock(b));
|
|
365
|
+
}
|
|
366
|
+
function parseTranscript(transcriptPath) {
|
|
367
|
+
if (!existsSync3(transcriptPath)) return void 0;
|
|
368
|
+
const content = readFileSync3(transcriptPath, "utf-8");
|
|
369
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
370
|
+
const summary = {
|
|
371
|
+
totalInputTokens: 0,
|
|
372
|
+
totalOutputTokens: 0,
|
|
373
|
+
totalCacheReadTokens: 0,
|
|
374
|
+
totalCacheCreationTokens: 0,
|
|
375
|
+
turnCount: 0,
|
|
376
|
+
toolsUsed: [],
|
|
377
|
+
hasThinking: false,
|
|
378
|
+
lastTurnTextBlocks: [],
|
|
379
|
+
llmCallPhases: []
|
|
380
|
+
};
|
|
381
|
+
const toolNames = /* @__PURE__ */ new Set();
|
|
382
|
+
let lastUsage;
|
|
383
|
+
let currentTurnTextBlocks = [];
|
|
384
|
+
let currentPhases = [];
|
|
385
|
+
let activePhase;
|
|
386
|
+
function finalizePhase() {
|
|
387
|
+
if (activePhase && (activePhase.text || activePhase.thinking || activePhase.toolCalls.length > 0)) {
|
|
388
|
+
currentPhases.push(activePhase);
|
|
389
|
+
}
|
|
390
|
+
activePhase = void 0;
|
|
391
|
+
}
|
|
392
|
+
function ensurePhase() {
|
|
393
|
+
if (!activePhase) {
|
|
394
|
+
activePhase = { text: "", thinking: "", toolCalls: [], inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, hasThinking: false };
|
|
395
|
+
}
|
|
396
|
+
return activePhase;
|
|
397
|
+
}
|
|
398
|
+
for (const line of lines) {
|
|
399
|
+
let entry;
|
|
400
|
+
try {
|
|
401
|
+
entry = JSON.parse(line);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (process.env["NEATLOGS_DEBUG"] === "true") {
|
|
404
|
+
console.warn(`[neatlogs/claude-code] Skipping malformed transcript line: ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (entry.type === "system" && entry.subtype === "turn_duration") {
|
|
409
|
+
if (typeof entry.durationMs === "number") {
|
|
410
|
+
summary.totalDurationMs = (summary.totalDurationMs ?? 0) + entry.durationMs;
|
|
411
|
+
}
|
|
412
|
+
if (typeof entry.version === "string") summary.codeVersion = entry.version;
|
|
413
|
+
if (typeof entry.gitBranch === "string") summary.gitBranch = entry.gitBranch;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (isTopLevelUserPrompt(entry)) {
|
|
417
|
+
currentTurnTextBlocks = [];
|
|
418
|
+
finalizePhase();
|
|
419
|
+
currentPhases = [];
|
|
420
|
+
}
|
|
421
|
+
if (isToolResultUserEntry(entry)) {
|
|
422
|
+
finalizePhase();
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (entry.type !== "assistant") continue;
|
|
426
|
+
const msg = entry.message;
|
|
427
|
+
if (!msg) continue;
|
|
428
|
+
summary.turnCount++;
|
|
429
|
+
if (typeof msg.model === "string") summary.model = msg.model;
|
|
430
|
+
if (typeof msg.stop_reason === "string") summary.stopReason = msg.stop_reason;
|
|
431
|
+
if (typeof entry.version === "string") summary.codeVersion = entry.version;
|
|
432
|
+
if (typeof entry.gitBranch === "string") summary.gitBranch = entry.gitBranch;
|
|
433
|
+
const usage = msg.usage;
|
|
434
|
+
if (usage) {
|
|
435
|
+
summary.totalInputTokens += usage.input_tokens ?? 0;
|
|
436
|
+
summary.totalOutputTokens += usage.output_tokens ?? 0;
|
|
437
|
+
summary.totalCacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
|
438
|
+
summary.totalCacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
|
|
439
|
+
if (typeof usage.service_tier === "string") summary.serviceTier = usage.service_tier;
|
|
440
|
+
lastUsage = usage;
|
|
441
|
+
}
|
|
442
|
+
const phase = ensurePhase();
|
|
443
|
+
const entryTimestamp = typeof entry.timestamp === "string" ? entry.timestamp : void 0;
|
|
444
|
+
if (entryTimestamp) {
|
|
445
|
+
if (!phase.startTimestamp) phase.startTimestamp = entryTimestamp;
|
|
446
|
+
phase.endTimestamp = entryTimestamp;
|
|
447
|
+
}
|
|
448
|
+
if (typeof msg.model === "string") phase.model = msg.model;
|
|
449
|
+
if (usage) {
|
|
450
|
+
phase.inputTokens += usage.input_tokens ?? 0;
|
|
451
|
+
phase.outputTokens += usage.output_tokens ?? 0;
|
|
452
|
+
phase.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
|
453
|
+
phase.cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
|
|
454
|
+
}
|
|
455
|
+
const msgContent = msg.content;
|
|
456
|
+
if (Array.isArray(msgContent)) {
|
|
457
|
+
for (const block of msgContent) {
|
|
458
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
459
|
+
toolNames.add(block.name);
|
|
460
|
+
phase.toolCalls.push({ id: block.id, name: block.name, input: block.input });
|
|
461
|
+
}
|
|
462
|
+
if (block.type === "thinking") {
|
|
463
|
+
summary.hasThinking = true;
|
|
464
|
+
phase.hasThinking = true;
|
|
465
|
+
if (typeof block.thinking === "string" && block.thinking.trim()) {
|
|
466
|
+
phase.thinking = phase.thinking ? phase.thinking + "\n" + block.thinking : block.thinking;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
470
|
+
currentTurnTextBlocks.push(block.text);
|
|
471
|
+
phase.text = phase.text ? phase.text + "\n" + block.text : block.text;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
summary.lastTurnTextBlocks = [...currentTurnTextBlocks];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
finalizePhase();
|
|
478
|
+
if (lastUsage) {
|
|
479
|
+
summary.lastTurnInputTokens = lastUsage.input_tokens;
|
|
480
|
+
summary.lastTurnOutputTokens = lastUsage.output_tokens;
|
|
481
|
+
summary.lastTurnCacheReadTokens = lastUsage.cache_read_input_tokens;
|
|
482
|
+
}
|
|
483
|
+
summary.toolsUsed = [...toolNames].sort();
|
|
484
|
+
if (summary.lastTurnTextBlocks.length > 0) {
|
|
485
|
+
summary.lastTurnFullOutput = summary.lastTurnTextBlocks.join("\n\n");
|
|
486
|
+
}
|
|
487
|
+
summary.llmCallPhases = currentPhases;
|
|
488
|
+
return summary;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/event-mapper.ts
|
|
492
|
+
function safeStringify(value) {
|
|
493
|
+
if (value === void 0 || value === null) return void 0;
|
|
494
|
+
try {
|
|
495
|
+
return JSON.stringify(value);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.warn(`[neatlogs/claude-code] JSON.stringify failed: ${err.message}`);
|
|
498
|
+
return String(value);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function sessionTraceKey(sessionId) {
|
|
502
|
+
return `trace_${sessionId}`;
|
|
503
|
+
}
|
|
504
|
+
function turnSpanKey(sessionId) {
|
|
505
|
+
return `turn_${sessionId}`;
|
|
506
|
+
}
|
|
507
|
+
function subagentSpanKey(agentId) {
|
|
508
|
+
return `subagentspan_${agentId}`;
|
|
509
|
+
}
|
|
510
|
+
function saveSpanContext(key, ctx) {
|
|
511
|
+
writeState(key, JSON.stringify(ctx));
|
|
512
|
+
}
|
|
513
|
+
function readSpanContext(key) {
|
|
514
|
+
const raw = readState(key);
|
|
515
|
+
if (!raw) return void 0;
|
|
516
|
+
try {
|
|
517
|
+
return JSON.parse(raw);
|
|
518
|
+
} catch {
|
|
519
|
+
return void 0;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function getSessionTrace(sessionId) {
|
|
523
|
+
return readSpanContext(sessionTraceKey(sessionId));
|
|
524
|
+
}
|
|
525
|
+
function getTurnParent(payload) {
|
|
526
|
+
if (payload.agent_id) {
|
|
527
|
+
const sub = readSpanContext(subagentSpanKey(payload.agent_id));
|
|
528
|
+
if (sub) return sub;
|
|
529
|
+
}
|
|
530
|
+
return readSpanContext(turnSpanKey(payload.session_id));
|
|
531
|
+
}
|
|
532
|
+
function deriveWorkflowName(payload) {
|
|
533
|
+
if (payload.prompt) {
|
|
534
|
+
return payload.prompt.slice(0, 60).replace(/\n/g, " ").trim() || "claude-code";
|
|
535
|
+
}
|
|
536
|
+
return "claude-code";
|
|
537
|
+
}
|
|
538
|
+
function ensureSessionTrace(payload, config, shipper) {
|
|
539
|
+
const existing = getSessionTrace(payload.session_id);
|
|
540
|
+
if (existing) return existing;
|
|
541
|
+
const model = readState(`model_${payload.session_id}`) || void 0;
|
|
542
|
+
const workflowName = deriveWorkflowName(payload);
|
|
543
|
+
writeState(`workflow_${payload.session_id}`, workflowName);
|
|
544
|
+
const traceId = generateTraceId();
|
|
545
|
+
const spanId = generateSpanId();
|
|
546
|
+
const now = nowNanoString();
|
|
547
|
+
const rootSpan = {
|
|
548
|
+
traceId,
|
|
549
|
+
spanId,
|
|
550
|
+
name: workflowName,
|
|
551
|
+
kind: 1,
|
|
552
|
+
startTimeUnixNano: now,
|
|
553
|
+
endTimeUnixNano: now,
|
|
554
|
+
attributes: [
|
|
555
|
+
{ key: "neatlogs.span.kind", value: { stringValue: "WORKFLOW" } },
|
|
556
|
+
attrString("neatlogs.llm.request_type", "generateText"),
|
|
557
|
+
attrString("neatlogs.llm.model_name", model),
|
|
558
|
+
attrString("neatlogs.llm.provider", "anthropic"),
|
|
559
|
+
attrString("neatlogs.user_id", config.userId),
|
|
560
|
+
attrString("neatlogs.session_id", payload.session_id),
|
|
561
|
+
attrString("neatlogs.workflow_name", workflowName),
|
|
562
|
+
attrString("neatlogs.input.value", payload.prompt),
|
|
563
|
+
attrString("cwd", payload.cwd),
|
|
564
|
+
{ key: "neatlogs.sdk.name", value: { stringValue: PACKAGE_NAME } },
|
|
565
|
+
{ key: "neatlogs.sdk.version", value: { stringValue: PACKAGE_VERSION } }
|
|
566
|
+
].filter((a) => a !== void 0),
|
|
567
|
+
status: { code: SpanStatusCode.OK }
|
|
568
|
+
};
|
|
569
|
+
shipper.enqueue(rootSpan);
|
|
570
|
+
const ctx = { traceIdHex: bytesToHex(traceId), spanIdHex: bytesToHex(spanId) };
|
|
571
|
+
saveSpanContext(sessionTraceKey(payload.session_id), ctx);
|
|
572
|
+
return ctx;
|
|
573
|
+
}
|
|
574
|
+
function isoToNanoString(iso, fallback) {
|
|
575
|
+
const ms = new Date(iso).getTime();
|
|
576
|
+
if (!Number.isFinite(ms)) return fallback;
|
|
577
|
+
return msToNanoString(ms);
|
|
578
|
+
}
|
|
579
|
+
function createSpan(name, parent, startNano, endNano, attributes, status) {
|
|
580
|
+
const traceId = parent ? hexToBytes(parent.traceIdHex) : generateTraceId();
|
|
581
|
+
const spanId = generateSpanId();
|
|
582
|
+
return {
|
|
583
|
+
traceId,
|
|
584
|
+
spanId,
|
|
585
|
+
parentSpanId: parent ? hexToBytes(parent.spanIdHex) : void 0,
|
|
586
|
+
name,
|
|
587
|
+
kind: 1,
|
|
588
|
+
startTimeUnixNano: startNano,
|
|
589
|
+
endTimeUnixNano: endNano,
|
|
590
|
+
attributes: attributes.filter((a) => a !== void 0),
|
|
591
|
+
status
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function mapHookEvent(payload, config, shipper) {
|
|
595
|
+
switch (payload.hook_event_name) {
|
|
596
|
+
case "SessionStart":
|
|
597
|
+
handleSessionStart(payload, config, shipper);
|
|
598
|
+
break;
|
|
599
|
+
case "UserPromptSubmit":
|
|
600
|
+
handleUserPromptSubmit(payload, config, shipper);
|
|
601
|
+
break;
|
|
602
|
+
case "PreToolUse":
|
|
603
|
+
if (payload.tool_use_id) {
|
|
604
|
+
writeState(`tool_${payload.tool_use_id}`, String(Date.now()));
|
|
605
|
+
}
|
|
606
|
+
break;
|
|
607
|
+
case "PostToolUse":
|
|
608
|
+
handlePostToolUse(payload, config, shipper);
|
|
609
|
+
break;
|
|
610
|
+
case "PostToolUseFailure":
|
|
611
|
+
handlePostToolUse(payload, config, shipper, payload.error ?? "Tool execution failed");
|
|
612
|
+
break;
|
|
613
|
+
case "SubagentStart":
|
|
614
|
+
handleSubagentStart(payload, config, shipper);
|
|
615
|
+
break;
|
|
616
|
+
case "SubagentStop":
|
|
617
|
+
handleSubagentStop(payload, config, shipper);
|
|
618
|
+
break;
|
|
619
|
+
case "PermissionDenied":
|
|
620
|
+
handlePermissionDenied(payload, config, shipper);
|
|
621
|
+
break;
|
|
622
|
+
case "Stop":
|
|
623
|
+
handleStop(payload, config, shipper);
|
|
624
|
+
break;
|
|
625
|
+
case "StopFailure":
|
|
626
|
+
handleStop(payload, config, shipper, { error: payload.error, errorDetails: payload.error_details });
|
|
627
|
+
break;
|
|
628
|
+
case "InstructionsLoaded":
|
|
629
|
+
handleInstructionsLoaded(payload);
|
|
630
|
+
break;
|
|
631
|
+
case "PostCompact":
|
|
632
|
+
handlePostCompact(payload, config, shipper);
|
|
633
|
+
break;
|
|
634
|
+
case "SessionEnd":
|
|
635
|
+
handleSessionEnd(payload, config, shipper);
|
|
636
|
+
break;
|
|
637
|
+
default:
|
|
638
|
+
if (config.debug) {
|
|
639
|
+
console.log(`[neatlogs/claude-code] Ignoring unhandled event: ${payload.hook_event_name}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function handleSessionStart(payload, config, shipper) {
|
|
644
|
+
writeState(`model_${payload.session_id}`, payload.model ?? "");
|
|
645
|
+
ensureSessionTrace(payload, config, shipper);
|
|
646
|
+
}
|
|
647
|
+
function handleUserPromptSubmit(payload, config, shipper) {
|
|
648
|
+
const sessionCtx = ensureSessionTrace(payload, config, shipper);
|
|
649
|
+
if (payload.prompt) {
|
|
650
|
+
const name = payload.prompt.slice(0, 60).replace(/\n/g, " ").trim() || "claude-code";
|
|
651
|
+
writeState(`workflow_${payload.session_id}`, name);
|
|
652
|
+
}
|
|
653
|
+
const now = nowNanoString();
|
|
654
|
+
const turnSpanId = generateSpanId();
|
|
655
|
+
const turnSpan = {
|
|
656
|
+
traceId: hexToBytes(sessionCtx.traceIdHex),
|
|
657
|
+
spanId: turnSpanId,
|
|
658
|
+
parentSpanId: hexToBytes(sessionCtx.spanIdHex),
|
|
659
|
+
name: payload.prompt?.slice(0, 80) || "turn",
|
|
660
|
+
kind: 1,
|
|
661
|
+
startTimeUnixNano: now,
|
|
662
|
+
endTimeUnixNano: now,
|
|
663
|
+
attributes: [
|
|
664
|
+
{ key: "neatlogs.span.kind", value: { stringValue: "CHAIN" } },
|
|
665
|
+
attrString("neatlogs.input.value", payload.prompt),
|
|
666
|
+
attrString("cwd", payload.cwd)
|
|
667
|
+
].filter((a) => a !== void 0)
|
|
668
|
+
};
|
|
669
|
+
shipper.enqueue(turnSpan);
|
|
670
|
+
const turnCtx = { traceIdHex: sessionCtx.traceIdHex, spanIdHex: bytesToHex(turnSpanId) };
|
|
671
|
+
saveSpanContext(turnSpanKey(payload.session_id), turnCtx);
|
|
672
|
+
writeState(`turnstart_${payload.session_id}`, String(Date.now()));
|
|
673
|
+
}
|
|
674
|
+
function handlePostToolUse(payload, config, shipper, error) {
|
|
675
|
+
ensureSessionTrace(payload, config, shipper);
|
|
676
|
+
const parent = getTurnParent(payload);
|
|
677
|
+
const endMs = Date.now();
|
|
678
|
+
const durationMs = payload.duration_ms ?? 0;
|
|
679
|
+
const startMs = durationMs > 0 ? endMs - durationMs : endMs;
|
|
680
|
+
const span = createSpan(
|
|
681
|
+
payload.tool_name ?? "tool",
|
|
682
|
+
parent,
|
|
683
|
+
msToNanoString(startMs),
|
|
684
|
+
msToNanoString(endMs),
|
|
685
|
+
[
|
|
686
|
+
attrString("neatlogs.span.kind", "TOOL"),
|
|
687
|
+
attrString("neatlogs.tool.name", payload.tool_name),
|
|
688
|
+
attrString("neatlogs.tool_call.id", payload.tool_use_id),
|
|
689
|
+
attrString("neatlogs.tool.input", safeStringify(payload.tool_input)),
|
|
690
|
+
attrString("neatlogs.tool.output", safeStringify(payload.tool_response)),
|
|
691
|
+
attrInt("neatlogs.llm.metrics.duration_ms", durationMs)
|
|
692
|
+
],
|
|
693
|
+
error ? { code: SpanStatusCode.ERROR, message: error } : void 0
|
|
694
|
+
);
|
|
695
|
+
shipper.enqueue(span);
|
|
696
|
+
if (payload.permission_mode === "plan") {
|
|
697
|
+
const sessionCtx = getSessionTrace(payload.session_id);
|
|
698
|
+
if (sessionCtx) {
|
|
699
|
+
const markerNow = nowNanoString();
|
|
700
|
+
const marker = createSpan("neatlogs.trace.complete", sessionCtx, markerNow, markerNow, []);
|
|
701
|
+
shipper.enqueue(marker);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function handleSubagentStart(payload, config, shipper) {
|
|
706
|
+
ensureSessionTrace(payload, config, shipper);
|
|
707
|
+
const parent = getTurnParent(payload);
|
|
708
|
+
if (payload.agent_id) {
|
|
709
|
+
writeState(`subagent_${payload.agent_id}`, String(Date.now()));
|
|
710
|
+
}
|
|
711
|
+
const now = nowNanoString();
|
|
712
|
+
const traceId = parent ? hexToBytes(parent.traceIdHex) : generateTraceId();
|
|
713
|
+
const spanId = generateSpanId();
|
|
714
|
+
const span = {
|
|
715
|
+
traceId,
|
|
716
|
+
spanId,
|
|
717
|
+
parentSpanId: parent ? hexToBytes(parent.spanIdHex) : void 0,
|
|
718
|
+
name: payload.agent_type ?? "subagent",
|
|
719
|
+
kind: 1,
|
|
720
|
+
startTimeUnixNano: now,
|
|
721
|
+
endTimeUnixNano: now,
|
|
722
|
+
attributes: [
|
|
723
|
+
{ key: "neatlogs.span.kind", value: { stringValue: "AGENT" } },
|
|
724
|
+
attrString("neatlogs.agent.name", payload.agent_type),
|
|
725
|
+
attrString("neatlogs.agent_id", payload.agent_id)
|
|
726
|
+
].filter((a) => a !== void 0)
|
|
727
|
+
};
|
|
728
|
+
shipper.enqueue(span);
|
|
729
|
+
if (payload.agent_id) {
|
|
730
|
+
saveSpanContext(subagentSpanKey(payload.agent_id), {
|
|
731
|
+
traceIdHex: bytesToHex(traceId),
|
|
732
|
+
spanIdHex: bytesToHex(spanId)
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function handleSubagentStop(payload, config, shipper) {
|
|
737
|
+
ensureSessionTrace(payload, config, shipper);
|
|
738
|
+
const endMs = Date.now();
|
|
739
|
+
const savedStartRaw = payload.agent_id ? readState(`subagent_${payload.agent_id}`) : void 0;
|
|
740
|
+
const savedStartMs = savedStartRaw ? parseInt(savedStartRaw, 10) : void 0;
|
|
741
|
+
const startMs = savedStartMs && Number.isFinite(savedStartMs) && savedStartMs <= endMs ? savedStartMs : endMs;
|
|
742
|
+
let agentInput;
|
|
743
|
+
if (payload.agent_transcript_path && existsSync4(payload.agent_transcript_path)) {
|
|
744
|
+
const summary = parseTranscript(payload.agent_transcript_path);
|
|
745
|
+
if (summary?.lastTurnTextBlocks.length) {
|
|
746
|
+
agentInput = summary.lastTurnTextBlocks[0];
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const parent = getTurnParent(payload);
|
|
750
|
+
const span = createSpan(
|
|
751
|
+
payload.agent_type ?? "subagent",
|
|
752
|
+
parent,
|
|
753
|
+
msToNanoString(startMs),
|
|
754
|
+
msToNanoString(endMs),
|
|
755
|
+
[
|
|
756
|
+
attrString("neatlogs.span.kind", "AGENT"),
|
|
757
|
+
attrString("neatlogs.agent.name", payload.agent_type),
|
|
758
|
+
attrString("neatlogs.agent_id", payload.agent_id),
|
|
759
|
+
attrString("neatlogs.input.value", agentInput),
|
|
760
|
+
attrString("neatlogs.output.value", payload.last_assistant_message),
|
|
761
|
+
attrInt("neatlogs.llm.metrics.duration_ms", endMs - startMs)
|
|
762
|
+
]
|
|
763
|
+
);
|
|
764
|
+
shipper.enqueue(span);
|
|
765
|
+
if (payload.agent_id) {
|
|
766
|
+
deleteState(`subagent_${payload.agent_id}`);
|
|
767
|
+
deleteState(subagentSpanKey(payload.agent_id));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
function handlePermissionDenied(payload, config, shipper) {
|
|
771
|
+
ensureSessionTrace(payload, config, shipper);
|
|
772
|
+
const parent = getTurnParent(payload);
|
|
773
|
+
const now = nowNanoString();
|
|
774
|
+
const span = createSpan(
|
|
775
|
+
payload.tool_name ?? "permission_denied",
|
|
776
|
+
parent,
|
|
777
|
+
now,
|
|
778
|
+
now,
|
|
779
|
+
[
|
|
780
|
+
attrString("neatlogs.span.kind", "TOOL"),
|
|
781
|
+
attrString("neatlogs.tool.name", payload.tool_name),
|
|
782
|
+
attrString("neatlogs.tool_call.id", payload.tool_use_id),
|
|
783
|
+
attrString("neatlogs.tool.input", safeStringify(payload.tool_input)),
|
|
784
|
+
attrString("neatlogs.tool.output", payload.reason ?? "Permission denied"),
|
|
785
|
+
attrString("error.message", payload.reason ?? "Permission denied")
|
|
786
|
+
],
|
|
787
|
+
{ code: SpanStatusCode.ERROR, message: payload.reason ?? "Permission denied" }
|
|
788
|
+
);
|
|
789
|
+
shipper.enqueue(span);
|
|
790
|
+
}
|
|
791
|
+
function handleStop(payload, config, shipper, extra) {
|
|
792
|
+
ensureSessionTrace(payload, config, shipper);
|
|
793
|
+
const turnParent = getTurnParent(payload);
|
|
794
|
+
let emittedLLMSpan = false;
|
|
795
|
+
if (payload.transcript_path) {
|
|
796
|
+
const summary = parseTranscript(payload.transcript_path);
|
|
797
|
+
if (config.debug) {
|
|
798
|
+
appendFileSync("/tmp/neatlogs-hook-debug.log", `[${(/* @__PURE__ */ new Date()).toISOString()}] handleStop: transcript_path=${payload.transcript_path}, summary=${!!summary}, phases=${summary?.llmCallPhases.length ?? 0}, hasThinking=${summary?.hasThinking}
|
|
799
|
+
`);
|
|
800
|
+
if (summary) {
|
|
801
|
+
for (let i = 0; i < Math.min(summary.llmCallPhases.length, 5); i++) {
|
|
802
|
+
const p = summary.llmCallPhases[i];
|
|
803
|
+
appendFileSync("/tmp/neatlogs-hook-debug.log", `[${(/* @__PURE__ */ new Date()).toISOString()}] phase[${i}]: text=${p.text.length} thinking=${p.thinking.length} tools=${p.toolCalls.length}
|
|
804
|
+
`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (summary && summary.llmCallPhases.length > 0) {
|
|
809
|
+
emitLLMPhaseSpans(payload, summary, shipper, turnParent, config);
|
|
810
|
+
emittedLLMSpan = true;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (!emittedLLMSpan && payload.last_assistant_message) {
|
|
814
|
+
const model = readState(`model_${payload.session_id}`) || "claude";
|
|
815
|
+
const now = nowNanoString();
|
|
816
|
+
const span = createSpan(
|
|
817
|
+
model,
|
|
818
|
+
turnParent,
|
|
819
|
+
now,
|
|
820
|
+
now,
|
|
821
|
+
[
|
|
822
|
+
attrString("neatlogs.span.kind", "LLM"),
|
|
823
|
+
attrString("neatlogs.llm.request_type", "generateText"),
|
|
824
|
+
attrString("neatlogs.llm.model_name", model),
|
|
825
|
+
attrString("neatlogs.llm.provider", "anthropic"),
|
|
826
|
+
attrString("neatlogs.output.value", payload.last_assistant_message)
|
|
827
|
+
]
|
|
828
|
+
);
|
|
829
|
+
shipper.enqueue(span);
|
|
830
|
+
}
|
|
831
|
+
if (extra?.error) {
|
|
832
|
+
const now = nowNanoString();
|
|
833
|
+
const span = createSpan(
|
|
834
|
+
"stop_failure",
|
|
835
|
+
turnParent,
|
|
836
|
+
now,
|
|
837
|
+
now,
|
|
838
|
+
[
|
|
839
|
+
attrString("neatlogs.span.kind", "CHAIN"),
|
|
840
|
+
attrString("error.message", extra.error),
|
|
841
|
+
attrString("error.details", extra.errorDetails)
|
|
842
|
+
],
|
|
843
|
+
{ code: SpanStatusCode.ERROR, message: extra.error }
|
|
844
|
+
);
|
|
845
|
+
shipper.enqueue(span);
|
|
846
|
+
}
|
|
847
|
+
const sessionCtx = getSessionTrace(payload.session_id);
|
|
848
|
+
if (sessionCtx) {
|
|
849
|
+
const now = nowNanoString();
|
|
850
|
+
const marker = createSpan(
|
|
851
|
+
"neatlogs.trace.complete",
|
|
852
|
+
sessionCtx,
|
|
853
|
+
now,
|
|
854
|
+
now,
|
|
855
|
+
[]
|
|
856
|
+
);
|
|
857
|
+
shipper.enqueue(marker);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function handlePostCompact(payload, config, shipper) {
|
|
861
|
+
ensureSessionTrace(payload, config, shipper);
|
|
862
|
+
const parent = getTurnParent(payload) ?? getSessionTrace(payload.session_id);
|
|
863
|
+
const now = nowNanoString();
|
|
864
|
+
const span = createSpan(
|
|
865
|
+
"context_compaction",
|
|
866
|
+
parent,
|
|
867
|
+
now,
|
|
868
|
+
now,
|
|
869
|
+
[
|
|
870
|
+
attrString("neatlogs.span.kind", "CHAIN"),
|
|
871
|
+
attrString("neatlogs.input.value", payload.trigger),
|
|
872
|
+
attrString("neatlogs.output.value", payload.compact_summary)
|
|
873
|
+
]
|
|
874
|
+
);
|
|
875
|
+
shipper.enqueue(span);
|
|
876
|
+
}
|
|
877
|
+
function handleInstructionsLoaded(payload) {
|
|
878
|
+
if (!payload.file_path || !payload.session_id) return;
|
|
879
|
+
if (!existsSync4(payload.file_path)) return;
|
|
880
|
+
const content = readFileSync4(payload.file_path, "utf-8");
|
|
881
|
+
const maxLen = 8192;
|
|
882
|
+
const truncated = content.length > maxLen ? content.slice(0, maxLen) + "\n...[truncated]" : content;
|
|
883
|
+
const fileKey = payload.file_path.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
884
|
+
const header = `# ${payload.memory_type ?? "instructions"}: ${payload.file_path}
|
|
885
|
+
`;
|
|
886
|
+
writeState(`instr_${payload.session_id}_${fileKey}`, header + truncated);
|
|
887
|
+
}
|
|
888
|
+
function handleSessionEnd(payload, config, shipper) {
|
|
889
|
+
const sessionCtx = getSessionTrace(payload.session_id);
|
|
890
|
+
if (sessionCtx) {
|
|
891
|
+
const now = nowNanoString();
|
|
892
|
+
const marker = createSpan(
|
|
893
|
+
"neatlogs.trace.complete",
|
|
894
|
+
sessionCtx,
|
|
895
|
+
now,
|
|
896
|
+
now,
|
|
897
|
+
[]
|
|
898
|
+
);
|
|
899
|
+
shipper.enqueue(marker);
|
|
900
|
+
}
|
|
901
|
+
deleteState(sessionTraceKey(payload.session_id));
|
|
902
|
+
deleteState(turnSpanKey(payload.session_id));
|
|
903
|
+
deleteState(`model_${payload.session_id}`);
|
|
904
|
+
deleteState(`workflow_${payload.session_id}`);
|
|
905
|
+
deleteState(`turnstart_${payload.session_id}`);
|
|
906
|
+
cleanupStateFiles(`instr_${payload.session_id}_`);
|
|
907
|
+
cleanupStateFiles(`tool_`);
|
|
908
|
+
cleanupStateFiles(`subagent_`);
|
|
909
|
+
}
|
|
910
|
+
function emitLLMPhaseSpans(_payload, summary, shipper, parent, config) {
|
|
911
|
+
if (config.debug) {
|
|
912
|
+
appendFileSync("/tmp/neatlogs-hook-debug.log", `[${(/* @__PURE__ */ new Date()).toISOString()}] emitLLMPhaseSpans: ${summary.llmCallPhases.length} phases, hasThinking=${summary.hasThinking}
|
|
913
|
+
`);
|
|
914
|
+
for (let i = 0; i < Math.min(summary.llmCallPhases.length, 5); i++) {
|
|
915
|
+
const p = summary.llmCallPhases[i];
|
|
916
|
+
appendFileSync("/tmp/neatlogs-hook-debug.log", `[${(/* @__PURE__ */ new Date()).toISOString()}] phase[${i}]: text=${p.text.length} thinking=${p.thinking.length} tools=${p.toolCalls.length}
|
|
917
|
+
`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
for (const phase of summary.llmCallPhases) {
|
|
921
|
+
const outputText = phase.text.trim();
|
|
922
|
+
if (!outputText && !phase.thinking) continue;
|
|
923
|
+
const model = phase.model ?? summary.model ?? "claude";
|
|
924
|
+
const now = nowNanoString();
|
|
925
|
+
const startNano = phase.startTimestamp ? isoToNanoString(phase.startTimestamp, now) : now;
|
|
926
|
+
const endNano = phase.endTimestamp ? isoToNanoString(phase.endTimestamp, startNano) : startNano;
|
|
927
|
+
const toolCallNames = phase.toolCalls.map((tc) => tc.name).join(", ");
|
|
928
|
+
const span = createSpan(
|
|
929
|
+
model,
|
|
930
|
+
parent,
|
|
931
|
+
startNano,
|
|
932
|
+
endNano,
|
|
933
|
+
[
|
|
934
|
+
attrString("neatlogs.span.kind", "LLM"),
|
|
935
|
+
attrString("neatlogs.llm.request_type", "generateText"),
|
|
936
|
+
attrString("neatlogs.llm.model_name", model),
|
|
937
|
+
attrString("neatlogs.llm.provider", "anthropic"),
|
|
938
|
+
attrInt("neatlogs.llm.token_count.prompt", phase.inputTokens),
|
|
939
|
+
attrInt("neatlogs.llm.token_count.completion", phase.outputTokens),
|
|
940
|
+
...phase.cacheReadTokens > 0 ? [attrInt("neatlogs.llm.token_count.cached", phase.cacheReadTokens)] : [],
|
|
941
|
+
...phase.cacheWriteTokens > 0 ? [attrInt("neatlogs.llm.token_count.cache_write", phase.cacheWriteTokens)] : [],
|
|
942
|
+
attrString("neatlogs.output.value", outputText),
|
|
943
|
+
...phase.thinking ? [attrString("neatlogs.llm.thinking", phase.thinking)] : [],
|
|
944
|
+
...toolCallNames ? [attrString("neatlogs.llm.tool_calls", toolCallNames)] : [],
|
|
945
|
+
...phase.hasThinking ? [attrString("neatlogs.llm.has_thinking", "true")] : []
|
|
946
|
+
]
|
|
947
|
+
);
|
|
948
|
+
shipper.enqueue(span);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/hook-handler.ts
|
|
953
|
+
var STDIN_TIMEOUT_MS = 5e3;
|
|
954
|
+
var DEBUG_LOG = "/tmp/neatlogs-hook-debug.log";
|
|
955
|
+
function debugLog(msg) {
|
|
956
|
+
appendFileSync2(DEBUG_LOG, `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
|
|
957
|
+
`);
|
|
958
|
+
}
|
|
959
|
+
function readStdin() {
|
|
960
|
+
return new Promise((resolve) => {
|
|
961
|
+
if (process.stdin.isTTY) {
|
|
962
|
+
resolve("");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const chunks = [];
|
|
966
|
+
let resolved = false;
|
|
967
|
+
function done(value) {
|
|
968
|
+
if (resolved) return;
|
|
969
|
+
resolved = true;
|
|
970
|
+
clearTimeout(timeout);
|
|
971
|
+
process.stdin.removeAllListeners();
|
|
972
|
+
process.stdin.destroy();
|
|
973
|
+
resolve(value);
|
|
974
|
+
}
|
|
975
|
+
const timeout = setTimeout(() => {
|
|
976
|
+
done(chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : "");
|
|
977
|
+
}, STDIN_TIMEOUT_MS);
|
|
978
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
979
|
+
process.stdin.on("end", () => done(Buffer.concat(chunks).toString("utf-8")));
|
|
980
|
+
process.stdin.on("error", (err) => {
|
|
981
|
+
console.warn(`[neatlogs/claude-code] stdin error: ${err.message}`);
|
|
982
|
+
done(chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : "");
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
async function handleHook() {
|
|
987
|
+
const raw = await readStdin();
|
|
988
|
+
if (!raw.trim()) return;
|
|
989
|
+
let payload;
|
|
990
|
+
try {
|
|
991
|
+
payload = JSON.parse(raw);
|
|
992
|
+
} catch (err) {
|
|
993
|
+
console.warn(`[neatlogs/claude-code] Failed to parse hook payload: ${err.message}`);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (!payload.session_id || !payload.hook_event_name) return;
|
|
997
|
+
const config = loadConfig();
|
|
998
|
+
if (config.debug) {
|
|
999
|
+
debugLog(`Event: ${payload.hook_event_name} | Keys: ${Object.keys(payload).join(", ")}`);
|
|
1000
|
+
debugLog(`Payload: ${raw.slice(0, 2e3)}`);
|
|
1001
|
+
}
|
|
1002
|
+
if (!config.apiKey) {
|
|
1003
|
+
if (config.debug) debugLog("No API key \u2014 skipping");
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const shipper = new TraceShipper({
|
|
1007
|
+
apiKey: config.apiKey,
|
|
1008
|
+
endpoint: config.endpoint,
|
|
1009
|
+
debug: config.debug,
|
|
1010
|
+
workflowName: payload.prompt?.slice(0, 60).replace(/\n/g, " ").trim() || readState(`workflow_${payload.session_id}`) || "claude-code"
|
|
1011
|
+
});
|
|
1012
|
+
mapHookEvent(payload, { userId: config.userId, debug: config.debug }, shipper);
|
|
1013
|
+
await shipper.flush();
|
|
1014
|
+
if (config.debug) {
|
|
1015
|
+
debugLog(`Flushed for ${payload.hook_event_name}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/setup.ts
|
|
1020
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
|
|
1021
|
+
import { homedir as homedir2 } from "os";
|
|
1022
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
1023
|
+
var HOOK_EVENTS = [
|
|
1024
|
+
"SessionStart",
|
|
1025
|
+
"UserPromptSubmit",
|
|
1026
|
+
"PreToolUse",
|
|
1027
|
+
"PostToolUse",
|
|
1028
|
+
"PostToolUseFailure",
|
|
1029
|
+
"SubagentStart",
|
|
1030
|
+
"SubagentStop",
|
|
1031
|
+
"PermissionDenied",
|
|
1032
|
+
"Stop",
|
|
1033
|
+
"StopFailure",
|
|
1034
|
+
"SessionEnd",
|
|
1035
|
+
"PostCompact",
|
|
1036
|
+
"InstructionsLoaded"
|
|
1037
|
+
];
|
|
1038
|
+
function getSettingsPath(scope, projectDir) {
|
|
1039
|
+
if (scope === "global") {
|
|
1040
|
+
return join3(homedir2(), ".claude", "settings.json");
|
|
1041
|
+
}
|
|
1042
|
+
const base = projectDir ?? process.cwd();
|
|
1043
|
+
return join3(base, ".claude", "settings.json");
|
|
1044
|
+
}
|
|
1045
|
+
function resolveCommand() {
|
|
1046
|
+
return "neatlogs-claude-code hook";
|
|
1047
|
+
}
|
|
1048
|
+
function registerHooks(scope, projectDir) {
|
|
1049
|
+
const settingsPath = getSettingsPath(scope, projectDir);
|
|
1050
|
+
const dir = dirname2(settingsPath);
|
|
1051
|
+
mkdirSync3(dir, { recursive: true });
|
|
1052
|
+
let settings = {};
|
|
1053
|
+
if (existsSync5(settingsPath)) {
|
|
1054
|
+
try {
|
|
1055
|
+
settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
console.warn(`[neatlogs/claude-code] Failed to parse existing settings: ${err.message}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const command = resolveCommand();
|
|
1061
|
+
const existingHooks = typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
|
|
1062
|
+
for (const event of HOOK_EVENTS) {
|
|
1063
|
+
const eventGroups = existingHooks[event] ?? [];
|
|
1064
|
+
const filtered = eventGroups.filter(
|
|
1065
|
+
(g) => !g.hooks.some((h) => h.command.includes("neatlogs-claude-code") || h.command.includes("neatlogs/claude-code"))
|
|
1066
|
+
);
|
|
1067
|
+
filtered.push({ matcher: "", hooks: [{ type: "command", command }] });
|
|
1068
|
+
existingHooks[event] = filtered;
|
|
1069
|
+
}
|
|
1070
|
+
settings.hooks = existingHooks;
|
|
1071
|
+
writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1072
|
+
console.log(`[neatlogs/claude-code] Registered ${HOOK_EVENTS.length} hooks in ${settingsPath}`);
|
|
1073
|
+
}
|
|
1074
|
+
export {
|
|
1075
|
+
getConfigPath,
|
|
1076
|
+
handleHook,
|
|
1077
|
+
loadConfig,
|
|
1078
|
+
registerHooks,
|
|
1079
|
+
updateConfig
|
|
1080
|
+
};
|