@tekyzinc/gsd-t 3.10.16 → 3.11.11

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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * estimate-tokens.js
3
+ *
4
+ * Local token estimator — replaces the Anthropic count_tokens API call.
5
+ * Uses byte-length heuristics to estimate token count from a parsed transcript.
6
+ *
7
+ * Claude's BPE tokenizer averages ~3.5 chars per token for English text/code
8
+ * (range: 3.0 for dense prose, 4.5 for simple ASCII). We use 3.5 as the
9
+ * divisor, which slightly overestimates token count — this is the safe
10
+ * direction for a context-window guard (triggers pause earlier, not later).
11
+ *
12
+ * The estimate includes JSON structural overhead from the messages array
13
+ * (keys, brackets, commas) since that's what the API would count too.
14
+ *
15
+ * Accuracy: within ~5-10% of the real count_tokens API. For threshold bands
16
+ * with 15-point gaps (normal < 70%, warn < 85%), this is more than sufficient.
17
+ *
18
+ * @module scripts/context-meter/estimate-tokens
19
+ */
20
+
21
+ "use strict";
22
+
23
+ const CHARS_PER_TOKEN = 3.5;
24
+
25
+ /**
26
+ * Estimate token count from a parsed transcript.
27
+ *
28
+ * @param {object} opts
29
+ * @param {string} opts.system - system prompt text
30
+ * @param {Array} opts.messages - messages array from transcript-parser.js
31
+ * @returns {{ inputTokens: number } | null}
32
+ */
33
+ function estimateTokens(opts) {
34
+ try {
35
+ if (!opts || typeof opts !== "object") return null;
36
+
37
+ const { system, messages } = opts;
38
+ if (!Array.isArray(messages)) return null;
39
+
40
+ let totalChars = 0;
41
+
42
+ if (typeof system === "string") {
43
+ totalChars += system.length;
44
+ }
45
+
46
+ for (const msg of messages) {
47
+ if (!msg || typeof msg !== "object") continue;
48
+ totalChars += measureContent(msg.content);
49
+ }
50
+
51
+ const inputTokens = Math.ceil(totalChars / CHARS_PER_TOKEN);
52
+ return { inputTokens };
53
+ } catch (_) {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Recursively measure character length of a message content value.
60
+ * Handles strings, arrays of blocks, and nested tool_result content.
61
+ */
62
+ function measureContent(content) {
63
+ if (typeof content === "string") return content.length;
64
+ if (!Array.isArray(content)) return 0;
65
+
66
+ let chars = 0;
67
+ for (const block of content) {
68
+ if (!block || typeof block !== "object") continue;
69
+
70
+ if (block.type === "text" && typeof block.text === "string") {
71
+ chars += block.text.length;
72
+ } else if (block.type === "tool_use") {
73
+ chars += (typeof block.name === "string" ? block.name.length : 0);
74
+ if (block.input != null) {
75
+ try {
76
+ chars += JSON.stringify(block.input).length;
77
+ } catch (_) {
78
+ // skip
79
+ }
80
+ }
81
+ } else if (block.type === "tool_result") {
82
+ chars += measureContent(block.content);
83
+ } else if (block.type === "image" && block.source) {
84
+ // base64 images: ~0.75 bytes per base64 char, tokenized differently
85
+ // but we count the source data length as a rough proxy
86
+ try {
87
+ chars += JSON.stringify(block.source).length;
88
+ } catch (_) {
89
+ // skip
90
+ }
91
+ }
92
+ }
93
+ return chars;
94
+ }
95
+
96
+ module.exports = { estimateTokens, CHARS_PER_TOKEN };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * estimate-tokens.test.js — unit tests for the local token estimator.
3
+ *
4
+ * @module scripts/context-meter/estimate-tokens.test
5
+ */
6
+
7
+ "use strict";
8
+
9
+ const { test } = require("node:test");
10
+ const assert = require("node:assert/strict");
11
+ const { estimateTokens, CHARS_PER_TOKEN } = require("./estimate-tokens");
12
+
13
+ test("null/undefined opts returns null", () => {
14
+ assert.equal(estimateTokens(null), null);
15
+ assert.equal(estimateTokens(undefined), null);
16
+ });
17
+
18
+ test("missing messages returns null", () => {
19
+ assert.equal(estimateTokens({ system: "hi" }), null);
20
+ assert.equal(estimateTokens({ system: "hi", messages: "not-array" }), null);
21
+ });
22
+
23
+ test("empty messages returns 0 tokens (system empty)", () => {
24
+ const r = estimateTokens({ system: "", messages: [] });
25
+ assert.ok(r);
26
+ assert.equal(r.inputTokens, 0);
27
+ });
28
+
29
+ test("system-only content counted", () => {
30
+ const sys = "a".repeat(350);
31
+ const r = estimateTokens({ system: sys, messages: [] });
32
+ assert.ok(r);
33
+ assert.equal(r.inputTokens, Math.ceil(350 / CHARS_PER_TOKEN));
34
+ });
35
+
36
+ test("text message content counted", () => {
37
+ const r = estimateTokens({
38
+ system: "",
39
+ messages: [
40
+ { role: "user", content: [{ type: "text", text: "a".repeat(700) }] },
41
+ ],
42
+ });
43
+ assert.ok(r);
44
+ assert.equal(r.inputTokens, Math.ceil(700 / CHARS_PER_TOKEN));
45
+ });
46
+
47
+ test("string content (user shorthand) counted", () => {
48
+ const r = estimateTokens({
49
+ system: "",
50
+ messages: [{ role: "user", content: "hello world" }],
51
+ });
52
+ assert.ok(r);
53
+ assert.equal(r.inputTokens, Math.ceil(11 / CHARS_PER_TOKEN));
54
+ });
55
+
56
+ test("tool_use input JSON counted", () => {
57
+ const input = { file_path: "/some/long/path/to/file.js" };
58
+ const inputJson = JSON.stringify(input);
59
+ const toolName = "Read";
60
+ const r = estimateTokens({
61
+ system: "",
62
+ messages: [
63
+ {
64
+ role: "assistant",
65
+ content: [{ type: "tool_use", id: "t1", name: toolName, input }],
66
+ },
67
+ ],
68
+ });
69
+ assert.ok(r);
70
+ assert.equal(r.inputTokens, Math.ceil((toolName.length + inputJson.length) / CHARS_PER_TOKEN));
71
+ });
72
+
73
+ test("tool_result content counted (string)", () => {
74
+ const resultText = "file contents here".repeat(10);
75
+ const r = estimateTokens({
76
+ system: "",
77
+ messages: [
78
+ {
79
+ role: "user",
80
+ content: [
81
+ { type: "tool_result", tool_use_id: "t1", content: resultText },
82
+ ],
83
+ },
84
+ ],
85
+ });
86
+ assert.ok(r);
87
+ assert.equal(r.inputTokens, Math.ceil(resultText.length / CHARS_PER_TOKEN));
88
+ });
89
+
90
+ test("tool_result content counted (array of text blocks)", () => {
91
+ const r = estimateTokens({
92
+ system: "",
93
+ messages: [
94
+ {
95
+ role: "user",
96
+ content: [
97
+ {
98
+ type: "tool_result",
99
+ tool_use_id: "t1",
100
+ content: [
101
+ { type: "text", text: "abc" },
102
+ { type: "text", text: "defgh" },
103
+ ],
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ });
109
+ assert.ok(r);
110
+ assert.equal(r.inputTokens, Math.ceil(8 / CHARS_PER_TOKEN));
111
+ });
112
+
113
+ test("multiple messages accumulate", () => {
114
+ const r = estimateTokens({
115
+ system: "sys".repeat(100),
116
+ messages: [
117
+ { role: "user", content: [{ type: "text", text: "a".repeat(200) }] },
118
+ { role: "assistant", content: [{ type: "text", text: "b".repeat(300) }] },
119
+ ],
120
+ });
121
+ assert.ok(r);
122
+ assert.equal(r.inputTokens, Math.ceil((300 + 200 + 300) / CHARS_PER_TOKEN));
123
+ });
124
+
125
+ test("skips blocks with missing type", () => {
126
+ const r = estimateTokens({
127
+ system: "",
128
+ messages: [
129
+ { role: "user", content: [{ text: "no type field" }, { type: "text", text: "ok" }] },
130
+ ],
131
+ });
132
+ assert.ok(r);
133
+ assert.equal(r.inputTokens, Math.ceil(2 / CHARS_PER_TOKEN));
134
+ });
135
+
136
+ test("handles null/non-object messages gracefully", () => {
137
+ const r = estimateTokens({
138
+ system: "",
139
+ messages: [null, undefined, 42, { role: "user", content: [{ type: "text", text: "ok" }] }],
140
+ });
141
+ assert.ok(r);
142
+ assert.equal(r.inputTokens, Math.ceil(2 / CHARS_PER_TOKEN));
143
+ });
144
+
145
+ test("realistic conversation produces reasonable estimate", () => {
146
+ const msgs = [];
147
+ for (let i = 0; i < 20; i++) {
148
+ msgs.push({ role: "user", content: [{ type: "text", text: "Tell me about X. ".repeat(5) }] });
149
+ msgs.push({
150
+ role: "assistant",
151
+ content: [{ type: "text", text: "Here is info about X. ".repeat(20) }],
152
+ });
153
+ }
154
+ const r = estimateTokens({ system: "You are a helpful assistant.", messages: msgs });
155
+ assert.ok(r);
156
+ assert.ok(r.inputTokens > 500, `expected >500 tokens, got ${r.inputTokens}`);
157
+ assert.ok(r.inputTokens < 10000, `expected <10000 tokens, got ${r.inputTokens}`);
158
+ });
@@ -71,8 +71,9 @@ function bandFor(pct) {
71
71
  * Build the `additionalContext` string the hook emits, or null if the
72
72
  * measured percentage is below the configured thresholdPct.
73
73
  *
74
- * Exact format (from .gsd-t/contracts/context-meter-contract.md line 139):
75
- * ⚠️ Context window at {pct.toFixed(1)}% of {modelWindowSize}. Run /user:gsd-t-pause to checkpoint and clear before continuing.
74
+ * M37 (v1.2.0): This is a MANDATORY STOP instruction, not a suggestion.
75
+ * The multi-line format is designed to be unignorable same enforcement
76
+ * weight as the Destructive Action Guard.
76
77
  *
77
78
  * `modelWindowSize` is emitted as the raw integer — no commas, no "K" suffix.
78
79
  *
@@ -82,7 +83,14 @@ function bandFor(pct) {
82
83
  function buildAdditionalContext({ pct, modelWindowSize, thresholdPct } = {}) {
83
84
  if (!Number.isFinite(pct) || !Number.isFinite(thresholdPct)) return null;
84
85
  if (pct < thresholdPct) return null;
85
- return `⚠️ Context window at ${pct.toFixed(1)}% of ${modelWindowSize}. Run /user:gsd-t-pause to checkpoint and clear before continuing.`;
86
+ return [
87
+ `🛑 MANDATORY STOP — Context window at ${pct.toFixed(1)}% of ${modelWindowSize} (threshold: ${thresholdPct}%).`,
88
+ `You MUST stop what you are doing RIGHT NOW and execute these steps in order:`,
89
+ `1. Run /user:gsd-t-pause to save your exact position`,
90
+ `2. Tell the user to run /clear to free the context window`,
91
+ `3. Tell the user to run /user:gsd-t-resume to continue from the saved position`,
92
+ `Do NOT continue working, spawn subagents, or start new tasks. This has the same enforcement weight as the Destructive Action Guard — violating it risks losing work to context overflow.`,
93
+ ].join("\n");
86
94
  }
87
95
 
88
96
  module.exports = { computePct, bandFor, buildAdditionalContext, BANDS };
@@ -154,7 +154,7 @@ test("buildAdditionalContext — below threshold returns null", () => {
154
154
  );
155
155
  });
156
156
 
157
- test("buildAdditionalContext — at threshold returns string", () => {
157
+ test("buildAdditionalContext — at threshold returns multi-line MANDATORY STOP", () => {
158
158
  const result = buildAdditionalContext({
159
159
  pct: 75,
160
160
  modelWindowSize: 200000,
@@ -162,18 +162,28 @@ test("buildAdditionalContext — at threshold returns string", () => {
162
162
  });
163
163
  assert.ok(typeof result === "string");
164
164
  assert.ok(result.includes("75.0%"));
165
+ assert.ok(result.includes("MANDATORY STOP"));
166
+ assert.ok(result.includes("/user:gsd-t-pause"));
167
+ assert.ok(result.includes("/clear"));
168
+ assert.ok(result.includes("/user:gsd-t-resume"));
169
+ assert.ok(result.includes("Destructive Action Guard"));
170
+ assert.ok(result.includes("\n"), "must be multi-line");
165
171
  });
166
172
 
167
- test("buildAdditionalContext — above threshold exact contract string", () => {
173
+ test("buildAdditionalContext — above threshold exact contract string (M37 multi-line)", () => {
168
174
  const result = buildAdditionalContext({
169
175
  pct: 76.2,
170
176
  modelWindowSize: 200000,
171
177
  thresholdPct: 75,
172
178
  });
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
- );
179
+ const lines = result.split("\n");
180
+ assert.equal(lines.length, 6, "must have exactly 6 lines");
181
+ assert.equal(lines[0], "🛑 MANDATORY STOP — Context window at 76.2% of 200000 (threshold: 75%).");
182
+ assert.equal(lines[1], "You MUST stop what you are doing RIGHT NOW and execute these steps in order:");
183
+ assert.equal(lines[2], "1. Run /user:gsd-t-pause to save your exact position");
184
+ assert.equal(lines[3], "2. Tell the user to run /clear to free the context window");
185
+ assert.equal(lines[4], "3. Tell the user to run /user:gsd-t-resume to continue from the saved position");
186
+ assert.ok(lines[5].includes("Destructive Action Guard"));
177
187
  });
178
188
 
179
189
  test("buildAdditionalContext — decimal formatting rounds via toFixed(1)", () => {
@@ -195,7 +205,7 @@ test("buildAdditionalContext — modelWindowSize emitted raw (no commas)", () =>
195
205
  modelWindowSize: 200000,
196
206
  thresholdPct: 75,
197
207
  });
198
- assert.ok(result.includes("of 200000."));
208
+ assert.ok(result.includes("of 200000"));
199
209
  assert.ok(!result.includes("200,000"));
200
210
  assert.ok(!result.includes("200K"));
201
211
  });
@@ -228,6 +238,7 @@ test("buildAdditionalContext — zero pct vs zero threshold emits", () => {
228
238
  });
229
239
  assert.ok(typeof result === "string");
230
240
  assert.ok(result.includes("0.0%"));
241
+ assert.ok(result.includes("MANDATORY STOP"));
231
242
  });
232
243
 
233
244
  test("buildAdditionalContext — pct over 100% still formats correctly", () => {
@@ -236,10 +247,8 @@ test("buildAdditionalContext — pct over 100% still formats correctly", () => {
236
247
  modelWindowSize: 200000,
237
248
  thresholdPct: 75,
238
249
  });
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
- );
250
+ assert.ok(result.startsWith("🛑 MANDATORY STOP — Context window at 102.3% of 200000"));
251
+ assert.ok(result.includes("MANDATORY STOP"));
243
252
  });
244
253
 
245
254
  test("buildAdditionalContext — different modelWindowSize (1M)", () => {
@@ -248,8 +257,6 @@ test("buildAdditionalContext — different modelWindowSize (1M)", () => {
248
257
  modelWindowSize: 1000000,
249
258
  thresholdPct: 75,
250
259
  });
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
- );
260
+ assert.ok(result.startsWith("🛑 MANDATORY STOP — Context window at 80.0% of 1000000"));
261
+ assert.ok(result.includes("threshold: 75%"));
255
262
  });