@tekyzinc/gsd-t 2.74.12 → 2.76.10
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/CHANGELOG.md +130 -0
- package/README.md +71 -1
- package/bin/advisor-integration.js +93 -0
- package/bin/check-headless-sessions.js +140 -0
- package/bin/context-meter-config.cjs +101 -0
- package/bin/context-meter-config.test.cjs +101 -0
- package/bin/gsd-t.js +710 -16
- package/bin/headless-auto-spawn.js +290 -0
- package/bin/model-selector.js +224 -0
- package/bin/runway-estimator.js +242 -0
- package/bin/token-budget.js +96 -89
- package/bin/token-optimizer.js +471 -0
- package/bin/token-telemetry.js +246 -0
- package/commands/gsd-t-audit.md +3 -3
- package/commands/gsd-t-backlog-list.md +38 -0
- package/commands/gsd-t-brainstorm.md +3 -3
- package/commands/gsd-t-complete-milestone.md +24 -0
- package/commands/gsd-t-debug.md +124 -7
- package/commands/gsd-t-discuss.md +10 -3
- package/commands/gsd-t-doc-ripple.md +32 -4
- package/commands/gsd-t-execute.md +107 -52
- package/commands/gsd-t-help.md +19 -0
- package/commands/gsd-t-integrate.md +67 -4
- package/commands/gsd-t-optimization-apply.md +91 -0
- package/commands/gsd-t-optimization-reject.md +94 -0
- package/commands/gsd-t-partition.md +7 -0
- package/commands/gsd-t-pause.md +3 -0
- package/commands/gsd-t-plan.md +10 -3
- package/commands/gsd-t-prd.md +3 -3
- package/commands/gsd-t-quick.md +71 -9
- package/commands/gsd-t-reflect.md +3 -7
- package/commands/gsd-t-resume.md +36 -0
- package/commands/gsd-t-status.md +31 -0
- package/commands/gsd-t-test-sync.md +7 -0
- package/commands/gsd-t-verify.md +12 -5
- package/commands/gsd-t-visualize.md +3 -7
- package/commands/gsd-t-wave.md +82 -18
- package/docs/GSD-T-README.md +52 -0
- package/docs/architecture.md +95 -0
- package/docs/infrastructure.md +117 -0
- package/docs/methodology.md +36 -0
- package/docs/prd-harness-evolution.md +51 -37
- package/docs/requirements.md +66 -0
- package/package.json +1 -1
- package/scripts/context-meter/count-tokens-client.js +221 -0
- package/scripts/context-meter/count-tokens-client.test.js +308 -0
- package/scripts/context-meter/test-injector.js +55 -0
- package/scripts/context-meter/threshold.js +88 -0
- package/scripts/context-meter/threshold.test.js +255 -0
- package/scripts/context-meter/transcript-parser.js +252 -0
- package/scripts/context-meter/transcript-parser.test.js +320 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +415 -0
- package/scripts/gsd-t-context-meter.js +350 -0
- package/scripts/gsd-t-context-meter.test.js +417 -0
- package/scripts/gsd-t-heartbeat.js +2 -2
- package/scripts/gsd-t-statusline.js +23 -8
- package/templates/CLAUDE-global.md +5 -1
- package/templates/CLAUDE-project.md +26 -6
- package/templates/context-meter-config.json +10 -0
- package/templates/prompts/README.md +1 -1
- package/bin/task-counter.cjs +0 -161
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/context-meter/threshold.test.js
|
|
3
|
+
*
|
|
4
|
+
* Tests for threshold.js — context-meter's pure-function band/emit module.
|
|
5
|
+
* Run: node --test scripts/context-meter/threshold.test.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const test = require("node:test");
|
|
9
|
+
const assert = require("node:assert/strict");
|
|
10
|
+
const {
|
|
11
|
+
computePct,
|
|
12
|
+
bandFor,
|
|
13
|
+
buildAdditionalContext,
|
|
14
|
+
BANDS,
|
|
15
|
+
} = require("./threshold");
|
|
16
|
+
|
|
17
|
+
// ── computePct ───────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
test("computePct — happy path 50%", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
computePct({ inputTokens: 100000, modelWindowSize: 200000 }),
|
|
22
|
+
50
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("computePct — zero window returns 0", () => {
|
|
27
|
+
assert.equal(
|
|
28
|
+
computePct({ inputTokens: 100000, modelWindowSize: 0 }),
|
|
29
|
+
0
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("computePct — negative window returns 0", () => {
|
|
34
|
+
assert.equal(
|
|
35
|
+
computePct({ inputTokens: 100000, modelWindowSize: -1 }),
|
|
36
|
+
0
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("computePct — negative input returns 0", () => {
|
|
41
|
+
assert.equal(
|
|
42
|
+
computePct({ inputTokens: -5, modelWindowSize: 200000 }),
|
|
43
|
+
0
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("computePct — NaN input returns 0", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
computePct({ inputTokens: NaN, modelWindowSize: 200000 }),
|
|
50
|
+
0
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("computePct — NaN window returns 0", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
computePct({ inputTokens: 100000, modelWindowSize: NaN }),
|
|
57
|
+
0
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("computePct — Infinity returns 0", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
computePct({ inputTokens: Infinity, modelWindowSize: 200000 }),
|
|
64
|
+
0
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("computePct — missing args returns 0", () => {
|
|
69
|
+
assert.equal(computePct({}), 0);
|
|
70
|
+
assert.equal(computePct(), 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("computePct — does NOT clamp above 100", () => {
|
|
74
|
+
assert.equal(
|
|
75
|
+
computePct({ inputTokens: 250000, modelWindowSize: 200000 }),
|
|
76
|
+
125
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("computePct — small fraction", () => {
|
|
81
|
+
const result = computePct({ inputTokens: 1, modelWindowSize: 200000 });
|
|
82
|
+
assert.ok(result > 0 && result < 0.001);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── bandFor — boundary sweep (v3.0.0 three-band: normal/warn/stop) ───────────
|
|
86
|
+
|
|
87
|
+
test("bandFor — 0 → normal", () => {
|
|
88
|
+
assert.equal(bandFor(0), "normal");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("bandFor — 69 → normal", () => {
|
|
92
|
+
assert.equal(bandFor(69), "normal");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("bandFor — 69.9 → normal", () => {
|
|
96
|
+
assert.equal(bandFor(69.9), "normal");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("bandFor — 70 → warn (inclusive lower)", () => {
|
|
100
|
+
assert.equal(bandFor(70), "warn");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("bandFor — 71 → warn", () => {
|
|
104
|
+
assert.equal(bandFor(71), "warn");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("bandFor — 84 → warn", () => {
|
|
108
|
+
assert.equal(bandFor(84), "warn");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("bandFor — 84.9 → warn", () => {
|
|
112
|
+
assert.equal(bandFor(84.9), "warn");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("bandFor — 85 → stop (inclusive lower)", () => {
|
|
116
|
+
assert.equal(bandFor(85), "stop");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("bandFor — 86 → stop", () => {
|
|
120
|
+
assert.equal(bandFor(86), "stop");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("bandFor — 95 → stop", () => {
|
|
124
|
+
assert.equal(bandFor(95), "stop");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("bandFor — 150 → stop (no upper clamp)", () => {
|
|
128
|
+
assert.equal(bandFor(150), "stop");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("bandFor — NaN → normal (fail-safe)", () => {
|
|
132
|
+
assert.equal(bandFor(NaN), "normal");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("bandFor — Infinity → normal (fail-safe: Infinity is NOT finite)", () => {
|
|
136
|
+
assert.equal(bandFor(Infinity), "normal");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("bandFor — undefined → normal", () => {
|
|
140
|
+
assert.equal(bandFor(undefined), "normal");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("BANDS constant mirrors bin/token-budget.js v3.0.0 three-band model", () => {
|
|
144
|
+
// Guard against accidental drift from the token-budget boundaries.
|
|
145
|
+
assert.deepEqual(BANDS, { warn: 70, stop: 85 });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── buildAdditionalContext ───────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
test("buildAdditionalContext — below threshold returns null", () => {
|
|
151
|
+
assert.equal(
|
|
152
|
+
buildAdditionalContext({ pct: 50, modelWindowSize: 200000, thresholdPct: 75 }),
|
|
153
|
+
null
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("buildAdditionalContext — at threshold returns string", () => {
|
|
158
|
+
const result = buildAdditionalContext({
|
|
159
|
+
pct: 75,
|
|
160
|
+
modelWindowSize: 200000,
|
|
161
|
+
thresholdPct: 75,
|
|
162
|
+
});
|
|
163
|
+
assert.ok(typeof result === "string");
|
|
164
|
+
assert.ok(result.includes("75.0%"));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("buildAdditionalContext — above threshold exact contract string", () => {
|
|
168
|
+
const result = buildAdditionalContext({
|
|
169
|
+
pct: 76.2,
|
|
170
|
+
modelWindowSize: 200000,
|
|
171
|
+
thresholdPct: 75,
|
|
172
|
+
});
|
|
173
|
+
assert.equal(
|
|
174
|
+
result,
|
|
175
|
+
"⚠️ Context window at 76.2% of 200000. Run /user:gsd-t-pause to checkpoint and clear before continuing."
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("buildAdditionalContext — decimal formatting rounds via toFixed(1)", () => {
|
|
180
|
+
const result = buildAdditionalContext({
|
|
181
|
+
pct: 76.25,
|
|
182
|
+
modelWindowSize: 200000,
|
|
183
|
+
thresholdPct: 75,
|
|
184
|
+
});
|
|
185
|
+
// toFixed(1) on 76.25 → "76.3" (banker rounds vary but V8 gives "76.3" here)
|
|
186
|
+
assert.ok(result.includes("76.3%") || result.includes("76.2%"));
|
|
187
|
+
// Either rounding is acceptable — what matters is one decimal place.
|
|
188
|
+
const match = result.match(/at (\d+\.\d)% of/);
|
|
189
|
+
assert.ok(match, "must have exactly one decimal place");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("buildAdditionalContext — modelWindowSize emitted raw (no commas)", () => {
|
|
193
|
+
const result = buildAdditionalContext({
|
|
194
|
+
pct: 80,
|
|
195
|
+
modelWindowSize: 200000,
|
|
196
|
+
thresholdPct: 75,
|
|
197
|
+
});
|
|
198
|
+
assert.ok(result.includes("of 200000."));
|
|
199
|
+
assert.ok(!result.includes("200,000"));
|
|
200
|
+
assert.ok(!result.includes("200K"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("buildAdditionalContext — NaN pct returns null", () => {
|
|
204
|
+
assert.equal(
|
|
205
|
+
buildAdditionalContext({ pct: NaN, modelWindowSize: 200000, thresholdPct: 75 }),
|
|
206
|
+
null
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("buildAdditionalContext — NaN thresholdPct returns null", () => {
|
|
211
|
+
assert.equal(
|
|
212
|
+
buildAdditionalContext({ pct: 80, modelWindowSize: 200000, thresholdPct: NaN }),
|
|
213
|
+
null
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("buildAdditionalContext — missing args returns null", () => {
|
|
218
|
+
assert.equal(buildAdditionalContext({}), null);
|
|
219
|
+
assert.equal(buildAdditionalContext(), null);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("buildAdditionalContext — zero pct vs zero threshold emits", () => {
|
|
223
|
+
// 0 >= 0 is true — edge case: if thresholdPct is 0, every call emits.
|
|
224
|
+
const result = buildAdditionalContext({
|
|
225
|
+
pct: 0,
|
|
226
|
+
modelWindowSize: 200000,
|
|
227
|
+
thresholdPct: 0,
|
|
228
|
+
});
|
|
229
|
+
assert.ok(typeof result === "string");
|
|
230
|
+
assert.ok(result.includes("0.0%"));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("buildAdditionalContext — pct over 100% still formats correctly", () => {
|
|
234
|
+
const result = buildAdditionalContext({
|
|
235
|
+
pct: 102.3,
|
|
236
|
+
modelWindowSize: 200000,
|
|
237
|
+
thresholdPct: 75,
|
|
238
|
+
});
|
|
239
|
+
assert.equal(
|
|
240
|
+
result,
|
|
241
|
+
"⚠️ Context window at 102.3% of 200000. Run /user:gsd-t-pause to checkpoint and clear before continuing."
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("buildAdditionalContext — different modelWindowSize (1M)", () => {
|
|
246
|
+
const result = buildAdditionalContext({
|
|
247
|
+
pct: 80,
|
|
248
|
+
modelWindowSize: 1000000,
|
|
249
|
+
thresholdPct: 75,
|
|
250
|
+
});
|
|
251
|
+
assert.equal(
|
|
252
|
+
result,
|
|
253
|
+
"⚠️ Context window at 80.0% of 1000000. Run /user:gsd-t-pause to checkpoint and clear before continuing."
|
|
254
|
+
);
|
|
255
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript-parser.js
|
|
3
|
+
*
|
|
4
|
+
* Parses a Claude Code transcript JSONL file and reconstructs it into the
|
|
5
|
+
* `{ system, messages }` shape expected by Anthropic's `count_tokens` endpoint.
|
|
6
|
+
*
|
|
7
|
+
* ------------------------------------------------------------------------
|
|
8
|
+
* Claude Code transcript JSONL format (UNDOCUMENTED — observed empirically
|
|
9
|
+
* from real session files under ~/.claude/projects/<slug>/*.jsonl).
|
|
10
|
+
*
|
|
11
|
+
* Each line is a single JSON object. Observed top-level `type` values:
|
|
12
|
+
*
|
|
13
|
+
* - "user" — user turn. `message` = { role: "user", content: STRING|ARRAY }.
|
|
14
|
+
* When `content` is an array, it contains blocks like
|
|
15
|
+
* { type: "tool_result", tool_use_id, content, is_error? }.
|
|
16
|
+
* `content` on the tool_result block can itself be a string
|
|
17
|
+
* or an array of text/image blocks.
|
|
18
|
+
*
|
|
19
|
+
* - "assistant" — assistant turn. `message` = { role, content: ARRAY, model,
|
|
20
|
+
* id, stop_reason, usage, ... }. `content` blocks observed:
|
|
21
|
+
* { type: "text", text }
|
|
22
|
+
* { type: "thinking", thinking, signature }
|
|
23
|
+
* { type: "tool_use", id, name, input, caller? }
|
|
24
|
+
*
|
|
25
|
+
* - "system" — system/tool hook metadata (subtype, hookInfos, etc.).
|
|
26
|
+
* No message payload; SKIPPED.
|
|
27
|
+
*
|
|
28
|
+
* - "summary" — session metadata; SKIPPED.
|
|
29
|
+
* - "attachment" — file/image attachment metadata; SKIPPED for count purposes
|
|
30
|
+
* (count_tokens only needs role/content message blocks).
|
|
31
|
+
* - "file-history-snapshot" — editor file state; SKIPPED.
|
|
32
|
+
* - "permission-mode" — session flag; SKIPPED.
|
|
33
|
+
* - "queue-operation" — internal; SKIPPED.
|
|
34
|
+
* - "last-prompt" — internal; SKIPPED.
|
|
35
|
+
* - (any other / unknown) — SKIPPED with a no-op (forward-compatible).
|
|
36
|
+
*
|
|
37
|
+
* Example scrubbed shapes (content replaced with "..."):
|
|
38
|
+
*
|
|
39
|
+
* {"type":"user","message":{"role":"user","content":"..."},"uuid":"...","sessionId":"..."}
|
|
40
|
+
*
|
|
41
|
+
* {"type":"user","message":{"role":"user","content":[
|
|
42
|
+
* {"type":"tool_result","tool_use_id":"toolu_01...","content":"...","is_error":false}
|
|
43
|
+
* ]},"uuid":"..."}
|
|
44
|
+
*
|
|
45
|
+
* {"type":"assistant","message":{"role":"assistant","content":[
|
|
46
|
+
* {"type":"text","text":"..."},
|
|
47
|
+
* {"type":"tool_use","id":"toolu_01...","name":"Read","input":{"file_path":"..."}}
|
|
48
|
+
* ],"model":"claude-...","usage":{...}}}
|
|
49
|
+
*
|
|
50
|
+
* ------------------------------------------------------------------------
|
|
51
|
+
* count_tokens request body shape:
|
|
52
|
+
*
|
|
53
|
+
* {
|
|
54
|
+
* "system": "",
|
|
55
|
+
* "messages": [
|
|
56
|
+
* { "role": "user"|"assistant", "content": [ {type, ...}, ... ] },
|
|
57
|
+
* ...
|
|
58
|
+
* ]
|
|
59
|
+
* }
|
|
60
|
+
*
|
|
61
|
+
* The parser:
|
|
62
|
+
* - Streams the file line-by-line via readline (transcripts can be large).
|
|
63
|
+
* - Keeps only user/assistant message content in insertion order.
|
|
64
|
+
* - Filters out assistant `thinking` blocks (not accepted by count_tokens).
|
|
65
|
+
* - Preserves the natural pairing of assistant `tool_use` blocks with their
|
|
66
|
+
* later user `tool_result` blocks — they already appear in chronological
|
|
67
|
+
* order in the JSONL, so pairing is implicit (same tool_use_id).
|
|
68
|
+
* - On unreadable file or a JSON.parse throw at the top-level: returns null.
|
|
69
|
+
* Malformed INDIVIDUAL lines inside an otherwise valid file are skipped.
|
|
70
|
+
*
|
|
71
|
+
* Zero external deps — built-in `fs`, `readline` only.
|
|
72
|
+
*
|
|
73
|
+
* @module scripts/context-meter/transcript-parser
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
"use strict";
|
|
77
|
+
|
|
78
|
+
const fs = require("fs");
|
|
79
|
+
const readline = require("readline");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse a Claude Code transcript JSONL file.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} transcriptPath - absolute path to a Claude Code transcript .jsonl
|
|
85
|
+
* @returns {Promise<{system: string, messages: Array<{role: string, content: Array}>} | null>}
|
|
86
|
+
* Resolves to the reconstructed body, or `null` on unreadable file /
|
|
87
|
+
* catastrophic parse failure. Caller treats `null` as "bail out, fail open".
|
|
88
|
+
*/
|
|
89
|
+
async function parseTranscript(transcriptPath) {
|
|
90
|
+
if (typeof transcriptPath !== "string" || transcriptPath.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Existence/readability check before opening a stream — readline errors on
|
|
95
|
+
// ENOENT are awkward to catch cleanly.
|
|
96
|
+
try {
|
|
97
|
+
fs.accessSync(transcriptPath, fs.constants.R_OK);
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const messages = [];
|
|
103
|
+
let system = "";
|
|
104
|
+
|
|
105
|
+
let stream;
|
|
106
|
+
try {
|
|
107
|
+
stream = fs.createReadStream(transcriptPath, { encoding: "utf8" });
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rl = readline.createInterface({
|
|
113
|
+
input: stream,
|
|
114
|
+
crlfDelay: Infinity,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for await (const rawLine of rl) {
|
|
119
|
+
const line = rawLine.trim();
|
|
120
|
+
if (line.length === 0) continue;
|
|
121
|
+
|
|
122
|
+
let evt;
|
|
123
|
+
try {
|
|
124
|
+
evt = JSON.parse(line);
|
|
125
|
+
} catch (_) {
|
|
126
|
+
// Malformed line in the middle — skip, keep going.
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!evt || typeof evt !== "object") continue;
|
|
131
|
+
|
|
132
|
+
const type = evt.type;
|
|
133
|
+
|
|
134
|
+
if (type === "user" || type === "assistant") {
|
|
135
|
+
const msg = evt.message;
|
|
136
|
+
if (!msg || typeof msg !== "object") continue;
|
|
137
|
+
|
|
138
|
+
const role = msg.role || type;
|
|
139
|
+
const content = normalizeContent(msg.content, role);
|
|
140
|
+
if (content === null) continue;
|
|
141
|
+
|
|
142
|
+
messages.push({ role, content });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// All other event types are skipped (summary, system, attachment,
|
|
147
|
+
// file-history-snapshot, permission-mode, queue-operation, last-prompt,
|
|
148
|
+
// and any unknown future type).
|
|
149
|
+
}
|
|
150
|
+
} catch (_) {
|
|
151
|
+
// Stream read error mid-flight — bail out fail-open.
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { system, messages };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Normalize a message.content value into the array-of-blocks shape expected
|
|
160
|
+
* by count_tokens. Returns null if the content is unusable.
|
|
161
|
+
*
|
|
162
|
+
* - String content → [{ type: "text", text: content }]
|
|
163
|
+
* - Array content → filtered copy retaining only supported block types
|
|
164
|
+
* - Anything else → null
|
|
165
|
+
*
|
|
166
|
+
* Blocks dropped:
|
|
167
|
+
* - assistant "thinking" blocks (not part of count_tokens message schema)
|
|
168
|
+
* - blocks missing their own `type` field
|
|
169
|
+
*
|
|
170
|
+
* Blocks kept:
|
|
171
|
+
* - text → { type, text }
|
|
172
|
+
* - tool_use → { type, id, name, input }
|
|
173
|
+
* - tool_result→ { type, tool_use_id, content, (is_error) }
|
|
174
|
+
* - image → { type, source } (pass-through)
|
|
175
|
+
* - anything else with a type string → passed through untouched (forward-compat)
|
|
176
|
+
*/
|
|
177
|
+
function normalizeContent(content, _role) {
|
|
178
|
+
if (typeof content === "string") {
|
|
179
|
+
return [{ type: "text", text: content }];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!Array.isArray(content)) return null;
|
|
183
|
+
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const block of content) {
|
|
186
|
+
if (!block || typeof block !== "object") continue;
|
|
187
|
+
const t = block.type;
|
|
188
|
+
if (typeof t !== "string") continue;
|
|
189
|
+
|
|
190
|
+
if (t === "thinking") continue;
|
|
191
|
+
|
|
192
|
+
if (t === "text") {
|
|
193
|
+
if (typeof block.text === "string") {
|
|
194
|
+
out.push({ type: "text", text: block.text });
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (t === "tool_use") {
|
|
200
|
+
// Must have id, name, input to be meaningful.
|
|
201
|
+
if (typeof block.id !== "string" || typeof block.name !== "string") continue;
|
|
202
|
+
out.push({
|
|
203
|
+
type: "tool_use",
|
|
204
|
+
id: block.id,
|
|
205
|
+
name: block.name,
|
|
206
|
+
input: block.input != null ? block.input : {},
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (t === "tool_result") {
|
|
212
|
+
if (typeof block.tool_use_id !== "string") continue;
|
|
213
|
+
const resultBlock = {
|
|
214
|
+
type: "tool_result",
|
|
215
|
+
tool_use_id: block.tool_use_id,
|
|
216
|
+
content: normalizeToolResultContent(block.content),
|
|
217
|
+
};
|
|
218
|
+
if (block.is_error === true) resultBlock.is_error = true;
|
|
219
|
+
out.push(resultBlock);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Unknown but typed block — pass through minimally (forward-compat).
|
|
224
|
+
out.push({ ...block });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (out.length === 0) return null;
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize the `content` field of a tool_result block. It may itself be a
|
|
233
|
+
* string or an array of blocks (text / image). count_tokens accepts either
|
|
234
|
+
* form, but we normalize string to array for consistency.
|
|
235
|
+
*/
|
|
236
|
+
function normalizeToolResultContent(content) {
|
|
237
|
+
if (typeof content === "string") return content;
|
|
238
|
+
if (!Array.isArray(content)) return "";
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const b of content) {
|
|
241
|
+
if (!b || typeof b !== "object") continue;
|
|
242
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
243
|
+
out.push({ type: "text", text: b.text });
|
|
244
|
+
} else if (b.type === "image" && b.source) {
|
|
245
|
+
out.push({ type: "image", source: b.source });
|
|
246
|
+
}
|
|
247
|
+
// other inner types dropped
|
|
248
|
+
}
|
|
249
|
+
return out.length > 0 ? out : "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { parseTranscript };
|