@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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.
Files changed (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. package/src/sdk-wave-e.ts +1 -1
@@ -1,457 +1,457 @@
1
- 'use strict';
2
-
3
- // Pure normalization of public SDK SessionMessage snapshots into redacted,
4
- // deterministic candidate records for native task messages. The module has
5
- // no I/O and no authority: it never reads transcripts, never persists,
6
- // never allocates journal dedup keys or TaskRefs, and never fabricates
7
- // fields the public SDK types do not promise. Every string destined for the
8
- // output — text values, tool-input object KEYS, tool names, model labels,
9
- // and free-form diagnostic details — must pass the caller-supplied redaction
10
- // callback; a missing, throwing, or malformed callback fails the whole
11
- // normalization closed with a fixed safe message that never echoes callback
12
- // or snapshot content. Linkage identifiers (entry uuid, tool call id, model
13
- // message id) are kept only when they match a strict identifier pattern AND
14
- // the caller's own redaction callback leaves them untouched — an identifier
15
- // the redaction boundary wants to change (an embedded credential, private
16
- // text) is rejected with a diagnostic, never published raw and never
17
- // published in redacted form (a changed identifier could not be paired
18
- // honestly). Raw tool names needed for internal conflict comparison live
19
- // only in a module WeakMap; no returned object carries them.
20
-
21
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
- const IDENTIFIER_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
23
- const USAGE_TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens'];
24
- // Unredacted tool names are comparison state, not output: they live only in
25
- // this WeakMap keyed by the published block/step objects and never travel on
26
- // any returned DTO.
27
- const rawToolNames = new WeakMap();
28
-
29
- const FAILURES = {
30
- inputInvalid: ['NATIVE_TASK_MESSAGES_INPUT_INVALID', 'native task message normalization refused: messages must be an array'],
31
- redactRequired: ['NATIVE_TASK_MESSAGES_REDACT_REQUIRED', 'native task message normalization refused: no redaction callback was provided'],
32
- redactFailed: ['NATIVE_TASK_MESSAGES_REDACT_FAILED', 'native task message normalization refused: the redaction callback failed'],
33
- };
34
-
35
- function fail(kind) {
36
- const error = new Error(FAILURES[kind][1]);
37
- error.code = FAILURES[kind][0];
38
- throw error;
39
- }
40
-
41
- // Diagnostics carry only codes, pattern-safe identifiers, counts, and
42
- // redacted free-form details — never raw snapshot text. Occurrence counts
43
- // reflect the raw input stream (duplicates included); the structural stats
44
- // in the result reflect the deduplicated snapshot.
45
- function createDiagnostics() {
46
- const counts = new Map();
47
- return {
48
- add(code, detail) {
49
- const key = detail ? code + '|' + detail : code;
50
- counts.set(key, (counts.get(key) || 0) + 1);
51
- },
52
- list() {
53
- const rows = [];
54
- for (const [key, count] of counts) {
55
- const sep = key.indexOf('|');
56
- rows.push(sep === -1 ? { code: key, detail: null, count } : { code: key.slice(0, sep), detail: key.slice(sep + 1), count });
57
- }
58
- rows.sort((a, b) => {
59
- if (a.code !== b.code) return a.code < b.code ? -1 : 1;
60
- const x = a.detail === null ? '' : a.detail;
61
- const y = b.detail === null ? '' : b.detail;
62
- return x < y ? -1 : x > y ? 1 : 0;
63
- });
64
- return rows;
65
- },
66
- };
67
- }
68
-
69
- function redactText(redact, text, field) {
70
- let value;
71
- try {
72
- value = redact(text, { field });
73
- } catch {
74
- fail('redactFailed');
75
- }
76
- if (typeof value !== 'string') fail('redactFailed');
77
- return value;
78
- }
79
-
80
- // A linkage identifier is publishable only when the pattern accepts it AND
81
- // the redaction boundary leaves it byte-identical. A changed value means the
82
- // identifier itself carries content the caller classifies as sensitive: it
83
- // is dropped with the existing invalid-identifier diagnostic (no detail, so
84
- // the offending value never reaches the diagnostics surface) while the rest
85
- // of the snapshot keeps normalizing. A throwing or non-string callback still
86
- // fails the whole normalization closed, exactly like every other redaction.
87
- function publishableIdentifier(redact, value, missingCode, invalidCode, diagnostics) {
88
- if (typeof value !== 'string' || value.length === 0) { diagnostics.add(missingCode); return null; }
89
- if (!IDENTIFIER_PATTERN.test(value)) { diagnostics.add(invalidCode); return null; }
90
- let safe;
91
- try {
92
- safe = redact(value, { field: 'identifier' });
93
- } catch {
94
- fail('redactFailed');
95
- }
96
- if (typeof safe !== 'string') fail('redactFailed');
97
- if (safe !== value) { diagnostics.add(invalidCode); return null; }
98
- return value;
99
- }
100
-
101
- // Object keys are publishable strings too, so they cross the same redaction
102
- // boundary as values. When two distinct source keys redact to the same
103
- // output key, the collision is reported and the later key (in sorted source
104
- // order) is dropped — a collision never silently overwrites a key.
105
- function redactValue(redact, value, field, diagnostics) {
106
- if (typeof value === 'string') return redactText(redact, value, field);
107
- if (Array.isArray(value)) return value.map(item => redactValue(redact, item, field, diagnostics));
108
- if (value !== null && typeof value === 'object') {
109
- const out = {};
110
- for (const key of Object.keys(value).sort()) {
111
- const safeKey = redactText(redact, key, field);
112
- if (Object.prototype.hasOwnProperty.call(out, safeKey)) diagnostics.add('redact-key-collision', field);
113
- else out[safeKey] = redactValue(redact, value[key], field, diagnostics);
114
- }
115
- return out;
116
- }
117
- return value;
118
- }
119
-
120
- // The public SessionMessage type promises no timestamp. Accept only real
121
- // calendar dates in the exact ISO shape observed in recorded snapshots:
122
- // positional digit/separator checks plus month/day (leap-aware), hour,
123
- // minute and second range validation. Anything else becomes a diagnostic;
124
- // absence is normal and never an error, and no timestamp is ever invented.
125
- function observedTimestamp(value) {
126
- if (value === undefined || value === null) return { state: 'absent' };
127
- if (typeof value !== 'string') return { state: 'invalid' };
128
- const digit = i => { const c = value.charAt(i); return c >= '0' && c <= '9'; };
129
- const digits = (from, to) => { for (let i = from; i < to; i += 1) if (!digit(i)) return false; return true; };
130
- let ok = digits(0, 4) && value.charAt(4) === '-'
131
- && digits(5, 7) && value.charAt(7) === '-'
132
- && digits(8, 10) && value.charAt(10) === 'T'
133
- && digits(11, 13) && value.charAt(13) === ':'
134
- && digits(14, 16) && value.charAt(16) === ':'
135
- && digits(17, 19);
136
- if (ok) {
137
- const dot = value.indexOf('.');
138
- if (dot === -1) ok = value.length === 20 && value.charAt(19) === 'Z';
139
- else ok = dot === 19 && value.length >= 22 && value.endsWith('Z') && digits(20, value.length - 1);
140
- }
141
- if (ok) {
142
- const year = Number(value.slice(0, 4));
143
- const month = Number(value.slice(5, 7));
144
- const day = Number(value.slice(8, 10));
145
- const hour = Number(value.slice(11, 13));
146
- const minute = Number(value.slice(14, 16));
147
- const second = Number(value.slice(17, 19));
148
- const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
149
- const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
150
- ok = year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= monthDays[month - 1]
151
- && hour <= 23 && minute <= 59 && second <= 59;
152
- }
153
- return ok ? { state: 'observed', value } : { state: 'invalid' };
154
- }
155
-
156
- function extractUsage(raw) {
157
- if (raw === undefined || raw === null) return { kind: 'absent' };
158
- if (typeof raw !== 'object' || Array.isArray(raw)) return { kind: 'invalid' };
159
- const usage = {};
160
- for (const field of USAGE_TOKEN_FIELDS) {
161
- if (!Object.prototype.hasOwnProperty.call(raw, field)) continue;
162
- const value = raw[field];
163
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return { kind: 'invalid' };
164
- usage[field] = value;
165
- }
166
- return Object.keys(usage).length > 0 ? { kind: 'valid', usage } : { kind: 'invalid' };
167
- }
168
-
169
- const isPlaceholderUsage = usage => USAGE_TOKEN_FIELDS.every(field => usage[field] === undefined || usage[field] === 0);
170
- const usageSignature = usage => JSON.stringify(USAGE_TOKEN_FIELDS.filter(field => usage[field] !== undefined).map(field => [field, usage[field]]));
171
-
172
- function normalizeBlocks(entry, redact, diagnostics) {
173
- const message = entry.message;
174
- const content = message === null || typeof message !== 'object' ? undefined : message.content;
175
- const textField = entry.type === 'assistant' ? 'assistant-text' : 'user-text';
176
- const blocks = [];
177
- const dropped = { thinking: 0, unknown: 0 };
178
- if (content === undefined || content === null) {
179
- diagnostics.add('content-missing', entry.type);
180
- return { blocks, dropped };
181
- }
182
- if (typeof content === 'string') {
183
- blocks.push({ kind: 'text', text: redactText(redact, content, textField) });
184
- return { blocks, dropped };
185
- }
186
- if (!Array.isArray(content)) {
187
- diagnostics.add('content-invalid', entry.type);
188
- return { blocks, dropped };
189
- }
190
- for (const item of content) {
191
- if (item === null || typeof item !== 'object' || typeof item.type !== 'string') {
192
- diagnostics.add('block-invalid', item === null || typeof item !== 'object' ? 'non-object' : 'type-missing');
193
- continue;
194
- }
195
- if (item.type === 'thinking') { dropped.thinking += 1; continue; }
196
- if (item.type === 'text') {
197
- if (typeof item.text !== 'string') { diagnostics.add('block-invalid', 'text'); continue; }
198
- blocks.push({ kind: 'text', text: redactText(redact, item.text, textField) });
199
- continue;
200
- }
201
- if (item.type === 'tool_use') {
202
- const toolUseId = publishableIdentifier(redact, item.id, 'tool-use-id-missing', 'tool-call-id-invalid', diagnostics);
203
- if (toolUseId === null) continue;
204
- if (typeof item.name !== 'string' || item.name.length === 0) { diagnostics.add('tool-use-name-missing'); continue; }
205
- const block = { kind: 'tool_use', toolUseId, name: redactText(redact, item.name, 'tool-name'), input: redactValue(redact, item.input === undefined ? null : item.input, 'tool-use-input', diagnostics) };
206
- rawToolNames.set(block, item.name);
207
- blocks.push(block);
208
- continue;
209
- }
210
- if (item.type === 'tool_result') {
211
- const toolUseId = publishableIdentifier(redact, item.tool_use_id, 'tool-result-id-missing', 'tool-call-id-invalid', diagnostics);
212
- if (toolUseId === null) continue;
213
- const block = { kind: 'tool_result', toolUseId };
214
- if (item.is_error !== undefined) {
215
- if (typeof item.is_error === 'boolean') block.isError = item.is_error;
216
- else diagnostics.add('tool-result-field-invalid', 'is_error');
217
- }
218
- const texts = [];
219
- if (typeof item.content === 'string') texts.push(redactText(redact, item.content, 'tool-result-text'));
220
- else if (Array.isArray(item.content)) {
221
- for (const part of item.content) {
222
- if (part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') texts.push(redactText(redact, part.text, 'tool-result-text'));
223
- else diagnostics.add('tool-result-item-unsupported', part !== null && typeof part === 'object' && typeof part.type === 'string' ? redactText(redact, part.type, 'diagnostic-text') : 'non-text');
224
- }
225
- } else if (item.content !== undefined && item.content !== null) diagnostics.add('tool-result-content-invalid', typeof item.content);
226
- block.texts = texts;
227
- blocks.push(block);
228
- continue;
229
- }
230
- dropped.unknown += 1;
231
- diagnostics.add('unknown-block-type', redactText(redact, item.type, 'diagnostic-text'));
232
- }
233
- return { blocks, dropped };
234
- }
235
-
236
- function normalizeEntry(entry, redact, diagnostics, stats) {
237
- if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { diagnostics.add('entry-invalid', entry === null || typeof entry !== 'object' ? 'non-object' : 'array'); return null; }
238
- if (entry.type === 'system') { stats.systemEntriesSkipped += 1; return null; }
239
- if (entry.type !== 'user' && entry.type !== 'assistant') { diagnostics.add('entry-type-unknown', typeof entry.type === 'string' ? redactText(redact, entry.type, 'diagnostic-text') : 'non-string'); return null; }
240
- if (typeof entry.uuid !== 'string' || !UUID_PATTERN.test(entry.uuid)) { diagnostics.add('entry-uuid-invalid'); return null; }
241
- const timestamp = observedTimestamp(entry.timestamp);
242
- if (timestamp.state === 'invalid') { diagnostics.add('timestamp-invalid'); stats.invalidTimestamps += 1; }
243
- const messageOk = entry.message !== null && typeof entry.message === 'object' && !Array.isArray(entry.message);
244
- if (!messageOk) diagnostics.add('message-invalid', entry.type);
245
- const normalized = { uuid: entry.uuid, role: entry.type };
246
- if (timestamp.state === 'observed') normalized.timestamp = timestamp.value;
247
- let usageExtract = { kind: 'absent' };
248
- if (messageOk) {
249
- const message = entry.message;
250
- if (entry.type === 'assistant') {
251
- const modelMessageId = publishableIdentifier(redact, message.id, 'model-id-missing', 'model-id-invalid', diagnostics);
252
- if (modelMessageId !== null) normalized.modelMessageId = modelMessageId;
253
- if (typeof message.model === 'string' && message.model.length > 0) normalized.model = redactText(redact, message.model, 'model');
254
- usageExtract = extractUsage(message.usage);
255
- if (usageExtract.kind === 'invalid') diagnostics.add('usage-invalid-value');
256
- }
257
- }
258
- const blockResult = messageOk ? normalizeBlocks(entry, redact, diagnostics) : { blocks: [], dropped: { thinking: 0, unknown: 0 } };
259
- normalized.blocks = blockResult.blocks;
260
- stats.thinkingBlocksDropped += blockResult.dropped.thinking;
261
- stats.unknownBlocksDropped += blockResult.dropped.unknown;
262
- return { normalized, usageExtract };
263
- }
264
-
265
- function normalizeTaskMessages(messages, options) {
266
- if (!Array.isArray(messages)) fail('inputInvalid');
267
- const redact = options !== null && typeof options === 'object' ? options.redact : undefined;
268
- if (typeof redact !== 'function') fail('redactRequired');
269
- const diagnostics = createDiagnostics();
270
- const stats = {
271
- inputEntries: messages.length, entries: 0, duplicatesCollapsed: 0, uuidConflicts: 0,
272
- byRole: { user: 0, assistant: 0 },
273
- textBlocks: 0, toolUseBlocks: 0, toolResultBlocks: 0,
274
- thinkingBlocksDropped: 0, unknownBlocksDropped: 0,
275
- systemEntriesSkipped: 0, invalidTimestamps: 0,
276
- };
277
- // Idempotency is per entry uuid inside one snapshot: identical re-reads
278
- // collapse, and one uuid carrying two different payloads is a conflict
279
- // that is reported, never silently overwritten.
280
- const byUuid = new Map();
281
- for (const raw of messages) {
282
- const item = normalizeEntry(raw, redact, diagnostics, stats);
283
- if (item === null) continue;
284
- const key = JSON.stringify({ entry: item.normalized, usage: item.usageExtract.kind === 'valid' ? item.usageExtract.usage : item.usageExtract.kind });
285
- const existing = byUuid.get(item.normalized.uuid);
286
- if (existing === undefined) { byUuid.set(item.normalized.uuid, { normalized: item.normalized, usageExtract: item.usageExtract, key, count: 1, conflict: false }); continue; }
287
- if (existing.key === key) { existing.count += 1; diagnostics.add('duplicate-entry', item.normalized.uuid); continue; }
288
- existing.conflict = true;
289
- diagnostics.add('uuid-conflict', item.normalized.uuid);
290
- }
291
- const survivors = [];
292
- for (const record of byUuid.values()) {
293
- if (record.conflict) { stats.uuidConflicts += 1; continue; }
294
- stats.duplicatesCollapsed += record.count - 1;
295
- stats.entries += 1;
296
- stats.byRole[record.normalized.role] += 1;
297
- for (const block of record.normalized.blocks) {
298
- if (block.kind === 'text') stats.textBlocks += 1;
299
- else if (block.kind === 'tool_use') stats.toolUseBlocks += 1;
300
- else stats.toolResultBlocks += 1;
301
- }
302
- survivors.push(record);
303
- }
304
- // Tool identity is the tool_use/tool_result id: chunked or re-read copies
305
- // of the same call merge into one step and never create a second step.
306
- // Name conflicts compare the raw tool names held in the module WeakMap;
307
- // only the redacted name is ever published.
308
- const steps = new Map();
309
- const stepRawNames = new Map();
310
- for (const record of survivors) {
311
- const uuid = record.normalized.uuid;
312
- for (const block of record.normalized.blocks) {
313
- if (block.kind === 'tool_use') {
314
- let step = steps.get(block.toolUseId);
315
- if (step === undefined) {
316
- step = { toolUseId: block.toolUseId, name: block.name, useEntryUuids: [uuid], resultEntryUuid: null, isError: undefined, resultTextCount: undefined, duplicateUseCount: 0, duplicateResultCount: 0, nameConflict: false };
317
- steps.set(block.toolUseId, step);
318
- stepRawNames.set(step, rawToolNames.get(block));
319
- } else if (!stepRawNames.has(step)) {
320
- stepRawNames.set(step, rawToolNames.get(block));
321
- step.name = block.name;
322
- if (!step.useEntryUuids.includes(uuid)) step.useEntryUuids.push(uuid);
323
- } else if (stepRawNames.get(step) !== rawToolNames.get(block)) {
324
- step.nameConflict = true;
325
- diagnostics.add('tool-use-id-conflict', block.toolUseId);
326
- step.duplicateUseCount += 1;
327
- if (!step.useEntryUuids.includes(uuid)) step.useEntryUuids.push(uuid);
328
- } else {
329
- step.duplicateUseCount += 1;
330
- if (!step.useEntryUuids.includes(uuid)) step.useEntryUuids.push(uuid);
331
- }
332
- } else if (block.kind === 'tool_result') {
333
- let step = steps.get(block.toolUseId);
334
- if (step === undefined) {
335
- step = { toolUseId: block.toolUseId, name: null, useEntryUuids: [], resultEntryUuid: null, isError: undefined, resultTextCount: undefined, duplicateUseCount: 0, duplicateResultCount: 0, nameConflict: false };
336
- steps.set(block.toolUseId, step);
337
- }
338
- if (step.resultEntryUuid === null) {
339
- step.resultEntryUuid = uuid;
340
- step.isError = block.isError;
341
- step.resultTextCount = block.texts.length;
342
- } else {
343
- step.duplicateResultCount += 1;
344
- }
345
- }
346
- }
347
- }
348
- stats.unpairedToolUse = 0;
349
- stats.unpairedToolResult = 0;
350
- for (const step of steps.values()) {
351
- step.paired = step.useEntryUuids.length > 0 && step.resultEntryUuid !== null;
352
- if (step.useEntryUuids.length === 0) { stats.unpairedToolResult += 1; diagnostics.add('unpaired-tool-result', step.toolUseId); }
353
- else if (step.resultEntryUuid === null) { stats.unpairedToolUse += 1; diagnostics.add('unpaired-tool-use', step.toolUseId); }
354
- }
355
- // Usage is attributed to the model response (message.id), never summed
356
- // per entry uuid. All-zero usages are placeholders distinguishable from a
357
- // response's actual non-zero usage; identical repeated actual usage counts
358
- // once; distinct actual usages for one id are a conflict that is reported
359
- // and excluded, never averaged. Usage observed on an entry with no valid
360
- // message id is unattributable: it contributes no numbers anywhere.
361
- const assistantSurvivors = survivors.filter(r => r.normalized.role === 'assistant');
362
- const groups = [];
363
- const byGroupKey = new Map();
364
- for (const record of assistantSurvivors) {
365
- const hasId = Object.prototype.hasOwnProperty.call(record.normalized, 'modelMessageId');
366
- const key = hasId ? 'id::' + record.normalized.modelMessageId : 'noid::' + record.normalized.uuid;
367
- let group = byGroupKey.get(key);
368
- if (group === undefined) { group = { modelMessageId: hasId ? record.normalized.modelMessageId : null, records: [] }; byGroupKey.set(key, group); groups.push(group); }
369
- group.records.push(record);
370
- }
371
- const usageRecords = [];
372
- let zero = 0, conflict = 0, missing = 0, placeholderUsages = 0, duplicateIdenticalUsages = 0;
373
- for (const group of groups) {
374
- const valid = group.records.filter(r => r.usageExtract.kind === 'valid').map(r => r.usageExtract.usage);
375
- if (group.modelMessageId === null) {
376
- if (valid.length > 0) for (const r of group.records) if (r.usageExtract.kind === 'valid') diagnostics.add('usage-without-model-id');
377
- continue;
378
- }
379
- const placeholders = valid.filter(isPlaceholderUsage);
380
- const substantive = valid.filter(u => !isPlaceholderUsage(u));
381
- let status, usage = null, placeholderCount = 0, duplicateIdenticalCount = 0;
382
- if (substantive.length > 0) {
383
- const signatures = new Set(substantive.map(usageSignature));
384
- if (signatures.size === 1) {
385
- status = 'resolved';
386
- usage = substantive[0];
387
- placeholderCount = placeholders.length;
388
- duplicateIdenticalCount = substantive.length - 1;
389
- placeholderUsages += placeholderCount;
390
- duplicateIdenticalUsages += duplicateIdenticalCount;
391
- } else {
392
- status = 'conflict';
393
- conflict += 1;
394
- placeholderUsages += placeholders.length;
395
- diagnostics.add('usage-conflict', group.modelMessageId);
396
- }
397
- } else if (placeholders.length > 0) {
398
- status = 'zero';
399
- usage = placeholders[0];
400
- zero += 1;
401
- } else {
402
- status = 'missing';
403
- missing += 1;
404
- }
405
- usageRecords.push({
406
- modelMessageId: group.modelMessageId,
407
- entryUuids: group.records.map(r => r.normalized.uuid),
408
- status,
409
- usage,
410
- placeholderCount,
411
- duplicateIdenticalCount,
412
- conflictDetail: status === 'conflict' ? 'distinct non-zero usage payloads observed for one model message id' : null,
413
- });
414
- }
415
- // Totals are complete only when every assistant survivor carried a valid
416
- // model message id and every one of those responses resolved to exactly
417
- // one usage observation (actual or an explicit SDK zero). Anything less —
418
- // missing usage, a conflict, or an unattributable response — makes the
419
- // grand totals explicitly unknown (null per field) instead of a silently
420
- // fabricated 0. The same honesty applies per field: a token field the
421
- // resolved responses never reported is unknown (null), never summed as 0 —
422
- // only a field every contributing response explicitly provided (zero
423
- // included) earns a numeric total. The knownTotals subtotal follows the
424
- // same per-field rule over the fully resolved responses only.
425
- const completeRecords = usageRecords.filter(r => r.status === 'resolved' || r.status === 'zero');
426
- const knownTotals = { responses: completeRecords.length, totals: {} };
427
- for (const field of USAGE_TOKEN_FIELDS) {
428
- const fieldObserved = completeRecords.every(r => r.usage[field] !== undefined);
429
- knownTotals.totals[field] = fieldObserved ? completeRecords.reduce((sum, r) => sum + r.usage[field], 0) : null;
430
- }
431
- const allAttributed = assistantSurvivors.every(r => Object.prototype.hasOwnProperty.call(r.normalized, 'modelMessageId'));
432
- const totalsComplete = assistantSurvivors.length > 0 && allAttributed && completeRecords.length === usageRecords.length;
433
- const totals = {};
434
- for (const field of USAGE_TOKEN_FIELDS) totals[field] = totalsComplete ? knownTotals.totals[field] : null;
435
- const usageSummary = {
436
- modelResponses: usageRecords.length,
437
- resolved: completeRecords.filter(r => r.status === 'resolved').length,
438
- zero,
439
- conflict,
440
- missing,
441
- placeholderUsages,
442
- duplicateIdenticalUsages,
443
- totalsComplete,
444
- totals,
445
- knownTotals,
446
- };
447
- return {
448
- entries: survivors.map(r => r.normalized),
449
- toolSteps: [...steps.values()],
450
- usageRecords,
451
- usageSummary,
452
- stats,
453
- diagnostics: diagnostics.list(),
454
- };
455
- }
456
-
457
- module.exports = { normalizeTaskMessages };
1
+ 'use strict';
2
+
3
+ // Pure normalization of public SDK SessionMessage snapshots into redacted,
4
+ // deterministic candidate records for native task messages. The module has
5
+ // no I/O and no authority: it never reads transcripts, never persists,
6
+ // never allocates journal dedup keys or TaskRefs, and never fabricates
7
+ // fields the public SDK types do not promise. Every string destined for the
8
+ // output — text values, tool-input object KEYS, tool names, model labels,
9
+ // and free-form diagnostic details — must pass the caller-supplied redaction
10
+ // callback; a missing, throwing, or malformed callback fails the whole
11
+ // normalization closed with a fixed safe message that never echoes callback
12
+ // or snapshot content. Linkage identifiers (entry uuid, tool call id, model
13
+ // message id) are kept only when they match a strict identifier pattern AND
14
+ // the caller's own redaction callback leaves them untouched — an identifier
15
+ // the redaction boundary wants to change (an embedded credential, private
16
+ // text) is rejected with a diagnostic, never published raw and never
17
+ // published in redacted form (a changed identifier could not be paired
18
+ // honestly). Raw tool names needed for internal conflict comparison live
19
+ // only in a module WeakMap; no returned object carries them.
20
+
21
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
23
+ const USAGE_TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens'];
24
+ // Unredacted tool names are comparison state, not output: they live only in
25
+ // this WeakMap keyed by the published block/step objects and never travel on
26
+ // any returned DTO.
27
+ const rawToolNames = new WeakMap();
28
+
29
+ const FAILURES = {
30
+ inputInvalid: ['NATIVE_TASK_MESSAGES_INPUT_INVALID', 'native task message normalization refused: messages must be an array'],
31
+ redactRequired: ['NATIVE_TASK_MESSAGES_REDACT_REQUIRED', 'native task message normalization refused: no redaction callback was provided'],
32
+ redactFailed: ['NATIVE_TASK_MESSAGES_REDACT_FAILED', 'native task message normalization refused: the redaction callback failed'],
33
+ };
34
+
35
+ function fail(kind) {
36
+ const error = new Error(FAILURES[kind][1]);
37
+ error.code = FAILURES[kind][0];
38
+ throw error;
39
+ }
40
+
41
+ // Diagnostics carry only codes, pattern-safe identifiers, counts, and
42
+ // redacted free-form details — never raw snapshot text. Occurrence counts
43
+ // reflect the raw input stream (duplicates included); the structural stats
44
+ // in the result reflect the deduplicated snapshot.
45
+ function createDiagnostics() {
46
+ const counts = new Map();
47
+ return {
48
+ add(code, detail) {
49
+ const key = detail ? code + '|' + detail : code;
50
+ counts.set(key, (counts.get(key) || 0) + 1);
51
+ },
52
+ list() {
53
+ const rows = [];
54
+ for (const [key, count] of counts) {
55
+ const sep = key.indexOf('|');
56
+ rows.push(sep === -1 ? { code: key, detail: null, count } : { code: key.slice(0, sep), detail: key.slice(sep + 1), count });
57
+ }
58
+ rows.sort((a, b) => {
59
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
60
+ const x = a.detail === null ? '' : a.detail;
61
+ const y = b.detail === null ? '' : b.detail;
62
+ return x < y ? -1 : x > y ? 1 : 0;
63
+ });
64
+ return rows;
65
+ },
66
+ };
67
+ }
68
+
69
+ function redactText(redact, text, field) {
70
+ let value;
71
+ try {
72
+ value = redact(text, { field });
73
+ } catch {
74
+ fail('redactFailed');
75
+ }
76
+ if (typeof value !== 'string') fail('redactFailed');
77
+ return value;
78
+ }
79
+
80
+ // A linkage identifier is publishable only when the pattern accepts it AND
81
+ // the redaction boundary leaves it byte-identical. A changed value means the
82
+ // identifier itself carries content the caller classifies as sensitive: it
83
+ // is dropped with the existing invalid-identifier diagnostic (no detail, so
84
+ // the offending value never reaches the diagnostics surface) while the rest
85
+ // of the snapshot keeps normalizing. A throwing or non-string callback still
86
+ // fails the whole normalization closed, exactly like every other redaction.
87
+ function publishableIdentifier(redact, value, missingCode, invalidCode, diagnostics) {
88
+ if (typeof value !== 'string' || value.length === 0) { diagnostics.add(missingCode); return null; }
89
+ if (!IDENTIFIER_PATTERN.test(value)) { diagnostics.add(invalidCode); return null; }
90
+ let safe;
91
+ try {
92
+ safe = redact(value, { field: 'identifier' });
93
+ } catch {
94
+ fail('redactFailed');
95
+ }
96
+ if (typeof safe !== 'string') fail('redactFailed');
97
+ if (safe !== value) { diagnostics.add(invalidCode); return null; }
98
+ return value;
99
+ }
100
+
101
+ // Object keys are publishable strings too, so they cross the same redaction
102
+ // boundary as values. When two distinct source keys redact to the same
103
+ // output key, the collision is reported and the later key (in sorted source
104
+ // order) is dropped — a collision never silently overwrites a key.
105
+ function redactValue(redact, value, field, diagnostics) {
106
+ if (typeof value === 'string') return redactText(redact, value, field);
107
+ if (Array.isArray(value)) return value.map(item => redactValue(redact, item, field, diagnostics));
108
+ if (value !== null && typeof value === 'object') {
109
+ const out = {};
110
+ for (const key of Object.keys(value).sort()) {
111
+ const safeKey = redactText(redact, key, field);
112
+ if (Object.prototype.hasOwnProperty.call(out, safeKey)) diagnostics.add('redact-key-collision', field);
113
+ else out[safeKey] = redactValue(redact, value[key], field, diagnostics);
114
+ }
115
+ return out;
116
+ }
117
+ return value;
118
+ }
119
+
120
+ // The public SessionMessage type promises no timestamp. Accept only real
121
+ // calendar dates in the exact ISO shape observed in recorded snapshots:
122
+ // positional digit/separator checks plus month/day (leap-aware), hour,
123
+ // minute and second range validation. Anything else becomes a diagnostic;
124
+ // absence is normal and never an error, and no timestamp is ever invented.
125
+ function observedTimestamp(value) {
126
+ if (value === undefined || value === null) return { state: 'absent' };
127
+ if (typeof value !== 'string') return { state: 'invalid' };
128
+ const digit = i => { const c = value.charAt(i); return c >= '0' && c <= '9'; };
129
+ const digits = (from, to) => { for (let i = from; i < to; i += 1) if (!digit(i)) return false; return true; };
130
+ let ok = digits(0, 4) && value.charAt(4) === '-'
131
+ && digits(5, 7) && value.charAt(7) === '-'
132
+ && digits(8, 10) && value.charAt(10) === 'T'
133
+ && digits(11, 13) && value.charAt(13) === ':'
134
+ && digits(14, 16) && value.charAt(16) === ':'
135
+ && digits(17, 19);
136
+ if (ok) {
137
+ const dot = value.indexOf('.');
138
+ if (dot === -1) ok = value.length === 20 && value.charAt(19) === 'Z';
139
+ else ok = dot === 19 && value.length >= 22 && value.endsWith('Z') && digits(20, value.length - 1);
140
+ }
141
+ if (ok) {
142
+ const year = Number(value.slice(0, 4));
143
+ const month = Number(value.slice(5, 7));
144
+ const day = Number(value.slice(8, 10));
145
+ const hour = Number(value.slice(11, 13));
146
+ const minute = Number(value.slice(14, 16));
147
+ const second = Number(value.slice(17, 19));
148
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
149
+ const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
150
+ ok = year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= monthDays[month - 1]
151
+ && hour <= 23 && minute <= 59 && second <= 59;
152
+ }
153
+ return ok ? { state: 'observed', value } : { state: 'invalid' };
154
+ }
155
+
156
+ function extractUsage(raw) {
157
+ if (raw === undefined || raw === null) return { kind: 'absent' };
158
+ if (typeof raw !== 'object' || Array.isArray(raw)) return { kind: 'invalid' };
159
+ const usage = {};
160
+ for (const field of USAGE_TOKEN_FIELDS) {
161
+ if (!Object.prototype.hasOwnProperty.call(raw, field)) continue;
162
+ const value = raw[field];
163
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return { kind: 'invalid' };
164
+ usage[field] = value;
165
+ }
166
+ return Object.keys(usage).length > 0 ? { kind: 'valid', usage } : { kind: 'invalid' };
167
+ }
168
+
169
+ const isPlaceholderUsage = usage => USAGE_TOKEN_FIELDS.every(field => usage[field] === undefined || usage[field] === 0);
170
+ const usageSignature = usage => JSON.stringify(USAGE_TOKEN_FIELDS.filter(field => usage[field] !== undefined).map(field => [field, usage[field]]));
171
+
172
+ function normalizeBlocks(entry, redact, diagnostics) {
173
+ const message = entry.message;
174
+ const content = message === null || typeof message !== 'object' ? undefined : message.content;
175
+ const textField = entry.type === 'assistant' ? 'assistant-text' : 'user-text';
176
+ const blocks = [];
177
+ const dropped = { thinking: 0, unknown: 0 };
178
+ if (content === undefined || content === null) {
179
+ diagnostics.add('content-missing', entry.type);
180
+ return { blocks, dropped };
181
+ }
182
+ if (typeof content === 'string') {
183
+ blocks.push({ kind: 'text', text: redactText(redact, content, textField) });
184
+ return { blocks, dropped };
185
+ }
186
+ if (!Array.isArray(content)) {
187
+ diagnostics.add('content-invalid', entry.type);
188
+ return { blocks, dropped };
189
+ }
190
+ for (const item of content) {
191
+ if (item === null || typeof item !== 'object' || typeof item.type !== 'string') {
192
+ diagnostics.add('block-invalid', item === null || typeof item !== 'object' ? 'non-object' : 'type-missing');
193
+ continue;
194
+ }
195
+ if (item.type === 'thinking') { dropped.thinking += 1; continue; }
196
+ if (item.type === 'text') {
197
+ if (typeof item.text !== 'string') { diagnostics.add('block-invalid', 'text'); continue; }
198
+ blocks.push({ kind: 'text', text: redactText(redact, item.text, textField) });
199
+ continue;
200
+ }
201
+ if (item.type === 'tool_use') {
202
+ const toolUseId = publishableIdentifier(redact, item.id, 'tool-use-id-missing', 'tool-call-id-invalid', diagnostics);
203
+ if (toolUseId === null) continue;
204
+ if (typeof item.name !== 'string' || item.name.length === 0) { diagnostics.add('tool-use-name-missing'); continue; }
205
+ const block = { kind: 'tool_use', toolUseId, name: redactText(redact, item.name, 'tool-name'), input: redactValue(redact, item.input === undefined ? null : item.input, 'tool-use-input', diagnostics) };
206
+ rawToolNames.set(block, item.name);
207
+ blocks.push(block);
208
+ continue;
209
+ }
210
+ if (item.type === 'tool_result') {
211
+ const toolUseId = publishableIdentifier(redact, item.tool_use_id, 'tool-result-id-missing', 'tool-call-id-invalid', diagnostics);
212
+ if (toolUseId === null) continue;
213
+ const block = { kind: 'tool_result', toolUseId };
214
+ if (item.is_error !== undefined) {
215
+ if (typeof item.is_error === 'boolean') block.isError = item.is_error;
216
+ else diagnostics.add('tool-result-field-invalid', 'is_error');
217
+ }
218
+ const texts = [];
219
+ if (typeof item.content === 'string') texts.push(redactText(redact, item.content, 'tool-result-text'));
220
+ else if (Array.isArray(item.content)) {
221
+ for (const part of item.content) {
222
+ if (part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') texts.push(redactText(redact, part.text, 'tool-result-text'));
223
+ else diagnostics.add('tool-result-item-unsupported', part !== null && typeof part === 'object' && typeof part.type === 'string' ? redactText(redact, part.type, 'diagnostic-text') : 'non-text');
224
+ }
225
+ } else if (item.content !== undefined && item.content !== null) diagnostics.add('tool-result-content-invalid', typeof item.content);
226
+ block.texts = texts;
227
+ blocks.push(block);
228
+ continue;
229
+ }
230
+ dropped.unknown += 1;
231
+ diagnostics.add('unknown-block-type', redactText(redact, item.type, 'diagnostic-text'));
232
+ }
233
+ return { blocks, dropped };
234
+ }
235
+
236
+ function normalizeEntry(entry, redact, diagnostics, stats) {
237
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { diagnostics.add('entry-invalid', entry === null || typeof entry !== 'object' ? 'non-object' : 'array'); return null; }
238
+ if (entry.type === 'system') { stats.systemEntriesSkipped += 1; return null; }
239
+ if (entry.type !== 'user' && entry.type !== 'assistant') { diagnostics.add('entry-type-unknown', typeof entry.type === 'string' ? redactText(redact, entry.type, 'diagnostic-text') : 'non-string'); return null; }
240
+ if (typeof entry.uuid !== 'string' || !UUID_PATTERN.test(entry.uuid)) { diagnostics.add('entry-uuid-invalid'); return null; }
241
+ const timestamp = observedTimestamp(entry.timestamp);
242
+ if (timestamp.state === 'invalid') { diagnostics.add('timestamp-invalid'); stats.invalidTimestamps += 1; }
243
+ const messageOk = entry.message !== null && typeof entry.message === 'object' && !Array.isArray(entry.message);
244
+ if (!messageOk) diagnostics.add('message-invalid', entry.type);
245
+ const normalized = { uuid: entry.uuid, role: entry.type };
246
+ if (timestamp.state === 'observed') normalized.timestamp = timestamp.value;
247
+ let usageExtract = { kind: 'absent' };
248
+ if (messageOk) {
249
+ const message = entry.message;
250
+ if (entry.type === 'assistant') {
251
+ const modelMessageId = publishableIdentifier(redact, message.id, 'model-id-missing', 'model-id-invalid', diagnostics);
252
+ if (modelMessageId !== null) normalized.modelMessageId = modelMessageId;
253
+ if (typeof message.model === 'string' && message.model.length > 0) normalized.model = redactText(redact, message.model, 'model');
254
+ usageExtract = extractUsage(message.usage);
255
+ if (usageExtract.kind === 'invalid') diagnostics.add('usage-invalid-value');
256
+ }
257
+ }
258
+ const blockResult = messageOk ? normalizeBlocks(entry, redact, diagnostics) : { blocks: [], dropped: { thinking: 0, unknown: 0 } };
259
+ normalized.blocks = blockResult.blocks;
260
+ stats.thinkingBlocksDropped += blockResult.dropped.thinking;
261
+ stats.unknownBlocksDropped += blockResult.dropped.unknown;
262
+ return { normalized, usageExtract };
263
+ }
264
+
265
+ function normalizeTaskMessages(messages, options) {
266
+ if (!Array.isArray(messages)) fail('inputInvalid');
267
+ const redact = options !== null && typeof options === 'object' ? options.redact : undefined;
268
+ if (typeof redact !== 'function') fail('redactRequired');
269
+ const diagnostics = createDiagnostics();
270
+ const stats = {
271
+ inputEntries: messages.length, entries: 0, duplicatesCollapsed: 0, uuidConflicts: 0,
272
+ byRole: { user: 0, assistant: 0 },
273
+ textBlocks: 0, toolUseBlocks: 0, toolResultBlocks: 0,
274
+ thinkingBlocksDropped: 0, unknownBlocksDropped: 0,
275
+ systemEntriesSkipped: 0, invalidTimestamps: 0,
276
+ };
277
+ // Idempotency is per entry uuid inside one snapshot: identical re-reads
278
+ // collapse, and one uuid carrying two different payloads is a conflict
279
+ // that is reported, never silently overwritten.
280
+ const byUuid = new Map();
281
+ for (const raw of messages) {
282
+ const item = normalizeEntry(raw, redact, diagnostics, stats);
283
+ if (item === null) continue;
284
+ const key = JSON.stringify({ entry: item.normalized, usage: item.usageExtract.kind === 'valid' ? item.usageExtract.usage : item.usageExtract.kind });
285
+ const existing = byUuid.get(item.normalized.uuid);
286
+ if (existing === undefined) { byUuid.set(item.normalized.uuid, { normalized: item.normalized, usageExtract: item.usageExtract, key, count: 1, conflict: false }); continue; }
287
+ if (existing.key === key) { existing.count += 1; diagnostics.add('duplicate-entry', item.normalized.uuid); continue; }
288
+ existing.conflict = true;
289
+ diagnostics.add('uuid-conflict', item.normalized.uuid);
290
+ }
291
+ const survivors = [];
292
+ for (const record of byUuid.values()) {
293
+ if (record.conflict) { stats.uuidConflicts += 1; continue; }
294
+ stats.duplicatesCollapsed += record.count - 1;
295
+ stats.entries += 1;
296
+ stats.byRole[record.normalized.role] += 1;
297
+ for (const block of record.normalized.blocks) {
298
+ if (block.kind === 'text') stats.textBlocks += 1;
299
+ else if (block.kind === 'tool_use') stats.toolUseBlocks += 1;
300
+ else stats.toolResultBlocks += 1;
301
+ }
302
+ survivors.push(record);
303
+ }
304
+ // Tool identity is the tool_use/tool_result id: chunked or re-read copies
305
+ // of the same call merge into one step and never create a second step.
306
+ // Name conflicts compare the raw tool names held in the module WeakMap;
307
+ // only the redacted name is ever published.
308
+ const steps = new Map();
309
+ const stepRawNames = new Map();
310
+ for (const record of survivors) {
311
+ const uuid = record.normalized.uuid;
312
+ for (const block of record.normalized.blocks) {
313
+ if (block.kind === 'tool_use') {
314
+ let step = steps.get(block.toolUseId);
315
+ if (step === undefined) {
316
+ step = { toolUseId: block.toolUseId, name: block.name, useEntryUuids: [uuid], resultEntryUuid: null, isError: undefined, resultTextCount: undefined, duplicateUseCount: 0, duplicateResultCount: 0, nameConflict: false };
317
+ steps.set(block.toolUseId, step);
318
+ stepRawNames.set(step, rawToolNames.get(block));
319
+ } else if (!stepRawNames.has(step)) {
320
+ stepRawNames.set(step, rawToolNames.get(block));
321
+ step.name = block.name;
322
+ if (!step.useEntryUuids.includes(uuid)) step.useEntryUuids.push(uuid);
323
+ } else if (stepRawNames.get(step) !== rawToolNames.get(block)) {
324
+ step.nameConflict = true;
325
+ diagnostics.add('tool-use-id-conflict', block.toolUseId);
326
+ step.duplicateUseCount += 1;
327
+ if (!step.useEntryUuids.includes(uuid)) step.useEntryUuids.push(uuid);
328
+ } else {
329
+ step.duplicateUseCount += 1;
330
+ if (!step.useEntryUuids.includes(uuid)) step.useEntryUuids.push(uuid);
331
+ }
332
+ } else if (block.kind === 'tool_result') {
333
+ let step = steps.get(block.toolUseId);
334
+ if (step === undefined) {
335
+ step = { toolUseId: block.toolUseId, name: null, useEntryUuids: [], resultEntryUuid: null, isError: undefined, resultTextCount: undefined, duplicateUseCount: 0, duplicateResultCount: 0, nameConflict: false };
336
+ steps.set(block.toolUseId, step);
337
+ }
338
+ if (step.resultEntryUuid === null) {
339
+ step.resultEntryUuid = uuid;
340
+ step.isError = block.isError;
341
+ step.resultTextCount = block.texts.length;
342
+ } else {
343
+ step.duplicateResultCount += 1;
344
+ }
345
+ }
346
+ }
347
+ }
348
+ stats.unpairedToolUse = 0;
349
+ stats.unpairedToolResult = 0;
350
+ for (const step of steps.values()) {
351
+ step.paired = step.useEntryUuids.length > 0 && step.resultEntryUuid !== null;
352
+ if (step.useEntryUuids.length === 0) { stats.unpairedToolResult += 1; diagnostics.add('unpaired-tool-result', step.toolUseId); }
353
+ else if (step.resultEntryUuid === null) { stats.unpairedToolUse += 1; diagnostics.add('unpaired-tool-use', step.toolUseId); }
354
+ }
355
+ // Usage is attributed to the model response (message.id), never summed
356
+ // per entry uuid. All-zero usages are placeholders distinguishable from a
357
+ // response's actual non-zero usage; identical repeated actual usage counts
358
+ // once; distinct actual usages for one id are a conflict that is reported
359
+ // and excluded, never averaged. Usage observed on an entry with no valid
360
+ // message id is unattributable: it contributes no numbers anywhere.
361
+ const assistantSurvivors = survivors.filter(r => r.normalized.role === 'assistant');
362
+ const groups = [];
363
+ const byGroupKey = new Map();
364
+ for (const record of assistantSurvivors) {
365
+ const hasId = Object.prototype.hasOwnProperty.call(record.normalized, 'modelMessageId');
366
+ const key = hasId ? 'id::' + record.normalized.modelMessageId : 'noid::' + record.normalized.uuid;
367
+ let group = byGroupKey.get(key);
368
+ if (group === undefined) { group = { modelMessageId: hasId ? record.normalized.modelMessageId : null, records: [] }; byGroupKey.set(key, group); groups.push(group); }
369
+ group.records.push(record);
370
+ }
371
+ const usageRecords = [];
372
+ let zero = 0, conflict = 0, missing = 0, placeholderUsages = 0, duplicateIdenticalUsages = 0;
373
+ for (const group of groups) {
374
+ const valid = group.records.filter(r => r.usageExtract.kind === 'valid').map(r => r.usageExtract.usage);
375
+ if (group.modelMessageId === null) {
376
+ if (valid.length > 0) for (const r of group.records) if (r.usageExtract.kind === 'valid') diagnostics.add('usage-without-model-id');
377
+ continue;
378
+ }
379
+ const placeholders = valid.filter(isPlaceholderUsage);
380
+ const substantive = valid.filter(u => !isPlaceholderUsage(u));
381
+ let status, usage = null, placeholderCount = 0, duplicateIdenticalCount = 0;
382
+ if (substantive.length > 0) {
383
+ const signatures = new Set(substantive.map(usageSignature));
384
+ if (signatures.size === 1) {
385
+ status = 'resolved';
386
+ usage = substantive[0];
387
+ placeholderCount = placeholders.length;
388
+ duplicateIdenticalCount = substantive.length - 1;
389
+ placeholderUsages += placeholderCount;
390
+ duplicateIdenticalUsages += duplicateIdenticalCount;
391
+ } else {
392
+ status = 'conflict';
393
+ conflict += 1;
394
+ placeholderUsages += placeholders.length;
395
+ diagnostics.add('usage-conflict', group.modelMessageId);
396
+ }
397
+ } else if (placeholders.length > 0) {
398
+ status = 'zero';
399
+ usage = placeholders[0];
400
+ zero += 1;
401
+ } else {
402
+ status = 'missing';
403
+ missing += 1;
404
+ }
405
+ usageRecords.push({
406
+ modelMessageId: group.modelMessageId,
407
+ entryUuids: group.records.map(r => r.normalized.uuid),
408
+ status,
409
+ usage,
410
+ placeholderCount,
411
+ duplicateIdenticalCount,
412
+ conflictDetail: status === 'conflict' ? 'distinct non-zero usage payloads observed for one model message id' : null,
413
+ });
414
+ }
415
+ // Totals are complete only when every assistant survivor carried a valid
416
+ // model message id and every one of those responses resolved to exactly
417
+ // one usage observation (actual or an explicit SDK zero). Anything less —
418
+ // missing usage, a conflict, or an unattributable response — makes the
419
+ // grand totals explicitly unknown (null per field) instead of a silently
420
+ // fabricated 0. The same honesty applies per field: a token field the
421
+ // resolved responses never reported is unknown (null), never summed as 0 —
422
+ // only a field every contributing response explicitly provided (zero
423
+ // included) earns a numeric total. The knownTotals subtotal follows the
424
+ // same per-field rule over the fully resolved responses only.
425
+ const completeRecords = usageRecords.filter(r => r.status === 'resolved' || r.status === 'zero');
426
+ const knownTotals = { responses: completeRecords.length, totals: {} };
427
+ for (const field of USAGE_TOKEN_FIELDS) {
428
+ const fieldObserved = completeRecords.every(r => r.usage[field] !== undefined);
429
+ knownTotals.totals[field] = fieldObserved ? completeRecords.reduce((sum, r) => sum + r.usage[field], 0) : null;
430
+ }
431
+ const allAttributed = assistantSurvivors.every(r => Object.prototype.hasOwnProperty.call(r.normalized, 'modelMessageId'));
432
+ const totalsComplete = assistantSurvivors.length > 0 && allAttributed && completeRecords.length === usageRecords.length;
433
+ const totals = {};
434
+ for (const field of USAGE_TOKEN_FIELDS) totals[field] = totalsComplete ? knownTotals.totals[field] : null;
435
+ const usageSummary = {
436
+ modelResponses: usageRecords.length,
437
+ resolved: completeRecords.filter(r => r.status === 'resolved').length,
438
+ zero,
439
+ conflict,
440
+ missing,
441
+ placeholderUsages,
442
+ duplicateIdenticalUsages,
443
+ totalsComplete,
444
+ totals,
445
+ knownTotals,
446
+ };
447
+ return {
448
+ entries: survivors.map(r => r.normalized),
449
+ toolSteps: [...steps.values()],
450
+ usageRecords,
451
+ usageSummary,
452
+ stats,
453
+ diagnostics: diagnostics.list(),
454
+ };
455
+ }
456
+
457
+ module.exports = { normalizeTaskMessages };