@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.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/bin/gsd-t-unattended-platform.cjs +5 -5
- package/bin/gsd-t-unattended-safety.cjs +0 -22
- package/bin/gsd-t-unattended.cjs +8 -20
- package/bin/gsd-t.js +26 -55
- package/commands/gsd-t-debug.md +10 -0
- package/commands/gsd-t-execute.md +10 -0
- package/commands/gsd-t-integrate.md +10 -0
- package/commands/gsd-t-quick.md +10 -0
- package/commands/gsd-t-resume.md +3 -1
- package/commands/gsd-t-unattended-stop.md +5 -3
- package/commands/gsd-t-unattended-watch.md +87 -13
- package/commands/gsd-t-unattended.md +2 -2
- package/commands/gsd-t-wave.md +10 -0
- package/docs/architecture.md +1 -1
- package/docs/requirements.md +2 -2
- package/package.json +1 -1
- package/scripts/context-meter/estimate-tokens.js +96 -0
- package/scripts/context-meter/estimate-tokens.test.js +158 -0
- package/scripts/context-meter/threshold.js +11 -3
- package/scripts/context-meter/threshold.test.js +22 -15
- package/scripts/gsd-t-agent-dashboard-server.js +424 -0
- package/scripts/gsd-t-agent-dashboard.html +724 -0
- package/scripts/gsd-t-context-meter.e2e.test.js +39 -130
- package/scripts/gsd-t-context-meter.js +12 -35
- package/scripts/gsd-t-context-meter.test.js +59 -98
- package/templates/CLAUDE-global.md +19 -0
|
@@ -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
|
-
*
|
|
75
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
240
|
-
|
|
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.
|
|
252
|
-
|
|
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
|
});
|