@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/biome.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"ignoreUnknown": false
|
|
10
|
+
},
|
|
11
|
+
"formatter": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"indentStyle": "tab"
|
|
14
|
+
},
|
|
15
|
+
"linter": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"rules": {
|
|
18
|
+
"recommended": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"javascript": {
|
|
22
|
+
"formatter": {
|
|
23
|
+
"quoteStyle": "double"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"assist": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"actions": {
|
|
29
|
+
"source": {
|
|
30
|
+
"organizeImports": "on"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/bun.lock
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "context-guardian",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@biomejs/biome": "^2.4.10",
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
"packages": {
|
|
13
|
+
"@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
|
|
14
|
+
|
|
15
|
+
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
|
|
16
|
+
|
|
17
|
+
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="],
|
|
18
|
+
|
|
19
|
+
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="],
|
|
20
|
+
|
|
21
|
+
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="],
|
|
22
|
+
|
|
23
|
+
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="],
|
|
24
|
+
|
|
25
|
+
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="],
|
|
26
|
+
|
|
27
|
+
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="],
|
|
28
|
+
|
|
29
|
+
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreCompact hook — safety net for native auto-compaction.
|
|
4
|
+
*
|
|
5
|
+
* When Claude Code's built-in compaction fires (auto or manual /compact),
|
|
6
|
+
* this hook runs CG's deterministic extraction and injects it as
|
|
7
|
+
* additionalContext. The native compaction model then works with pre-cleaned
|
|
8
|
+
* input, producing a better summary than it would from the raw transcript.
|
|
9
|
+
*
|
|
10
|
+
* This is a silent safety net — no user-facing output.
|
|
11
|
+
*
|
|
12
|
+
* @module precompact-hook
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { log } from "../lib/logger.mjs";
|
|
16
|
+
import { extractConversation } from "../lib/transcript.mjs";
|
|
17
|
+
|
|
18
|
+
let input;
|
|
19
|
+
try {
|
|
20
|
+
input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
21
|
+
} catch (e) {
|
|
22
|
+
process.stderr.write(`cg: precompact parse error: ${e.message}\n`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { session_id = "unknown", transcript_path, trigger = "unknown" } = input;
|
|
27
|
+
log(`PRECOMPACT session=${session_id} trigger=${trigger}`);
|
|
28
|
+
|
|
29
|
+
if (!transcript_path || !fs.existsSync(transcript_path)) {
|
|
30
|
+
log(`precompact-skip: no transcript`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const extraction = extractConversation(transcript_path);
|
|
36
|
+
|
|
37
|
+
if (!extraction || extraction === "(no transcript available)") {
|
|
38
|
+
log(`precompact-skip: empty extraction`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Inject the extraction as context for the compaction model.
|
|
43
|
+
// Limit to 50K chars to avoid overwhelming the compaction prompt.
|
|
44
|
+
const MAX_INJECT = 50000;
|
|
45
|
+
const trimmed =
|
|
46
|
+
extraction.length > MAX_INJECT
|
|
47
|
+
? `${extraction.slice(0, MAX_INJECT)}\n\n[...extraction truncated at ${MAX_INJECT} chars for compaction input...]`
|
|
48
|
+
: extraction;
|
|
49
|
+
|
|
50
|
+
const output = {
|
|
51
|
+
hookSpecificOutput: {
|
|
52
|
+
hookEventName: "PreCompact",
|
|
53
|
+
additionalContext: [
|
|
54
|
+
"[Context Guardian — Pre-Compaction Extraction]",
|
|
55
|
+
"The following is a high-fidelity deterministic extraction of the conversation.",
|
|
56
|
+
"Tool outputs that can be re-obtained (file reads, search results) have been stripped.",
|
|
57
|
+
"All user messages, assistant reasoning, code changes, and command outputs are preserved.",
|
|
58
|
+
"Use this extraction as the primary input for your compaction. It is already noise-reduced — prefer keeping its content over re-summarizing.",
|
|
59
|
+
"",
|
|
60
|
+
trimmed,
|
|
61
|
+
].join("\n"),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
process.stdout.write(JSON.stringify(output));
|
|
66
|
+
log(
|
|
67
|
+
`precompact-injected session=${session_id} chars=${extraction.length} injected=${trimmed.length}`,
|
|
68
|
+
);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
log(`precompact-error: ${e.message}`);
|
|
71
|
+
// Fail silently — don't block compaction
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { log } from "../lib/logger.mjs";
|
|
6
|
+
import { atomicWriteFileSync, resolveDataDir } from "../lib/paths.mjs";
|
|
7
|
+
|
|
8
|
+
let input;
|
|
9
|
+
try {
|
|
10
|
+
input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
11
|
+
} catch (e) {
|
|
12
|
+
process.stderr.write(`cg: failed to parse stdin: ${e.message}\n`);
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STALE_MS = 30 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
// Clean up stale session-scoped state files (state-*.json) in data dir.
|
|
19
|
+
// Each session writes its own state file; old ones accumulate.
|
|
20
|
+
const dataDir = resolveDataDir();
|
|
21
|
+
if (fs.existsSync(dataDir)) {
|
|
22
|
+
try {
|
|
23
|
+
const now3 = Date.now();
|
|
24
|
+
for (const f of fs
|
|
25
|
+
.readdirSync(dataDir)
|
|
26
|
+
.filter((f) => f.startsWith("state-") && f.endsWith(".json"))) {
|
|
27
|
+
const filePath = path.join(dataDir, f);
|
|
28
|
+
try {
|
|
29
|
+
if (now3 - fs.statSync(filePath).mtimeMs > STALE_MS) {
|
|
30
|
+
fs.unlinkSync(filePath);
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Compact synthetics use unique titles (cg:{hash}) per cycle, so stale-title
|
|
39
|
+
// collisions are no longer possible. No defensive purge needed at startup.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Self-healing: if the marketplace repo dir is missing, background-clone it.
|
|
44
|
+
// Claude Code resolves CLAUDE_PLUGIN_ROOT from the marketplace location for
|
|
45
|
+
// some hooks; if that dir doesn't exist, hooks fail with
|
|
46
|
+
// "Plugin directory does not exist". Fire-and-forget so we don't block startup.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
try {
|
|
49
|
+
const knownPath = path.join(
|
|
50
|
+
os.homedir(),
|
|
51
|
+
".claude",
|
|
52
|
+
"plugins",
|
|
53
|
+
"known_marketplaces.json",
|
|
54
|
+
);
|
|
55
|
+
if (fs.existsSync(knownPath)) {
|
|
56
|
+
const known = JSON.parse(fs.readFileSync(knownPath, "utf8"));
|
|
57
|
+
const entry = known["context-guardian"];
|
|
58
|
+
if (entry?.installLocation && !fs.existsSync(entry.installLocation)) {
|
|
59
|
+
const url =
|
|
60
|
+
entry.source?.url ||
|
|
61
|
+
(entry.source?.repo
|
|
62
|
+
? `https://github.com/${entry.source.repo}.git`
|
|
63
|
+
: null);
|
|
64
|
+
if (url?.startsWith("https://")) {
|
|
65
|
+
log(
|
|
66
|
+
`self-heal: marketplace dir missing at ${entry.installLocation}, cloning from ${url}`,
|
|
67
|
+
);
|
|
68
|
+
const { spawn } = await import("node:child_process");
|
|
69
|
+
const child = spawn(
|
|
70
|
+
"git",
|
|
71
|
+
["clone", "--depth", "1", url, entry.installLocation],
|
|
72
|
+
{ stdio: "ignore", detached: true },
|
|
73
|
+
);
|
|
74
|
+
child.on("error", (e) => log(`self-heal-clone-error: ${e.message}`));
|
|
75
|
+
child.unref();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
log(`self-heal-error: ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Statusline dominance — the statusline is CG's primary UX for context
|
|
85
|
+
// pressure. We ensure it's always configured and reclaim it if overwritten.
|
|
86
|
+
// Takes effect next session (Claude Code reads settings at startup, before hooks).
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
let statuslineReclaimed = false;
|
|
89
|
+
try {
|
|
90
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
91
|
+
let settings = {};
|
|
92
|
+
if (fs.existsSync(settingsPath)) {
|
|
93
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
94
|
+
}
|
|
95
|
+
const pluginRoot =
|
|
96
|
+
process.env.CLAUDE_PLUGIN_ROOT || path.resolve(import.meta.dirname, "..");
|
|
97
|
+
const statuslineCmd = `node ${pluginRoot}/lib/statusline.mjs`;
|
|
98
|
+
const isCG = settings.statusLine?.command?.includes("statusline.mjs");
|
|
99
|
+
|
|
100
|
+
if (!settings.statusLine) {
|
|
101
|
+
// No statusline configured — set ours
|
|
102
|
+
settings.statusLine = { type: "command", command: statuslineCmd };
|
|
103
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
104
|
+
atomicWriteFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
105
|
+
log("auto-configured statusline in settings.json");
|
|
106
|
+
} else if (!isCG) {
|
|
107
|
+
// Another statusline is configured — reclaim it for CG
|
|
108
|
+
const prev = settings.statusLine.command || "(unknown)";
|
|
109
|
+
settings.statusLine = { type: "command", command: statuslineCmd };
|
|
110
|
+
atomicWriteFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
111
|
+
log(`statusline-reclaimed: overwriting "${prev}" with CG statusline`);
|
|
112
|
+
statuslineReclaimed = true;
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
log(`statusline-autoconfig-error: ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
log(
|
|
119
|
+
`session-start session=${input.session_id || "unknown"} cwd=${input.cwd || "unknown"}`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Warn user if statusline was reclaimed from another tool
|
|
123
|
+
if (statuslineReclaimed) {
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
hookSpecificOutput: {
|
|
127
|
+
hookEventName: "SessionStart",
|
|
128
|
+
additionalContext:
|
|
129
|
+
"[Context Guardian] Statusline reclaimed — another tool had overwritten it. Takes effect next session.",
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
package/hooks/stop.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { loadConfig, resolveMaxTokens } from "../lib/config.mjs";
|
|
4
|
+
import { estimateSavings } from "../lib/estimate.mjs";
|
|
5
|
+
import { log } from "../lib/logger.mjs";
|
|
6
|
+
import {
|
|
7
|
+
atomicWriteFileSync,
|
|
8
|
+
ensureDataDir,
|
|
9
|
+
STATUSLINE_STATE_DIR,
|
|
10
|
+
stateFile,
|
|
11
|
+
statuslineStateFile,
|
|
12
|
+
} from "../lib/paths.mjs";
|
|
13
|
+
import { estimateTokens, getTokenUsage } from "../lib/tokens.mjs";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Stop hook — writes fresh token counts after each assistant response.
|
|
17
|
+
//
|
|
18
|
+
// PERFORMANCE: Does NOT call estimateSavings (which reads the full transcript).
|
|
19
|
+
// The submit hook already computed and saved savings estimates. This hook only
|
|
20
|
+
// updates the token counts (cheap — tail-reads 32KB) and carries forward the
|
|
21
|
+
// existing savings estimates from the state file.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
let input;
|
|
24
|
+
try {
|
|
25
|
+
input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
26
|
+
} catch (e) {
|
|
27
|
+
process.stderr.write(`cg: failed to parse stdin: ${e.message}\n`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { session_id = "unknown", transcript_path } = input;
|
|
32
|
+
log(`STOP session=${session_id}`);
|
|
33
|
+
|
|
34
|
+
if (!transcript_path || !fs.existsSync(transcript_path)) process.exit(0);
|
|
35
|
+
|
|
36
|
+
// Measure raw transcript file size — proxy for API request payload size.
|
|
37
|
+
let payloadBytes = 0;
|
|
38
|
+
try {
|
|
39
|
+
payloadBytes = fs.statSync(transcript_path).size;
|
|
40
|
+
} catch {}
|
|
41
|
+
|
|
42
|
+
const cfg = loadConfig();
|
|
43
|
+
const threshold = cfg.threshold ?? 0.35;
|
|
44
|
+
|
|
45
|
+
const realUsage = getTokenUsage(transcript_path);
|
|
46
|
+
const currentTokens = realUsage
|
|
47
|
+
? realUsage.current_tokens
|
|
48
|
+
: estimateTokens(transcript_path);
|
|
49
|
+
const maxTokens = realUsage?.max_tokens || resolveMaxTokens() || 200000;
|
|
50
|
+
const pct = currentTokens / maxTokens;
|
|
51
|
+
const source = realUsage ? "real" : "estimated";
|
|
52
|
+
|
|
53
|
+
const headroom = Math.max(0, Math.round(maxTokens * threshold - currentTokens));
|
|
54
|
+
const pctDisplay = (pct * 100).toFixed(1);
|
|
55
|
+
const thresholdDisplay = Math.round(threshold * 100);
|
|
56
|
+
let recommendation;
|
|
57
|
+
if (pct < threshold * 0.5)
|
|
58
|
+
recommendation = "All clear. Plenty of context remaining.";
|
|
59
|
+
else if (pct < threshold)
|
|
60
|
+
recommendation = "Approaching threshold. Consider wrapping up complex tasks.";
|
|
61
|
+
else
|
|
62
|
+
recommendation =
|
|
63
|
+
"At threshold. Compaction recommended — run /cg:compact or /cg:prune.";
|
|
64
|
+
|
|
65
|
+
// Don't overwrite a recent state file with estimated data — checkpoint writes
|
|
66
|
+
// or the submit hook may have written accurate post-compaction counts that we'd clobber.
|
|
67
|
+
if (source === "estimated") {
|
|
68
|
+
const sf = stateFile(session_id);
|
|
69
|
+
try {
|
|
70
|
+
const sfStat = fs.statSync(sf);
|
|
71
|
+
if (Date.now() - sfStat.mtimeMs < 30000) {
|
|
72
|
+
log(
|
|
73
|
+
`state-skip session=${session_id} — not overwriting recent state with estimate`,
|
|
74
|
+
);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Carry forward savings estimates and baseline overhead from the existing state file.
|
|
81
|
+
// This avoids re-reading and re-parsing the full transcript (~50MB at scale).
|
|
82
|
+
let smartEstimatePct = 0;
|
|
83
|
+
let recentEstimatePct = 0;
|
|
84
|
+
let baselineOverhead = 0;
|
|
85
|
+
let baselineResponseCount = 0;
|
|
86
|
+
try {
|
|
87
|
+
const sf = stateFile(session_id);
|
|
88
|
+
if (fs.existsSync(sf)) {
|
|
89
|
+
const prev = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
90
|
+
smartEstimatePct = prev.smart_estimate_pct ?? 0;
|
|
91
|
+
recentEstimatePct = prev.recent_estimate_pct ?? 0;
|
|
92
|
+
baselineOverhead = prev.baseline_overhead ?? 0;
|
|
93
|
+
baselineResponseCount = prev.baseline_response_count ?? 0;
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
log(`state-read-error session=${session_id}: ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (baselineResponseCount < 2 && currentTokens > 0) {
|
|
100
|
+
if (baselineOverhead) {
|
|
101
|
+
baselineOverhead = Math.min(baselineOverhead, currentTokens);
|
|
102
|
+
} else {
|
|
103
|
+
baselineOverhead = currentTokens;
|
|
104
|
+
}
|
|
105
|
+
baselineResponseCount++;
|
|
106
|
+
log(
|
|
107
|
+
`baseline-overhead session=${session_id} tokens=${baselineOverhead} response=${baselineResponseCount}`,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Recompute estimates now that we have the baseline — the submit hook ran
|
|
111
|
+
// before us and wrote 0 estimates because it didn't have the baseline yet.
|
|
112
|
+
try {
|
|
113
|
+
const savings = estimateSavings(
|
|
114
|
+
transcript_path,
|
|
115
|
+
currentTokens,
|
|
116
|
+
maxTokens,
|
|
117
|
+
baselineOverhead,
|
|
118
|
+
);
|
|
119
|
+
smartEstimatePct = savings.smartPct;
|
|
120
|
+
recentEstimatePct = savings.recentPct;
|
|
121
|
+
log(
|
|
122
|
+
`baseline-recompute session=${session_id} smart=${smartEstimatePct}% recent=${recentEstimatePct}%`,
|
|
123
|
+
);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
log(`baseline-recompute-error: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
ensureDataDir();
|
|
131
|
+
const remaining = Math.max(
|
|
132
|
+
0,
|
|
133
|
+
Math.round(thresholdDisplay - Number.parseFloat(pctDisplay)),
|
|
134
|
+
);
|
|
135
|
+
const stateJson = JSON.stringify({
|
|
136
|
+
current_tokens: currentTokens,
|
|
137
|
+
max_tokens: maxTokens,
|
|
138
|
+
pct,
|
|
139
|
+
pct_display: pctDisplay,
|
|
140
|
+
threshold,
|
|
141
|
+
threshold_display: thresholdDisplay,
|
|
142
|
+
remaining_to_alert: remaining,
|
|
143
|
+
headroom,
|
|
144
|
+
recommendation,
|
|
145
|
+
source,
|
|
146
|
+
model: realUsage?.model || "unknown",
|
|
147
|
+
smart_estimate_pct: smartEstimatePct,
|
|
148
|
+
recent_estimate_pct: recentEstimatePct,
|
|
149
|
+
baseline_overhead: baselineOverhead,
|
|
150
|
+
baseline_response_count: baselineResponseCount,
|
|
151
|
+
payload_bytes: payloadBytes,
|
|
152
|
+
session_id,
|
|
153
|
+
transcript_path,
|
|
154
|
+
ts: Date.now(),
|
|
155
|
+
});
|
|
156
|
+
atomicWriteFileSync(stateFile(session_id), stateJson);
|
|
157
|
+
|
|
158
|
+
// Also write to fixed fallback location so the statusline can find it
|
|
159
|
+
// (statusline process doesn't receive CLAUDE_PLUGIN_DATA).
|
|
160
|
+
const slFile = statuslineStateFile(session_id);
|
|
161
|
+
if (slFile !== stateFile(session_id)) {
|
|
162
|
+
fs.mkdirSync(STATUSLINE_STATE_DIR, { recursive: true });
|
|
163
|
+
atomicWriteFileSync(slFile, stateJson);
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
log(`state-write-error session=${session_id}: ${e.message}`);
|
|
167
|
+
process.stderr.write(`cg: state-write-error: ${e.message}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
log(
|
|
171
|
+
`state-update session=${session_id} tokens=${currentTokens}/${maxTokens} pct=${pctDisplay}% source=${source}`,
|
|
172
|
+
);
|
package/hooks/submit.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* UserPromptSubmit hook — Context Guardian's main entry point.
|
|
4
|
+
*
|
|
5
|
+
* Runs on every user message. Writes token usage state for the statusline
|
|
6
|
+
* and /cg:stats. Compaction is handled entirely by skills (compact-cli.mjs).
|
|
7
|
+
*
|
|
8
|
+
* @module submit-hook
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import { loadConfig, resolveMaxTokens } from "../lib/config.mjs";
|
|
12
|
+
import { estimateSavings } from "../lib/estimate.mjs";
|
|
13
|
+
import { log } from "../lib/logger.mjs";
|
|
14
|
+
import {
|
|
15
|
+
atomicWriteFileSync,
|
|
16
|
+
ensureDataDir,
|
|
17
|
+
STATUSLINE_STATE_DIR,
|
|
18
|
+
stateFile,
|
|
19
|
+
statuslineStateFile,
|
|
20
|
+
} from "../lib/paths.mjs";
|
|
21
|
+
import { estimateTokens, getTokenUsage } from "../lib/tokens.mjs";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Input
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
let input;
|
|
27
|
+
try {
|
|
28
|
+
input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
process.stderr.write(`cg: failed to parse stdin: ${e.message}\n`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
const { session_id = "unknown", transcript_path } = input;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Token usage check — write state for statusline and /cg:stats
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
if (!transcript_path || !fs.existsSync(transcript_path)) process.exit(0);
|
|
39
|
+
|
|
40
|
+
// Measure raw transcript file size — proxy for API request payload size.
|
|
41
|
+
// The ~20MB API payload limit is separate from the token context window and
|
|
42
|
+
// can lock users out of a session entirely (can't even compact).
|
|
43
|
+
let payloadBytes = 0;
|
|
44
|
+
try {
|
|
45
|
+
payloadBytes = fs.statSync(transcript_path).size;
|
|
46
|
+
} catch {}
|
|
47
|
+
|
|
48
|
+
const cfg = loadConfig();
|
|
49
|
+
const threshold = cfg.threshold ?? 0.35;
|
|
50
|
+
|
|
51
|
+
const realUsage = getTokenUsage(transcript_path);
|
|
52
|
+
const currentTokens = realUsage
|
|
53
|
+
? realUsage.current_tokens
|
|
54
|
+
: estimateTokens(transcript_path);
|
|
55
|
+
const maxTokens = realUsage?.max_tokens || resolveMaxTokens() || 200000;
|
|
56
|
+
const pct = currentTokens / maxTokens;
|
|
57
|
+
const source = realUsage ? "real" : "estimated";
|
|
58
|
+
|
|
59
|
+
log(
|
|
60
|
+
`check session=${session_id} tokens=${currentTokens}/${maxTokens} pct=${(pct * 100).toFixed(1)}% threshold=${(threshold * 100).toFixed(0)}% source=${source}`,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Write state for statusline and /cg:stats
|
|
64
|
+
const headroom = Math.max(0, Math.round(maxTokens * threshold - currentTokens));
|
|
65
|
+
const pctDisplay = (pct * 100).toFixed(1);
|
|
66
|
+
const thresholdDisplay = Math.round(threshold * 100);
|
|
67
|
+
let recommendation;
|
|
68
|
+
if (pct < threshold * 0.5)
|
|
69
|
+
recommendation = "All clear. Plenty of context remaining.";
|
|
70
|
+
else if (pct < threshold)
|
|
71
|
+
recommendation = "Approaching threshold. Consider wrapping up complex tasks.";
|
|
72
|
+
else
|
|
73
|
+
recommendation =
|
|
74
|
+
"At threshold. Compaction recommended — run /cg:compact or /cg:prune.";
|
|
75
|
+
|
|
76
|
+
// Read measured baseline overhead from state (captured by stop hook on first response)
|
|
77
|
+
let baselineOverhead = 0;
|
|
78
|
+
try {
|
|
79
|
+
const sf = stateFile(session_id);
|
|
80
|
+
if (fs.existsSync(sf)) {
|
|
81
|
+
const prev = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
82
|
+
baselineOverhead = prev.baseline_overhead ?? 0;
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
log(`state-read-error session=${session_id}: ${e.message}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const savings = estimateSavings(
|
|
89
|
+
transcript_path,
|
|
90
|
+
currentTokens,
|
|
91
|
+
maxTokens,
|
|
92
|
+
baselineOverhead,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
ensureDataDir();
|
|
97
|
+
const remaining = Math.max(
|
|
98
|
+
0,
|
|
99
|
+
Math.round(thresholdDisplay - Number.parseFloat(pctDisplay)),
|
|
100
|
+
);
|
|
101
|
+
const stateJson = JSON.stringify({
|
|
102
|
+
current_tokens: currentTokens,
|
|
103
|
+
max_tokens: maxTokens,
|
|
104
|
+
pct,
|
|
105
|
+
pct_display: pctDisplay,
|
|
106
|
+
threshold,
|
|
107
|
+
threshold_display: thresholdDisplay,
|
|
108
|
+
remaining_to_alert: remaining,
|
|
109
|
+
headroom,
|
|
110
|
+
recommendation,
|
|
111
|
+
source,
|
|
112
|
+
model: realUsage?.model || "unknown",
|
|
113
|
+
smart_estimate_pct: savings.smartPct,
|
|
114
|
+
recent_estimate_pct: savings.recentPct,
|
|
115
|
+
baseline_overhead: baselineOverhead,
|
|
116
|
+
payload_bytes: payloadBytes,
|
|
117
|
+
session_id,
|
|
118
|
+
transcript_path,
|
|
119
|
+
ts: Date.now(),
|
|
120
|
+
});
|
|
121
|
+
atomicWriteFileSync(stateFile(session_id), stateJson);
|
|
122
|
+
|
|
123
|
+
// Also write to fixed fallback location so the statusline can find it
|
|
124
|
+
// (statusline process doesn't receive CLAUDE_PLUGIN_DATA).
|
|
125
|
+
const slFile = statuslineStateFile(session_id);
|
|
126
|
+
if (slFile !== stateFile(session_id)) {
|
|
127
|
+
fs.mkdirSync(STATUSLINE_STATE_DIR, { recursive: true });
|
|
128
|
+
atomicWriteFileSync(slFile, stateJson);
|
|
129
|
+
}
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(`state-write-error session=${session_id}: ${e.message}`);
|
|
132
|
+
process.stderr.write(`cg: state-write-error: ${e.message}\n`);
|
|
133
|
+
}
|