@drewpayment/mink 0.12.0 → 0.13.0-beta.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 (60) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.bun.js +748 -10
  39. package/dist/cli.node.js +752 -12
  40. package/package.json +1 -1
  41. package/src/cli.ts +14 -0
  42. package/src/commands/init.ts +5 -1
  43. package/src/commands/post-read.ts +18 -0
  44. package/src/commands/post-tool.ts +48 -0
  45. package/src/commands/retrieve.ts +32 -0
  46. package/src/core/code-skeleton.ts +108 -0
  47. package/src/core/compress-tool-output.ts +127 -0
  48. package/src/core/compression.ts +81 -0
  49. package/src/core/hook-output.ts +42 -0
  50. package/src/core/output-compression.ts +252 -0
  51. package/src/core/token-estimate.ts +40 -0
  52. package/src/repositories/compression-cache-repo.ts +97 -0
  53. package/src/repositories/token-ledger-repo.ts +87 -0
  54. package/src/storage/schema.ts +50 -1
  55. package/src/types/compression.ts +29 -0
  56. package/src/types/config.ts +40 -0
  57. package/src/types/hook-input.ts +4 -0
  58. package/src/types/token-ledger.ts +33 -0
  59. /package/dashboard/out/_next/static/{Cr7-P-E43jbsBjy4hA6wH → Yl3F-J4CwvYf6yWG-SSmG}/_buildManifest.js +0 -0
  60. /package/dashboard/out/_next/static/{Cr7-P-E43jbsBjy4hA6wH → Yl3F-J4CwvYf6yWG-SSmG}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.12.0",
3
+ "version": "0.13.0-beta.2",
4
4
  "description": "A hidden presence that moves alongside the developer — token efficiency and cross-project wiki for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -53,6 +53,12 @@ switch (command) {
53
53
  break;
54
54
  }
55
55
 
56
+ case "post-tool": {
57
+ const { postTool } = await import("./commands/post-tool");
58
+ await postTool(cwd);
59
+ break;
60
+ }
61
+
56
62
  case "pre-write": {
57
63
  const { preWrite } = await import("./commands/pre-write");
58
64
  await preWrite(cwd);
@@ -71,6 +77,12 @@ switch (command) {
71
77
  break;
72
78
  }
73
79
 
80
+ case "retrieve": {
81
+ const { retrieve } = await import("./commands/retrieve");
82
+ retrieve(cwd, process.argv.slice(3));
83
+ break;
84
+ }
85
+
74
86
  case "cron": {
75
87
  const { cron } = await import("./commands/cron");
76
88
  await cron(cwd, process.argv.slice(3));
@@ -263,6 +275,7 @@ switch (command) {
263
275
  console.log(" restore [backup] Restore state from a backup");
264
276
  console.log(" bug search <term> Search the bug log");
265
277
  console.log(" detect-waste Detect and flag wasteful patterns");
278
+ console.log(" retrieve <token> Return a compressed tool output's original (spec 21)");
266
279
  console.log(" reflect Generate learning memory reflections");
267
280
  console.log(" designqc [target] Capture design screenshots (spec 13)");
268
281
  console.log(" framework-advisor Generate framework advisor knowledge file (spec 14)");
@@ -272,6 +285,7 @@ switch (command) {
272
285
  console.log(" session-stop Finalize session and log data");
273
286
  console.log(" pre-read / post-read File read hooks");
274
287
  console.log(" pre-write / post-write File write hooks");
288
+ console.log(" post-tool Tool-output compression hook (Bash/Grep/MCP, spec 21)");
275
289
  break;
276
290
 
277
291
  default:
@@ -91,6 +91,9 @@ export function buildHooksConfig(cliPath: string): HooksConfig {
91
91
  { matcher: "Read", hooks: hook(`${prefix} post-read`) },
92
92
  { matcher: "Edit", hooks: hook(`${prefix} post-write`) },
93
93
  { matcher: "Write", hooks: hook(`${prefix} post-write`) },
94
+ // Tool-output compression (spec 21) — a no-op until enabled via config.
95
+ { matcher: "Bash", hooks: hook(`${prefix} post-tool`) },
96
+ { matcher: "Grep", hooks: hook(`${prefix} post-tool`) },
94
97
  ],
95
98
  };
96
99
  }
@@ -102,7 +105,8 @@ function isMinkCommand(cmd: string): boolean {
102
105
  cmd.includes("pre-read") ||
103
106
  cmd.includes("post-read") ||
104
107
  cmd.includes("pre-write") ||
105
- cmd.includes("post-write");
108
+ cmd.includes("post-write") ||
109
+ cmd.includes("post-tool");
106
110
  if (!hasMinkSubcommand) return false;
107
111
  // Match the new bin-shim format (`mink <subcmd>` or `/abs/path/to/mink <subcmd>`)
108
112
  // as well as legacy formats (`bun run .../cli.js ...`, `node .../cli.js ...`,
@@ -9,6 +9,8 @@ import { estimateTokens, isBinaryFile } from "../core/token-estimate";
9
9
  import { extractDescription } from "../core/description";
10
10
  import { createActionLogWriter } from "../core/action-log";
11
11
  import { getOrCreateDeviceId } from "../core/device";
12
+ import { compressToolOutput } from "../core/compress-tool-output";
13
+ import { emitUpdatedToolOutput } from "../core/hook-output";
12
14
  import type { SessionState } from "../types/session";
13
15
  import type { FileIndexEntry, IndexLookup } from "../types/file-index";
14
16
  import type { PostToolUseInput } from "../types/hook-input";
@@ -202,6 +204,22 @@ export async function postRead(cwd: string): Promise<void> {
202
204
 
203
205
  // Persist state
204
206
  atomicWriteJson(sessionPath(cwd), state);
207
+
208
+ // Tool-output compression (spec 21). Substitute a compact, reversible
209
+ // summary for a large whole-file read. Skipped for ranged reads (their
210
+ // output is only a slice) and a no-op unless compression is enabled. Uses
211
+ // the on-disk content as the canonical original so signature extraction
212
+ // works on raw source and `mink retrieve` returns the file itself.
213
+ const isRanged =
214
+ input.tool_input.offset != null || input.tool_input.limit != null;
215
+ if (!isRanged && content && content.length > 0) {
216
+ try {
217
+ const outcome = compressToolOutput(cwd, "Read", content, filePath);
218
+ if (outcome) emitUpdatedToolOutput(outcome.updatedToolOutput);
219
+ } catch {
220
+ // Compression is advisory — never break the read over it.
221
+ }
222
+ }
205
223
  } catch {
206
224
  // Never crash — exit silently
207
225
  } finally {
@@ -0,0 +1,48 @@
1
+ // Generic PostToolUse compression hook (spec 21) for tools that produce large,
2
+ // non-file output — Bash, Grep/Glob, and MCP tools. The Read tool is handled by
3
+ // post-read (which has the on-disk content and ranged-read awareness); this hook
4
+ // compresses the payload text directly.
5
+ //
6
+ // Like every Mink hook: non-blocking, time-boxed, and silent on failure. It is a
7
+ // no-op unless compression is enabled, so wiring it up costs nothing until the
8
+ // user opts in.
9
+
10
+ import { readStdinJson } from "../core/stdin";
11
+ import { extractToolOutputText, emitUpdatedToolOutput } from "../core/hook-output";
12
+ import { compressToolOutput } from "../core/compress-tool-output";
13
+ import type { PostToolUseInput } from "../types/hook-input";
14
+
15
+ function isPostToolUseInput(value: unknown): value is PostToolUseInput {
16
+ if (value === null || typeof value !== "object") return false;
17
+ const obj = value as Record<string, unknown>;
18
+ return typeof obj.tool_name === "string";
19
+ }
20
+
21
+ // Tools whose output we compress here. Read is excluded (post-read owns it).
22
+ function isCompressibleTool(toolName: string): boolean {
23
+ return (
24
+ toolName === "Bash" ||
25
+ toolName === "Grep" ||
26
+ toolName === "Glob" ||
27
+ toolName.startsWith("mcp__")
28
+ );
29
+ }
30
+
31
+ export async function postTool(cwd: string): Promise<void> {
32
+ const timer = setTimeout(() => process.exit(0), 5000);
33
+ try {
34
+ const input = await readStdinJson();
35
+ if (!isPostToolUseInput(input)) return;
36
+ if (!isCompressibleTool(input.tool_name)) return;
37
+
38
+ const output = extractToolOutputText(input);
39
+ if (!output) return;
40
+
41
+ const outcome = compressToolOutput(cwd, input.tool_name, output);
42
+ if (outcome) emitUpdatedToolOutput(outcome.updatedToolOutput);
43
+ } catch {
44
+ // Never crash — exit silently.
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
@@ -0,0 +1,32 @@
1
+ // `mink retrieve <token>` — return the byte-exact original of a previously
2
+ // compressed tool output (spec 21 §Reversibility). Prints the original to
3
+ // stdout on a hit; on a miss (unknown or expired token) it prints a short,
4
+ // non-fatal notice to stderr and exits 0 so the assistant is never stranded by
5
+ // an error.
6
+
7
+ import { CompressionCacheRepo } from "../repositories/compression-cache-repo";
8
+
9
+ export function retrieve(cwd: string, args: string[]): void {
10
+ const token = args[0];
11
+ if (!token) {
12
+ process.stderr.write("[mink] usage: mink retrieve <token>\n");
13
+ return;
14
+ }
15
+
16
+ let entry = null;
17
+ try {
18
+ entry = CompressionCacheRepo.for(cwd).get(token);
19
+ } catch {
20
+ // Treat any storage error as a miss — never throw at the assistant.
21
+ entry = null;
22
+ }
23
+
24
+ if (!entry) {
25
+ process.stderr.write(
26
+ `[mink] no retrievable output for token "${token}" (unknown or expired)\n`
27
+ );
28
+ return;
29
+ }
30
+
31
+ process.stdout.write(entry.content);
32
+ }
@@ -0,0 +1,108 @@
1
+ // Deterministic, dependency-free code skeleton extraction (spec 21 phase 3).
2
+ //
3
+ // Produces a structural outline of source: top-level declarations and the direct
4
+ // members of classes/interfaces, with function/method bodies elided to "{ … }".
5
+ // It is brace-depth aware (with strings and comments masked so stray braces don't
6
+ // desync the depth), which lets it descend into a class to capture method
7
+ // signatures while suppressing the statements inside those methods.
8
+ //
9
+ // This is a heuristic skeleton, not a real parser — Mink stays zero-dependency.
10
+ // Because tool-output compression is reversible (the original is cached), a
11
+ // slightly imperfect skeleton is harmless: the model can always `mink retrieve`.
12
+ // The same extractor is intended to enrich the file index later.
13
+
14
+ const MAX_SIGNATURES = 80;
15
+ const INDENT = " ";
16
+
17
+ // Declarations that always anchor the skeleton.
18
+ const DECL_ALWAYS =
19
+ /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function|class|interface|type|enum|namespace|module|def|fn|func|impl|struct|trait)\b/;
20
+ // Variable declarations only matter to the public surface when exported.
21
+ const DECL_EXPORTED_VAR = /^\s*export\s+(?:default\s+)?(?:const|let|var)\b/;
22
+ // Inside a class/interface body (depth >= 1): method signatures and fields.
23
+ const MEMBER =
24
+ /^\s*(?:public\s+|private\s+|protected\s+|readonly\s+|static\s+|async\s+|get\s+|set\s+|#)*[\w$]+\??\s*(?:\(|:|=)/;
25
+ // Markdown headings (only honoured for markdown files).
26
+ const HEADING = /^#{1,6}\s+\S/;
27
+ // Keywords whose block we descend into to capture members rather than elide.
28
+ const DESCEND = /\b(?:class|interface|enum|namespace|module|struct|trait|impl)\b/;
29
+
30
+ function countChar(s: string, c: string): number {
31
+ let n = 0;
32
+ for (let i = 0; i < s.length; i++) if (s[i] === c) n++;
33
+ return n;
34
+ }
35
+
36
+ // Net brace delta for a line, with strings and comments masked so braces inside
37
+ // them don't affect depth tracking.
38
+ function netBraces(line: string): number {
39
+ let s = line.replace(/\/\/.*$/, "");
40
+ s = s.replace(/\/\*.*?\*\//g, "");
41
+ s = s.replace(/"(?:\\.|[^"\\])*"/g, '""');
42
+ s = s.replace(/'(?:\\.|[^'\\])*'/g, "''");
43
+ s = s.replace(/`(?:\\.|[^`\\])*`/g, "``");
44
+ return countChar(s, "{") - countChar(s, "}");
45
+ }
46
+
47
+ function stripOpenBrace(sig: string): string {
48
+ return sig.replace(/\{\s*$/, "").trimEnd();
49
+ }
50
+
51
+ export interface CodeSkeleton {
52
+ lines: string[];
53
+ totalLines: number;
54
+ }
55
+
56
+ // Extract a skeleton, or null when the content has no recognisable structure
57
+ // (the caller then falls back to a generic text window).
58
+ export function extractCodeSkeleton(
59
+ content: string,
60
+ opts: { markdown?: boolean } = {}
61
+ ): CodeSkeleton | null {
62
+ const rawLines = content.split("\n");
63
+ const totalLines =
64
+ rawLines.length > 0 && rawLines[rawLines.length - 1] === ""
65
+ ? rawLines.length - 1
66
+ : rawLines.length;
67
+
68
+ const out: string[] = [];
69
+ let depth = 0;
70
+ let suppress = Infinity; // suppress lines while inside an elided function body
71
+
72
+ for (const line of rawLines) {
73
+ if (out.length >= MAX_SIGNATURES) break;
74
+ const start = depth;
75
+ const net = netBraces(line);
76
+
77
+ if (start < suppress) {
78
+ const isHeading = opts.markdown === true && HEADING.test(line);
79
+ const captured =
80
+ isHeading ||
81
+ DECL_ALWAYS.test(line) ||
82
+ DECL_EXPORTED_VAR.test(line) ||
83
+ (start >= 1 && MEMBER.test(line));
84
+
85
+ if (captured) {
86
+ // Trim the source indentation; we re-indent by structural depth.
87
+ const sig = line.trim();
88
+ if (net > 0) {
89
+ if (DESCEND.test(line) && !isHeading) {
90
+ out.push(INDENT.repeat(start) + stripOpenBrace(sig) + " {");
91
+ // descend — keep capturing members at the next depth
92
+ } else {
93
+ out.push(INDENT.repeat(start) + stripOpenBrace(sig) + " { … }");
94
+ suppress = start + 1; // skip this body's contents
95
+ }
96
+ } else {
97
+ out.push(INDENT.repeat(start) + sig);
98
+ }
99
+ }
100
+ }
101
+
102
+ depth = Math.max(0, depth + net);
103
+ if (depth < suppress) suppress = Infinity; // left the elided body
104
+ }
105
+
106
+ if (out.length === 0) return null;
107
+ return { lines: out, totalLines };
108
+ }
@@ -0,0 +1,127 @@
1
+ // Compression pipeline orchestrator (spec 21). Ties together the config/holdout
2
+ // decisions, the pure engine, the reversible cache, and the ledger. Returns the
3
+ // replacement text to emit, or null to pass the original through unchanged.
4
+ //
5
+ // Invariants:
6
+ // - Disabled by default (config gate) → no-op.
7
+ // - Reversible or nothing: the original is stored BEFORE we return a compressed
8
+ // result; if storage fails we pass the original through, so a compressed
9
+ // result is never shown without a retrievable original (spec 21 edge case).
10
+ // - Every failure degrades to "no compression" — a hook must never throw.
11
+ // - Holdout arms pass the original through but are still measured.
12
+
13
+ import {
14
+ loadCompressionConfig,
15
+ isEligible,
16
+ meetsMinSavings,
17
+ selectHoldout,
18
+ } from "./compression";
19
+ import { countTokens } from "./token-estimate";
20
+ import { compressOutput, detectContentKind } from "./output-compression";
21
+ import type { CompressionResult } from "../types/compression";
22
+ import { CompressionCacheRepo } from "../repositories/compression-cache-repo";
23
+ import { TokenLedgerRepo } from "../repositories/token-ledger-repo";
24
+
25
+ // Deterministic FNV-1a → hex, used as a stable per-event key so an identical
26
+ // output always lands in the same holdout arm (spec 21 edge case).
27
+ function contentKey(s: string): string {
28
+ let h = 0x811c9dc5;
29
+ for (let i = 0; i < s.length; i++) {
30
+ h ^= s.charCodeAt(i);
31
+ h = Math.imul(h, 0x01000193);
32
+ }
33
+ return (h >>> 0).toString(16);
34
+ }
35
+
36
+ // Render the replacement: stable compressed body first, volatile retrieval
37
+ // footer (random token) last — matching Mink's "volatile at the end" cache
38
+ // discipline so the body forms a stable prefix.
39
+ function render(result: CompressionResult, token: string): string {
40
+ return (
41
+ result.compressed +
42
+ "\n\n" +
43
+ `— mink: compressed ${result.kind} output (${result.omittedNote}). ` +
44
+ `Full original: mink retrieve ${token}`
45
+ );
46
+ }
47
+
48
+ function safeRecord(
49
+ cwd: string,
50
+ toolName: string,
51
+ contentKind: string,
52
+ originalTokens: number,
53
+ compressedTokens: number,
54
+ holdout: boolean
55
+ ): void {
56
+ try {
57
+ TokenLedgerRepo.for(cwd).recordCompression({
58
+ toolName,
59
+ contentKind,
60
+ originalTokens,
61
+ compressedTokens,
62
+ holdout,
63
+ });
64
+ } catch {
65
+ // Measurement is best-effort — never block the hook over a ledger write.
66
+ }
67
+ }
68
+
69
+ export interface CompressOutcome {
70
+ updatedToolOutput: string;
71
+ token: string;
72
+ }
73
+
74
+ export function compressToolOutput(
75
+ cwd: string,
76
+ toolName: string,
77
+ output: string,
78
+ filePath?: string
79
+ ): CompressOutcome | null {
80
+ let cfg;
81
+ try {
82
+ cfg = loadCompressionConfig();
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (!cfg.enabled) return null;
87
+ if (typeof output !== "string" || output.length === 0) return null;
88
+
89
+ const originalTokens = countTokens(output);
90
+ if (!isEligible(originalTokens, cfg)) return null;
91
+
92
+ const eventKey = contentKey(output);
93
+
94
+ // Holdout arm: pass the original through, but record it as a control.
95
+ if (selectHoldout(eventKey, cfg.holdoutFraction)) {
96
+ const kind = detectContentKind(toolName, output, filePath);
97
+ safeRecord(cwd, toolName, kind, originalTokens, originalTokens, true);
98
+ return null;
99
+ }
100
+
101
+ const result = compressOutput(toolName, output, filePath);
102
+ if (!result) return null;
103
+
104
+ const token = CompressionCacheRepo.newToken();
105
+ const replacement = render(result, token);
106
+ const compressedTokens = countTokens(replacement);
107
+
108
+ // Discard a weak compression and pass the original through.
109
+ if (!meetsMinSavings(originalTokens, compressedTokens, cfg)) return null;
110
+
111
+ // Store the original FIRST. If we cannot, do not compress — never show a
112
+ // compressed result without a retrievable original.
113
+ try {
114
+ CompressionCacheRepo.for(cwd).store({
115
+ toolName,
116
+ contentKind: result.kind,
117
+ content: output,
118
+ retentionHours: cfg.retentionHours,
119
+ token,
120
+ });
121
+ } catch {
122
+ return null;
123
+ }
124
+
125
+ safeRecord(cwd, toolName, result.kind, originalTokens, compressedTokens, false);
126
+ return { updatedToolOutput: replacement, token };
127
+ }
@@ -0,0 +1,81 @@
1
+ // Tool-output compression — configuration and decision logic (spec 21).
2
+ //
3
+ // This module is pure: it reads config and makes the eligibility / holdout /
4
+ // min-savings decisions. It never touches the database or the tool payload, so
5
+ // it is trivially testable. Phase 2 wires the actual compressors and the
6
+ // reversible cache on top of these decisions; Phase 1 ships the measurement
7
+ // instrument and leaves `enabled` off by default.
8
+
9
+ import { resolveConfigValue } from "./global-config";
10
+ import type { ConfigKey } from "../types/config";
11
+
12
+ export interface CompressionConfig {
13
+ enabled: boolean;
14
+ thresholdTokens: number;
15
+ minSavingsRatio: number;
16
+ holdoutFraction: number;
17
+ retentionHours: number;
18
+ }
19
+
20
+ function numberValue(key: ConfigKey, fallback: number, min: number, max: number): number {
21
+ const raw = resolveConfigValue(key).value;
22
+ const n = Number(raw);
23
+ if (!Number.isFinite(n)) return fallback;
24
+ return Math.min(max, Math.max(min, n));
25
+ }
26
+
27
+ export function loadCompressionConfig(): CompressionConfig {
28
+ return {
29
+ enabled: resolveConfigValue("compression.enabled").value === "true",
30
+ thresholdTokens: numberValue("compression.threshold-tokens", 800, 0, Number.MAX_SAFE_INTEGER),
31
+ minSavingsRatio: numberValue("compression.min-savings-ratio", 0.25, 0, 1),
32
+ holdoutFraction: numberValue("compression.holdout-fraction", 0.1, 0, 1),
33
+ retentionHours: numberValue("compression.retention-hours", 168, 0, Number.MAX_SAFE_INTEGER),
34
+ };
35
+ }
36
+
37
+ // An output is eligible for compression only once it crosses the size threshold;
38
+ // small outputs are never touched (spec 21 §Eligibility).
39
+ export function isEligible(originalTokens: number, config: CompressionConfig): boolean {
40
+ return config.enabled && originalTokens >= config.thresholdTokens;
41
+ }
42
+
43
+ // A compression attempt is kept only if it saves at least the configured
44
+ // fraction of tokens; otherwise the original is used (spec 21 §Thresholds).
45
+ export function meetsMinSavings(
46
+ originalTokens: number,
47
+ compressedTokens: number,
48
+ config: CompressionConfig
49
+ ): boolean {
50
+ if (originalTokens <= 0) return false;
51
+ const ratio = (originalTokens - compressedTokens) / originalTokens;
52
+ return ratio >= config.minSavingsRatio;
53
+ }
54
+
55
+ export function measuredSavings(originalTokens: number, compressedTokens: number): number {
56
+ return Math.max(0, originalTokens - compressedTokens);
57
+ }
58
+
59
+ // Deterministic FNV-1a hash → a stable fraction in [0, 1) for a given key. Used
60
+ // so holdout selection is stable per event: the same event always lands in the
61
+ // same arm, which keeps measurement from being double-counted (spec 21 edge
62
+ // case "Holdout selection must be stable for a given event").
63
+ function hashUnitInterval(key: string): number {
64
+ let h = 0x811c9dc5;
65
+ for (let i = 0; i < key.length; i++) {
66
+ h ^= key.charCodeAt(i);
67
+ h = Math.imul(h, 0x01000193);
68
+ }
69
+ // Map the 32-bit unsigned result into [0, 1).
70
+ return (h >>> 0) / 0x100000000;
71
+ }
72
+
73
+ // Decide whether a given event is held out (left uncompressed as a control).
74
+ // Selection is deterministic in `eventKey`, so callers must pass a key that is
75
+ // stable for the event (e.g. a hash of the original output) and not, say, a
76
+ // timestamp.
77
+ export function selectHoldout(eventKey: string, fraction: number): boolean {
78
+ if (fraction <= 0) return false;
79
+ if (fraction >= 1) return true;
80
+ return hashUnitInterval(eventKey) < fraction;
81
+ }
@@ -0,0 +1,42 @@
1
+ // Helpers for PostToolUse hooks that replace a tool's result (spec 21). The
2
+ // replacement mechanism is Claude Code's `hookSpecificOutput.updatedToolOutput`
3
+ // (verified against the hooks reference): whatever JSON we print to stdout here
4
+ // substitutes the original output before the model sees it.
5
+
6
+ import type { PostToolUseInput } from "../types/hook-input";
7
+
8
+ // Best-effort extraction of the human-visible text from a PostToolUse payload,
9
+ // across the shapes Claude Code uses for different tools (Bash stdout, Grep
10
+ // content, MCP results). Returns null when no text is present, in which case the
11
+ // caller must not compress (there is nothing to safely capture or replace).
12
+ export function extractToolOutputText(input: PostToolUseInput): string | null {
13
+ const tr = input.tool_response as Record<string, unknown> | undefined;
14
+ if (tr) {
15
+ if (typeof tr.content === "string") return tr.content;
16
+ if (Array.isArray(tr.content)) {
17
+ const parts = (tr.content as Array<{ text?: unknown }>)
18
+ .map((p) => (p && typeof p.text === "string" ? p.text : ""))
19
+ .filter((s) => s.length > 0);
20
+ if (parts.length > 0) return parts.join("");
21
+ }
22
+ if (typeof tr.stdout === "string" && tr.stdout.length > 0) return tr.stdout;
23
+ if (typeof tr.text === "string") return tr.text;
24
+ const file = tr.file as { content?: unknown } | undefined;
25
+ if (file && typeof file.content === "string") return file.content;
26
+ }
27
+ const to = input.tool_output;
28
+ if (to && typeof to.content === "string") return to.content;
29
+ return null;
30
+ }
31
+
32
+ // Print the replacement so Claude Code swaps it in for the original output.
33
+ export function emitUpdatedToolOutput(text: string): void {
34
+ process.stdout.write(
35
+ JSON.stringify({
36
+ hookSpecificOutput: {
37
+ hookEventName: "PostToolUse",
38
+ updatedToolOutput: text,
39
+ },
40
+ })
41
+ );
42
+ }