token-pilot 0.19.2 → 0.23.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 (96) hide show
  1. package/.claude-plugin/hooks/hooks.json +30 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +165 -0
  4. package/README.md +194 -313
  5. package/dist/agents/tp-audit-scanner.md +49 -0
  6. package/dist/agents/tp-commit-writer.md +41 -0
  7. package/dist/agents/tp-dead-code-finder.md +43 -0
  8. package/dist/agents/tp-debugger.md +45 -0
  9. package/dist/agents/tp-history-explorer.md +43 -0
  10. package/dist/agents/tp-impact-analyzer.md +44 -0
  11. package/dist/agents/tp-migration-scout.md +43 -0
  12. package/dist/agents/tp-onboard.md +40 -0
  13. package/dist/agents/tp-pr-reviewer.md +41 -0
  14. package/dist/agents/tp-refactor-planner.md +42 -0
  15. package/dist/agents/tp-run.md +48 -0
  16. package/dist/agents/tp-session-restorer.md +47 -0
  17. package/dist/agents/tp-test-triage.md +40 -0
  18. package/dist/agents/tp-test-writer.md +46 -0
  19. package/dist/cli/agent-frontmatter.d.ts +48 -0
  20. package/dist/cli/agent-frontmatter.js +189 -0
  21. package/dist/cli/bless-agents.d.ts +65 -0
  22. package/dist/cli/bless-agents.js +307 -0
  23. package/dist/cli/claudeignore.d.ts +33 -0
  24. package/dist/cli/claudeignore.js +88 -0
  25. package/dist/cli/claudemd-hygiene.d.ts +26 -0
  26. package/dist/cli/claudemd-hygiene.js +43 -0
  27. package/dist/cli/doctor-drift.d.ts +31 -0
  28. package/dist/cli/doctor-drift.js +130 -0
  29. package/dist/cli/doctor-env-check.d.ts +25 -0
  30. package/dist/cli/doctor-env-check.js +91 -0
  31. package/dist/cli/install-agents.d.ts +108 -0
  32. package/dist/cli/install-agents.js +402 -0
  33. package/dist/cli/save-doc.d.ts +42 -0
  34. package/dist/cli/save-doc.js +145 -0
  35. package/dist/cli/scan-agents.d.ts +46 -0
  36. package/dist/cli/scan-agents.js +227 -0
  37. package/dist/cli/stats.d.ts +36 -0
  38. package/dist/cli/stats.js +131 -0
  39. package/dist/cli/typo-guard.d.ts +27 -0
  40. package/dist/cli/typo-guard.js +119 -0
  41. package/dist/cli/unbless-agents.d.ts +33 -0
  42. package/dist/cli/unbless-agents.js +85 -0
  43. package/dist/cli/uninstall-agents.d.ts +36 -0
  44. package/dist/cli/uninstall-agents.js +117 -0
  45. package/dist/config/defaults.d.ts +1 -1
  46. package/dist/config/defaults.js +14 -8
  47. package/dist/config/loader.d.ts +1 -1
  48. package/dist/config/loader.js +105 -11
  49. package/dist/core/context-registry.d.ts +16 -1
  50. package/dist/core/context-registry.js +60 -28
  51. package/dist/core/event-log.d.ts +79 -0
  52. package/dist/core/event-log.js +190 -0
  53. package/dist/core/session-registry.d.ts +43 -0
  54. package/dist/core/session-registry.js +113 -0
  55. package/dist/core/session-savings.d.ts +19 -0
  56. package/dist/core/session-savings.js +60 -0
  57. package/dist/handlers/session-budget.d.ts +32 -0
  58. package/dist/handlers/session-budget.js +61 -0
  59. package/dist/handlers/session-snapshot-persist.d.ts +22 -0
  60. package/dist/handlers/session-snapshot-persist.js +76 -0
  61. package/dist/hooks/adaptive-threshold.d.ts +27 -0
  62. package/dist/hooks/adaptive-threshold.js +46 -0
  63. package/dist/hooks/format-deny-message.d.ts +21 -0
  64. package/dist/hooks/format-deny-message.js +147 -0
  65. package/dist/hooks/installer.js +130 -31
  66. package/dist/hooks/path-safety.d.ts +16 -0
  67. package/dist/hooks/path-safety.js +34 -0
  68. package/dist/hooks/post-bash.d.ts +46 -0
  69. package/dist/hooks/post-bash.js +77 -0
  70. package/dist/hooks/post-task.d.ts +67 -0
  71. package/dist/hooks/post-task.js +136 -0
  72. package/dist/hooks/session-start.d.ts +45 -0
  73. package/dist/hooks/session-start.js +179 -0
  74. package/dist/hooks/summary-ast-index.d.ts +28 -0
  75. package/dist/hooks/summary-ast-index.js +122 -0
  76. package/dist/hooks/summary-head-tail.d.ts +15 -0
  77. package/dist/hooks/summary-head-tail.js +78 -0
  78. package/dist/hooks/summary-pipeline.d.ts +35 -0
  79. package/dist/hooks/summary-pipeline.js +63 -0
  80. package/dist/hooks/summary-regex.d.ts +14 -0
  81. package/dist/hooks/summary-regex.js +130 -0
  82. package/dist/hooks/summary-types.d.ts +29 -0
  83. package/dist/hooks/summary-types.js +9 -0
  84. package/dist/index.d.ts +15 -3
  85. package/dist/index.js +538 -149
  86. package/dist/integration/context-mode-detector.d.ts +7 -1
  87. package/dist/integration/context-mode-detector.js +51 -15
  88. package/dist/server/tool-definitions.d.ts +149 -0
  89. package/dist/server/tool-definitions.js +424 -202
  90. package/dist/server.d.ts +1 -1
  91. package/dist/server.js +456 -179
  92. package/dist/templates/agent-builder.d.ts +49 -0
  93. package/dist/templates/agent-builder.js +104 -0
  94. package/dist/types.d.ts +38 -4
  95. package/package.json +4 -2
  96. package/skills/stats/SKILL.md +13 -2
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Phase 6 subtasks 6.1 + 6.2 — hook-events JSONL log.
3
+ *
4
+ * Writes to `<projectRoot>/.token-pilot/hook-events.jsonl` with the
5
+ * schema specified in TP-c2a acceptance:
6
+ *
7
+ * { ts, session_id, agent_type, agent_id, event, file, lines,
8
+ * estTokens, summaryTokens, savedTokens }
9
+ *
10
+ * Rotation: when the current file grows past `ROTATION_THRESHOLD_BYTES`
11
+ * (10 MB), it is renamed to `hook-events.<unix-ms>.jsonl` and a new
12
+ * empty file begins.
13
+ *
14
+ * Retention: `applyRetention` deletes rotated files older than
15
+ * `RETENTION_MAX_AGE_DAYS` (30 days) and trims the directory down to
16
+ * `RETENTION_MAX_TOTAL_BYTES` (100 MB) by removing the oldest archives
17
+ * first. The current file is never deleted.
18
+ *
19
+ * Legacy coexistence: the old `.token-pilot/hook-denied.jsonl` is left
20
+ * in place — this module writes only to the new file.
21
+ */
22
+ import { promises as fs } from "node:fs";
23
+ import { join } from "node:path";
24
+ export const ROTATION_THRESHOLD_BYTES = 10_000_000;
25
+ export const RETENTION_MAX_AGE_DAYS = 30;
26
+ export const RETENTION_MAX_TOTAL_BYTES = 100_000_000;
27
+ const CURRENT_FILE = "hook-events.jsonl";
28
+ const ARCHIVE_RE = /^hook-events\.\d+\.jsonl$/;
29
+ export function eventLogDir(projectRoot) {
30
+ return join(projectRoot, ".token-pilot");
31
+ }
32
+ export function currentLogPath(projectRoot) {
33
+ return join(eventLogDir(projectRoot), CURRENT_FILE);
34
+ }
35
+ // ─── pure: rotation predicate ───────────────────────────────────────────────
36
+ /**
37
+ * Decide whether the current log file has grown past the rotation
38
+ * threshold and should be archived before the next append.
39
+ */
40
+ export function shouldRotate(stat, thresholdBytes = ROTATION_THRESHOLD_BYTES) {
41
+ return stat.size >= thresholdBytes;
42
+ }
43
+ // ─── pure: retention policy ─────────────────────────────────────────────────
44
+ /**
45
+ * Given the full list of archive files with their mtime + size, return
46
+ * the subset whose paths should be deleted to satisfy:
47
+ * (a) maxAgeDays — delete anything older
48
+ * (b) maxTotalBytes — delete oldest first until total fits
49
+ *
50
+ * `now` is passed in to keep the function deterministic for tests.
51
+ */
52
+ export function retentionDeletions(files, now, maxAgeDays = RETENTION_MAX_AGE_DAYS, maxTotalBytes = RETENTION_MAX_TOTAL_BYTES) {
53
+ const toDelete = new Set();
54
+ const maxAgeMs = maxAgeDays * 86_400_000;
55
+ // (a) age-based
56
+ const survivors = [];
57
+ for (const f of files) {
58
+ if (now.getTime() - f.mtime.getTime() > maxAgeMs) {
59
+ toDelete.add(f.path);
60
+ }
61
+ else {
62
+ survivors.push(f);
63
+ }
64
+ }
65
+ // (b) size-based — delete oldest first from survivors until cap is met
66
+ const totalSize = survivors.reduce((sum, f) => sum + f.size, 0);
67
+ if (totalSize > maxTotalBytes) {
68
+ const byOldest = [...survivors].sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
69
+ let trimmed = totalSize;
70
+ for (const f of byOldest) {
71
+ if (trimmed <= maxTotalBytes)
72
+ break;
73
+ toDelete.add(f.path);
74
+ trimmed -= f.size;
75
+ }
76
+ }
77
+ return [...toDelete];
78
+ }
79
+ // ─── FS wrappers ────────────────────────────────────────────────────────────
80
+ async function ensureLogDir(projectRoot) {
81
+ await fs.mkdir(eventLogDir(projectRoot), { recursive: true });
82
+ }
83
+ async function rotateIfNeeded(projectRoot, thresholdBytes = ROTATION_THRESHOLD_BYTES) {
84
+ const current = currentLogPath(projectRoot);
85
+ let stat;
86
+ try {
87
+ const s = await fs.stat(current);
88
+ stat = { size: s.size };
89
+ }
90
+ catch {
91
+ return; // no current file → nothing to rotate
92
+ }
93
+ if (!shouldRotate(stat, thresholdBytes))
94
+ return;
95
+ const archivePath = join(eventLogDir(projectRoot), `hook-events.${Date.now()}.jsonl`);
96
+ try {
97
+ await fs.rename(current, archivePath);
98
+ }
99
+ catch {
100
+ // Rename raced with another process; caller will just append onto
101
+ // whichever file now exists.
102
+ }
103
+ }
104
+ /**
105
+ * Append one event to the current log file. Rotates first if the
106
+ * current file has reached the threshold. Never throws — a failure
107
+ * here must not break hook dispatch.
108
+ */
109
+ export async function appendEvent(projectRoot, event) {
110
+ try {
111
+ await ensureLogDir(projectRoot);
112
+ await rotateIfNeeded(projectRoot);
113
+ const line = JSON.stringify(event) + "\n";
114
+ await fs.appendFile(currentLogPath(projectRoot), line);
115
+ }
116
+ catch {
117
+ /* silent — telemetry is best-effort */
118
+ }
119
+ }
120
+ /**
121
+ * Read all events from the current log file. Malformed JSONL lines are
122
+ * skipped silently (a corrupted line should not poison the whole
123
+ * dataset). Returns [] if the file is missing.
124
+ */
125
+ export async function loadEvents(projectRoot) {
126
+ let raw;
127
+ try {
128
+ raw = await fs.readFile(currentLogPath(projectRoot), "utf-8");
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const out = [];
134
+ for (const line of raw.split("\n")) {
135
+ if (!line.trim())
136
+ continue;
137
+ try {
138
+ out.push(JSON.parse(line));
139
+ }
140
+ catch {
141
+ // skip malformed
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Enumerate all archive files (`hook-events.<ts>.jsonl`) with metadata
148
+ * needed by `retentionDeletions`.
149
+ */
150
+ async function listArchives(projectRoot) {
151
+ const dir = eventLogDir(projectRoot);
152
+ let entries;
153
+ try {
154
+ entries = await fs.readdir(dir);
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ const out = [];
160
+ for (const name of entries) {
161
+ if (!ARCHIVE_RE.test(name))
162
+ continue;
163
+ const full = join(dir, name);
164
+ try {
165
+ const s = await fs.stat(full);
166
+ out.push({ path: full, mtime: s.mtime, size: s.size });
167
+ }
168
+ catch {
169
+ /* skip unreadable */
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ /**
175
+ * Apply age + size retention. Safe to call on startup; no-op when the
176
+ * directory does not exist.
177
+ */
178
+ export async function applyRetention(projectRoot, now = new Date()) {
179
+ const archives = await listArchives(projectRoot);
180
+ const victims = retentionDeletions(archives, now);
181
+ for (const p of victims) {
182
+ try {
183
+ await fs.unlink(p);
184
+ }
185
+ catch {
186
+ /* ignore */
187
+ }
188
+ }
189
+ }
190
+ //# sourceMappingURL=event-log.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * TP-69m — session-scoped ContextRegistry.
3
+ *
4
+ * The single `ContextRegistry` lived for the MCP server process lifetime,
5
+ * so a restart — or the way Claude Code spawns short-lived server
6
+ * instances — threw away "already loaded X" knowledge. This manager keeps
7
+ * one registry per `session_id`, persists each to disk under
8
+ * `.token-pilot/context-registries/<id>.json`, and LRU-evicts cold
9
+ * sessions from memory.
10
+ *
11
+ * Session IDs that fail slug validation (empty, traversal, path separators)
12
+ * get an ephemeral registry that is never persisted — a safe fallback for
13
+ * callers that don't know their session_id yet.
14
+ */
15
+ import { ContextRegistry } from "./context-registry.js";
16
+ export declare const REGISTRIES_SUBDIR = ".token-pilot/context-registries";
17
+ export interface SessionRegistryManagerOptions {
18
+ inMemoryCap?: number;
19
+ }
20
+ export declare class SessionRegistryManager {
21
+ private readonly projectRoot;
22
+ private readonly inMemoryCap;
23
+ /** Insertion order = LRU order (re-insert on access). */
24
+ private readonly live;
25
+ /** Ephemeral (unsafe-id / empty) registries are kept apart from the LRU. */
26
+ private readonly ephemeral;
27
+ constructor(projectRoot: string, opts?: SessionRegistryManagerOptions);
28
+ /**
29
+ * Return the registry associated with a session id, creating / loading
30
+ * as needed. Ephemeral for unsafe ids; otherwise LRU-cached and
31
+ * disk-backed.
32
+ */
33
+ getFor(sessionId: string): ContextRegistry;
34
+ /** Flush one session's registry to disk. Silent on failure. */
35
+ flush(sessionId: string): Promise<void>;
36
+ /** Flush every live registry. Called on server shutdown. */
37
+ flushAll(): Promise<void>;
38
+ /** LRU insertion-order snapshot of in-memory session ids (for tests). */
39
+ inMemoryIds(): string[];
40
+ private pathFor;
41
+ private evict;
42
+ }
43
+ //# sourceMappingURL=session-registry.d.ts.map
@@ -0,0 +1,113 @@
1
+ /**
2
+ * TP-69m — session-scoped ContextRegistry.
3
+ *
4
+ * The single `ContextRegistry` lived for the MCP server process lifetime,
5
+ * so a restart — or the way Claude Code spawns short-lived server
6
+ * instances — threw away "already loaded X" knowledge. This manager keeps
7
+ * one registry per `session_id`, persists each to disk under
8
+ * `.token-pilot/context-registries/<id>.json`, and LRU-evicts cold
9
+ * sessions from memory.
10
+ *
11
+ * Session IDs that fail slug validation (empty, traversal, path separators)
12
+ * get an ephemeral registry that is never persisted — a safe fallback for
13
+ * callers that don't know their session_id yet.
14
+ */
15
+ import { promises as fs, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { ContextRegistry } from "./context-registry.js";
18
+ export const REGISTRIES_SUBDIR = ".token-pilot/context-registries";
19
+ const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/;
20
+ export class SessionRegistryManager {
21
+ projectRoot;
22
+ inMemoryCap;
23
+ /** Insertion order = LRU order (re-insert on access). */
24
+ live = new Map();
25
+ /** Ephemeral (unsafe-id / empty) registries are kept apart from the LRU. */
26
+ ephemeral = new Map();
27
+ constructor(projectRoot, opts = {}) {
28
+ this.projectRoot = projectRoot;
29
+ this.inMemoryCap = opts.inMemoryCap ?? 8;
30
+ }
31
+ /**
32
+ * Return the registry associated with a session id, creating / loading
33
+ * as needed. Ephemeral for unsafe ids; otherwise LRU-cached and
34
+ * disk-backed.
35
+ */
36
+ getFor(sessionId) {
37
+ if (!isSafeId(sessionId)) {
38
+ let reg = this.ephemeral.get(sessionId);
39
+ if (!reg) {
40
+ reg = new ContextRegistry();
41
+ this.ephemeral.set(sessionId, reg);
42
+ }
43
+ return reg;
44
+ }
45
+ const existing = this.live.get(sessionId);
46
+ if (existing) {
47
+ // Refresh LRU position.
48
+ this.live.delete(sessionId);
49
+ this.live.set(sessionId, existing);
50
+ return existing;
51
+ }
52
+ const reg = new ContextRegistry();
53
+ // Best-effort sync load from disk — hook path cannot await.
54
+ const path = this.pathFor(sessionId);
55
+ try {
56
+ const raw = readFileSync(path, "utf-8");
57
+ reg.loadSnapshot(JSON.parse(raw));
58
+ }
59
+ catch {
60
+ /* no prior state, or corrupt file — start empty */
61
+ }
62
+ this.live.set(sessionId, reg);
63
+ this.evict();
64
+ return reg;
65
+ }
66
+ /** Flush one session's registry to disk. Silent on failure. */
67
+ async flush(sessionId) {
68
+ if (!isSafeId(sessionId))
69
+ return;
70
+ const reg = this.live.get(sessionId);
71
+ if (!reg)
72
+ return;
73
+ const path = this.pathFor(sessionId);
74
+ try {
75
+ await fs.mkdir(join(this.projectRoot, REGISTRIES_SUBDIR), {
76
+ recursive: true,
77
+ });
78
+ await fs.writeFile(path, JSON.stringify(reg.toSnapshot()));
79
+ }
80
+ catch {
81
+ /* best-effort */
82
+ }
83
+ }
84
+ /** Flush every live registry. Called on server shutdown. */
85
+ async flushAll() {
86
+ const ids = Array.from(this.live.keys());
87
+ for (const id of ids)
88
+ await this.flush(id);
89
+ }
90
+ /** LRU insertion-order snapshot of in-memory session ids (for tests). */
91
+ inMemoryIds() {
92
+ return Array.from(this.live.keys());
93
+ }
94
+ pathFor(sessionId) {
95
+ return join(this.projectRoot, REGISTRIES_SUBDIR, `${sessionId}.json`);
96
+ }
97
+ evict() {
98
+ while (this.live.size > this.inMemoryCap) {
99
+ const oldest = this.live.keys().next().value;
100
+ if (!oldest)
101
+ break;
102
+ // Best-effort async flush before dropping from memory.
103
+ void this.flush(oldest);
104
+ this.live.delete(oldest);
105
+ }
106
+ }
107
+ }
108
+ function isSafeId(id) {
109
+ if (!id)
110
+ return false;
111
+ return SAFE_ID_RE.test(id);
112
+ }
113
+ //# sourceMappingURL=session-registry.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sum `savedTokens` for all hook-events matching a given session_id.
3
+ *
4
+ * Used by the adaptive-threshold path to estimate how much of the
5
+ * session's context budget has already been burned. Sync implementation
6
+ * because the hook process is short-lived and a blocking read is
7
+ * simpler than pulling in async plumbing.
8
+ *
9
+ * Silent on every error — telemetry must never break the hook.
10
+ */
11
+ export declare function loadSessionSavedTokens(projectRoot: string, sessionId: string): number;
12
+ export interface SessionStats {
13
+ savedTokens: number;
14
+ eventCount: number;
15
+ firstTsMs: number | null;
16
+ lastTsMs: number | null;
17
+ }
18
+ export declare function loadSessionStats(projectRoot: string, sessionId: string): SessionStats;
19
+ //# sourceMappingURL=session-savings.d.ts.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Sum `savedTokens` for all hook-events matching a given session_id.
3
+ *
4
+ * Used by the adaptive-threshold path to estimate how much of the
5
+ * session's context budget has already been burned. Sync implementation
6
+ * because the hook process is short-lived and a blocking read is
7
+ * simpler than pulling in async plumbing.
8
+ *
9
+ * Silent on every error — telemetry must never break the hook.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ export function loadSessionSavedTokens(projectRoot, sessionId) {
14
+ return loadSessionStats(projectRoot, sessionId).savedTokens;
15
+ }
16
+ export function loadSessionStats(projectRoot, sessionId) {
17
+ const empty = {
18
+ savedTokens: 0,
19
+ eventCount: 0,
20
+ firstTsMs: null,
21
+ lastTsMs: null,
22
+ };
23
+ if (!sessionId)
24
+ return empty;
25
+ const path = join(projectRoot, ".token-pilot", "hook-events.jsonl");
26
+ let raw;
27
+ try {
28
+ raw = readFileSync(path, "utf-8");
29
+ }
30
+ catch {
31
+ return empty;
32
+ }
33
+ let savedTokens = 0;
34
+ let eventCount = 0;
35
+ let firstTsMs = null;
36
+ let lastTsMs = null;
37
+ for (const line of raw.split("\n")) {
38
+ if (!line.trim())
39
+ continue;
40
+ try {
41
+ const e = JSON.parse(line);
42
+ if (e.session_id !== sessionId)
43
+ continue;
44
+ if (typeof e.savedTokens === "number")
45
+ savedTokens += e.savedTokens;
46
+ eventCount += 1;
47
+ if (typeof e.ts === "number") {
48
+ if (firstTsMs == null || e.ts < firstTsMs)
49
+ firstTsMs = e.ts;
50
+ if (lastTsMs == null || e.ts > lastTsMs)
51
+ lastTsMs = e.ts;
52
+ }
53
+ }
54
+ catch {
55
+ /* skip malformed */
56
+ }
57
+ }
58
+ return { savedTokens, eventCount, firstTsMs, lastTsMs };
59
+ }
60
+ //# sourceMappingURL=session-savings.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * TP-hsz — `session_budget` MCP tool.
3
+ *
4
+ * Reports *hook pressure* for the current session: the total tokens the
5
+ * Read-hook has suppressed so far (`savedTokens` in hook-events.jsonl)
6
+ * divided by a configurable reference budget. This is a proxy for "how
7
+ * chatty is the agent being with big files", not a measurement of the
8
+ * Claude Code context window itself — Token Pilot has no visibility into
9
+ * what the model actually has in context.
10
+ *
11
+ * The adaptive threshold curve uses this same signal to tighten when
12
+ * pressure is high, so `burnFraction` here matches what the hook sees.
13
+ * An agent that Reads many files with bounded `offset/limit` will see a
14
+ * low `burnFraction` even if its context is nearly full — the budget is
15
+ * about Token Pilot interventions, not total window occupancy.
16
+ */
17
+ export interface SessionBudgetArgs {
18
+ sessionId: string;
19
+ }
20
+ export interface SessionBudgetConfig {
21
+ baseThreshold: number;
22
+ adaptiveThreshold: boolean;
23
+ adaptiveBudgetTokens: number;
24
+ }
25
+ export interface SessionBudgetResult {
26
+ content: Array<{
27
+ type: "text";
28
+ text: string;
29
+ }>;
30
+ }
31
+ export declare function handleSessionBudget(args: SessionBudgetArgs, projectRoot: string, cfg: SessionBudgetConfig): Promise<SessionBudgetResult>;
32
+ //# sourceMappingURL=session-budget.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * TP-hsz — `session_budget` MCP tool.
3
+ *
4
+ * Reports *hook pressure* for the current session: the total tokens the
5
+ * Read-hook has suppressed so far (`savedTokens` in hook-events.jsonl)
6
+ * divided by a configurable reference budget. This is a proxy for "how
7
+ * chatty is the agent being with big files", not a measurement of the
8
+ * Claude Code context window itself — Token Pilot has no visibility into
9
+ * what the model actually has in context.
10
+ *
11
+ * The adaptive threshold curve uses this same signal to tighten when
12
+ * pressure is high, so `burnFraction` here matches what the hook sees.
13
+ * An agent that Reads many files with bounded `offset/limit` will see a
14
+ * low `burnFraction` even if its context is nearly full — the budget is
15
+ * about Token Pilot interventions, not total window occupancy.
16
+ */
17
+ import { loadSessionStats } from "../core/session-savings.js";
18
+ import { computeEffectiveThreshold } from "../hooks/adaptive-threshold.js";
19
+ export async function handleSessionBudget(args, projectRoot, cfg) {
20
+ const sessionId = args.sessionId ?? "";
21
+ const stats = loadSessionStats(projectRoot, sessionId);
22
+ const savedTokens = stats.savedTokens;
23
+ const budget = cfg.adaptiveBudgetTokens > 0 ? cfg.adaptiveBudgetTokens : 0;
24
+ const burnRaw = budget > 0 ? savedTokens / budget : 0;
25
+ const burnFraction = Math.min(1, Math.max(0, burnRaw));
26
+ const effectiveThreshold = computeEffectiveThreshold({
27
+ baseThreshold: cfg.baseThreshold,
28
+ sessionSavedTokens: savedTokens,
29
+ sessionBudgetTokens: budget,
30
+ enabled: cfg.adaptiveThreshold,
31
+ });
32
+ // Time-to-compact projection. Silent (null fields) when we lack data.
33
+ let avgSavedPerEvent = null;
34
+ let eventsUntilExhaustion = null;
35
+ if (stats.eventCount > 0 && savedTokens > 0) {
36
+ avgSavedPerEvent = savedTokens / stats.eventCount;
37
+ if (budget > 0 && avgSavedPerEvent > 0) {
38
+ const remaining = Math.max(0, budget - savedTokens);
39
+ eventsUntilExhaustion = Math.floor(remaining / avgSavedPerEvent);
40
+ }
41
+ }
42
+ const payload = {
43
+ semantics: "burnFraction is hook-suppression pressure, NOT context-window occupancy. See session-budget.ts docs.",
44
+ sessionId,
45
+ savedTokens,
46
+ budgetTokens: budget,
47
+ burnFraction: Number(burnFraction.toFixed(4)),
48
+ baseThreshold: cfg.baseThreshold,
49
+ effectiveThreshold,
50
+ adaptive: cfg.adaptiveThreshold,
51
+ eventCount: stats.eventCount,
52
+ avgSavedPerEvent: avgSavedPerEvent != null ? Math.round(avgSavedPerEvent) : null,
53
+ eventsUntilExhaustion,
54
+ firstEventMs: stats.firstTsMs,
55
+ lastEventMs: stats.lastTsMs,
56
+ };
57
+ return {
58
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
59
+ };
60
+ }
61
+ //# sourceMappingURL=session-budget.js.map
@@ -0,0 +1,22 @@
1
+ export declare const SNAPSHOT_SUBDIR = ".token-pilot/snapshots";
2
+ export declare const LATEST_FILE = "latest.md";
3
+ export declare const MAX_ARCHIVED_SNAPSHOTS = 10;
4
+ export interface PersistSnapshotInput {
5
+ projectRoot: string;
6
+ body: string;
7
+ /** For deterministic tests; defaults to `new Date()`. */
8
+ now?: Date;
9
+ }
10
+ export interface PersistSnapshotResult {
11
+ archivedPath: string | null;
12
+ latestPath: string;
13
+ }
14
+ export declare function persistSnapshot(input: PersistSnapshotInput): Promise<PersistSnapshotResult>;
15
+ export interface LoadedSnapshot {
16
+ body: string;
17
+ mtimeMs: number;
18
+ ageMs: number;
19
+ path: string;
20
+ }
21
+ export declare function loadLatestSnapshot(projectRoot: string): Promise<LoadedSnapshot | null>;
22
+ //# sourceMappingURL=session-snapshot-persist.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * TP-340 — persistence layer for session_snapshot.
3
+ *
4
+ * When an agent calls `session_snapshot` we save the rendered block to
5
+ * `.token-pilot/snapshots/<iso>.md` (archived history)
6
+ * `.token-pilot/snapshots/latest.md` (always the newest)
7
+ *
8
+ * A SessionStart hook later reads `latest.md` and surfaces a short pointer
9
+ * so the next Claude Code turn after compaction / `/clear` / a new window
10
+ * can pick the thread back up without re-hydrating context manually.
11
+ *
12
+ * Retention: keep the last `MAX_ARCHIVED_SNAPSHOTS` (oldest trimmed first).
13
+ */
14
+ import { promises as fs } from "node:fs";
15
+ import { join } from "node:path";
16
+ export const SNAPSHOT_SUBDIR = ".token-pilot/snapshots";
17
+ export const LATEST_FILE = "latest.md";
18
+ export const MAX_ARCHIVED_SNAPSHOTS = 10;
19
+ function formatIsoStamp(d) {
20
+ // Safe-for-filename ISO: 2026-04-18T12-00-00Z
21
+ return d.toISOString().replace(/:/g, "-").replace(/\..+/, "Z");
22
+ }
23
+ export async function persistSnapshot(input) {
24
+ const now = input.now ?? new Date();
25
+ const dir = join(input.projectRoot, SNAPSHOT_SUBDIR);
26
+ await fs.mkdir(dir, { recursive: true });
27
+ const stamp = formatIsoStamp(now);
28
+ const archivedPath = join(dir, `${stamp}.md`);
29
+ const latestPath = join(dir, LATEST_FILE);
30
+ await fs.writeFile(archivedPath, input.body);
31
+ await fs.writeFile(latestPath, input.body);
32
+ await trimArchive(dir);
33
+ return { archivedPath, latestPath };
34
+ }
35
+ async function trimArchive(dir) {
36
+ let entries;
37
+ try {
38
+ entries = await fs.readdir(dir);
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ const archived = entries
44
+ .filter((n) => n.endsWith(".md") && n !== LATEST_FILE)
45
+ .sort(); // ISO-stamped names sort chronologically
46
+ if (archived.length <= MAX_ARCHIVED_SNAPSHOTS)
47
+ return;
48
+ const excess = archived.length - MAX_ARCHIVED_SNAPSHOTS;
49
+ for (let i = 0; i < excess; i++) {
50
+ try {
51
+ await fs.unlink(join(dir, archived[i]));
52
+ }
53
+ catch {
54
+ /* best effort */
55
+ }
56
+ }
57
+ }
58
+ export async function loadLatestSnapshot(projectRoot) {
59
+ const path = join(projectRoot, SNAPSHOT_SUBDIR, LATEST_FILE);
60
+ try {
61
+ const [body, s] = await Promise.all([
62
+ fs.readFile(path, "utf-8"),
63
+ fs.stat(path),
64
+ ]);
65
+ return {
66
+ body,
67
+ mtimeMs: s.mtimeMs,
68
+ ageMs: Math.max(0, Date.now() - s.mtimeMs),
69
+ path,
70
+ };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ //# sourceMappingURL=session-snapshot-persist.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Adaptive threshold: lower the effective denyThreshold as the Read-hook
3
+ * sees more suppressed-token activity in this session, so large-file reads
4
+ * become stricter the chattier the agent has been with big files.
5
+ *
6
+ * Piecewise curve, opt-in only:
7
+ * pressure < 30% of budget → base threshold unchanged
8
+ * pressure ≥ 30%, < 60% → base × 0.75
9
+ * pressure ≥ 60%, < 80% → base × 0.5
10
+ * pressure ≥ 80% → base × 0.3 (minimum 50 lines)
11
+ *
12
+ * Burn fraction = sessionSavedTokens / sessionBudgetTokens, where
13
+ * `sessionSavedTokens` is the sum of `savedTokens` entries in
14
+ * hook-events.jsonl for the current session_id. This is a PROXY for how
15
+ * aggressively the agent has been trying to pull large files, not a
16
+ * measurement of Claude Code's actual context-window occupancy — Token
17
+ * Pilot has no visibility into that. If the agent reads many files with
18
+ * bounded `offset/limit`, none of that contributes to the burn signal.
19
+ */
20
+ export interface AdaptiveThresholdInput {
21
+ baseThreshold: number;
22
+ sessionSavedTokens: number;
23
+ sessionBudgetTokens: number;
24
+ enabled: boolean;
25
+ }
26
+ export declare function computeEffectiveThreshold(input: AdaptiveThresholdInput): number;
27
+ //# sourceMappingURL=adaptive-threshold.d.ts.map