@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4

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 (122) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +652 -613
  4. package/apps/agent-terminal/app.js +143 -67
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +172 -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 +189 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +652 -521
  11. package/apps/agent-terminal/user-question-card.js +68 -0
  12. package/apps/ai-profile-settings-host/index.html +25 -25
  13. package/apps/ai-profile-settings-host/page.css +1 -1
  14. package/apps/ai-profile-settings-host/page.js +113 -113
  15. package/apps/minimal-test-host/README.md +15 -15
  16. package/apps/minimal-test-host/host.css +2 -2
  17. package/apps/minimal-test-host/host.js +35 -35
  18. package/apps/minimal-test-host/index.html +58 -58
  19. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  20. package/docs/sdk/FILE_LAYOUT.md +31 -31
  21. package/docs/sdk/INTEGRATION.md +126 -126
  22. package/docs/sdk/README.md +21 -21
  23. package/docs/sdk/SESSION_EVENTS.md +15 -13
  24. package/docs/sdk/openapi.yaml +16 -1
  25. package/package.json +1 -1
  26. package/packages/client/README.md +29 -29
  27. package/packages/client/index.d.ts +122 -122
  28. package/packages/client/index.js +260 -260
  29. package/packages/client/package.json +2 -2
  30. package/packages/contracts/README.md +9 -9
  31. package/packages/contracts/index.d.ts +519 -517
  32. package/packages/contracts/index.js +312 -312
  33. package/packages/contracts/package.json +1 -1
  34. package/packages/core/README.md +8 -8
  35. package/packages/core/index.d.ts +76 -76
  36. package/packages/core/index.js +222 -222
  37. package/packages/core/package.json +2 -2
  38. package/packages/server/README.md +17 -17
  39. package/packages/server/bin/agent-terminal-server.js +42 -42
  40. package/packages/server/bin/claude-workbench.js +225 -225
  41. package/packages/server/index.d.ts +171 -171
  42. package/packages/server/index.js +230 -230
  43. package/packages/server/lib/agent-discovery.js +133 -133
  44. package/packages/server/lib/agent-registry.js +143 -143
  45. package/packages/server/lib/agent-spawn-env.js +17 -17
  46. package/packages/server/lib/attachment-store.js +78 -78
  47. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  48. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  49. package/packages/server/lib/capture-settings-store.js +35 -35
  50. package/packages/server/lib/claude-history-recovery.js +87 -87
  51. package/packages/server/lib/cli-session-recovery.js +28 -28
  52. package/packages/server/lib/context-resolver.js +55 -55
  53. package/packages/server/lib/conversation-capture.js +51 -51
  54. package/packages/server/lib/delegation-phase-store.js +357 -357
  55. package/packages/server/lib/delegation-store.js +474 -468
  56. package/packages/server/lib/diagnostics.js +32 -32
  57. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  58. package/packages/server/lib/drivers/codex-driver.js +1461 -1442
  59. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  60. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  61. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  62. package/packages/server/lib/event-stream.js +138 -138
  63. package/packages/server/lib/folder-picker.js +623 -623
  64. package/packages/server/lib/http-handler.js +339 -339
  65. package/packages/server/lib/json-file.js +26 -26
  66. package/packages/server/lib/knowledge-context.js +33 -33
  67. package/packages/server/lib/model-detection.js +295 -295
  68. package/packages/server/lib/parent-context.js +44 -0
  69. package/packages/server/lib/profile-store.js +283 -283
  70. package/packages/server/lib/project-store.js +102 -102
  71. package/packages/server/lib/runtime-loader.js +120 -120
  72. package/packages/server/lib/session-archive-store.js +53 -53
  73. package/packages/server/lib/session-history.js +612 -612
  74. package/packages/server/lib/system-doctor.js +125 -125
  75. package/packages/server/lib/ui-preferences-store.js +48 -48
  76. package/packages/server/lib/user-questions.js +67 -0
  77. package/packages/server/lib/workbench-service.js +1696 -1675
  78. package/packages/server/package.json +2 -2
  79. package/packages/ui/README.md +30 -30
  80. package/packages/ui/ai-profile-settings.d.ts +45 -45
  81. package/packages/ui/ai-profile-settings.js +283 -283
  82. package/packages/ui/claude-terminal.d.ts +31 -31
  83. package/packages/ui/claude-terminal.js +343 -343
  84. package/packages/ui/index.d.ts +18 -18
  85. package/packages/ui/index.js +51 -51
  86. package/packages/ui/package.json +2 -2
  87. package/packages/ui/theme.css +13 -13
  88. package/src/assets/claude-prompts.json +21 -21
  89. package/src/backend/http/ai-profile-routes.js +61 -61
  90. package/src/backend/http/claude-routes.js +170 -170
  91. package/src/backend/http/node-helpers.js +45 -45
  92. package/src/backend/index.js +21 -21
  93. package/src/backend/lib/ai-adapter.js +144 -144
  94. package/src/backend/lib/ai-profile-store.js +259 -259
  95. package/src/backend/lib/ai-vendor-presets.js +83 -83
  96. package/src/backend/lib/ai-workspace.js +140 -140
  97. package/src/backend/lib/automation-config.js +253 -253
  98. package/src/backend/lib/claude-cli-runner.js +2408 -2392
  99. package/src/backend/lib/data-dir.js +169 -169
  100. package/src/backend/lib/llm-client.js +176 -176
  101. package/src/backend/lib/model-context-windows.js +82 -82
  102. package/src/backend/lib/native-task-control.js +217 -217
  103. package/src/backend/lib/native-task-history-index.js +475 -475
  104. package/src/backend/lib/native-task-history-reader.js +526 -526
  105. package/src/backend/lib/native-task-messages.js +457 -457
  106. package/src/backend/lib/native-task-registry.js +325 -325
  107. package/src/backend/lib/native-task-runtime.js +242 -242
  108. package/src/backend/lib/native-task-trace.js +440 -440
  109. package/src/backend/lib/native-task-transcript.js +393 -393
  110. package/src/backend/lib/prompt-registry.js +166 -166
  111. package/src/backend/lib/runtime-config.js +121 -121
  112. package/src/backend/lib/sdk-session-stream.js +143 -143
  113. package/src/backend/lib/sdk-turn-stream.js +16 -16
  114. package/src/backend/lib/store-scope.js +131 -131
  115. package/src/backend/lib/task-identity.js +104 -104
  116. package/src/backend/lib/task-state-projection.js +58 -58
  117. package/src/index.js +7 -7
  118. package/src/sdk-wave-a.ts +1 -1
  119. package/src/sdk-wave-b.ts +1 -1
  120. package/src/sdk-wave-c.ts +1 -1
  121. package/src/sdk-wave-d.ts +1 -1
  122. 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 };