@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/stats.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculate compaction stats and format a display block.
|
|
5
|
+
*
|
|
6
|
+
* @param {number} preTokens — token count before compaction (0 = unavailable)
|
|
7
|
+
* @param {number} maxTokens — context window limit
|
|
8
|
+
* @param {string} checkpointContent — the exported checkpoint text
|
|
9
|
+
* @returns {{ stats: object, block: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function formatCompactionStats(
|
|
12
|
+
preTokens,
|
|
13
|
+
maxTokens,
|
|
14
|
+
checkpointContent,
|
|
15
|
+
{ overhead = 0, prePayloadBytes = 0 } = {},
|
|
16
|
+
) {
|
|
17
|
+
const postTokens =
|
|
18
|
+
Math.round(Buffer.byteLength(checkpointContent, "utf8") / 4) + overhead;
|
|
19
|
+
|
|
20
|
+
// If preTokens is missing/zero, stats are unreliable — show what we can.
|
|
21
|
+
const hasPreData = preTokens > 0;
|
|
22
|
+
const saved = hasPreData ? Math.max(0, preTokens - postTokens) : 0;
|
|
23
|
+
const savedPct =
|
|
24
|
+
hasPreData && preTokens > 0
|
|
25
|
+
? ((saved / preTokens) * 100).toFixed(1)
|
|
26
|
+
: "0.0";
|
|
27
|
+
const prePct =
|
|
28
|
+
hasPreData && maxTokens > 0
|
|
29
|
+
? ((preTokens / maxTokens) * 100).toFixed(1)
|
|
30
|
+
: "?";
|
|
31
|
+
const postPct =
|
|
32
|
+
maxTokens > 0 ? ((postTokens / maxTokens) * 100).toFixed(1) : "0.0";
|
|
33
|
+
|
|
34
|
+
// Session size: transcript file + system overhead (before), checkpoint content (after)
|
|
35
|
+
const overheadBytes = overhead * 4;
|
|
36
|
+
const totalPreBytes = prePayloadBytes + overheadBytes;
|
|
37
|
+
const postPayloadBytes = Buffer.byteLength(checkpointContent, "utf8");
|
|
38
|
+
|
|
39
|
+
const stats = {
|
|
40
|
+
preTokens,
|
|
41
|
+
postTokens,
|
|
42
|
+
maxTokens,
|
|
43
|
+
saved,
|
|
44
|
+
savedPct: Number.parseFloat(savedPct),
|
|
45
|
+
prePct: hasPreData ? Number.parseFloat(prePct) : 0,
|
|
46
|
+
postPct: Number.parseFloat(postPct),
|
|
47
|
+
prePayloadBytes: totalPreBytes,
|
|
48
|
+
postPayloadBytes,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const beforeLine = hasPreData
|
|
52
|
+
? `│ Before: ${preTokens.toLocaleString()} tokens (~${prePct}% of context)`
|
|
53
|
+
: `│ Before: unknown (token data unavailable)`;
|
|
54
|
+
const savedLine = hasPreData
|
|
55
|
+
? `│ Saved: ~${saved.toLocaleString()} tokens (${savedPct}% reduction)`
|
|
56
|
+
: `│ Saved: unknown`;
|
|
57
|
+
|
|
58
|
+
// Session size line — only shown if we have the pre-compaction file size
|
|
59
|
+
const payloadLine =
|
|
60
|
+
totalPreBytes > 0
|
|
61
|
+
? `│ Session: ${Math.max(0.1, totalPreBytes / (1024 * 1024)).toFixed(1)}MB → ${Math.max(0.1, postPayloadBytes / (1024 * 1024)).toFixed(1)}MB`
|
|
62
|
+
: "";
|
|
63
|
+
|
|
64
|
+
const lines = [
|
|
65
|
+
`┌──────────────────────────────────────────────────────────────────────────────────────────────────`,
|
|
66
|
+
`│ Compaction Stats`,
|
|
67
|
+
`│`,
|
|
68
|
+
beforeLine,
|
|
69
|
+
`│ After: ~${postTokens.toLocaleString()} tokens (~${postPct}% of context)`,
|
|
70
|
+
savedLine,
|
|
71
|
+
];
|
|
72
|
+
if (payloadLine) lines.push(payloadLine);
|
|
73
|
+
lines.push(
|
|
74
|
+
`│`,
|
|
75
|
+
`└──────────────────────────────────────────────────────────────────────────────────────────────────`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const block = lines.join("\n");
|
|
79
|
+
|
|
80
|
+
return { stats, block };
|
|
81
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Statusline renderer for Claude Code's terminal status bar.
|
|
4
|
+
*
|
|
5
|
+
* Reads JSON from stdin (piped by Claude Code) containing session stats,
|
|
6
|
+
* outputs a compact context usage line. Zero context window cost.
|
|
7
|
+
*
|
|
8
|
+
* Auto-configured by the session-start hook.
|
|
9
|
+
*
|
|
10
|
+
* @module statusline
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
let raw = "";
|
|
18
|
+
process.stdin.setEncoding("utf8");
|
|
19
|
+
process.stdin.on("data", (chunk) => {
|
|
20
|
+
raw += chunk;
|
|
21
|
+
});
|
|
22
|
+
process.stdin.on("error", () => {
|
|
23
|
+
process.stdout.write("Context: --");
|
|
24
|
+
});
|
|
25
|
+
process.stdin.on("end", () => {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(raw);
|
|
28
|
+
process.stdout.write(render(data));
|
|
29
|
+
} catch {
|
|
30
|
+
process.stdout.write("Context: --");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Render the statusline output from Claude Code's session data.
|
|
36
|
+
*
|
|
37
|
+
* Claude Code pipes JSON with this structure:
|
|
38
|
+
* context_window: { used_percentage, remaining_percentage, total_input_tokens, total_output_tokens }
|
|
39
|
+
* model: { id, display_name }
|
|
40
|
+
*/
|
|
41
|
+
function render(data) {
|
|
42
|
+
const pctRaw = data.context_window?.used_percentage;
|
|
43
|
+
if (pctRaw == null) {
|
|
44
|
+
const dim = "\x1b[2m";
|
|
45
|
+
const reset = "\x1b[0m";
|
|
46
|
+
return `${dim}Context usage: --${reset}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pct = Math.round(pctRaw);
|
|
50
|
+
|
|
51
|
+
// Read threshold from config if available, fallback to 35%
|
|
52
|
+
const dataDir =
|
|
53
|
+
process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".claude", "cg");
|
|
54
|
+
let threshold = 35;
|
|
55
|
+
try {
|
|
56
|
+
const configPath = path.join(dataDir, "config.json");
|
|
57
|
+
if (fs.existsSync(configPath)) {
|
|
58
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
59
|
+
if (cfg.threshold) threshold = Math.round(cfg.threshold * 100);
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
|
|
63
|
+
// Color strategy:
|
|
64
|
+
// - Green: labels dim/grey, only numbers colored green
|
|
65
|
+
// - Yellow: labels dim/grey, only numbers colored yellow
|
|
66
|
+
// - Red: entire label+number is bold red (maximum visibility)
|
|
67
|
+
const reset = "\x1b[0m";
|
|
68
|
+
const dim = "\x1b[2m";
|
|
69
|
+
|
|
70
|
+
let contextStr;
|
|
71
|
+
if (pct >= threshold) {
|
|
72
|
+
contextStr = `\x1b[1;31mContext usage: ${pct}%${reset}`;
|
|
73
|
+
} else {
|
|
74
|
+
const numColor = pct < threshold * 0.7 ? "\x1b[32m" : "\x1b[33m";
|
|
75
|
+
contextStr = `${dim}Context usage:${reset} ${numColor}${pct}%${reset}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Session size — proxy for the ~20MB API request limit (separate from token limit).
|
|
79
|
+
// Read from the most recent state file written by submit/stop hooks.
|
|
80
|
+
let sessionStr = `${dim}--${reset}`;
|
|
81
|
+
try {
|
|
82
|
+
const stateFiles = fs
|
|
83
|
+
.readdirSync(dataDir)
|
|
84
|
+
.filter((f) => f.startsWith("state-") && f.endsWith(".json"));
|
|
85
|
+
if (stateFiles.length > 0) {
|
|
86
|
+
// Pick the most recently modified state file
|
|
87
|
+
let newest = stateFiles[0];
|
|
88
|
+
let newestMtime = 0;
|
|
89
|
+
for (const f of stateFiles) {
|
|
90
|
+
const mt = fs.statSync(path.join(dataDir, f)).mtimeMs;
|
|
91
|
+
if (mt > newestMtime) {
|
|
92
|
+
newestMtime = mt;
|
|
93
|
+
newest = f;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const state = JSON.parse(
|
|
97
|
+
fs.readFileSync(path.join(dataDir, newest), "utf8"),
|
|
98
|
+
);
|
|
99
|
+
// Total payload = transcript file + system overhead (prompts, tools, CLAUDE.md).
|
|
100
|
+
// The transcript JSONL only contains conversation messages, not the full
|
|
101
|
+
// API request. baseline_overhead (tokens) × 4 ≈ system overhead in bytes.
|
|
102
|
+
const overheadBytes = (state.baseline_overhead || 0) * 4;
|
|
103
|
+
const totalBytes = (state.payload_bytes || 0) + overheadBytes;
|
|
104
|
+
if (totalBytes > 0) {
|
|
105
|
+
const mb = Math.max(0.1, totalBytes / (1024 * 1024)).toFixed(1);
|
|
106
|
+
if (mb >= 15) {
|
|
107
|
+
sessionStr = `\x1b[1;31mSession size: ${mb}/20MB${reset}`;
|
|
108
|
+
} else {
|
|
109
|
+
const numColor = mb < 10 ? "\x1b[32m" : "\x1b[33m";
|
|
110
|
+
sessionStr = `${dim}Session size:${reset} ${numColor}${mb}${dim}/20MB${reset}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
const untilAlert = Math.max(0, threshold - pct);
|
|
117
|
+
const tail =
|
|
118
|
+
untilAlert > 0
|
|
119
|
+
? `${dim}/cg:stats for more${reset}`
|
|
120
|
+
: `\x1b[1;31mcompaction recommended \u2014 /cg:compact${reset}`;
|
|
121
|
+
|
|
122
|
+
return `${contextStr} ${dim}|${reset} ${sessionStr} ${dim}|${reset} ${tail}`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a synthetic JSONL session file to Claude Code's session directory.
|
|
3
|
+
* This enables `/resume cg:{hash}` (or `/resume cg:{name}`) to load CG
|
|
4
|
+
* checkpoints as real conversation messages — not additionalContext.
|
|
5
|
+
*
|
|
6
|
+
* The synthetic session contains:
|
|
7
|
+
* Line 1: User message with the checkpoint content
|
|
8
|
+
* Line 2: Assistant message acknowledging the context
|
|
9
|
+
* Line 3: custom-title metadata entry
|
|
10
|
+
*
|
|
11
|
+
* Each compact cycle generates a unique title (`cg:{4-hex}`) to avoid
|
|
12
|
+
* CC's in-memory session caching, which prevents reuse of a static title
|
|
13
|
+
* across multiple resume cycles within the same CC process.
|
|
14
|
+
*
|
|
15
|
+
* Manifest entries carry a `type` field ("compact" or "handoff").
|
|
16
|
+
* Compact entries are ephemeral — deleted on next compact.
|
|
17
|
+
* Handoff entries persist until explicitly removed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { log } from "./logger.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Max length before hash truncation, matching CC's sessionStoragePortable.ts.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_SANITIZED_LENGTH = 200;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* djb2 string hash — matches CC's utils/hash.ts.
|
|
33
|
+
* Used as fallback when Bun.hash isn't available.
|
|
34
|
+
*/
|
|
35
|
+
function djb2Hash(str) {
|
|
36
|
+
let hash = 0;
|
|
37
|
+
for (let i = 0; i < str.length; i++) {
|
|
38
|
+
hash = Math.trunc((hash << 5) - hash + str.codePointAt(i));
|
|
39
|
+
}
|
|
40
|
+
return hash;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sanitize a cwd path to match Claude Code's project directory naming.
|
|
45
|
+
* Mirrors sanitizePath() from sessionStoragePortable.ts exactly:
|
|
46
|
+
* 1. Replace all non-alphanumeric chars with hyphens
|
|
47
|
+
* 2. If > 200 chars, truncate and append a djb2 hash suffix
|
|
48
|
+
*
|
|
49
|
+
* /home/ricky/myapp → -home-ricky-myapp
|
|
50
|
+
*/
|
|
51
|
+
function sanitizeCwd(cwd) {
|
|
52
|
+
const sanitized = cwd.replaceAll(/[^a-zA-Z0-9]/g, "-");
|
|
53
|
+
if (sanitized.length <= MAX_SANITIZED_LENGTH) {
|
|
54
|
+
return sanitized;
|
|
55
|
+
}
|
|
56
|
+
// CC uses Bun.hash (wyhash) when running in Bun, djb2Hash in Node.
|
|
57
|
+
// Hooks run as Node subprocesses, but CC itself runs in Bun — so for
|
|
58
|
+
// long paths the hashes would differ. In practice, paths > 200 chars
|
|
59
|
+
// are extremely rare. If this becomes an issue, we can read the actual
|
|
60
|
+
// directory name from disk instead of computing it.
|
|
61
|
+
const hash = Math.abs(djb2Hash(cwd)).toString(36);
|
|
62
|
+
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${hash}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the sessions directory for a project.
|
|
67
|
+
*/
|
|
68
|
+
export function getSessionsDir(projectCwd) {
|
|
69
|
+
return path.join(
|
|
70
|
+
os.homedir(),
|
|
71
|
+
".claude",
|
|
72
|
+
"projects",
|
|
73
|
+
sanitizeCwd(projectCwd),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get the manifest file path. Stored in plugin data dir alongside other state.
|
|
79
|
+
*/
|
|
80
|
+
function getManifestPath() {
|
|
81
|
+
const dataDir =
|
|
82
|
+
process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".claude", "cg");
|
|
83
|
+
return path.join(dataDir, "synthetic-sessions.json");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the manifest of tracked synthetic sessions.
|
|
88
|
+
* @returns {Record<string, { uuid: string, path: string }>}
|
|
89
|
+
*/
|
|
90
|
+
export function readManifest() {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(fs.readFileSync(getManifestPath(), "utf8"));
|
|
93
|
+
} catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write the manifest.
|
|
100
|
+
*/
|
|
101
|
+
function writeManifest(manifest) {
|
|
102
|
+
const manifestPath = getManifestPath();
|
|
103
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
104
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), {
|
|
105
|
+
mode: 0o600,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scan a sessions directory for JSONL files whose custom-title matches `title`
|
|
111
|
+
* and delete them. Skips the file tracked by the manifest (identified by
|
|
112
|
+
* `knownUuid`) since that's already handled above.
|
|
113
|
+
*
|
|
114
|
+
* This is a defensive sweep for orphaned files: pre-manifest synthetics,
|
|
115
|
+
* files CC grew by appending messages after /resume, or files left behind
|
|
116
|
+
* when the manifest was corrupted. Without this, CC's title search returns
|
|
117
|
+
* multiple matches and /resume fails with "Found N sessions matching cg".
|
|
118
|
+
*
|
|
119
|
+
* Reads only the last 200 bytes of each file (the custom-title line is always
|
|
120
|
+
* last), so this is fast even with hundreds of sessions.
|
|
121
|
+
*/
|
|
122
|
+
export function purgeStaleTitle(sessionsDir, title, knownUuid) {
|
|
123
|
+
if (!fs.existsSync(sessionsDir)) return;
|
|
124
|
+
|
|
125
|
+
const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
126
|
+
const needle = `"customTitle":"${title}"`;
|
|
127
|
+
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
// Skip the file we already handled via manifest (retitled or about to create)
|
|
130
|
+
if (knownUuid && f.startsWith(knownUuid)) continue;
|
|
131
|
+
|
|
132
|
+
const fullPath = path.join(sessionsDir, f);
|
|
133
|
+
try {
|
|
134
|
+
// Scan the full file — after /resume, CC appends real conversation
|
|
135
|
+
// data which pushes the original custom-title entries far from the
|
|
136
|
+
// tail. A tail-only read misses them, leaving stale "cg" titles that
|
|
137
|
+
// cause /resume to match the wrong session.
|
|
138
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
139
|
+
if (content.includes(needle) && content.includes('"custom-title"')) {
|
|
140
|
+
fs.unlinkSync(fullPath);
|
|
141
|
+
log(`synthetic-session: purged stale title="${title}" file=${f}`);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Skip unreadable files
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Write a synthetic JSONL session that `/resume <title>` can find and load.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} opts
|
|
153
|
+
* @param {string} opts.checkpointContent - Full checkpoint markdown text
|
|
154
|
+
* @param {string} opts.title - Session title: "cg:{hash}" or "cg:{label}"
|
|
155
|
+
* @param {"compact"|"handoff"} opts.type - Entry type for manifest cleanup
|
|
156
|
+
* @param {string} opts.projectCwd - Absolute path to project root
|
|
157
|
+
* @returns {{ sessionUuid: string, jsonlPath: string }}
|
|
158
|
+
*/
|
|
159
|
+
export function writeSyntheticSession({
|
|
160
|
+
checkpointContent,
|
|
161
|
+
title,
|
|
162
|
+
type = "compact",
|
|
163
|
+
projectCwd,
|
|
164
|
+
}) {
|
|
165
|
+
const sessionsDir = getSessionsDir(projectCwd);
|
|
166
|
+
const manifest = readManifest();
|
|
167
|
+
|
|
168
|
+
// Clean up previous compact synthetics — they're ephemeral.
|
|
169
|
+
// Each compact cycle generates a unique title (cg:{hash}), so there's no
|
|
170
|
+
// title collision with CC's in-memory cache. Safe to delete unconditionally.
|
|
171
|
+
// Handoff entries are never cleaned up by compaction.
|
|
172
|
+
if (type === "compact") {
|
|
173
|
+
for (const [key, entry] of Object.entries(manifest)) {
|
|
174
|
+
if (entry.type !== "compact") continue;
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(entry.path);
|
|
177
|
+
log(`synthetic-session: cleaned compact ${entry.uuid}`);
|
|
178
|
+
} catch {
|
|
179
|
+
// File may already be gone
|
|
180
|
+
}
|
|
181
|
+
delete manifest[key];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// For handoff titles, clean up any previous entry with the same title
|
|
186
|
+
if (type === "handoff" && manifest[title]) {
|
|
187
|
+
try {
|
|
188
|
+
fs.unlinkSync(manifest[title].path);
|
|
189
|
+
log(
|
|
190
|
+
`synthetic-session: cleaned previous handoff ${manifest[title].uuid}`,
|
|
191
|
+
);
|
|
192
|
+
} catch {
|
|
193
|
+
// File may already be gone
|
|
194
|
+
}
|
|
195
|
+
delete manifest[title];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Generate a fresh random UUID for the new synthetic session
|
|
199
|
+
const sessionUuid = crypto.randomUUID();
|
|
200
|
+
const userUuid = crypto.randomUUID();
|
|
201
|
+
const assistantUuid = crypto.randomUUID();
|
|
202
|
+
|
|
203
|
+
const now = new Date();
|
|
204
|
+
const userTimestamp = now.toISOString();
|
|
205
|
+
const assistantTimestamp = new Date(now.getTime() + 1).toISOString();
|
|
206
|
+
|
|
207
|
+
// Line 1: User message with checkpoint as plain string content
|
|
208
|
+
const userMsg = {
|
|
209
|
+
parentUuid: null,
|
|
210
|
+
isSidechain: false,
|
|
211
|
+
type: "user",
|
|
212
|
+
message: {
|
|
213
|
+
role: "user",
|
|
214
|
+
content: checkpointContent,
|
|
215
|
+
},
|
|
216
|
+
uuid: userUuid,
|
|
217
|
+
timestamp: userTimestamp,
|
|
218
|
+
userType: "external",
|
|
219
|
+
cwd: projectCwd,
|
|
220
|
+
sessionId: sessionUuid,
|
|
221
|
+
version: "1.0.0",
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Line 2: Assistant acknowledgment
|
|
225
|
+
const assistantMsg = {
|
|
226
|
+
parentUuid: userUuid,
|
|
227
|
+
isSidechain: false,
|
|
228
|
+
type: "assistant",
|
|
229
|
+
message: {
|
|
230
|
+
role: "assistant",
|
|
231
|
+
content: [
|
|
232
|
+
{
|
|
233
|
+
type: "text",
|
|
234
|
+
text: "Context restored from checkpoint. I have the full session history above including all decisions, code changes, errors, and reasoning. Ready to continue — what would you like to work on?",
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
stop_reason: "end_turn",
|
|
238
|
+
},
|
|
239
|
+
uuid: assistantUuid,
|
|
240
|
+
timestamp: assistantTimestamp,
|
|
241
|
+
userType: "external",
|
|
242
|
+
cwd: projectCwd,
|
|
243
|
+
sessionId: sessionUuid,
|
|
244
|
+
version: "1.0.0",
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Line 3: Custom title for /resume search
|
|
248
|
+
const titleEntry = {
|
|
249
|
+
type: "custom-title",
|
|
250
|
+
customTitle: title,
|
|
251
|
+
sessionId: sessionUuid,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Build JSONL content
|
|
255
|
+
const jsonlContent = `${[userMsg, assistantMsg, titleEntry]
|
|
256
|
+
.map((entry) => JSON.stringify(entry))
|
|
257
|
+
.join("\n")}\n`;
|
|
258
|
+
|
|
259
|
+
// Write to Claude Code's session directory
|
|
260
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
261
|
+
const jsonlPath = path.join(sessionsDir, `${sessionUuid}.jsonl`);
|
|
262
|
+
fs.writeFileSync(jsonlPath, jsonlContent, { mode: 0o600 });
|
|
263
|
+
|
|
264
|
+
// Update manifest with type for cleanup discrimination
|
|
265
|
+
manifest[title] = { uuid: sessionUuid, path: jsonlPath, type };
|
|
266
|
+
writeManifest(manifest);
|
|
267
|
+
|
|
268
|
+
log(
|
|
269
|
+
`synthetic-session written: title="${title}" uuid=${sessionUuid} path=${jsonlPath}`,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return { sessionUuid, jsonlPath };
|
|
273
|
+
}
|
package/lib/tokens.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { contentBytesOf, flattenContent } from "./content.mjs";
|
|
3
|
+
|
|
4
|
+
// Matches any compact/restore marker that signals a compaction boundary.
|
|
5
|
+
const COMPACT_MARKER_RE = /^\[(SMART COMPACT|KEEP RECENT|RESTORED CONTEXT)/;
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Get real token usage from the transcript JSONL.
|
|
9
|
+
//
|
|
10
|
+
// Every assistant message includes `message.usage` with:
|
|
11
|
+
// input_tokens, cache_creation_input_tokens, cache_read_input_tokens
|
|
12
|
+
//
|
|
13
|
+
// Total context used = input_tokens + cache_creation + cache_read
|
|
14
|
+
//
|
|
15
|
+
// Reads backwards from the end of the file for efficiency.
|
|
16
|
+
// Returns { current_tokens, output_tokens } or null if no usage data found.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export function getTokenUsage(transcriptPath) {
|
|
19
|
+
if (!transcriptPath) return null;
|
|
20
|
+
|
|
21
|
+
let stat;
|
|
22
|
+
try {
|
|
23
|
+
stat = fs.statSync(transcriptPath);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Tiered read: try a small chunk first (covers most cases where the last
|
|
29
|
+
// assistant message is recent and short). Fall back to 2MB for large responses.
|
|
30
|
+
const tiers = [32 * 1024, 2 * 1024 * 1024];
|
|
31
|
+
const fd = fs.openSync(transcriptPath, "r");
|
|
32
|
+
try {
|
|
33
|
+
for (const tier of tiers) {
|
|
34
|
+
const readSize = Math.min(stat.size, tier);
|
|
35
|
+
const buf = Buffer.alloc(readSize);
|
|
36
|
+
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
|
37
|
+
|
|
38
|
+
const text = buf.toString("utf8");
|
|
39
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
40
|
+
const result = _findUsage(lines);
|
|
41
|
+
if (result) return result;
|
|
42
|
+
// If file is smaller than tier, no point trying a bigger read
|
|
43
|
+
if (stat.size <= tier) return null;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
} finally {
|
|
47
|
+
fs.closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _findUsage(lines) {
|
|
52
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
53
|
+
try {
|
|
54
|
+
const obj = JSON.parse(lines[i]);
|
|
55
|
+
const usage = obj.message?.usage;
|
|
56
|
+
if (usage && typeof usage.input_tokens === "number") {
|
|
57
|
+
const inputTokens = usage.input_tokens || 0;
|
|
58
|
+
const cacheCreate = usage.cache_creation_input_tokens || 0;
|
|
59
|
+
const cacheRead = usage.cache_read_input_tokens || 0;
|
|
60
|
+
const output = usage.output_tokens || 0;
|
|
61
|
+
|
|
62
|
+
// Detect max_tokens from model name in the same message.
|
|
63
|
+
// Only Opus 4.6+ has 1M tokens. Format: "claude-opus-4-6"
|
|
64
|
+
const model = (obj.message?.model || "").toLowerCase();
|
|
65
|
+
let max_tokens = 200000; // default for all Sonnet/Haiku/older Opus
|
|
66
|
+
const opusMatch = model.match(/opus[- ]?(\d+)[- .]?(\d+)?/);
|
|
67
|
+
if (opusMatch) {
|
|
68
|
+
const major = Number.parseInt(opusMatch[1], 10);
|
|
69
|
+
const minor = Number.parseInt(opusMatch[2] || "0", 10);
|
|
70
|
+
if (major > 4 || (major === 4 && minor >= 6)) {
|
|
71
|
+
max_tokens = 1000000;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
current_tokens: inputTokens + cacheCreate + cacheRead,
|
|
77
|
+
output_tokens: output,
|
|
78
|
+
max_tokens,
|
|
79
|
+
model: obj.message?.model || "unknown",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Byte-based token estimation — fallback when no usage data is available
|
|
90
|
+
// (e.g., very first message before any assistant response).
|
|
91
|
+
// Counts content bytes after the last compact marker, divides by 4.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
export function estimateTokens(transcriptPath) {
|
|
94
|
+
if (!transcriptPath) return 0;
|
|
95
|
+
|
|
96
|
+
// Read the last ~1MB — enough to cover content since the last compact marker.
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = fs.statSync(transcriptPath);
|
|
100
|
+
} catch {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
const readSize = Math.min(stat.size, 1024 * 1024);
|
|
104
|
+
const buf = Buffer.alloc(readSize);
|
|
105
|
+
const fd = fs.openSync(transcriptPath, "r");
|
|
106
|
+
try {
|
|
107
|
+
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
|
108
|
+
} finally {
|
|
109
|
+
fs.closeSync(fd);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lines = buf
|
|
113
|
+
.toString("utf8")
|
|
114
|
+
.split("\n")
|
|
115
|
+
.filter((l) => l.trim());
|
|
116
|
+
|
|
117
|
+
let startIdx = 0;
|
|
118
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
119
|
+
try {
|
|
120
|
+
const obj = JSON.parse(lines[i]);
|
|
121
|
+
const text = flattenContent(obj.message?.content);
|
|
122
|
+
if (
|
|
123
|
+
COMPACT_MARKER_RE.test(text) ||
|
|
124
|
+
text.startsWith("# Context Checkpoint")
|
|
125
|
+
) {
|
|
126
|
+
startIdx = i;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let bytes = 0;
|
|
133
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
134
|
+
try {
|
|
135
|
+
bytes += contentBytesOf(JSON.parse(lines[i]).message?.content);
|
|
136
|
+
} catch {}
|
|
137
|
+
}
|
|
138
|
+
return Math.round(bytes / 4);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Session overhead estimation — tokens from system prompt, tool definitions,
|
|
143
|
+
// memory files, and skills that survive compaction unchanged.
|
|
144
|
+
// Uses transcript file size / 4 to estimate conversation tokens, then
|
|
145
|
+
// subtracts from real token count. Both estimate.mjs and checkpoint.mjs
|
|
146
|
+
// use this for consistent predictions.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
export function estimateOverhead(
|
|
149
|
+
currentTokens,
|
|
150
|
+
transcriptPath,
|
|
151
|
+
baselineOverhead = 0,
|
|
152
|
+
) {
|
|
153
|
+
// If we have a measured baseline from the first response, use it.
|
|
154
|
+
// This was captured by the stop hook when context was almost entirely
|
|
155
|
+
// system prompts, CLAUDE.md, tool definitions, etc.
|
|
156
|
+
if (baselineOverhead > 0) return baselineOverhead;
|
|
157
|
+
|
|
158
|
+
// Fallback: conservative minimum. System prompts + CLAUDE.md + tool
|
|
159
|
+
// definitions are always present and can never be compacted away.
|
|
160
|
+
const MIN_OVERHEAD = 15_000;
|
|
161
|
+
|
|
162
|
+
if (!transcriptPath || !currentTokens) return MIN_OVERHEAD;
|
|
163
|
+
try {
|
|
164
|
+
const conversationTokens = Math.round(fs.statSync(transcriptPath).size / 4);
|
|
165
|
+
const computed = Math.max(0, currentTokens - conversationTokens);
|
|
166
|
+
return Math.max(MIN_OVERHEAD, computed);
|
|
167
|
+
} catch {
|
|
168
|
+
return MIN_OVERHEAD;
|
|
169
|
+
}
|
|
170
|
+
}
|