@ricky-stevens/context-guardian 2.1.0
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/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +63 -0
- package/.github/workflows/ci.yml +66 -0
- package/CLAUDE.md +132 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/biome.json +34 -0
- package/bun.lock +31 -0
- package/hooks/precompact.mjs +73 -0
- package/hooks/session-start.mjs +133 -0
- package/hooks/stop.mjs +172 -0
- package/hooks/submit.mjs +133 -0
- package/lib/checkpoint.mjs +258 -0
- package/lib/compact-cli.mjs +124 -0
- package/lib/compact-output.mjs +350 -0
- package/lib/config.mjs +40 -0
- package/lib/content.mjs +33 -0
- package/lib/diagnostics.mjs +221 -0
- package/lib/estimate.mjs +254 -0
- package/lib/extract-helpers.mjs +869 -0
- package/lib/handoff.mjs +329 -0
- package/lib/logger.mjs +34 -0
- package/lib/mcp-tools.mjs +200 -0
- package/lib/paths.mjs +90 -0
- package/lib/stats.mjs +81 -0
- package/lib/statusline.mjs +123 -0
- package/lib/synthetic-session.mjs +273 -0
- package/lib/tokens.mjs +170 -0
- package/lib/tool-summary.mjs +399 -0
- package/lib/transcript.mjs +939 -0
- package/lib/trim.mjs +158 -0
- package/package.json +22 -0
- package/skills/compact/SKILL.md +20 -0
- package/skills/config/SKILL.md +70 -0
- package/skills/handoff/SKILL.md +26 -0
- package/skills/prune/SKILL.md +20 -0
- package/skills/stats/SKILL.md +100 -0
- package/sonar-project.properties +12 -0
- package/test/checkpoint.test.mjs +171 -0
- package/test/compact-cli.test.mjs +230 -0
- package/test/compact-output.test.mjs +284 -0
- package/test/compaction-e2e.test.mjs +809 -0
- package/test/content.test.mjs +86 -0
- package/test/diagnostics.test.mjs +188 -0
- package/test/edge-cases.test.mjs +543 -0
- package/test/estimate.test.mjs +262 -0
- package/test/extract-helpers-coverage.test.mjs +333 -0
- package/test/extract-helpers.test.mjs +234 -0
- package/test/handoff.test.mjs +738 -0
- package/test/integration.test.mjs +582 -0
- package/test/logger.test.mjs +70 -0
- package/test/manual-compaction-test.md +426 -0
- package/test/mcp-tools.test.mjs +443 -0
- package/test/paths.test.mjs +250 -0
- package/test/quick-compaction-test.md +191 -0
- package/test/stats.test.mjs +88 -0
- package/test/statusline.test.mjs +222 -0
- package/test/submit.test.mjs +232 -0
- package/test/synthetic-session.test.mjs +600 -0
- package/test/tokens.test.mjs +293 -0
- package/test/tool-summary.test.mjs +771 -0
- package/test/transcript-coverage.test.mjs +369 -0
- package/test/transcript.test.mjs +596 -0
- package/test/trim.test.mjs +356 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing optimisations for compacted checkpoint output.
|
|
3
|
+
*
|
|
4
|
+
* Applies information density improvements after extraction:
|
|
5
|
+
* - Strips phatic assistant filler (R4)
|
|
6
|
+
* - Strips operational noise like stats boxes (R3)
|
|
7
|
+
* - Groups consecutive tool notes into single lines (R2)
|
|
8
|
+
* - Detects and strips project root from paths (R1)
|
|
9
|
+
* - Merges bare tool result lines into previous message
|
|
10
|
+
*
|
|
11
|
+
* @module compact-output
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Constants
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Assistant messages that are ONLY collapsible tool notes. */
|
|
19
|
+
const COLLAPSIBLE_NOTE_RE =
|
|
20
|
+
/^(?:\*\*Assistant:\*\* )?→ (?:Read |Grep |Glob |Ran |Write |Serena: (?:find |get |search |list ))/;
|
|
21
|
+
|
|
22
|
+
/** Phatic assistant filler — trivial responses that add no recall value. */
|
|
23
|
+
const PHATIC_RE =
|
|
24
|
+
/^\*\*Assistant:\*\* (?:Confirmed[. —!]|Ready[. —!]|Starting all |Memories checked|Looking at the |Moving on to |Done[.!]|Got it[.!]|File (?:created|edited)[.!])/;
|
|
25
|
+
|
|
26
|
+
/** Meta-tool invocations that are infrastructure, not session content. */
|
|
27
|
+
const META_TOOL_RE =
|
|
28
|
+
/^→ (?:Tool: `?ToolSearch|Serena: list.memor|Serena: check.onboard)/;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Noise detection
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Operational content that becomes meaningless after checkpoint restore.
|
|
36
|
+
*/
|
|
37
|
+
function isOperationalNoise(msg) {
|
|
38
|
+
const isAsst = msg.startsWith("**Assistant:**");
|
|
39
|
+
if (!isAsst && !msg.startsWith("→") && !msg.startsWith("←")) return false;
|
|
40
|
+
|
|
41
|
+
const text = msg.replace("**Assistant:** ", "");
|
|
42
|
+
|
|
43
|
+
// CG stats boxes
|
|
44
|
+
if (text.includes("Context Guardian Stats") && text.includes("┌"))
|
|
45
|
+
return true;
|
|
46
|
+
if (text.includes('"success":true') && text.includes("statsBlock"))
|
|
47
|
+
return true;
|
|
48
|
+
if (text.includes("Checkpoint saved") && text.includes("NOT applied"))
|
|
49
|
+
return true;
|
|
50
|
+
// CG operational tool calls
|
|
51
|
+
if (text.startsWith("→ Ran `date +%s`")) return true;
|
|
52
|
+
if (/^→ Read .*state-.*\.json/.test(text)) return true;
|
|
53
|
+
// Meta-tool invocations (ToolSearch, memory checks)
|
|
54
|
+
if (META_TOOL_RE.test(text)) return true;
|
|
55
|
+
if (META_TOOL_RE.test(msg)) return true;
|
|
56
|
+
// Diagnostics JSON output
|
|
57
|
+
if (/^\{?"checks"/.test(text) || /diagnostics\.mjs/.test(text)) return true;
|
|
58
|
+
if (/← \{"checks"/.test(msg)) return true;
|
|
59
|
+
// Bare diagnostics run
|
|
60
|
+
if (/^→ Ran.*diagnostics/.test(text)) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Phase helpers (extracted for cognitive complexity)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Phase 1: Strip noise and meta-tool content.
|
|
70
|
+
* @param {string[]} messages
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
function filterNoise(messages) {
|
|
74
|
+
return messages.filter((msg) => {
|
|
75
|
+
if (PHATIC_RE.test(msg) && msg.length < 200) return false;
|
|
76
|
+
if (isOperationalNoise(msg)) return false;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classify an assistant message body as trivial (should be skipped).
|
|
83
|
+
* @param {string} body - The text after "**Assistant:** "
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isTrivialAssistantBody(body) {
|
|
87
|
+
return (
|
|
88
|
+
!body || /^(?:Done\.?|Got it\.?|File (?:created|edited)\.?)$/i.test(body)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Decide whether an assistant line should merge without re-prefixing.
|
|
94
|
+
* @param {string} lastLine - Previous line in the exchange
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
function shouldMergeAssistant(lastLine) {
|
|
98
|
+
return (
|
|
99
|
+
lastLine.startsWith("**Assistant:**") ||
|
|
100
|
+
/^[→←]/.test(lastLine) ||
|
|
101
|
+
/^[→←]/.test(lastLine.trim())
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handle a single assistant message within an exchange.
|
|
107
|
+
* Merges or prefixes based on previous line context.
|
|
108
|
+
* @param {string} msg - The raw message
|
|
109
|
+
* @param {object} current - The current exchange { lines: string[] }
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
function handleAssistantLine(msg, current) {
|
|
113
|
+
const body = msg.slice(14).trim();
|
|
114
|
+
if (isTrivialAssistantBody(body)) return;
|
|
115
|
+
|
|
116
|
+
const lastLine = current.lines.at(-1);
|
|
117
|
+
if (shouldMergeAssistant(lastLine)) {
|
|
118
|
+
current.lines.push(body);
|
|
119
|
+
} else {
|
|
120
|
+
current.lines.push(`**Assistant:** ${body}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle a pre-first-user message (startup noise).
|
|
126
|
+
* @param {string} msg
|
|
127
|
+
* @param {object[]} exchanges
|
|
128
|
+
*/
|
|
129
|
+
function handlePreUserMessage(msg, exchanges) {
|
|
130
|
+
if (msg.length > 50 && !isOperationalNoise(msg)) {
|
|
131
|
+
if (!exchanges.length) exchanges.push({ lines: [] });
|
|
132
|
+
exchanges[0].lines.push(msg);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Phase 2: Group filtered messages into exchanges.
|
|
138
|
+
* An exchange = one User message + all following Asst/tool messages until next User.
|
|
139
|
+
* @param {string[]} filtered
|
|
140
|
+
* @returns {object[]}
|
|
141
|
+
*/
|
|
142
|
+
function groupIntoExchanges(filtered) {
|
|
143
|
+
const exchanges = [];
|
|
144
|
+
let current = null;
|
|
145
|
+
|
|
146
|
+
for (const msg of filtered) {
|
|
147
|
+
if (msg.startsWith("**User:**")) {
|
|
148
|
+
if (current) exchanges.push(current);
|
|
149
|
+
current = { lines: [msg] };
|
|
150
|
+
} else if (current) {
|
|
151
|
+
if (msg.startsWith("**Assistant:**")) {
|
|
152
|
+
handleAssistantLine(msg, current);
|
|
153
|
+
} else {
|
|
154
|
+
current.lines.push(msg);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
handlePreUserMessage(msg, exchanges);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (current) exchanges.push(current);
|
|
161
|
+
return exchanges;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Flush accumulated collapsible tool lines into the collapsed array.
|
|
166
|
+
* @param {string[]} toolBatch - Accumulated tool lines
|
|
167
|
+
* @param {string[]} collapsed - Output array
|
|
168
|
+
*/
|
|
169
|
+
function flushTools(toolBatch, collapsed) {
|
|
170
|
+
if (toolBatch.length === 0) return;
|
|
171
|
+
if (toolBatch.length === 1) {
|
|
172
|
+
collapsed.push(toolBatch[0]);
|
|
173
|
+
} else {
|
|
174
|
+
const items = toolBatch.map((t) =>
|
|
175
|
+
t.replace(/^→ /, "").replaceAll("`", "").trim(),
|
|
176
|
+
);
|
|
177
|
+
collapsed.push(`→ ${items.join("; ")}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Collapse consecutive collapsible tool lines in an exchange.
|
|
183
|
+
* @param {string[]} lines - Exchange lines
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
function collapseToolLines(lines) {
|
|
187
|
+
const collapsed = [];
|
|
188
|
+
const toolBatch = [];
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (isCollapsibleToolLine(line)) {
|
|
192
|
+
toolBatch.push(extractToolLine(line));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
flushTools(toolBatch, collapsed);
|
|
196
|
+
toolBatch.length = 0;
|
|
197
|
+
collapsed.push(line);
|
|
198
|
+
}
|
|
199
|
+
flushTools(toolBatch, collapsed);
|
|
200
|
+
return collapsed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Check if a line is a collapsible single-line tool note.
|
|
205
|
+
* @param {string} line
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
function isCollapsibleToolLine(line) {
|
|
209
|
+
if (
|
|
210
|
+
!COLLAPSIBLE_NOTE_RE.test(line) &&
|
|
211
|
+
!COLLAPSIBLE_NOTE_RE.test(`Asst: ${line}`)
|
|
212
|
+
) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const toolLine = extractToolLine(line);
|
|
216
|
+
return toolLine.startsWith("→") && toolLine.split("\n").length <= 2;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract the tool portion of a line, stripping "Asst:" prefix if present.
|
|
221
|
+
* @param {string} line
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
function extractToolLine(line) {
|
|
225
|
+
return line.startsWith("Asst:") ? line.slice(5).trim() : line;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Phase 4: Build output with [N] anchors and collapsed tool lines.
|
|
230
|
+
* @param {object[]} exchanges
|
|
231
|
+
* @returns {string[]}
|
|
232
|
+
*/
|
|
233
|
+
function buildAnchoredOutput(exchanges) {
|
|
234
|
+
const result = [];
|
|
235
|
+
let exchangeNum = 0;
|
|
236
|
+
|
|
237
|
+
for (const ex of exchanges) {
|
|
238
|
+
const hasUser = ex.lines.some((l) => l.startsWith("**User:**"));
|
|
239
|
+
if (hasUser) exchangeNum++;
|
|
240
|
+
|
|
241
|
+
const collapsed = collapseToolLines(ex.lines);
|
|
242
|
+
if (collapsed.length === 0) continue;
|
|
243
|
+
|
|
244
|
+
const anchor = hasUser ? `[${exchangeNum}] ` : "";
|
|
245
|
+
const firstLine = collapsed[0];
|
|
246
|
+
const rest = collapsed.slice(1);
|
|
247
|
+
|
|
248
|
+
const block = [`${anchor}${firstLine}`, ...rest].join("\n");
|
|
249
|
+
result.push(block);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Message compaction
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Post-process messages for maximum information density.
|
|
261
|
+
* Groups messages into exchanges, strips noise, shortens prefixes,
|
|
262
|
+
* and adds [N] anchors for cross-reference with the Conversation Index.
|
|
263
|
+
*
|
|
264
|
+
* @param {string[]} messages - The formatted message strings
|
|
265
|
+
* @returns {string[]} Optimised exchange blocks (one entry per exchange)
|
|
266
|
+
*/
|
|
267
|
+
export function compactMessages(messages) {
|
|
268
|
+
if (messages.length === 0) return messages;
|
|
269
|
+
|
|
270
|
+
const filtered = filterNoise(messages);
|
|
271
|
+
const exchanges = groupIntoExchanges(filtered);
|
|
272
|
+
return buildAnchoredOutput(exchanges);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Project root detection
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Collect file paths from tool_use blocks in assistant messages.
|
|
281
|
+
* @param {string[]} lines - Raw JSONL lines
|
|
282
|
+
* @param {number} startIdx - First line to scan
|
|
283
|
+
* @returns {string[]}
|
|
284
|
+
*/
|
|
285
|
+
function collectToolPaths(lines, startIdx) {
|
|
286
|
+
const paths = [];
|
|
287
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
288
|
+
try {
|
|
289
|
+
const obj = JSON.parse(lines[i]);
|
|
290
|
+
if (obj.type !== "assistant" || !Array.isArray(obj.message?.content))
|
|
291
|
+
continue;
|
|
292
|
+
for (const b of obj.message.content) {
|
|
293
|
+
if (b.type !== "tool_use") continue;
|
|
294
|
+
const fp =
|
|
295
|
+
b.input?.file_path || b.input?.path || b.input?.relative_path || "";
|
|
296
|
+
if (fp.startsWith("/") && fp.split("/").filter(Boolean).length >= 3) {
|
|
297
|
+
paths.push(fp);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
return paths;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Build a map of directory prefix → occurrence count from a list of paths.
|
|
307
|
+
* @param {string[]} paths
|
|
308
|
+
* @returns {Map<string, number>}
|
|
309
|
+
*/
|
|
310
|
+
function buildPrefixCounts(paths) {
|
|
311
|
+
const counts = new Map();
|
|
312
|
+
for (const p of paths) {
|
|
313
|
+
const parts = p.split("/");
|
|
314
|
+
for (let len = 3; len < parts.length; len++) {
|
|
315
|
+
const prefix = `${parts.slice(0, len).join("/")}/`;
|
|
316
|
+
counts.set(prefix, (counts.get(prefix) || 0) + 1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return counts;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Select the longest prefix covering at least 70% of paths.
|
|
324
|
+
* @param {Map<string, number>} counts
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
function selectBestPrefix(counts) {
|
|
328
|
+
const maxCount = Math.max(...counts.values());
|
|
329
|
+
const threshold = Math.floor(maxCount * 0.7);
|
|
330
|
+
let best = "";
|
|
331
|
+
for (const [prefix, count] of counts) {
|
|
332
|
+
if (count >= threshold && prefix.length > best.length) {
|
|
333
|
+
best = prefix;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return best && counts.get(best) >= 3 ? best : "";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Detect the project root directory from tool_use file paths in the transcript.
|
|
341
|
+
* Scans for Read/Edit/Write/Grep tool inputs and finds the most common
|
|
342
|
+
* directory prefix. Returns it with trailing slash, or empty string.
|
|
343
|
+
*/
|
|
344
|
+
export function detectProjectRoot(lines, startIdx) {
|
|
345
|
+
const paths = collectToolPaths(lines, startIdx);
|
|
346
|
+
if (paths.length < 3) return "";
|
|
347
|
+
|
|
348
|
+
const counts = buildPrefixCounts(paths);
|
|
349
|
+
return selectBestPrefix(counts);
|
|
350
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { CONFIG_FILE } from "./paths.mjs";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
threshold: 0.35,
|
|
6
|
+
max_tokens: 200000,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Load / Save
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
let _cachedConfig = null;
|
|
14
|
+
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
if (_cachedConfig) return _cachedConfig;
|
|
17
|
+
try {
|
|
18
|
+
_cachedConfig = {
|
|
19
|
+
...DEFAULT_CONFIG,
|
|
20
|
+
...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")),
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
_cachedConfig = { ...DEFAULT_CONFIG };
|
|
24
|
+
}
|
|
25
|
+
return _cachedConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Resolve max_tokens.
|
|
30
|
+
// 1. Explicit max_tokens in config (covers most cases)
|
|
31
|
+
// 2. Safe default (200K)
|
|
32
|
+
//
|
|
33
|
+
// The submit hook detects max_tokens from the model name in the transcript
|
|
34
|
+
// (getTokenUsage in tokens.mjs). This config value is the initial fallback
|
|
35
|
+
// before any assistant response provides real model info.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
export function resolveMaxTokens() {
|
|
38
|
+
const cfg = loadConfig();
|
|
39
|
+
return cfg.max_tokens ?? 200000;
|
|
40
|
+
}
|
package/lib/content.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the first text string from a Claude message content field.
|
|
3
|
+
* Handles string, array-of-blocks, and null/undefined.
|
|
4
|
+
*/
|
|
5
|
+
export function flattenContent(content) {
|
|
6
|
+
if (!content) return "";
|
|
7
|
+
if (typeof content === "string") return content;
|
|
8
|
+
if (Array.isArray(content))
|
|
9
|
+
return content
|
|
10
|
+
.filter((b) => b.type === "text")
|
|
11
|
+
.map((b) => b.text)
|
|
12
|
+
.join("\n");
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Count the byte size of message content (text + tool input).
|
|
18
|
+
* Used for token estimation when real counts are unavailable.
|
|
19
|
+
*/
|
|
20
|
+
export function contentBytesOf(content) {
|
|
21
|
+
if (!content) return 0;
|
|
22
|
+
if (typeof content === "string") return Buffer.byteLength(content, "utf8");
|
|
23
|
+
if (Array.isArray(content)) {
|
|
24
|
+
let sum = 0;
|
|
25
|
+
for (const b of content) {
|
|
26
|
+
if (b.text) sum += Buffer.byteLength(b.text, "utf8");
|
|
27
|
+
if (b.input) sum += Buffer.byteLength(JSON.stringify(b.input), "utf8");
|
|
28
|
+
if (b.content) sum += contentBytesOf(b.content);
|
|
29
|
+
}
|
|
30
|
+
return sum;
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { resolveDataDir } from "./paths.mjs";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Diagnostics — lightweight health checks for /cg:stats.
|
|
9
|
+
// Outputs JSON to stdout: { checks: [{name, ok, detail}...] }
|
|
10
|
+
// Always exits 0 so it never breaks the skill.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// CLI args override env vars (skills run via Bash without plugin env).
|
|
14
|
+
// But Claude often drops the args, so we auto-discover as much as possible.
|
|
15
|
+
const argSessionId = process.argv[2] || process.env.CLAUDE_SESSION_ID || "";
|
|
16
|
+
const argPluginRoot = process.argv[3] || process.env.CLAUDE_PLUGIN_ROOT || "";
|
|
17
|
+
const argPluginData = process.argv[4] || process.env.CLAUDE_PLUGIN_DATA || "";
|
|
18
|
+
|
|
19
|
+
// Plugin root: infer from this file's location (lib/diagnostics.mjs → parent dir)
|
|
20
|
+
const pluginRoot = argPluginRoot || path.resolve(import.meta.dirname, "..");
|
|
21
|
+
|
|
22
|
+
// Data dir: try CLI arg, env var, then discover any cg* dirs.
|
|
23
|
+
// Scans both global (~/.claude/plugins/data/) and project-local (.claude/plugins/data/)
|
|
24
|
+
// to handle all install scopes: user, project, local/inline.
|
|
25
|
+
function discoverDataDirs() {
|
|
26
|
+
const dirs = [argPluginData, resolveDataDir()].filter(Boolean);
|
|
27
|
+
const scanRoots = [
|
|
28
|
+
path.join(os.homedir(), ".claude", "plugins", "data"),
|
|
29
|
+
path.join(process.cwd(), ".claude", "plugins", "data"),
|
|
30
|
+
];
|
|
31
|
+
for (const root of scanRoots) {
|
|
32
|
+
try {
|
|
33
|
+
for (const d of fs.readdirSync(root)) {
|
|
34
|
+
if (d.startsWith("cg")) {
|
|
35
|
+
dirs.push(path.join(root, d));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return [...new Set(dirs)];
|
|
41
|
+
}
|
|
42
|
+
const KNOWN_DATA_DIRS = discoverDataDirs();
|
|
43
|
+
|
|
44
|
+
// Find the most recent state file across all known data dirs
|
|
45
|
+
function findRecentState() {
|
|
46
|
+
let best = null;
|
|
47
|
+
for (const dir of KNOWN_DATA_DIRS) {
|
|
48
|
+
try {
|
|
49
|
+
for (const f of fs
|
|
50
|
+
.readdirSync(dir)
|
|
51
|
+
.filter((f) => f.startsWith("state-") && f.endsWith(".json"))) {
|
|
52
|
+
const fp = path.join(dir, f);
|
|
53
|
+
const stat = fs.statSync(fp);
|
|
54
|
+
if (!best || stat.mtimeMs > best.mtimeMs) {
|
|
55
|
+
best = {
|
|
56
|
+
path: fp,
|
|
57
|
+
mtimeMs: stat.mtimeMs,
|
|
58
|
+
dir,
|
|
59
|
+
sessionId: f.replace("state-", "").replace(".json", ""),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const recentState = findRecentState();
|
|
69
|
+
const DATA_DIR = recentState?.dir || argPluginData || resolveDataDir();
|
|
70
|
+
const sessionId = argSessionId || recentState?.sessionId || "unknown";
|
|
71
|
+
const stateFile = (sid) =>
|
|
72
|
+
path.join(DATA_DIR, `state-${sid || "unknown"}.json`);
|
|
73
|
+
|
|
74
|
+
const checks = [];
|
|
75
|
+
|
|
76
|
+
function check(name, ok, detail) {
|
|
77
|
+
checks.push({ name, ok, detail });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 1. Data directory writable
|
|
81
|
+
try {
|
|
82
|
+
const tmp = path.join(DATA_DIR, `.diag-${Date.now()}`);
|
|
83
|
+
fs.writeFileSync(tmp, "ok");
|
|
84
|
+
fs.unlinkSync(tmp);
|
|
85
|
+
check("data_dir", true, DATA_DIR);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
check("data_dir", false, `Not writable: ${e.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. State file present
|
|
91
|
+
try {
|
|
92
|
+
const sf = stateFile(sessionId);
|
|
93
|
+
if (fs.existsSync(sf)) {
|
|
94
|
+
check("state_file", true, "Present");
|
|
95
|
+
} else {
|
|
96
|
+
check(
|
|
97
|
+
"state_file",
|
|
98
|
+
false,
|
|
99
|
+
"Missing — send a message first so hooks can write token counts",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
check("state_file", false, e.message);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Transcript readable
|
|
107
|
+
try {
|
|
108
|
+
const sf = stateFile(sessionId);
|
|
109
|
+
if (fs.existsSync(sf)) {
|
|
110
|
+
const state = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
111
|
+
if (state.transcript_path && fs.existsSync(state.transcript_path)) {
|
|
112
|
+
check("transcript", true, "Readable");
|
|
113
|
+
} else if (state.transcript_path) {
|
|
114
|
+
check("transcript", false, `Not found: ${state.transcript_path}`);
|
|
115
|
+
} else {
|
|
116
|
+
check("transcript", false, "No transcript_path in state file");
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
check("transcript", false, "Skipped — no state file");
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
check("transcript", false, e.message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 4. Plugin root exists
|
|
126
|
+
if (fs.existsSync(pluginRoot)) {
|
|
127
|
+
check("plugin_root", true, pluginRoot);
|
|
128
|
+
} else {
|
|
129
|
+
check("plugin_root", false, `Directory missing: ${pluginRoot}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 5. Hook files present
|
|
133
|
+
const hookFiles = [
|
|
134
|
+
"hooks/submit.mjs",
|
|
135
|
+
"hooks/stop.mjs",
|
|
136
|
+
"hooks/session-start.mjs",
|
|
137
|
+
"hooks/precompact.mjs",
|
|
138
|
+
];
|
|
139
|
+
const root = pluginRoot;
|
|
140
|
+
const missingHooks = hookFiles.filter(
|
|
141
|
+
(h) => !fs.existsSync(path.join(root, h)),
|
|
142
|
+
);
|
|
143
|
+
if (missingHooks.length === 0) {
|
|
144
|
+
check("hooks", true, "All 4 hook files present");
|
|
145
|
+
} else {
|
|
146
|
+
check("hooks", false, `Missing: ${missingHooks.join(", ")}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 6. Marketplace directory exists
|
|
150
|
+
try {
|
|
151
|
+
const knownPath = path.join(
|
|
152
|
+
os.homedir(),
|
|
153
|
+
".claude",
|
|
154
|
+
"plugins",
|
|
155
|
+
"known_marketplaces.json",
|
|
156
|
+
);
|
|
157
|
+
if (fs.existsSync(knownPath)) {
|
|
158
|
+
const known = JSON.parse(fs.readFileSync(knownPath, "utf8"));
|
|
159
|
+
const entry = known["context-guardian"];
|
|
160
|
+
if (entry) {
|
|
161
|
+
const mktDir = entry.installLocation;
|
|
162
|
+
if (fs.existsSync(mktDir)) {
|
|
163
|
+
check("marketplace", true, "Repository cache present");
|
|
164
|
+
} else {
|
|
165
|
+
check(
|
|
166
|
+
"marketplace",
|
|
167
|
+
false,
|
|
168
|
+
`Missing: ${mktDir} — run: /plugin marketplace add https://github.com/Ricky-Stevens/context-guardian`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
check(
|
|
173
|
+
"marketplace",
|
|
174
|
+
true,
|
|
175
|
+
"Not marketplace-installed (OK for local dev)",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
check("marketplace", true, "No marketplace registry (OK for local dev)");
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
check(
|
|
183
|
+
"marketplace",
|
|
184
|
+
false,
|
|
185
|
+
`Error reading marketplace registry: ${e.message}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 7. Statusline configured — critical for CG since the statusline is the
|
|
190
|
+
// sole UX for context pressure alerts (no blocking or menus).
|
|
191
|
+
try {
|
|
192
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
193
|
+
if (fs.existsSync(settingsPath)) {
|
|
194
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
195
|
+
if (settings.statusLine?.command?.includes("statusline.mjs")) {
|
|
196
|
+
check("statusline", true, "Configured");
|
|
197
|
+
} else if (settings.statusLine) {
|
|
198
|
+
check(
|
|
199
|
+
"statusline",
|
|
200
|
+
false,
|
|
201
|
+
"Another statusline is active — CG cannot display context warnings. Session-start will reclaim it on next restart.",
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
check(
|
|
205
|
+
"statusline",
|
|
206
|
+
false,
|
|
207
|
+
"Not configured — will be auto-configured on next session start",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
check(
|
|
212
|
+
"statusline",
|
|
213
|
+
false,
|
|
214
|
+
"No settings.json found — will be auto-configured on next session start",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
check("statusline", false, e.message);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
process.stdout.write(JSON.stringify({ checks }));
|