@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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +63 -0
  3. package/.github/workflows/ci.yml +66 -0
  4. package/CLAUDE.md +132 -0
  5. package/LICENSE +21 -0
  6. package/README.md +362 -0
  7. package/biome.json +34 -0
  8. package/bun.lock +31 -0
  9. package/hooks/precompact.mjs +73 -0
  10. package/hooks/session-start.mjs +133 -0
  11. package/hooks/stop.mjs +172 -0
  12. package/hooks/submit.mjs +133 -0
  13. package/lib/checkpoint.mjs +258 -0
  14. package/lib/compact-cli.mjs +124 -0
  15. package/lib/compact-output.mjs +350 -0
  16. package/lib/config.mjs +40 -0
  17. package/lib/content.mjs +33 -0
  18. package/lib/diagnostics.mjs +221 -0
  19. package/lib/estimate.mjs +254 -0
  20. package/lib/extract-helpers.mjs +869 -0
  21. package/lib/handoff.mjs +329 -0
  22. package/lib/logger.mjs +34 -0
  23. package/lib/mcp-tools.mjs +200 -0
  24. package/lib/paths.mjs +90 -0
  25. package/lib/stats.mjs +81 -0
  26. package/lib/statusline.mjs +123 -0
  27. package/lib/synthetic-session.mjs +273 -0
  28. package/lib/tokens.mjs +170 -0
  29. package/lib/tool-summary.mjs +399 -0
  30. package/lib/transcript.mjs +939 -0
  31. package/lib/trim.mjs +158 -0
  32. package/package.json +22 -0
  33. package/skills/compact/SKILL.md +20 -0
  34. package/skills/config/SKILL.md +70 -0
  35. package/skills/handoff/SKILL.md +26 -0
  36. package/skills/prune/SKILL.md +20 -0
  37. package/skills/stats/SKILL.md +100 -0
  38. package/sonar-project.properties +12 -0
  39. package/test/checkpoint.test.mjs +171 -0
  40. package/test/compact-cli.test.mjs +230 -0
  41. package/test/compact-output.test.mjs +284 -0
  42. package/test/compaction-e2e.test.mjs +809 -0
  43. package/test/content.test.mjs +86 -0
  44. package/test/diagnostics.test.mjs +188 -0
  45. package/test/edge-cases.test.mjs +543 -0
  46. package/test/estimate.test.mjs +262 -0
  47. package/test/extract-helpers-coverage.test.mjs +333 -0
  48. package/test/extract-helpers.test.mjs +234 -0
  49. package/test/handoff.test.mjs +738 -0
  50. package/test/integration.test.mjs +582 -0
  51. package/test/logger.test.mjs +70 -0
  52. package/test/manual-compaction-test.md +426 -0
  53. package/test/mcp-tools.test.mjs +443 -0
  54. package/test/paths.test.mjs +250 -0
  55. package/test/quick-compaction-test.md +191 -0
  56. package/test/stats.test.mjs +88 -0
  57. package/test/statusline.test.mjs +222 -0
  58. package/test/submit.test.mjs +232 -0
  59. package/test/synthetic-session.test.mjs +600 -0
  60. package/test/tokens.test.mjs +293 -0
  61. package/test/tool-summary.test.mjs +771 -0
  62. package/test/transcript-coverage.test.mjs +369 -0
  63. package/test/transcript.test.mjs +596 -0
  64. package/test/trim.test.mjs +356 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Tool-aware content summarisation for Claude Code transcripts.
3
+ *
4
+ * Generates compact representations of tool_use invocations and tool_result
5
+ * outputs, preserving decision-relevant content while removing re-obtainable
6
+ * noise. This is the core of Context Guardian's "noise removal, not
7
+ * summarisation" approach.
8
+ *
9
+ * Built-in tool rules live here; MCP-specific rules are in mcp-tools.mjs.
10
+ *
11
+ * @module tool-summary
12
+ */
13
+
14
+ import {
15
+ isSerenaReadTool,
16
+ isSerenaWriteTool,
17
+ summarizeMcpToolUse,
18
+ } from "./mcp-tools.mjs";
19
+ import {
20
+ isErrorResponse,
21
+ isShortErrorResponse,
22
+ startEndTrim,
23
+ } from "./trim.mjs";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants — size thresholds for different content types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Maximum chars for edit diffs before start+end trim. */
30
+ const EDIT_LIMIT = 3000;
31
+ /** Maximum chars for write content before start+end trim. */
32
+ const WRITE_LIMIT = 3000;
33
+ /** Maximum chars for Bash commands before start+end trim (heredocs). */
34
+ const BASH_CMD_LIMIT = 3000;
35
+ /** Maximum chars for Bash output before start+end trim. */
36
+ const BASH_OUTPUT_LIMIT = 5000;
37
+ /** Maximum chars for agent results before start+end trim. */
38
+ const AGENT_RESULT_LIMIT = 2000;
39
+ /** Maximum chars for unknown tool results — kept if under, trimmed if over. */
40
+ const UNKNOWN_RESULT_LIMIT = 1000;
41
+ /** Maximum chars for unknown MCP tool inputs before start+end trim. */
42
+ const UNKNOWN_INPUT_LIMIT = 1000;
43
+ /** Maximum chars for web search results before start+end trim. */
44
+ const WEB_SEARCH_LIMIT = 5000;
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Edit diff formatting
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Format an Edit tool invocation as a compact old/new diff block.
52
+ * Uses labeled old:/new: format (NOT unified diff) to avoid doubling
53
+ * content size — each line appears only once.
54
+ *
55
+ * @param {string} filePath - The file being edited
56
+ * @param {string} oldStr - The old_string being replaced
57
+ * @param {string} newStr - The new_string replacement
58
+ * @returns {string} Formatted diff string
59
+ */
60
+ export function formatEditDiff(filePath, oldStr, newStr) {
61
+ const parts = [`→ Edit \`${filePath}\`:`];
62
+
63
+ if (oldStr && newStr) {
64
+ const oldTrimmed = startEndTrim(oldStr, EDIT_LIMIT / 2);
65
+ const newTrimmed = startEndTrim(newStr, EDIT_LIMIT / 2);
66
+ parts.push(
67
+ indent("old: |", 4),
68
+ indent(oldTrimmed, 6),
69
+ indent("new: |", 4),
70
+ indent(newTrimmed, 6),
71
+ );
72
+ } else if (newStr && !oldStr) {
73
+ const newTrimmed = startEndTrim(newStr, EDIT_LIMIT);
74
+ parts.push(indent("new: |", 4), indent(newTrimmed, 6));
75
+ } else if (oldStr && !newStr) {
76
+ const oldTrimmed = startEndTrim(oldStr, EDIT_LIMIT);
77
+ parts.push(indent("old: | [deleted]", 4), indent(oldTrimmed, 6));
78
+ }
79
+
80
+ return parts.join("\n");
81
+ }
82
+
83
+ /**
84
+ * Indent every line of a text block by a given number of spaces.
85
+ * @param {string} text - Text to indent
86
+ * @param {number} spaces - Number of leading spaces
87
+ * @returns {string} Indented text
88
+ */
89
+ function indent(text, spaces) {
90
+ const pad = " ".repeat(spaces);
91
+ return text
92
+ .split("\n")
93
+ .map((line) => pad + line)
94
+ .join("\n");
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Bash command classification
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Read-like Bash commands whose output is re-obtainable from disk.
103
+ * Results from these are stripped (like Read/Grep), keeping only errors.
104
+ * Action commands (tests, builds, curl, etc.) keep their full output.
105
+ */
106
+ const READ_LIKE_BASH_RE =
107
+ /^\s*(?:ls|cat|head|tail|find|wc|du|date|pwd|which|file|stat|echo|tree|realpath)\b/;
108
+ const READ_LIKE_GIT_RE =
109
+ /^\s*git\s+(?:log|show|diff|status|branch|tag|remote|config)\b/;
110
+
111
+ function isReadLikeBash(command) {
112
+ if (!command) return false;
113
+ // For piped/chained commands, check the first command
114
+ const first = command.split(/[|;&]/).shift().trim();
115
+ return READ_LIKE_BASH_RE.test(first) || READ_LIKE_GIT_RE.test(first);
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Tool use summarisation — per-tool handlers
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Summarise an Edit tool invocation.
124
+ */
125
+ function summarizeEdit(input) {
126
+ return formatEditDiff(
127
+ input?.file_path || input?.path || "unknown",
128
+ input?.old_string || "",
129
+ input?.new_string || "",
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Summarise a Write tool invocation.
135
+ */
136
+ function summarizeWrite(input) {
137
+ const fp = input?.file_path || input?.path || "unknown";
138
+ const content = input?.content || "";
139
+ if (content.length <= WRITE_LIMIT) {
140
+ return `→ Write \`${fp}\`:\n${indent(content, 4)}`;
141
+ }
142
+ return `→ Write \`${fp}\` (${content.length} chars):\n${indent(startEndTrim(content, WRITE_LIMIT), 4)}`;
143
+ }
144
+
145
+ /**
146
+ * Summarise a Read tool invocation.
147
+ */
148
+ function summarizeRead(input) {
149
+ const fp = input?.file_path || input?.path || "unknown";
150
+ const rangeInfo = input?.offset ? ` (from line ${input.offset})` : "";
151
+ return `→ Read \`${fp}\`${rangeInfo}`;
152
+ }
153
+
154
+ /**
155
+ * Summarise a NotebookEdit tool invocation.
156
+ */
157
+ function summarizeNotebookEdit(input) {
158
+ const content = input?.new_source || input?.source || "";
159
+ if (content.length <= WRITE_LIMIT) {
160
+ return `→ NotebookEdit cell:\n${indent(content, 4)}`;
161
+ }
162
+ return `→ NotebookEdit cell (${content.length} chars):\n${indent(startEndTrim(content, WRITE_LIMIT), 4)}`;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Tool use summarisation (assistant message → tool_use blocks)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Generate a compact summary string for a tool_use content block.
171
+ * Returns the formatted summary line(s) to include in the checkpoint.
172
+ *
173
+ * @param {object} block - A content block with type: "tool_use"
174
+ * @returns {string|null} Formatted tool summary, or null to omit
175
+ */
176
+ export function summarizeToolUse(block) {
177
+ const { name, input } = block;
178
+ if (!name) return `→ Tool: [unknown]`;
179
+
180
+ // ── Built-in Claude Code tools ────────────────────────────────────────
181
+
182
+ if (name === "Edit") return summarizeEdit(input);
183
+ if (name === "Write") return summarizeWrite(input);
184
+ if (name === "Read") return summarizeRead(input);
185
+
186
+ if (name === "Bash") {
187
+ const cmd = input?.command || "";
188
+ return `→ Ran \`${startEndTrim(cmd, BASH_CMD_LIMIT)}\``;
189
+ }
190
+
191
+ if (name === "Grep") {
192
+ const pattern = input?.pattern || "";
193
+ const searchPath = input?.path || "";
194
+ const pathSuffix = searchPath ? ` in \`${searchPath}\`` : "";
195
+ return `→ Grep \`${pattern}\`${pathSuffix}`;
196
+ }
197
+
198
+ if (name === "Glob") return `→ Glob \`${input?.pattern || ""}\``;
199
+
200
+ if (name === "Agent")
201
+ return `→ Agent: ${input?.description || "[no description]"}`;
202
+
203
+ if (name === "AskUserQuestion") {
204
+ const question = input?.question || input?.text || JSON.stringify(input);
205
+ return `→ Asked user: ${startEndTrim(question, 500)}`;
206
+ }
207
+
208
+ if (name === "WebSearch") return `→ WebSearch: \`${input?.query || ""}\``;
209
+ if (name === "WebFetch") return `→ WebFetch: \`${input?.url || ""}\``;
210
+
211
+ if (name === "NotebookEdit") return summarizeNotebookEdit(input);
212
+
213
+ // ── MCP tools — delegate to mcp-tools.mjs ─────────────────────────────
214
+ if (name.startsWith("mcp__")) {
215
+ return summarizeMcpToolUse(name, input, indent, summarizeUnknownTool);
216
+ }
217
+
218
+ // ── Unknown built-in tools — conservative: preserve key params ────────
219
+ return summarizeUnknownTool(name, input);
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Unknown tool fallback
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Generate a conservative summary for an unrecognised tool.
228
+ * Always preserves the tool name and key input parameters.
229
+ */
230
+ function summarizeUnknownTool(name, input) {
231
+ const inputStr = input ? JSON.stringify(input) : "";
232
+ if (inputStr.length <= UNKNOWN_INPUT_LIMIT) {
233
+ return `→ Tool: \`${name}\` ${inputStr}`;
234
+ }
235
+ return `→ Tool: \`${name}\` ${startEndTrim(inputStr, UNKNOWN_INPUT_LIMIT)}`;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Tool result summarisation — per-tool handlers
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Handle result for AskUserQuestion — always kept.
244
+ */
245
+ function handleAskUserResult(content) {
246
+ return `← User answered: ${content}`;
247
+ }
248
+
249
+ /**
250
+ * Handle result for re-obtainable tools — only keep short errors.
251
+ */
252
+ function handleReObtainableResult(content) {
253
+ return isShortErrorResponse(content) ? `← Error: ${content}` : null;
254
+ }
255
+
256
+ /**
257
+ * Handle result for Bash tool — keep action output, strip read-like output.
258
+ */
259
+ function handleBashResult(content, toolInfo) {
260
+ if (!content) return null;
261
+ if (isReadLikeBash(toolInfo?.input?.command)) {
262
+ return isShortErrorResponse(content) ? `← Error: ${content}` : null;
263
+ }
264
+ return `← ${startEndTrim(content, BASH_OUTPUT_LIMIT)}`;
265
+ }
266
+
267
+ /**
268
+ * Handle result for Agent tool — keep with trim.
269
+ */
270
+ function handleAgentResult(content) {
271
+ return content
272
+ ? `← Agent result:\n${startEndTrim(content, AGENT_RESULT_LIMIT)}`
273
+ : null;
274
+ }
275
+
276
+ /**
277
+ * Handle result for unknown/fallback tools — keep small, trim large.
278
+ */
279
+ function handleUnknownResult(content) {
280
+ if (!content) return null;
281
+ return content.length < UNKNOWN_RESULT_LIMIT
282
+ ? `← ${content}`
283
+ : `← ${startEndTrim(content, UNKNOWN_RESULT_LIMIT)}`;
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Tool result summarisation (user message → tool_result blocks)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /** Re-obtainable tools — results can be fetched again from disk. */
291
+ const RE_OBTAINABLE_TOOLS = new Set([
292
+ "Read",
293
+ "Grep",
294
+ "Glob",
295
+ "WebFetch",
296
+ "NotebookEdit",
297
+ ]);
298
+
299
+ /** Tools whose results are just success/failure confirmations or internal setup. */
300
+ const DISPOSABLE_RESULT_TOOLS = new Set(["Edit", "Write", "ToolSearch"]);
301
+
302
+ /**
303
+ * Generate a summary for a tool_result content block, or null to remove it.
304
+ * Decision is based on the originating tool type (looked up from the ID map).
305
+ *
306
+ * @param {object} resultBlock - A content block with type: "tool_result"
307
+ * @param {object|null} toolInfo - The originating tool's {name, input}, or null
308
+ * @returns {string|null} Formatted result summary, or null to omit
309
+ */
310
+ export function summarizeToolResult(resultBlock, toolInfo) {
311
+ const content = extractResultText(resultBlock);
312
+ const toolName = toolInfo?.name || "";
313
+
314
+ // AskUserQuestion — ALWAYS keep (user decision channel)
315
+ if (toolName === "AskUserQuestion") return handleAskUserResult(content);
316
+
317
+ // Re-obtainable — only keep short error responses
318
+ if (RE_OBTAINABLE_TOOLS.has(toolName) || isSerenaReadTool(toolName)) {
319
+ return handleReObtainableResult(content);
320
+ }
321
+
322
+ // Write tools — just success/failure
323
+ if (DISPOSABLE_RESULT_TOOLS.has(toolName) || isSerenaWriteTool(toolName))
324
+ return null;
325
+
326
+ // WebSearch — ephemeral results, keep
327
+ if (toolName === "WebSearch")
328
+ return `← Search results:\n${startEndTrim(content, WEB_SEARCH_LIMIT)}`;
329
+
330
+ // Bash — keep action output (tests, builds), strip read-like output (ls, cat, find)
331
+ if (toolName === "Bash") return handleBashResult(content, toolInfo);
332
+
333
+ // Agent — keep with trim
334
+ if (toolName === "Agent") return handleAgentResult(content);
335
+
336
+ // Sequential Thinking — redundant with input
337
+ if (toolName.includes("sequential-thinking")) return null;
338
+ // Context-mode — sandbox-internal
339
+ if (toolName.includes("context-mode")) return null;
340
+ // Serena memory — externally persisted
341
+ if (toolName.includes("serena") && toolName.includes("memory")) return null;
342
+
343
+ // Non-re-obtainable with errors — always keep
344
+ if (content && isErrorResponse(content))
345
+ return `← Error: ${startEndTrim(content, BASH_OUTPUT_LIMIT)}`;
346
+
347
+ // Unknown — conservative: keep small, trim large
348
+ return handleUnknownResult(content);
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Helpers
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Extract text content from a tool_result block.
357
+ * The content field can be a string or an array of content blocks.
358
+ */
359
+ function extractResultText(block) {
360
+ if (!block) return "";
361
+ const c = block.content;
362
+ if (!c) return "";
363
+ if (typeof c === "string") return c;
364
+ if (Array.isArray(c)) {
365
+ return c
366
+ .filter((b) => b.type === "text")
367
+ .map((b) => b.text)
368
+ .join("\n");
369
+ }
370
+ return "";
371
+ }
372
+
373
+ /**
374
+ * Generate a placeholder for non-text, non-tool content blocks
375
+ * (images, documents, unknown types).
376
+ *
377
+ * @param {object} block - A content block
378
+ * @returns {string|null} Placeholder text, or null if not applicable
379
+ */
380
+ export function contentBlockPlaceholder(block) {
381
+ if (!block?.type) return null;
382
+ if (block.type === "image") return "[User shared an image]";
383
+ if (block.type === "document") {
384
+ const name = block.source?.filename || block.filename || null;
385
+ return name
386
+ ? `[User shared a document: ${name}]`
387
+ : "[User shared a document]";
388
+ }
389
+ if (
390
+ block.type !== "text" &&
391
+ block.type !== "tool_use" &&
392
+ block.type !== "tool_result" &&
393
+ block.type !== "thinking" &&
394
+ block.type !== "redacted_thinking"
395
+ ) {
396
+ return `[Unknown content block: ${block.type}]`;
397
+ }
398
+ return null;
399
+ }