Setting the file. One moment.
Transcript Digest · Rp Telemetry · wix/skills · Skills Docs
ContentsBack to the top of the page
Number 45.5
Position 5 of 5
Type JavaScript
Size 11 KB
Lines 318 lib/ transcript-digest.js
JavaScript · 318 lines · 11 KB
15 // reads a list of file paths, so it does not care which locus produced them.
16
17 const fs = require ( 'node:fs' );
18 const path = require ( 'node:path' );
19
20 // A repeated identical tool call is the signature of the unbounded-retry hang
21 // class (spec 0039 §3) — this is the threshold at which a streak counts as a
22 // loop rather than ordinary retry-with-backoff.
23 const RETRY_LOOP_MIN_REPEATS = 3 ;
24 // A gap this long between two timestamped transcript records is time the
25 // human or an external system held the run, not active agent work — mirrors
26 // the recorder's own IMPLICIT_WAIT_MIN_MS (telemetry-recorder.js) so the two
27 // idle notions agree on the same threshold.
28 const IDLE_GAP_MIN_MS = 60 * 1000 ;
29 const TOP_FAILING_MAX = 10 ;
30 const RETRY_LOOPS_MAX = 10 ;
31
32 function stableStringify ( value ) {
33 if (Array. isArray (value)) return `[${ value . map ( stableStringify ). join ( ',' ) }]` ;
34 if (value && typeof value === 'object' ) {
35 const keys = Object. keys (value). sort ();
36 return `{${ keys . map (( k ) => `${ JSON . stringify ( k ) }:${ stableStringify ( value [ k ]) }` ). join ( ',' ) }}` ;
37 }
38 return JSON . stringify (value);
39 }
40
41 // Claude Code's own project-directory escaping: every character outside
42 // [A-Za-z0-9] becomes `-` (verified against this repo's own
43 // ~/.claude/projects/<escaped-cwd> directory names).
44 function escapeCwd ( cwd ) {
45 return String (cwd). replace ( / [ ^ A-Za-z0-9] / g , '-' );
46 }
47
48 function readSessionRecords ( file ) {
49 const raw = fs. readFileSync (file, 'utf8' );
50 const records = [];
51 let malformed = 0 ;
52 for ( const line of raw. split ( ' \n ' )) {
53 if (line. trim () === '' ) continue ;
54 try {
55 records. push ( JSON . parse (line));
56 } catch {
57 malformed += 1 ;
58 }
59 }
60 return { records, malformed };
61 }
62
63 // First/last top-level `timestamp` and the `cwd` of a session file, without
64 // holding the whole parse result — used to decide which sibling sessions
65 // belong to this run before paying for the full digest pass.
66 function peekSession ( file ) {
67 const { records } = readSessionRecords (file);
68 let firstTs = null ;
69 let lastTs = null ;
70 let cwd = null ;
71 for ( const record of records) {
72 if ( typeof record.timestamp !== 'string' ) continue ;
73 const ms = Date. parse (record.timestamp);
74 if ( ! Number. isFinite (ms)) continue ;
75 if (firstTs === null ) firstTs = ms;
76 lastTs = ms;
77 if (cwd === null && typeof record.cwd === 'string' ) cwd = record.cwd;
78 }
79 return { firstTs, lastTs, cwd };
80 }
81
82 // Every sibling `.jsonl` in the same project directory as `anchorTranscript`
83 // whose recorded `cwd` matches and whose time range overlaps
84 // [windowStartMs, windowEndMs]. A resumed run spans several sessionIds (spec
85 // 0039 §4, §8.1) — this is the correlation the manifest (§4.1) then writes
86 // down once so a later reader never has to re-derive it.
87 function discoverRunSessions ( anchorTranscript , { windowStartMs , windowEndMs }) {
88 const dir = path. dirname (anchorTranscript);
89 const anchor = peekSession (anchorTranscript);
90 const cwd = anchor.cwd;
91 const entries = fs. readdirSync (dir). filter (( name ) => name. endsWith ( '.jsonl' ));
92 const included = [];
93 for ( const name of entries) {
94 const file = path. join (dir, name);
95 const info = peekSession (file);
96 if (info.firstTs === null ) continue ;
97 if (cwd !== null && info.cwd !== null && info.cwd !== cwd) continue ;
98 if (info.lastTs < windowStartMs || info.firstTs > windowEndMs) continue ;
99 included. push ({
100 sessionId: path. basename (name, '.jsonl' ),
101 path: file,
102 firstTs: info.firstTs,
103 lastTs: info.lastTs,
104 });
105 }
106 included. sort (( a , b ) => a.firstTs - b.firstTs);
107 if (included. length === 0 ) {
108 // The anchor transcript itself always counts, even if its own window
109 // check above (identical file) somehow fails a boundary comparison.
110 included. push ({ sessionId: path. basename (anchorTranscript, '.jsonl' ), path: anchorTranscript, ... anchor });
111 }
112 return included;
113 }
114
115 function toolUseBlocks ( message ) {
116 const content = message && message.content;
117 if ( ! Array. isArray (content)) return [];
118 return content. filter (( c ) => c && c.type === 'tool_use' );
119 }
120
121 function toolResultBlocks ( message ) {
122 const content = message && message.content;
123 if ( ! Array. isArray (content)) return [];
124 return content. filter (( c ) => c && c.type === 'tool_result' );
125 }
126
127 // A genuine human turn, as distinct from a tool result: both travel as
128 // `type: "user"` records in Claude Code's transcript (matching the
129 // Anthropic Messages API, where a tool_result is also role:"user"). A tool
130 // result carries `toolUseResult` and/or tool_result content blocks; a
131 // sidechain user record is the orchestrator's prompt *to* a subagent, not a
132 // person. Excluding both is what makes this the friction half of
133 // adoption-vs-friction (spec 0039 §8.1) rather than an inflated turn count.
134 function isHumanTurn ( record ) {
135 if (record.type !== 'user' || record.isSidechain || record.isMeta) return false ;
136 if (record.toolUseResult !== undefined ) return false ;
137 if ( toolResultBlocks (record.message). length > 0 ) return false ;
138 return true ;
139 }
140
141 function topN ( counts , n ) {
142 return [ ... counts. entries ()]
143 . map (([ tool , count ]) => ({ tool, n: count }))
144 . sort (( a , b ) => b.n - a.n || a.tool. localeCompare (b.tool))
145 . slice ( 0 , n);
146 }
147
148 // The deterministic digest itself (spec 0039 §3): counts, durations, tool
149 // names — never client data, never free text. `stageForTs(ms)` is an
150 // optional pure lookup (typically built from the telemetry journal's
151 // stage_start/stage_end intervals) used only to label which stage a retry
152 // loop or edit fell in; omitting it leaves `stage: null` rather than guessing.
153 function computeDigest ( sessionFiles , { tailComplete = true , stageForTs = () => null } = {}) {
154 if ( ! Array. isArray (sessionFiles) || sessionFiles. length === 0 ) {
155 throw new Error ( 'computeDigest requires at least one session file' );
156 }
157
158 let ccVersion = null ;
159 let transcriptTurns = 0 ;
160 let humanTurns = 0 ;
161 let agentTurns = 0 ;
162 let sidechainTurns = 0 ;
163 let inputTokens = 0 ;
164 let outputTokens = 0 ;
165 let cacheReadTokens = 0 ;
166 let cacheCreationTokens = 0 ;
167 let toolCalls = 0 ;
168 let toolFailures = 0 ;
169 const toolFailureCounts = new Map ();
170 const toolNameById = new Map ();
171 const editedFiles = new Set ();
172 let hookBlocks = 0 ;
173 let hookErrors = 0 ;
174 const retryLoops = [];
175 const timestampsMs = [];
176
177 let streakKey = null ;
178 let streakTool = null ;
179 let streakLen = 0 ;
180 let streakStartTs = null ;
181 const flushStreak = () => {
182 if (streakLen >= RETRY_LOOP_MIN_REPEATS ) {
183 retryLoops. push ({ tool: streakTool, repeats: streakLen, stage: stageForTs (streakStartTs) });
184 }
185 streakKey = null ;
186 streakTool = null ;
187 streakLen = 0 ;
188 streakStartTs = null ;
189 };
190
191 for ( const session of [ ... sessionFiles]. sort (( a , b ) => (a.firstTs || 0 ) - (b.firstTs || 0 ))) {
192 const { records } = readSessionRecords (session.path);
193 for ( const record of records) {
194 if ( typeof record.version === 'string' ) ccVersion = record.version;
195 const tsMs = typeof record.timestamp === 'string' ? Date. parse (record.timestamp) : NaN ;
196 if (Number. isFinite (tsMs)) timestampsMs. push (tsMs);
197
198 if (record.type === 'assistant' ) {
199 transcriptTurns += 1 ;
200 if (record.isSidechain) sidechainTurns += 1 ;
201 else agentTurns += 1 ;
202 const usage = record.message && record.message.usage;
203 if (usage) {
204 inputTokens += usage.input_tokens || 0 ;
205 outputTokens += usage.output_tokens || 0 ;
206 cacheReadTokens += usage.cache_read_input_tokens || 0 ;
207 cacheCreationTokens += usage.cache_creation_input_tokens || 0 ;
208 }
209 for ( const block of toolUseBlocks (record.message)) {
210 toolNameById. set (block.id, block.name);
211 toolCalls += 1 ;
212 const key = `${ block . name }:${ stableStringify ( block . input ) }` ;
213 if (key === streakKey) {
214 streakLen += 1 ;
215 } else {
216 flushStreak ();
217 streakKey = key;
218 streakTool = block.name;
219 streakLen = 1 ;
220 streakStartTs = Number. isFinite (tsMs) ? tsMs : streakStartTs;
221 }
222 }
223 continue ;
224 }
225
226 if (record.type === 'user' ) {
227 if ( isHumanTurn (record)) {
228 transcriptTurns += 1 ;
229 humanTurns += 1 ;
230 }
231 for ( const result of toolResultBlocks (record.message)) {
232 const name = toolNameById. get (result.tool_use_id) || 'unknown' ;
233 if (result.is_error) {
234 toolFailures += 1 ;
235 toolFailureCounts. set (name, (toolFailureCounts. get (name) || 0 ) + 1 );
236 // A tool call whose result is an error breaks any retry streak it
237 // was part of only if the NEXT call changes shape; a genuine retry
238 // loop is agent-driven (repeated tool_use), so failures are
239 // counted but do not themselves reset the streak tracker above.
240 }
241 }
242 continue ;
243 }
244
245 if (record.type === 'system' ) {
246 if (Array. isArray (record.hookErrors)) hookErrors += record.hookErrors. length ;
247 if (record.preventedContinuation === true ) hookBlocks += 1 ;
248 continue ;
249 }
250
251 if (record.type === 'file-history-snapshot' && record.isSnapshotUpdate === true ) {
252 const backups = record.snapshot && record.snapshot.trackedFileBackups;
253 if (backups && typeof backups === 'object' ) {
254 for ( const filePath of Object. keys (backups)) editedFiles. add (filePath);
255 }
256 }
257 }
258 }
259 flushStreak ();
260
261 timestampsMs. sort (( a , b ) => a - b);
262 const wallMs = timestampsMs. length > 1 ? timestampsMs[timestampsMs. length - 1 ] - timestampsMs[ 0 ] : 0 ;
263 let idleMs = 0 ;
264 let longestGapMs = 0 ;
265 for ( let i = 1 ; i < timestampsMs. length ; i += 1 ) {
266 const gap = timestampsMs[i] - timestampsMs[i - 1 ];
267 if (gap > longestGapMs) longestGapMs = gap;
268 if (gap >= IDLE_GAP_MIN_MS ) idleMs += gap;
269 }
270 const activeMs = Math. max ( 0 , wallMs - idleMs);
271
272 retryLoops. sort (( a , b ) => b.repeats - a.repeats);
273
274 return {
275 source: {
276 sessions: sessionFiles. length ,
277 transcript_turns: transcriptTurns,
278 cc_version: ccVersion,
279 tail_complete: tailComplete,
280 },
281 cost: {
282 input_tokens: inputTokens,
283 output_tokens: outputTokens,
284 cache_read_input_tokens: cacheReadTokens,
285 cache_creation_input_tokens: cacheCreationTokens,
286 },
287 interaction: {
288 human_turns: humanTurns,
289 agent_turns: agentTurns,
290 sidechain_turns: sidechainTurns,
291 },
292 time: {
293 wall_ms: wallMs,
294 active_ms: activeMs,
295 idle_ms: idleMs,
296 longest_gap_ms: longestGapMs,
297 },
298 tools: {
299 calls: toolCalls,
300 failures: toolFailures,
301 top_failing: topN (toolFailureCounts, TOP_FAILING_MAX ),
302 },
303 signals: {
304 retry_loops: retryLoops. slice ( 0 , RETRY_LOOPS_MAX ),
305 hook_blocks: hookBlocks,
306 hook_errors: hookErrors,
307 files_edited_mid_run: editedFiles.size,
308 },
309 };
310 }
311
312 module . exports = {
313 RETRY_LOOP_MIN_REPEATS,
314 IDLE_GAP_MIN_MS,
315 escapeCwd,
316 discoverRunSessions,
317 computeDigest,
318 };