@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,329 @@
1
+ /**
2
+ * Session handoff — save extracted conversation to a project-local file
3
+ * so a future session can pick up where you left off.
4
+ *
5
+ * Writes to .context-guardian/ in the project root for visibility.
6
+ * Synthetic JSONL sessions are also created for `/resume cg:{label}` access.
7
+ *
8
+ * @module handoff
9
+ */
10
+
11
+ import { Buffer } from "node:buffer";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { resolveMaxTokens } from "./config.mjs";
15
+ import { log } from "./logger.mjs";
16
+ import { stateFile } from "./paths.mjs";
17
+ import { estimateOverhead, getTokenUsage } from "./tokens.mjs";
18
+ import { extractConversation } from "./transcript.mjs";
19
+
20
+ /** Directory name for CG artifacts in the project root */
21
+ export const CG_DIR_NAME = ".context-guardian";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Perform handoff
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Generate a handoff file from the current session transcript.
29
+ *
30
+ * @param {object} opts
31
+ * @param {string} opts.transcriptPath - Path to the JSONL transcript
32
+ * @param {string} opts.sessionId - Current session ID
33
+ * @param {string} [opts.label] - Optional user-provided name for the handoff
34
+ * @returns {{ statsBlock: string, handoffPath: string } | null}
35
+ */
36
+ export function performHandoff({ transcriptPath, sessionId, label = "" }) {
37
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
38
+
39
+ const content = extractConversation(transcriptPath);
40
+ if (
41
+ !content ||
42
+ content === "(no transcript available)" ||
43
+ content.includes("Messages preserved: 0")
44
+ ) {
45
+ return null;
46
+ }
47
+
48
+ const projectDir = process.cwd();
49
+ const cgDir = path.join(projectDir, CG_DIR_NAME);
50
+ fs.mkdirSync(cgDir, { recursive: true });
51
+
52
+ const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-").slice(0, 19);
53
+ // Slugify label for filename: lowercase, replace non-alphanumeric with dashes, trim
54
+ const slug = label
55
+ ? `${label
56
+ .toLowerCase()
57
+ .replaceAll(/[^a-z0-9]+/g, "-")
58
+ .replaceAll(/^-|-$/g, "")
59
+ .slice(0, 50)}-`
60
+ : "";
61
+ const handoffPath = path.join(cgDir, `cg-handoff-${slug}${stamp}.md`);
62
+
63
+ const labelLine = label ? `\n> Label: ${label}` : "";
64
+ const fullContent = `# Session Handoff\n> Created: ${new Date().toISOString()}\n> Session: ${sessionId}${labelLine}\n\n${content}`;
65
+ fs.writeFileSync(handoffPath, fullContent);
66
+
67
+ // Rotate old handoff files (keep last 5)
68
+ rotateFiles(cgDir, "cg-handoff-", 5);
69
+
70
+ // Compute stats
71
+ const usage = getTokenUsage(transcriptPath);
72
+ const preTokens =
73
+ usage?.current_tokens || Math.round(Buffer.byteLength(content, "utf8") / 4);
74
+ const maxTokens = usage?.max_tokens || resolveMaxTokens() || 200000;
75
+ const postTokens = Math.round(Buffer.byteLength(fullContent, "utf8") / 4);
76
+
77
+ let baselineOverhead = 0;
78
+ try {
79
+ const sf = stateFile(sessionId);
80
+ if (fs.existsSync(sf)) {
81
+ const prev = JSON.parse(fs.readFileSync(sf, "utf8"));
82
+ baselineOverhead = prev.baseline_overhead ?? 0;
83
+ }
84
+ } catch {}
85
+
86
+ const overhead = estimateOverhead(
87
+ preTokens,
88
+ transcriptPath,
89
+ baselineOverhead,
90
+ );
91
+ const effectivePost = postTokens + overhead;
92
+ const saved = Math.max(0, preTokens - effectivePost);
93
+ const savedPct =
94
+ preTokens > 0 ? ((saved / preTokens) * 100).toFixed(1) : "0.0";
95
+ const prePct =
96
+ maxTokens > 0 ? ((preTokens / maxTokens) * 100).toFixed(1) : "?";
97
+ const postPct =
98
+ maxTokens > 0 ? ((effectivePost / maxTokens) * 100).toFixed(1) : "0.0";
99
+
100
+ // Measure transcript file size + system overhead for session size reporting
101
+ let prePayloadBytes = 0;
102
+ try {
103
+ prePayloadBytes = fs.statSync(transcriptPath).size;
104
+ } catch {}
105
+ const overheadBytes = overhead * 4;
106
+ const totalPreBytes = prePayloadBytes + overheadBytes;
107
+ const postPayloadBytes = Buffer.byteLength(fullContent, "utf8");
108
+
109
+ const lines = [
110
+ `┌──────────────────────────────────────────────────────────────────────────────────────────────────`,
111
+ `│ Session Handoff`,
112
+ `│`,
113
+ `│ Before: ${preTokens.toLocaleString()} tokens (~${prePct}% of context)`,
114
+ `│ After: ~${effectivePost.toLocaleString()} tokens (~${postPct}% of context)`,
115
+ `│ Saved: ~${saved.toLocaleString()} tokens (${savedPct}% reduction)`,
116
+ ];
117
+ if (totalPreBytes > 0) {
118
+ lines.push(
119
+ `│ Session: ${Math.max(0.1, totalPreBytes / (1024 * 1024)).toFixed(1)}MB → ${Math.max(0.1, postPayloadBytes / (1024 * 1024)).toFixed(1)}MB`,
120
+ );
121
+ }
122
+ lines.push(
123
+ `│`,
124
+ `│ Saved to: ${handoffPath}`,
125
+ `│`,
126
+ `└──────────────────────────────────────────────────────────────────────────────────────────────────`,
127
+ );
128
+ const statsBlock = lines.join("\n");
129
+
130
+ log(
131
+ `handoff-saved session=${sessionId} file=${handoffPath} pre=${preTokens} post=${effectivePost}`,
132
+ );
133
+
134
+ return { statsBlock, handoffPath };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // List available restore files
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Scan .context-guardian/ for handoff and checkpoint files.
143
+ * Returns them sorted newest-first with metadata parsed from headers.
144
+ *
145
+ * @param {string} projectDir - Project root directory
146
+ * @returns {Array<{ path: string, filename: string, type: string, created: string, goal: string, size: number }>}
147
+ */
148
+ export function listRestoreFiles(
149
+ projectDir,
150
+ { includeCheckpoints = false } = {},
151
+ ) {
152
+ const cgDir = path.join(projectDir, CG_DIR_NAME);
153
+ if (!fs.existsSync(cgDir)) return [];
154
+
155
+ const files = [];
156
+ for (const f of fs.readdirSync(cgDir)) {
157
+ let type = null;
158
+ if (f.startsWith("cg-handoff-") && f.endsWith(".md")) type = "handoff";
159
+ else if (
160
+ includeCheckpoints &&
161
+ f.startsWith("cg-checkpoint-") &&
162
+ f.endsWith(".md")
163
+ )
164
+ type = "checkpoint";
165
+ if (!type) continue;
166
+
167
+ const fullPath = path.join(cgDir, f);
168
+ try {
169
+ const stat = fs.statSync(fullPath);
170
+ const head = readFileHead(fullPath, 512);
171
+ const created = parseCreatedDate(head) || stat.mtime.toISOString();
172
+ const label = parseLabel(head);
173
+ const goal = parseGoal(head);
174
+ const sizeKB = Math.round(stat.size / 1024);
175
+
176
+ files.push({
177
+ path: fullPath,
178
+ filename: f,
179
+ type,
180
+ created,
181
+ label,
182
+ goal,
183
+ size: sizeKB,
184
+ });
185
+ } catch {
186
+ // Skip unreadable files
187
+ }
188
+ }
189
+
190
+ // Sort newest first
191
+ files.sort((a, b) => b.created.localeCompare(a.created));
192
+
193
+ // Limit: 10 per type
194
+ if (includeCheckpoints) {
195
+ const handoffs = files.filter((f) => f.type === "handoff").slice(0, 10);
196
+ const checkpoints = files
197
+ .filter((f) => f.type === "checkpoint")
198
+ .slice(0, 10);
199
+ return [...handoffs, ...checkpoints].sort((a, b) =>
200
+ b.created.localeCompare(a.created),
201
+ );
202
+ }
203
+ return files.slice(0, 10);
204
+ }
205
+
206
+ /**
207
+ * Format the restore menu for display.
208
+ *
209
+ * @param {Array} files - From listRestoreFiles()
210
+ * @returns {string} Formatted menu text
211
+ */
212
+ export function formatRestoreMenu(files, { showType = false } = {}) {
213
+ if (files.length === 0) {
214
+ return [
215
+ `┌──────────────────────────────────────────────────────────────────────────`,
216
+ `│ No saved sessions found in .context-guardian/`,
217
+ `│ Run /cg:handoff [name] to save your current session.`,
218
+ `└──────────────────────────────────────────────────────────────────────────`,
219
+ ].join("\n");
220
+ }
221
+
222
+ const lines = [
223
+ `┌──────────────────────────────────────────────────────────────────────────`,
224
+ `│ Previous Sessions`,
225
+ `├──────────────────────────────────────────────────────────────────────────`,
226
+ ];
227
+
228
+ for (let i = 0; i < files.length; i++) {
229
+ const f = files[i];
230
+ const date = formatDate(f.created);
231
+ const goalDisplay = f.label || f.goal || "no description";
232
+ const typeLabel = f.type === "handoff" ? "HANDOFF" : "CHECKPOINT";
233
+ const typeSuffix = showType ? ` [${typeLabel}]` : "";
234
+ lines.push(
235
+ `│ [${i + 1}] ${goalDisplay} (${date} · ${f.size}KB)${typeSuffix}`,
236
+ );
237
+ }
238
+
239
+ lines.push(
240
+ `│`,
241
+ `│ Reply with a number to restore, or continue to start a new session.`,
242
+ `└──────────────────────────────────────────────────────────────────────────`,
243
+ );
244
+
245
+ return lines.join("\n");
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Helpers
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function readFileHead(filePath, bytes) {
253
+ const fd = fs.openSync(filePath, "r");
254
+ try {
255
+ const buf = Buffer.alloc(bytes);
256
+ const bytesRead = fs.readSync(fd, buf, 0, bytes, 0);
257
+ return buf.toString("utf8", 0, bytesRead);
258
+ } finally {
259
+ fs.closeSync(fd);
260
+ }
261
+ }
262
+
263
+ function parseCreatedDate(head) {
264
+ const m = head.match(/> Created: (.+)/);
265
+ return m ? m[1].trim() : null;
266
+ }
267
+
268
+ function parseLabel(head) {
269
+ const m = head.match(/> Label: (.+)/);
270
+ return m ? m[1].trim() : null;
271
+ }
272
+
273
+ function parseGoal(head) {
274
+ const m = head.match(/Goal: (.+)/);
275
+ if (!m) return null;
276
+ const goal = m[1].trim();
277
+ return goal === "[not available]" ? null : goal;
278
+ }
279
+
280
+ function formatDate(isoString) {
281
+ try {
282
+ const d = new Date(isoString);
283
+ const now = new Date();
284
+ const diffMs = now - d;
285
+ const diffM = Math.round(diffMs / (60 * 1000));
286
+ const diffH = Math.round(diffMs / (60 * 60 * 1000));
287
+
288
+ if (diffM < 1) return "just now";
289
+ if (diffM === 1) return "1 minute ago";
290
+ if (diffM < 60) return `${diffM} minutes ago`;
291
+ if (diffH === 1) return "1 hour ago";
292
+ if (diffH < 24) return `${diffH} hours ago`;
293
+ const diffD = Math.round(diffH / 24);
294
+ if (diffD === 1) return "yesterday";
295
+ return `${diffD} days ago`;
296
+ } catch {
297
+ return isoString;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Rotate files matching a prefix, keeping the most recent N.
303
+ */
304
+ export function rotateFiles(dir, prefix, maxKeep) {
305
+ try {
306
+ const files = fs
307
+ .readdirSync(dir)
308
+ .filter((f) => f.startsWith(prefix) && f.endsWith(".md"));
309
+
310
+ // Sort by file mtime (newest first) — filename sort is unreliable
311
+ // when labels are prepended before the timestamp.
312
+ files.sort((a, b) => {
313
+ try {
314
+ return (
315
+ fs.statSync(path.join(dir, b)).mtimeMs -
316
+ fs.statSync(path.join(dir, a)).mtimeMs
317
+ );
318
+ } catch {
319
+ return 0;
320
+ }
321
+ });
322
+
323
+ for (const f of files.slice(maxKeep)) {
324
+ try {
325
+ fs.unlinkSync(path.join(dir, f));
326
+ } catch {}
327
+ }
328
+ } catch {}
329
+ }
package/lib/logger.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import { LOG_DIR, LOG_FILE } from "./paths.mjs";
3
+
4
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MiB
5
+
6
+ /**
7
+ * Append a timestamped line to the shared log file.
8
+ * Silently swallows errors — logging must never break the hook.
9
+ * Rotates the log file when it exceeds MAX_LOG_SIZE.
10
+ */
11
+ let logDirReady = false;
12
+
13
+ export function log(msg) {
14
+ try {
15
+ if (!logDirReady) {
16
+ fs.mkdirSync(LOG_DIR, { recursive: true });
17
+ logDirReady = true;
18
+ }
19
+ // Rotate if log exceeds size limit
20
+ try {
21
+ if (
22
+ fs.existsSync(LOG_FILE) &&
23
+ fs.statSync(LOG_FILE).size > MAX_LOG_SIZE
24
+ ) {
25
+ const rotated = `${LOG_FILE}.1`;
26
+ try {
27
+ fs.unlinkSync(rotated);
28
+ } catch {}
29
+ fs.renameSync(LOG_FILE, rotated);
30
+ }
31
+ } catch {}
32
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
33
+ } catch {}
34
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * MCP tool summarisation rules for specific server integrations.
3
+ *
4
+ * Handles Serena, Sequential Thinking, Context-mode, Context7, and
5
+ * unknown MCP tools. Each server has tailored rules that preserve
6
+ * high-value content while removing re-obtainable noise.
7
+ *
8
+ * @module mcp-tools
9
+ */
10
+
11
+ import { startEndTrim } from "./trim.mjs";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Maximum chars for sequential thinking thoughts before start+end trim. */
18
+ const THOUGHT_LIMIT = 2000;
19
+ /** Maximum chars for write content before start+end trim. */
20
+ const WRITE_LIMIT = 3000;
21
+ // ---------------------------------------------------------------------------
22
+ // MCP tool use dispatch
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Summarise an MCP tool_use block based on server and tool name.
27
+ *
28
+ * @param {string} name - Full MCP tool name (e.g. "mcp__serena__find_symbol")
29
+ * @param {object} input - The tool input object
30
+ * @param {function} indent - Indent helper function
31
+ * @param {function} summarizeUnknown - Fallback for unknown tools
32
+ * @returns {string|null} Formatted summary, or null to remove
33
+ */
34
+ export function summarizeMcpToolUse(name, input, indent, summarizeUnknown) {
35
+ if (name.includes("__serena__"))
36
+ return summarizeSerenaTool(name, input, indent);
37
+ if (name.includes("__sequential-thinking__"))
38
+ return summarizeThinking(name, input);
39
+ if (name.includes("context-mode")) return summarizeContextMode(name, input);
40
+ if (name.includes("__context7__")) {
41
+ return `→ Docs: \`${input?.libraryName || input?.query || name}\``;
42
+ }
43
+ return summarizeUnknown(name, input);
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Serena rules
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Summarise a Serena MCP tool based on the specific operation.
52
+ * Write tools preserve code changes; read/query tools are note-only.
53
+ *
54
+ * @param {string} name - Full tool name
55
+ * @param {object} input - Tool input
56
+ * @param {function} indent - Indent helper
57
+ * @returns {string|null} Formatted summary, or null to remove
58
+ */
59
+ function summarizeSerenaTool(name, input, indent) {
60
+ const toolName = name.split("__").pop();
61
+
62
+ // Write operations — preserve code changes like Edit/Write
63
+ if (toolName === "replace_symbol_body") {
64
+ const body = input?.new_body || "";
65
+ const sym = input?.name_path || input?.symbol_name || "unknown";
66
+ const file = input?.relative_path || "";
67
+ const trimmed = startEndTrim(body, WRITE_LIMIT);
68
+ return `→ Serena: replaced \`${sym}\` in \`${file}\`:\n${indent(trimmed, 4)}`;
69
+ }
70
+ if (
71
+ toolName === "insert_after_symbol" ||
72
+ toolName === "insert_before_symbol"
73
+ ) {
74
+ const body = input?.code || input?.body || "";
75
+ const sym = input?.name_path || "";
76
+ const trimmed = startEndTrim(body, WRITE_LIMIT);
77
+ const dir = toolName.includes("after") ? "after" : "before";
78
+ return `→ Serena: inserted ${dir} \`${sym}\`:\n${indent(trimmed, 4)}`;
79
+ }
80
+ if (toolName === "rename_symbol") {
81
+ return `→ Serena: renamed \`${input?.old_name || ""}\` → \`${input?.new_name || ""}\``;
82
+ }
83
+
84
+ // Memory operations — externally persisted, note only
85
+ if (toolName === "write_memory" || toolName === "edit_memory") {
86
+ return `→ Serena: wrote memory \`${input?.name || input?.title || ""}\``;
87
+ }
88
+ if (
89
+ ["read_memory", "list_memories", "rename_memory", "delete_memory"].includes(
90
+ toolName,
91
+ )
92
+ ) {
93
+ return `→ Serena: ${toolName.replaceAll("_", " ")}`;
94
+ }
95
+
96
+ // Setup/onboarding — noise
97
+ if (
98
+ [
99
+ "onboarding",
100
+ "check_onboarding_performed",
101
+ "initial_instructions",
102
+ ].includes(toolName)
103
+ ) {
104
+ return null;
105
+ }
106
+
107
+ // Read/query operations — re-obtainable, note only
108
+ const query =
109
+ input?.name_path || input?.pattern || input?.relative_path || "";
110
+ const label = toolName.replaceAll("_", " ");
111
+ const querySuffix = query ? ` \`${query}\`` : "";
112
+ return `→ Serena: ${label}${querySuffix}`;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Sequential Thinking rules
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Summarise a sequential thinking tool call.
121
+ * The thought field IS the reasoning chain — preserve it.
122
+ */
123
+ function summarizeThinking(_name, input) {
124
+ const thought = input?.thought || "";
125
+ const step = input?.thoughtNumber || "?";
126
+ const total = input?.totalThoughts || "?";
127
+ const trimmed = startEndTrim(thought, THOUGHT_LIMIT);
128
+ return `→ Thinking (step ${step}/${total}): ${trimmed}`;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Context-mode rules
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Summarise a context-mode tool call.
137
+ * Results are sandbox-internal — assistant text has the summary.
138
+ */
139
+ function summarizeContextMode(name, input) {
140
+ if (name.includes("batch_execute")) {
141
+ const n = Array.isArray(input?.commands) ? input.commands.length : "?";
142
+ return `→ Context-mode: batch executed ${n} commands`;
143
+ }
144
+ if (name.includes("execute")) {
145
+ return `→ Context-mode: executed ${input?.language || "code"}`;
146
+ }
147
+ if (name.includes("search")) {
148
+ const queries = Array.isArray(input?.queries)
149
+ ? input.queries.join(", ")
150
+ : "";
151
+ return `→ Context-mode: searched ${queries}`;
152
+ }
153
+ if (name.includes("fetch_and_index")) {
154
+ return `→ Context-mode: fetched \`${input?.url || ""}\``;
155
+ }
156
+ // index, stats — operational noise
157
+ return null;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Serena tool classification (used by tool_result handling)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Check if a tool name is a Serena read/query operation.
166
+ * @param {string} name - Tool name
167
+ * @returns {boolean}
168
+ */
169
+ export function isSerenaReadTool(name) {
170
+ if (!name?.includes("serena")) return false;
171
+ const tool = name.split("__").pop();
172
+ return [
173
+ "find_symbol",
174
+ "get_symbols_overview",
175
+ "search_for_pattern",
176
+ "list_dir",
177
+ "find_file",
178
+ "find_referencing_symbols",
179
+ "read_memory",
180
+ "list_memories",
181
+ ].includes(tool);
182
+ }
183
+
184
+ /**
185
+ * Check if a tool name is a Serena write operation.
186
+ * @param {string} name - Tool name
187
+ * @returns {boolean}
188
+ */
189
+ export function isSerenaWriteTool(name) {
190
+ if (!name?.includes("serena")) return false;
191
+ const tool = name.split("__").pop();
192
+ return [
193
+ "replace_symbol_body",
194
+ "insert_after_symbol",
195
+ "insert_before_symbol",
196
+ "rename_symbol",
197
+ "write_memory",
198
+ "edit_memory",
199
+ ].includes(tool);
200
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,90 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Plugin data directory — persistent storage that survives plugin updates.
8
+ // Falls back to ~/.claude/cg/ for standalone / local testing.
9
+ // ---------------------------------------------------------------------------
10
+ /** Resolve the data directory at call time, not import time.
11
+ * This allows tests to set CLAUDE_PLUGIN_DATA after paths.mjs is first imported. */
12
+ export function resolveDataDir() {
13
+ return (
14
+ process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".claude", "cg")
15
+ );
16
+ }
17
+
18
+ /** @deprecated Use resolveDataDir() for call-time resolution. Kept for backwards compat. */
19
+ export const DATA_DIR = resolveDataDir();
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Logging
23
+ // ---------------------------------------------------------------------------
24
+ export const LOG_DIR = path.join(os.homedir(), ".claude", "logs");
25
+ export const LOG_FILE = path.join(LOG_DIR, "cg.log");
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Persistent state files (plugin-scoped, survive /clear)
29
+ // ---------------------------------------------------------------------------
30
+ export const CONFIG_FILE = path.join(resolveDataDir(), "config.json");
31
+ export const CHECKPOINTS_DIR = path.join(resolveDataDir(), "checkpoints");
32
+
33
+ // Session-scoped state file — each session writes its own token counts
34
+ // so multiple concurrent sessions don't clobber each other.
35
+ export function stateFile(sessionId) {
36
+ return path.join(resolveDataDir(), `state-${sessionId || "unknown"}.json`);
37
+ }
38
+
39
+ // Fixed fallback location for statusline reads. The statusline process doesn't
40
+ // receive CLAUDE_PLUGIN_DATA, so it always reads from ~/.claude/cg/.
41
+ // Hooks write here in addition to the primary data dir.
42
+ export const STATUSLINE_STATE_DIR = path.join(os.homedir(), ".claude", "cg");
43
+
44
+ export function statuslineStateFile(sessionId) {
45
+ return path.join(
46
+ STATUSLINE_STATE_DIR,
47
+ `state-${sessionId || "unknown"}.json`,
48
+ );
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Ensure the data directory exists on first use.
53
+ // ---------------------------------------------------------------------------
54
+ export function ensureDataDir() {
55
+ fs.mkdirSync(resolveDataDir(), { recursive: true });
56
+ }
57
+
58
+ /**
59
+ * Atomic write — writes to a temp file then renames. Prevents partial/corrupt
60
+ * state files on crash, disk full, or concurrent access. Rename is atomic on
61
+ * POSIX systems when source and target are on the same filesystem.
62
+ *
63
+ * @param {string} filePath - Target file path
64
+ * @param {string} data - Content to write
65
+ */
66
+ export function atomicWriteFileSync(filePath, data) {
67
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString("hex")}.tmp`;
68
+ fs.writeFileSync(tmp, data);
69
+ fs.renameSync(tmp, filePath);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Rotate checkpoint files — keep only the most recent N.
74
+ // Files are named session-YYYY-MM-DDTHH-MM-SS-<hash>.md, so alphabetical
75
+ // sort gives chronological order.
76
+ // ---------------------------------------------------------------------------
77
+ export function rotateCheckpoints(maxKeep = 10) {
78
+ try {
79
+ const files = fs
80
+ .readdirSync(CHECKPOINTS_DIR)
81
+ .filter((f) => f.startsWith("session-") && f.endsWith(".md"))
82
+ .sort()
83
+ .reverse(); // newest first
84
+ for (const f of files.slice(maxKeep)) {
85
+ try {
86
+ fs.unlinkSync(path.join(CHECKPOINTS_DIR, f));
87
+ } catch {}
88
+ }
89
+ } catch {}
90
+ }