@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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +13 -13
- package/docs/sdk/openapi.yaml +8 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1442 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2392 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- 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 };
|