logdig 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.
@@ -0,0 +1,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ const READ_TOOLS = new Set([
5
+ "read",
6
+ "grep",
7
+ "find",
8
+ "ls",
9
+ "web_search",
10
+ "fetch_content",
11
+ "get_search_content",
12
+ "source_check",
13
+ ]);
14
+
15
+ const EDIT_TOOLS = new Set(["edit", "write", "apply_patch", "git"]);
16
+ const SECRET_PATTERNS = [
17
+ [/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]"],
18
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g, "[REDACTED]"],
19
+ [/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]"],
20
+ [/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [REDACTED]"],
21
+ [/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|token|password|secret|credential)\s*[:=]\s*["']?)[^\s"']+/gi, "$1[REDACTED]"],
22
+ ];
23
+
24
+ export function parseSessionJsonl(text, sourcePath = "<session>") {
25
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
26
+ if (lines.length === 0) throw new Error(`${sourcePath}: empty session file`);
27
+
28
+ const records = lines.map((line, index) => {
29
+ try {
30
+ return JSON.parse(line);
31
+ } catch {
32
+ throw new Error(`${sourcePath}:${index + 1}: invalid JSON`);
33
+ }
34
+ });
35
+
36
+ const [header, ...entries] = records;
37
+ if (header.type !== "session" || typeof header.id !== "string") {
38
+ throw new Error(`${sourcePath}: missing Pi session header`);
39
+ }
40
+
41
+ return { header, entries, sourcePath };
42
+ }
43
+
44
+ export function sessionFromJsonl(text, sourcePath = "<session>") {
45
+ return parseSessionJsonl(text, sourcePath);
46
+ }
47
+
48
+ export function fingerprintSession(session) {
49
+ return createHash("sha256")
50
+ .update(JSON.stringify([session.header, session.entries]))
51
+ .digest("hex");
52
+ }
53
+
54
+ export function textContent(content) {
55
+ if (typeof content === "string") return content;
56
+ if (!Array.isArray(content)) return "";
57
+
58
+ return content
59
+ .filter((block) => block && block.type === "text" && typeof block.text === "string")
60
+ .map((block) => block.text)
61
+ .join("\n")
62
+ .trim();
63
+ }
64
+
65
+ export function redactAndClip(value, maxLength = 1800) {
66
+ let text = String(value ?? "").replaceAll("\0", "");
67
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement);
68
+ if (text.length <= maxLength) return text;
69
+ return `${text.slice(0, maxLength)}\n[truncated]`;
70
+ }
71
+
72
+ function timestampOf(entry) {
73
+ const value = entry.timestamp ?? entry.message?.timestamp;
74
+ if (typeof value === "number") return value;
75
+ if (typeof value === "string") {
76
+ const parsed = Date.parse(value);
77
+ return Number.isFinite(parsed) ? parsed : undefined;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ export function sessionMetrics(session) {
83
+ const metrics = {};
84
+ const fields = ["input", "output", "cacheRead", "cacheWrite"];
85
+ const totals = Object.fromEntries(fields.map((field) => [field, 0]));
86
+ let cost = 0;
87
+ let hasCost = false;
88
+ const recorded = new Set();
89
+ const started = timestampOf(session.header);
90
+ let first = started;
91
+ let last = started;
92
+
93
+ for (const entry of session.entries || []) {
94
+ const time = timestampOf(entry);
95
+ if (time !== undefined) {
96
+ first = Math.min(first ?? time, time);
97
+ last = Math.max(last ?? time, time);
98
+ }
99
+ const usage = entry.type === "usage" || entry.type === "compaction" || entry.type === "branch_summary"
100
+ ? entry.usage
101
+ : entry.type === "message" && ["assistant", "toolResult"].includes(entry.message?.role) ? entry.message.usage : undefined;
102
+ if (!usage || typeof usage !== "object") continue;
103
+ for (const field of fields) {
104
+ if (!Number.isFinite(usage[field]) || usage[field] < 0) continue;
105
+ totals[field] += usage[field];
106
+ recorded.add(field);
107
+ }
108
+ if (Number.isFinite(usage.cost?.total) && usage.cost.total >= 0) {
109
+ cost += usage.cost.total;
110
+ hasCost = true;
111
+ }
112
+ }
113
+
114
+ if (first !== undefined) metrics.startedAt = new Date(first).toISOString();
115
+ if (last !== undefined) metrics.endedAt = new Date(last).toISOString();
116
+ if (first !== undefined && last !== undefined) metrics.durationSeconds = Math.round((last - first) / 1000);
117
+ if (hasCost) metrics.costUsd = Number(cost.toFixed(6));
118
+ for (const [field, key] of [["cacheRead", "cacheReadTokens"], ["input", "inputTokens"], ["output", "outputTokens"], ["cacheWrite", "cacheWriteTokens"]]) {
119
+ if (recorded.has(field)) metrics[key] = totals[field];
120
+ }
121
+ return metrics;
122
+ }
123
+
124
+ function localParts(timestamp, timeZone) {
125
+ const parts = new Intl.DateTimeFormat("en-CA", {
126
+ timeZone,
127
+ year: "numeric",
128
+ month: "2-digit",
129
+ day: "2-digit",
130
+ hour: "2-digit",
131
+ minute: "2-digit",
132
+ hourCycle: "h23",
133
+ }).formatToParts(new Date(timestamp));
134
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
135
+ return {
136
+ date: `${values.year}-${values.month}-${values.day}`,
137
+ time: `${values.hour}:${values.minute}`,
138
+ };
139
+ }
140
+
141
+ function commandIsNoteworthy(command) {
142
+ return /\b(test|build|lint|typecheck|check|git\s+(?:diff|status|log|add|commit|switch|checkout)|npm|pnpm|yarn|bun|cargo|pytest|vitest|wrangler|deploy|curl|rm\s|mv\s|cp\s)/i.test(command);
143
+ }
144
+
145
+ function selectedToolArguments(name, args) {
146
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
147
+ if (name === "bash") {
148
+ const command = typeof args.command === "string" ? args.command : "";
149
+ return commandIsNoteworthy(command) ? redactAndClip(command, 360) : "";
150
+ }
151
+
152
+ const selected = Object.entries(args).filter(([key]) =>
153
+ /^(?:path|file|files|filePath|cwd|name|subject|action|status|query|id|url)$/i.test(key),
154
+ );
155
+ if (selected.length === 0) return "";
156
+
157
+ return selected
158
+ .map(([key, value]) => `${key}=${redactAndClip(Array.isArray(value) ? value.join(", ") : value, 160)}`)
159
+ .join(" ");
160
+ }
161
+
162
+ function noteworthyToolCall(name, args) {
163
+ if (READ_TOOLS.has(name)) return false;
164
+ if (name === "bash") return Boolean(selectedToolArguments(name, args));
165
+ return true;
166
+ }
167
+
168
+ function outputExcerpt(content, limit = 420) {
169
+ const text = redactAndClip(textContent(content), limit + 80);
170
+ if (text.length <= limit) return text;
171
+ const half = Math.floor((limit - 24) / 2);
172
+ return `${text.slice(0, half)}\n[…output clipped…]\n${text.slice(-half)}`;
173
+ }
174
+
175
+ function eventFor(entry, timeZone, toolCalls) {
176
+ const timestamp = timestampOf(entry);
177
+ if (timestamp === undefined) return undefined;
178
+ const { date, time } = localParts(timestamp, timeZone);
179
+ const message = entry.message;
180
+
181
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
182
+ const summary = typeof entry.summary === "string" ? entry.summary : "";
183
+ if (!summary.trim()) return undefined;
184
+ return { date, time, kind: entry.type === "compaction" ? "context summary" : "branch summary", text: redactAndClip(summary, 1400) };
185
+ }
186
+
187
+ if (entry.type !== "message" || !message) return undefined;
188
+ const { role, content } = message;
189
+
190
+ if (role === "user") {
191
+ const text = textContent(content);
192
+ return {
193
+ date,
194
+ time,
195
+ kind: "user intent",
196
+ text: text ? redactAndClip(text) : "non-text user input (payload omitted)",
197
+ };
198
+ }
199
+
200
+ if (role === "assistant") {
201
+ const blocks = Array.isArray(content) ? content : [];
202
+ const calls = blocks.filter((block) => block?.type === "toolCall");
203
+ const text = textContent(content);
204
+ const events = [];
205
+
206
+ if (text && ["stop", "length", "error", "aborted"].includes(message.stopReason)) {
207
+ events.push({ date, time, kind: "assistant outcome", text: redactAndClip(text) });
208
+ }
209
+
210
+ for (const call of calls) {
211
+ if (!noteworthyToolCall(call.name, call.arguments)) continue;
212
+ toolCalls.set(call.id, { name: call.name, command: selectedToolArguments(call.name, call.arguments) });
213
+ const details = selectedToolArguments(call.name, call.arguments);
214
+ events.push({
215
+ date,
216
+ time,
217
+ kind: `action: ${call.name}`,
218
+ text: details || "tool was used",
219
+ });
220
+ }
221
+ return events.length > 0 ? events : undefined;
222
+ }
223
+
224
+ if (role === "toolResult") {
225
+ const call = toolCalls.get(message.toolCallId);
226
+ const toolName = message.toolName || call?.name || "tool";
227
+ const shouldInclude = message.isError || EDIT_TOOLS.has(toolName) || (toolName === "bash" && call?.command);
228
+ if (!shouldInclude) return undefined;
229
+ const output = outputExcerpt(content);
230
+ if (!output) return undefined;
231
+ return {
232
+ date,
233
+ time,
234
+ kind: message.isError ? `error: ${toolName}` : `result: ${toolName}`,
235
+ text: output,
236
+ };
237
+ }
238
+
239
+ if (role === "bashExecution") {
240
+ const command = typeof message.command === "string" ? message.command : "";
241
+ if (!commandIsNoteworthy(command)) return undefined;
242
+ const output = outputExcerpt(message.output || "", 360);
243
+ return {
244
+ date,
245
+ time,
246
+ kind: `shell: ${message.exitCode === 0 ? "success" : "exit " + message.exitCode}`,
247
+ text: `${redactAndClip(command, 240)}${output ? `\n${output}` : ""}`,
248
+ };
249
+ }
250
+
251
+ return undefined;
252
+ }
253
+
254
+ export function eventsForSession(session, timeZone) {
255
+ const events = [];
256
+ const toolCalls = new Map();
257
+ const cwd = session.header.cwd || "";
258
+ const project = (cwd ? path.basename(cwd) : "").replace(/^\d{4}-\d{2}-\d{2}-(?=.)/, "") || "unknown project";
259
+
260
+ for (const entry of session.entries) {
261
+ const generated = eventFor(entry, timeZone, toolCalls);
262
+ if (!generated) continue;
263
+ for (const event of Array.isArray(generated) ? generated : [generated]) {
264
+ events.push({
265
+ ...event,
266
+ sessionId: session.header.id,
267
+ project,
268
+ cwd,
269
+ });
270
+ }
271
+ }
272
+
273
+ return events;
274
+ }
275
+
276
+