@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
package/lib/handoff.mjs
ADDED
|
@@ -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
|
+
}
|