token-pilot 0.19.2 → 0.22.2

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 (89) hide show
  1. package/.claude-plugin/hooks/hooks.json +21 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +172 -315
  5. package/dist/agents/tp-commit-writer.md +41 -0
  6. package/dist/agents/tp-dead-code-finder.md +43 -0
  7. package/dist/agents/tp-debugger.md +45 -0
  8. package/dist/agents/tp-impact-analyzer.md +44 -0
  9. package/dist/agents/tp-migration-scout.md +43 -0
  10. package/dist/agents/tp-onboard.md +40 -0
  11. package/dist/agents/tp-pr-reviewer.md +41 -0
  12. package/dist/agents/tp-refactor-planner.md +42 -0
  13. package/dist/agents/tp-run.md +48 -0
  14. package/dist/agents/tp-test-triage.md +40 -0
  15. package/dist/agents/tp-test-writer.md +46 -0
  16. package/dist/cli/agent-frontmatter.d.ts +48 -0
  17. package/dist/cli/agent-frontmatter.js +189 -0
  18. package/dist/cli/bless-agents.d.ts +65 -0
  19. package/dist/cli/bless-agents.js +307 -0
  20. package/dist/cli/claudeignore.d.ts +33 -0
  21. package/dist/cli/claudeignore.js +88 -0
  22. package/dist/cli/claudemd-hygiene.d.ts +26 -0
  23. package/dist/cli/claudemd-hygiene.js +43 -0
  24. package/dist/cli/doctor-drift.d.ts +31 -0
  25. package/dist/cli/doctor-drift.js +130 -0
  26. package/dist/cli/doctor-env-check.d.ts +25 -0
  27. package/dist/cli/doctor-env-check.js +91 -0
  28. package/dist/cli/install-agents.d.ts +108 -0
  29. package/dist/cli/install-agents.js +402 -0
  30. package/dist/cli/save-doc.d.ts +42 -0
  31. package/dist/cli/save-doc.js +145 -0
  32. package/dist/cli/scan-agents.d.ts +46 -0
  33. package/dist/cli/scan-agents.js +227 -0
  34. package/dist/cli/stats.d.ts +36 -0
  35. package/dist/cli/stats.js +131 -0
  36. package/dist/cli/unbless-agents.d.ts +33 -0
  37. package/dist/cli/unbless-agents.js +85 -0
  38. package/dist/cli/uninstall-agents.d.ts +36 -0
  39. package/dist/cli/uninstall-agents.js +117 -0
  40. package/dist/config/defaults.d.ts +1 -1
  41. package/dist/config/defaults.js +14 -8
  42. package/dist/config/loader.d.ts +1 -1
  43. package/dist/config/loader.js +105 -11
  44. package/dist/core/context-registry.d.ts +16 -1
  45. package/dist/core/context-registry.js +60 -28
  46. package/dist/core/event-log.d.ts +79 -0
  47. package/dist/core/event-log.js +190 -0
  48. package/dist/core/session-registry.d.ts +43 -0
  49. package/dist/core/session-registry.js +113 -0
  50. package/dist/core/session-savings.d.ts +19 -0
  51. package/dist/core/session-savings.js +60 -0
  52. package/dist/handlers/session-budget.d.ts +32 -0
  53. package/dist/handlers/session-budget.js +61 -0
  54. package/dist/handlers/session-snapshot-persist.d.ts +22 -0
  55. package/dist/handlers/session-snapshot-persist.js +76 -0
  56. package/dist/hooks/adaptive-threshold.d.ts +27 -0
  57. package/dist/hooks/adaptive-threshold.js +46 -0
  58. package/dist/hooks/format-deny-message.d.ts +21 -0
  59. package/dist/hooks/format-deny-message.js +147 -0
  60. package/dist/hooks/installer.js +121 -31
  61. package/dist/hooks/path-safety.d.ts +16 -0
  62. package/dist/hooks/path-safety.js +34 -0
  63. package/dist/hooks/post-bash.d.ts +46 -0
  64. package/dist/hooks/post-bash.js +77 -0
  65. package/dist/hooks/session-start.d.ts +45 -0
  66. package/dist/hooks/session-start.js +179 -0
  67. package/dist/hooks/summary-ast-index.d.ts +28 -0
  68. package/dist/hooks/summary-ast-index.js +122 -0
  69. package/dist/hooks/summary-head-tail.d.ts +15 -0
  70. package/dist/hooks/summary-head-tail.js +78 -0
  71. package/dist/hooks/summary-pipeline.d.ts +35 -0
  72. package/dist/hooks/summary-pipeline.js +63 -0
  73. package/dist/hooks/summary-regex.d.ts +14 -0
  74. package/dist/hooks/summary-regex.js +130 -0
  75. package/dist/hooks/summary-types.d.ts +29 -0
  76. package/dist/hooks/summary-types.js +9 -0
  77. package/dist/index.d.ts +15 -3
  78. package/dist/index.js +509 -149
  79. package/dist/integration/context-mode-detector.d.ts +7 -1
  80. package/dist/integration/context-mode-detector.js +51 -15
  81. package/dist/server/tool-definitions.d.ts +149 -0
  82. package/dist/server/tool-definitions.js +424 -202
  83. package/dist/server.d.ts +1 -1
  84. package/dist/server.js +456 -179
  85. package/dist/templates/agent-builder.d.ts +49 -0
  86. package/dist/templates/agent-builder.js +104 -0
  87. package/dist/types.d.ts +38 -4
  88. package/package.json +4 -2
  89. package/skills/stats/SKILL.md +13 -2
@@ -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
@@ -0,0 +1,46 @@
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
+ const MIN_FLOOR_LINES = 50;
21
+ export function computeEffectiveThreshold(input) {
22
+ const { baseThreshold, sessionSavedTokens, sessionBudgetTokens, enabled } = input;
23
+ if (!enabled)
24
+ return baseThreshold;
25
+ if (!Number.isFinite(sessionBudgetTokens) || sessionBudgetTokens <= 0) {
26
+ return baseThreshold;
27
+ }
28
+ if (!Number.isFinite(sessionSavedTokens) || sessionSavedTokens <= 0) {
29
+ return baseThreshold;
30
+ }
31
+ const burn = sessionSavedTokens / sessionBudgetTokens;
32
+ let multiplier;
33
+ if (burn < 0.3)
34
+ multiplier = 1;
35
+ else if (burn < 0.6)
36
+ multiplier = 0.75;
37
+ else if (burn < 0.8)
38
+ multiplier = 0.5;
39
+ else
40
+ multiplier = 0.3;
41
+ const scaled = Math.round(baseThreshold * multiplier);
42
+ if (multiplier === 1)
43
+ return baseThreshold;
44
+ return Math.max(scaled, MIN_FLOOR_LINES);
45
+ }
46
+ //# sourceMappingURL=adaptive-threshold.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Render a HookSummary into the body of a PreToolUse deny message.
3
+ *
4
+ * The formatted string becomes `hookSpecificOutput.permissionDecisionReason`
5
+ * when the hook decides to block a Read. It is the ONLY output the agent
6
+ * sees for the blocked call, so it carries both the structural summary and
7
+ * the escape-hatch instructions.
8
+ *
9
+ * Kept separate from the hook entry point for unit-testability.
10
+ */
11
+ import type { HookSummary } from "./summary-types.js";
12
+ import type { PipelineTier } from "./summary-pipeline.js";
13
+ export interface FormatOptions {
14
+ filePath: string;
15
+ summary: HookSummary;
16
+ tier: PipelineTier;
17
+ /** Soft cap on the rendered message token count (estimated). Default 1200. */
18
+ maxTokens?: number;
19
+ }
20
+ export declare function formatDenyMessage(opts: FormatOptions): string;
21
+ //# sourceMappingURL=format-deny-message.d.ts.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Render a HookSummary into the body of a PreToolUse deny message.
3
+ *
4
+ * The formatted string becomes `hookSpecificOutput.permissionDecisionReason`
5
+ * when the hook decides to block a Read. It is the ONLY output the agent
6
+ * sees for the blocked call, so it carries both the structural summary and
7
+ * the escape-hatch instructions.
8
+ *
9
+ * Kept separate from the hook entry point for unit-testability.
10
+ */
11
+ const DEFAULT_MAX_TOKENS = 1200;
12
+ function estimateTokens(text) {
13
+ if (text.length === 0)
14
+ return 0;
15
+ const charEstimate = Math.ceil(text.length / 4);
16
+ const whitespaceRatio = (text.match(/\s/g)?.length ?? 0) / text.length;
17
+ const adjustment = 1 - whitespaceRatio * 0.3;
18
+ return Math.ceil(charEstimate * adjustment);
19
+ }
20
+ function formatSignalLine(s) {
21
+ return `L${s.line}: ${s.text}`;
22
+ }
23
+ function header(opts) {
24
+ const { filePath, summary } = opts;
25
+ return (`File "${filePath}" has ${summary.totalLines} lines (~${summary.estimatedTokens} tokens).\n` +
26
+ `Read denied to save context; structural summary follows.`);
27
+ }
28
+ function footer() {
29
+ return [
30
+ "How to proceed:",
31
+ "- Structural overview (preferred): mcp__token-pilot__smart_read(path).",
32
+ "- For specific lines: Read(path, offset, limit) — bounded reads are passed through.",
33
+ "- For a single symbol: mcp__token-pilot__read_symbol(path, name).",
34
+ "- For edit context: mcp__token-pilot__read_for_edit(path, symbol).",
35
+ "- Full read (expensive): set TOKEN_PILOT_BYPASS=1 for this session.",
36
+ ].join("\n");
37
+ }
38
+ function partition(signals) {
39
+ const sections = {
40
+ imports: [],
41
+ exports: [],
42
+ declarations: [],
43
+ raws: [],
44
+ };
45
+ for (const s of signals) {
46
+ if (s.kind === "import")
47
+ sections.imports.push(s);
48
+ else if (s.kind === "export")
49
+ sections.exports.push(s);
50
+ else if (s.kind === "raw")
51
+ sections.raws.push(s);
52
+ else
53
+ sections.declarations.push(s);
54
+ }
55
+ return sections;
56
+ }
57
+ function renderSections(sections, note) {
58
+ const lines = [];
59
+ let signalLineCount = 0;
60
+ if (note) {
61
+ lines.push(`Note: ${note}`);
62
+ lines.push("");
63
+ }
64
+ if (sections.imports.length > 0) {
65
+ lines.push("=== Imports ===");
66
+ sections.imports.forEach((s) => {
67
+ lines.push(formatSignalLine(s));
68
+ signalLineCount++;
69
+ });
70
+ lines.push("");
71
+ }
72
+ if (sections.exports.length > 0) {
73
+ lines.push("=== Exports / Public symbols ===");
74
+ sections.exports.forEach((s) => {
75
+ lines.push(formatSignalLine(s));
76
+ signalLineCount++;
77
+ });
78
+ lines.push("");
79
+ }
80
+ if (sections.declarations.length > 0) {
81
+ lines.push("=== Declarations ===");
82
+ sections.declarations.forEach((s) => {
83
+ lines.push(formatSignalLine(s));
84
+ signalLineCount++;
85
+ });
86
+ lines.push("");
87
+ }
88
+ if (sections.raws.length > 0) {
89
+ lines.push("=== Content preview (head + tail) ===");
90
+ sections.raws.forEach((s) => {
91
+ lines.push(formatSignalLine(s));
92
+ signalLineCount++;
93
+ });
94
+ lines.push("");
95
+ }
96
+ return { body: lines.join("\n"), signalLineCount };
97
+ }
98
+ export function formatDenyMessage(opts) {
99
+ const maxTokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
100
+ // Build with full signal list first.
101
+ let sections = partition(opts.summary.signals);
102
+ let { body } = renderSections(sections, opts.summary.note);
103
+ let message = [header(opts), "", body, footer()].join("\n");
104
+ let trimmed = false;
105
+ // If we overflow, drop signals from the END of each section in lockstep
106
+ // until we fit. Keeps the overall signal distribution intact.
107
+ while (estimateTokens(message) > maxTokens) {
108
+ const totalSignals = sections.imports.length +
109
+ sections.exports.length +
110
+ sections.declarations.length +
111
+ sections.raws.length;
112
+ if (totalSignals === 0)
113
+ break;
114
+ // Trim 10 % of remaining signals per pass (minimum 1) to converge quickly.
115
+ const drop = Math.max(1, Math.floor(totalSignals * 0.1));
116
+ let toDrop = drop;
117
+ for (const bucket of [
118
+ "raws",
119
+ "declarations",
120
+ "exports",
121
+ "imports",
122
+ ]) {
123
+ while (toDrop > 0 && sections[bucket].length > 0) {
124
+ sections[bucket].pop();
125
+ toDrop--;
126
+ }
127
+ if (toDrop === 0)
128
+ break;
129
+ }
130
+ trimmed = true;
131
+ ({ body } = renderSections(sections, opts.summary.note));
132
+ message = [header(opts), "", body, footer()].join("\n");
133
+ }
134
+ if (trimmed) {
135
+ const trimmedNote = "\n(trimmed to fit budget; call mcp__token-pilot__outline(path) for full structure)";
136
+ message = [
137
+ header(opts),
138
+ "",
139
+ body.trimEnd(),
140
+ trimmedNote,
141
+ "",
142
+ footer(),
143
+ ].join("\n");
144
+ }
145
+ return message;
146
+ }
147
+ //# sourceMappingURL=format-deny-message.js.map