@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,230 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, beforeEach, describe, it } from "node:test";
7
+
8
+ const CLI_PATH = path.resolve("lib/compact-cli.mjs");
9
+
10
+ let tmpDir;
11
+ let dataDir;
12
+ let transcriptPath;
13
+
14
+ function makeAssistant(text, usage) {
15
+ return {
16
+ type: "assistant",
17
+ message: {
18
+ role: "assistant",
19
+ model: "claude-sonnet-4-20250514",
20
+ content: [{ type: "text", text }],
21
+ usage: usage || {
22
+ input_tokens: 1000,
23
+ cache_creation_input_tokens: 0,
24
+ cache_read_input_tokens: 0,
25
+ output_tokens: 50,
26
+ },
27
+ },
28
+ };
29
+ }
30
+
31
+ function makeUser(text) {
32
+ return { type: "user", message: { role: "user", content: text } };
33
+ }
34
+
35
+ function writeLine(obj) {
36
+ fs.appendFileSync(transcriptPath, `${JSON.stringify(obj)}\n`);
37
+ }
38
+
39
+ beforeEach(() => {
40
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-cli-"));
41
+ dataDir = path.join(tmpDir, "data");
42
+ fs.mkdirSync(dataDir, { recursive: true });
43
+ fs.mkdirSync(path.join(dataDir, "checkpoints"), { recursive: true });
44
+ transcriptPath = path.join(tmpDir, "transcript.jsonl");
45
+ fs.writeFileSync(transcriptPath, "");
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(tmpDir, { recursive: true, force: true });
50
+ });
51
+
52
+ function runCli(args = []) {
53
+ try {
54
+ const stdout = execFileSync("node", [CLI_PATH, ...args], {
55
+ encoding: "utf8",
56
+ timeout: 10000,
57
+ env: {
58
+ ...process.env,
59
+ CLAUDE_PLUGIN_DATA: dataDir,
60
+ HOME: os.homedir(),
61
+ },
62
+ cwd: tmpDir,
63
+ });
64
+ return JSON.parse(stdout);
65
+ } catch (e) {
66
+ if (e.stdout?.trim()) return JSON.parse(e.stdout);
67
+ throw e;
68
+ }
69
+ }
70
+
71
+ // ===========================================================================
72
+ // Invalid mode
73
+ // ===========================================================================
74
+
75
+ describe("invalid mode", () => {
76
+ it("returns error for unknown mode", () => {
77
+ const result = runCli(["bogus", "sid", dataDir]);
78
+ assert.equal(result.success, false);
79
+ assert.ok(result.error.includes("Invalid mode"));
80
+ });
81
+
82
+ it("returns error for empty mode", () => {
83
+ const result = runCli([]);
84
+ assert.equal(result.success, false);
85
+ assert.ok(result.error.includes("Invalid mode"));
86
+ });
87
+ });
88
+
89
+ // ===========================================================================
90
+ // Missing session data
91
+ // ===========================================================================
92
+
93
+ describe("missing session data", () => {
94
+ it("returns error when no state file exists", () => {
95
+ const result = runCli(["smart", "nonexistent", dataDir]);
96
+ assert.equal(result.success, false);
97
+ assert.ok(result.error.includes("No session data"));
98
+ });
99
+ });
100
+
101
+ // ===========================================================================
102
+ // Missing transcript
103
+ // ===========================================================================
104
+
105
+ describe("missing transcript", () => {
106
+ it("returns error when transcript_path in state does not exist", () => {
107
+ fs.writeFileSync(
108
+ path.join(dataDir, "state-sid1.json"),
109
+ JSON.stringify({ transcript_path: "/nonexistent/transcript.jsonl" }),
110
+ );
111
+ const result = runCli(["smart", "sid1", dataDir]);
112
+ assert.equal(result.success, false);
113
+ assert.ok(result.error.includes("Transcript not found"));
114
+ });
115
+
116
+ it("returns error when transcript_path is empty", () => {
117
+ fs.writeFileSync(
118
+ path.join(dataDir, "state-sid2.json"),
119
+ JSON.stringify({ transcript_path: "" }),
120
+ );
121
+ const result = runCli(["smart", "sid2", dataDir]);
122
+ assert.equal(result.success, false);
123
+ assert.ok(result.error.includes("Transcript not found"));
124
+ });
125
+ });
126
+
127
+ // ===========================================================================
128
+ // Smart compaction
129
+ // ===========================================================================
130
+
131
+ describe("smart compaction", () => {
132
+ it("succeeds with extractable transcript content", () => {
133
+ // Build a minimal but extractable transcript
134
+ writeLine(makeUser("Please refactor the login module"));
135
+ writeLine(
136
+ makeAssistant(
137
+ "I'll refactor the login module. Here's my plan:\n1. Extract validation\n2. Add error handling",
138
+ ),
139
+ );
140
+ writeLine(makeUser("Looks good, go ahead"));
141
+ writeLine(
142
+ makeAssistant(
143
+ "Done. I've extracted the validation into a separate function.",
144
+ ),
145
+ );
146
+
147
+ fs.writeFileSync(
148
+ path.join(dataDir, "state-smart1.json"),
149
+ JSON.stringify({ transcript_path: transcriptPath }),
150
+ );
151
+
152
+ const result = runCli(["smart", "smart1", dataDir]);
153
+ assert.equal(result.success, true);
154
+ assert.ok(typeof result.statsBlock === "string");
155
+ });
156
+
157
+ it("returns error with empty transcript", () => {
158
+ fs.writeFileSync(transcriptPath, "");
159
+ fs.writeFileSync(
160
+ path.join(dataDir, "state-empty1.json"),
161
+ JSON.stringify({ transcript_path: transcriptPath }),
162
+ );
163
+
164
+ const result = runCli(["smart", "empty1", dataDir]);
165
+ assert.equal(result.success, false);
166
+ assert.ok(result.error.includes("No extractable content"));
167
+ });
168
+ });
169
+
170
+ // ===========================================================================
171
+ // Recent compaction
172
+ // ===========================================================================
173
+
174
+ describe("recent compaction", () => {
175
+ it("succeeds with extractable transcript content", () => {
176
+ writeLine(makeUser("Fix the bug in auth.js"));
177
+ writeLine(
178
+ makeAssistant("I found the issue. The token was not being refreshed."),
179
+ );
180
+ writeLine(makeUser("Great, apply the fix"));
181
+ writeLine(
182
+ makeAssistant("Applied. The token refresh now happens on every request."),
183
+ );
184
+
185
+ fs.writeFileSync(
186
+ path.join(dataDir, "state-recent1.json"),
187
+ JSON.stringify({ transcript_path: transcriptPath }),
188
+ );
189
+
190
+ const result = runCli(["recent", "recent1", dataDir]);
191
+ assert.equal(result.success, true);
192
+ assert.ok(typeof result.statsBlock === "string");
193
+ });
194
+ });
195
+
196
+ // ===========================================================================
197
+ // Output format
198
+ // ===========================================================================
199
+
200
+ describe("output format", () => {
201
+ it("always outputs valid JSON", () => {
202
+ // Even error cases must be parseable JSON
203
+ const result = runCli(["smart", "nope", dataDir]);
204
+ assert.ok(typeof result === "object");
205
+ assert.ok("success" in result);
206
+ });
207
+
208
+ it("error response has success=false and error string", () => {
209
+ const result = runCli(["invalid-mode", "sid", dataDir]);
210
+ assert.equal(result.success, false);
211
+ assert.ok(typeof result.error === "string");
212
+ assert.ok(result.error.length > 0);
213
+ });
214
+
215
+ it("success response has success=true and statsBlock string", () => {
216
+ writeLine(makeUser("Do something"));
217
+ writeLine(makeAssistant("Done with the task."));
218
+
219
+ fs.writeFileSync(
220
+ path.join(dataDir, "state-fmt1.json"),
221
+ JSON.stringify({ transcript_path: transcriptPath }),
222
+ );
223
+
224
+ const result = runCli(["smart", "fmt1", dataDir]);
225
+ if (result.success) {
226
+ assert.equal(typeof result.statsBlock, "string");
227
+ }
228
+ // If not enough content, success=false is also acceptable
229
+ });
230
+ });
@@ -0,0 +1,284 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { compactMessages, detectProjectRoot } from "../lib/compact-output.mjs";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // compactMessages — exchange-grouped output with noise stripping
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe("compactMessages", () => {
10
+ it("returns empty array for empty input", () => {
11
+ assert.deepEqual(compactMessages([]), []);
12
+ });
13
+
14
+ it("groups user + assistant messages into exchange blocks with [N] anchors", () => {
15
+ const msgs = [
16
+ "**User:** hello",
17
+ "**Assistant:** hi there, how can I help?",
18
+ "**User:** fix the bug",
19
+ "**Assistant:** Done, I fixed it.",
20
+ ];
21
+ const result = compactMessages(msgs);
22
+ assert.ok(result.length >= 1);
23
+ assert.ok(result[0].includes("[1]"));
24
+ assert.ok(result[0].includes("hello"));
25
+ assert.ok(result[0].includes("hi there"));
26
+ });
27
+
28
+ // R4: Phatic stripping
29
+ it("strips phatic 'Confirmed' assistant messages under 200 chars", () => {
30
+ const msgs = [
31
+ "**User:** remember this",
32
+ "**Assistant:** Confirmed — ready for the test.",
33
+ "**User:** next thing",
34
+ "**Assistant:** Working on it.",
35
+ ];
36
+ const result = compactMessages(msgs);
37
+ assert.ok(!result.some((m) => m.includes("Confirmed")));
38
+ });
39
+
40
+ it("strips 'Done.' trivial assistant messages", () => {
41
+ const msgs = [
42
+ "**User:** edit the file",
43
+ "**Assistant:** → Edit `foo.js`:\n old: | x\n new: | y",
44
+ "**Assistant:** Done.",
45
+ "**User:** next",
46
+ "**Assistant:** OK.",
47
+ ];
48
+ const result = compactMessages(msgs);
49
+ assert.ok(!result.some((m) => /\bDone\.\s*$/.test(m)));
50
+ });
51
+
52
+ it("does NOT strip long assistant messages starting with phatic words", () => {
53
+ const longMsg =
54
+ "**Assistant:** Confirmed — here is the detailed analysis: " +
55
+ "x".repeat(200);
56
+ const msgs = ["**User:** question", longMsg];
57
+ const result = compactMessages(msgs);
58
+ assert.ok(result.some((m) => m.includes("detailed analysis")));
59
+ });
60
+
61
+ it("does NOT strip assistant messages that don't match phatic patterns", () => {
62
+ const msgs = [
63
+ "**User:** fix it",
64
+ "**Assistant:** I'll investigate the bug now.",
65
+ ];
66
+ const result = compactMessages(msgs);
67
+ assert.ok(result.some((m) => m.includes("investigate the bug")));
68
+ });
69
+
70
+ // R3: Operational noise stripping
71
+ it("strips stats box messages", () => {
72
+ const msgs = [
73
+ "**User:** check stats",
74
+ "**Assistant:** ┌───\n│ Context Guardian Stats\n│\n│ Current usage: 50,000\n└───",
75
+ ];
76
+ const result = compactMessages(msgs);
77
+ assert.ok(!result.some((m) => m.includes("Context Guardian Stats")));
78
+ });
79
+
80
+ it("strips date +%s commands", () => {
81
+ const msgs = [
82
+ "**User:** check time",
83
+ "**Assistant:** → Ran `date +%s`\n← 1774736467",
84
+ ];
85
+ const result = compactMessages(msgs);
86
+ assert.ok(!result.some((m) => m.includes("date +%s")));
87
+ });
88
+
89
+ it("strips meta-tool ToolSearch invocations", () => {
90
+ const msgs = [
91
+ "**User:** find the tool",
92
+ '**Assistant:** → Tool: `ToolSearch` {"query":"select:mcp__serena__list_memories"}',
93
+ "**Assistant:** → Serena: list memories",
94
+ "**Assistant:** Here are the results.",
95
+ ];
96
+ const result = compactMessages(msgs);
97
+ assert.ok(!result.some((m) => m.includes("ToolSearch")));
98
+ });
99
+
100
+ // R2: Tool note grouping within exchanges
101
+ it("groups consecutive Read notes into one line", () => {
102
+ const msgs = [
103
+ "**User:** read these",
104
+ "**Assistant:** → Read `lib/a.mjs`",
105
+ "**Assistant:** → Read `lib/b.mjs`",
106
+ "**Assistant:** → Read `lib/c.mjs`",
107
+ ];
108
+ const result = compactMessages(msgs);
109
+ // All reads should be in one exchange block
110
+ const block = result.find((m) => m.includes("a.mjs"));
111
+ assert.ok(block);
112
+ assert.ok(block.includes("b.mjs"));
113
+ assert.ok(block.includes("c.mjs"));
114
+ });
115
+
116
+ // Merging consecutive assistant messages
117
+ it("merges consecutive assistant text into one exchange block", () => {
118
+ const msgs = [
119
+ "**User:** analyze this",
120
+ "**Assistant:** First observation.",
121
+ "**Assistant:** Second observation.",
122
+ "**Assistant:** Third observation.",
123
+ ];
124
+ const result = compactMessages(msgs);
125
+ // Should be one exchange block containing all three
126
+ const block = result.find((m) => m.includes("First observation"));
127
+ assert.ok(block);
128
+ assert.ok(block.includes("Second observation"));
129
+ assert.ok(block.includes("Third observation"));
130
+ });
131
+
132
+ it("merges bare ← lines into exchange block", () => {
133
+ const msgs = [
134
+ "**User:** run tests",
135
+ "**Assistant:** → Ran `npm test`",
136
+ "← 14 passed",
137
+ ];
138
+ const result = compactMessages(msgs);
139
+ const block = result[0];
140
+ assert.ok(block.includes("npm test"));
141
+ assert.ok(block.includes("14 passed"));
142
+ });
143
+
144
+ it("separates exchanges at User boundaries", () => {
145
+ const msgs = [
146
+ "**User:** first question",
147
+ "**Assistant:** first answer",
148
+ "**User:** second question",
149
+ "**Assistant:** second answer",
150
+ ];
151
+ const result = compactMessages(msgs);
152
+ assert.ok(result.length >= 2);
153
+ assert.ok(result[0].includes("first"));
154
+ assert.ok(result[1].includes("second"));
155
+ });
156
+
157
+ it("adds [N] exchange anchors", () => {
158
+ const msgs = [
159
+ "**User:** q1",
160
+ "**Assistant:** a1",
161
+ "**User:** q2",
162
+ "**Assistant:** a2",
163
+ "**User:** q3",
164
+ "**Assistant:** a3",
165
+ ];
166
+ const result = compactMessages(msgs);
167
+ assert.ok(result[0].includes("[1]"));
168
+ assert.ok(result[1].includes("[2]"));
169
+ assert.ok(result[2].includes("[3]"));
170
+ });
171
+ });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // detectProjectRoot
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe("detectProjectRoot", () => {
178
+ function makeLine(toolName, filePath) {
179
+ return JSON.stringify({
180
+ type: "assistant",
181
+ message: {
182
+ role: "assistant",
183
+ content: [
184
+ {
185
+ type: "tool_use",
186
+ id: "t1",
187
+ name: toolName,
188
+ input: { file_path: filePath },
189
+ },
190
+ ],
191
+ },
192
+ });
193
+ }
194
+
195
+ it("returns empty string for fewer than 3 paths", () => {
196
+ const lines = [
197
+ makeLine("Read", "/home/user/project/a.js"),
198
+ makeLine("Read", "/home/user/project/b.js"),
199
+ ];
200
+ assert.equal(detectProjectRoot(lines, 0), "");
201
+ });
202
+
203
+ it("detects common project root from multiple paths", () => {
204
+ const lines = [
205
+ makeLine("Read", "/home/user/code/myproject/lib/a.mjs"),
206
+ makeLine("Read", "/home/user/code/myproject/lib/b.mjs"),
207
+ makeLine("Read", "/home/user/code/myproject/test/c.mjs"),
208
+ makeLine("Read", "/home/user/code/myproject/test/d.mjs"),
209
+ makeLine("Read", "/home/user/code/myproject/hooks/e.mjs"),
210
+ ];
211
+ const root = detectProjectRoot(lines, 0);
212
+ assert.ok(root.includes("/home/user/code/myproject/"));
213
+ });
214
+
215
+ it("picks project root over subdirectory", () => {
216
+ const lines = [
217
+ makeLine("Read", "/home/user/code/proj/lib/a.mjs"),
218
+ makeLine("Read", "/home/user/code/proj/lib/b.mjs"),
219
+ makeLine("Read", "/home/user/code/proj/lib/c.mjs"),
220
+ makeLine("Read", "/home/user/code/proj/test/d.mjs"),
221
+ makeLine("Read", "/home/user/code/proj/test/e.mjs"),
222
+ makeLine("Read", "/home/user/code/proj/hooks/f.mjs"),
223
+ ];
224
+ const root = detectProjectRoot(lines, 0);
225
+ assert.equal(root, "/home/user/code/proj/");
226
+ });
227
+
228
+ it("handles mixed paths with different roots", () => {
229
+ const lines = [
230
+ makeLine("Read", "/home/user/code/proj/lib/a.mjs"),
231
+ makeLine("Read", "/home/user/code/proj/lib/b.mjs"),
232
+ makeLine("Read", "/home/user/code/proj/lib/c.mjs"),
233
+ makeLine("Read", "/home/user/code/proj/test/d.mjs"),
234
+ makeLine("Read", "/home/user/code/proj/hooks/e.mjs"),
235
+ makeLine("Read", "/tmp/other/deep/file.txt"),
236
+ ];
237
+ const root = detectProjectRoot(lines, 0);
238
+ assert.ok(root.includes("/home/user/code/proj/"));
239
+ });
240
+
241
+ it("returns empty for non-absolute paths", () => {
242
+ const lines = [
243
+ makeLine("Read", "lib/a.mjs"),
244
+ makeLine("Read", "lib/b.mjs"),
245
+ makeLine("Read", "test/c.mjs"),
246
+ ];
247
+ assert.equal(detectProjectRoot(lines, 0), "");
248
+ });
249
+
250
+ it("respects startIdx", () => {
251
+ const lines = [
252
+ makeLine("Read", "/old/project/a.mjs"),
253
+ makeLine("Read", "/old/project/b.mjs"),
254
+ makeLine("Read", "/old/project/c.mjs"),
255
+ makeLine("Read", "/new/project/d.mjs"),
256
+ ];
257
+ assert.equal(detectProjectRoot(lines, 3), "");
258
+ });
259
+
260
+ it("skips non-assistant lines", () => {
261
+ const lines = [
262
+ JSON.stringify({
263
+ type: "user",
264
+ message: { role: "user", content: "hi" },
265
+ }),
266
+ makeLine("Read", "/home/user/proj/a.mjs"),
267
+ makeLine("Read", "/home/user/proj/b.mjs"),
268
+ makeLine("Read", "/home/user/proj/c.mjs"),
269
+ ];
270
+ const root = detectProjectRoot(lines, 0);
271
+ assert.ok(root.includes("/home/user/proj/"));
272
+ });
273
+
274
+ it("handles malformed JSON gracefully", () => {
275
+ const lines = [
276
+ "not valid json{{{",
277
+ makeLine("Read", "/home/user/proj/a.mjs"),
278
+ makeLine("Read", "/home/user/proj/b.mjs"),
279
+ makeLine("Read", "/home/user/proj/c.mjs"),
280
+ ];
281
+ const root = detectProjectRoot(lines, 0);
282
+ assert.ok(root.includes("/home/user/proj/"));
283
+ });
284
+ });