@drewpayment/mink 0.13.0-beta.1 → 0.13.0-beta.3

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 (105) hide show
  1. package/README.md +4 -20
  2. package/dashboard/out/404.html +1 -1
  3. package/dashboard/out/_next/static/U9AeObddt4LmJkKRZpEfy/_buildManifest.js +1 -0
  4. package/dashboard/out/_next/static/chunks/app/(panels)/activity/page-c285fb9f63d9a82a.js +1 -0
  5. package/dashboard/out/_next/static/chunks/app/(panels)/bugs/page-f3ba7d8f50a96568.js +1 -0
  6. package/dashboard/out/_next/static/chunks/app/(panels)/capture/page-e004bec9af99a244.js +1 -0
  7. package/dashboard/out/_next/static/chunks/app/(panels)/compression/page-21e1af119b3f81ff.js +1 -0
  8. package/dashboard/out/_next/static/chunks/app/(panels)/config/page-d47fb6f588ccfd4b.js +1 -0
  9. package/dashboard/out/_next/static/chunks/app/(panels)/daemon/page-52f913e751416717.js +1 -0
  10. package/dashboard/out/_next/static/chunks/app/(panels)/design/page-53a76719b9af5830.js +1 -0
  11. package/dashboard/out/_next/static/chunks/app/(panels)/discord/page-04502d12c4a96cf7.js +1 -0
  12. package/dashboard/out/_next/static/chunks/app/(panels)/file-index/page-a1bd10e04bb219d9.js +1 -0
  13. package/dashboard/out/_next/static/chunks/app/(panels)/insights/page-7367274963571b6b.js +1 -0
  14. package/dashboard/out/_next/static/chunks/app/(panels)/learning/{page-b766adc79099adb4.js → page-4a03cf7b9a6106fd.js} +1 -1
  15. package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-38b8430b5c56e807.js +1 -0
  16. package/dashboard/out/_next/static/chunks/app/(panels)/scheduler/page-510b78c9b0a61012.js +1 -0
  17. package/dashboard/out/_next/static/chunks/app/(panels)/sync/page-b7215c2a29a7d7a7.js +1 -0
  18. package/dashboard/out/_next/static/chunks/app/(panels)/tokens/page-1be7ed35a5c9bd39.js +1 -0
  19. package/dashboard/out/_next/static/chunks/app/(panels)/waste/page-24a726e6d63f771a.js +1 -0
  20. package/dashboard/out/_next/static/chunks/app/(panels)/wiki/page-230d2d1cae6507a8.js +1 -0
  21. package/dashboard/out/_next/static/chunks/app/layout-70a6d18f8e464960.js +1 -0
  22. package/dashboard/out/action-log.html +1 -1
  23. package/dashboard/out/action-log.txt +4 -4
  24. package/dashboard/out/activity.html +1 -1
  25. package/dashboard/out/activity.txt +5 -5
  26. package/dashboard/out/bugs.html +1 -1
  27. package/dashboard/out/bugs.txt +5 -5
  28. package/dashboard/out/capture.html +1 -1
  29. package/dashboard/out/capture.txt +5 -5
  30. package/dashboard/out/compression.html +1 -0
  31. package/dashboard/out/compression.txt +24 -0
  32. package/dashboard/out/config.html +1 -1
  33. package/dashboard/out/config.txt +5 -5
  34. package/dashboard/out/daemon.html +1 -1
  35. package/dashboard/out/daemon.txt +5 -5
  36. package/dashboard/out/design.html +1 -1
  37. package/dashboard/out/design.txt +5 -5
  38. package/dashboard/out/discord.html +1 -1
  39. package/dashboard/out/discord.txt +5 -5
  40. package/dashboard/out/file-index.html +1 -1
  41. package/dashboard/out/file-index.txt +5 -5
  42. package/dashboard/out/index.html +1 -1
  43. package/dashboard/out/index.txt +4 -4
  44. package/dashboard/out/insights.html +1 -1
  45. package/dashboard/out/insights.txt +5 -5
  46. package/dashboard/out/learning.html +1 -1
  47. package/dashboard/out/learning.txt +5 -5
  48. package/dashboard/out/overview.html +1 -1
  49. package/dashboard/out/overview.txt +5 -5
  50. package/dashboard/out/scheduler.html +1 -1
  51. package/dashboard/out/scheduler.txt +5 -5
  52. package/dashboard/out/sync.html +1 -1
  53. package/dashboard/out/sync.txt +5 -5
  54. package/dashboard/out/tokens.html +1 -1
  55. package/dashboard/out/tokens.txt +5 -5
  56. package/dashboard/out/waste.html +1 -1
  57. package/dashboard/out/waste.txt +5 -5
  58. package/dashboard/out/wiki.html +1 -1
  59. package/dashboard/out/wiki.txt +5 -5
  60. package/dist/cli.bun.js +1300 -908
  61. package/dist/cli.node.js +1319 -928
  62. package/package.json +1 -1
  63. package/src/cli.ts +17 -20
  64. package/src/commands/init.ts +14 -123
  65. package/src/commands/post-read.ts +18 -0
  66. package/src/commands/post-tool.ts +48 -0
  67. package/src/commands/retrieve.ts +32 -0
  68. package/src/commands/status.ts +13 -1
  69. package/src/core/code-skeleton.ts +108 -0
  70. package/src/core/compress-tool-output.ts +127 -0
  71. package/src/core/compression.ts +81 -0
  72. package/src/core/dashboard-api.ts +20 -1
  73. package/src/core/dashboard-server.ts +3 -0
  74. package/src/core/hook-output.ts +42 -0
  75. package/src/core/output-compression.ts +252 -0
  76. package/src/core/token-estimate.ts +40 -0
  77. package/src/repositories/compression-cache-repo.ts +97 -0
  78. package/src/repositories/token-ledger-repo.ts +142 -0
  79. package/src/storage/schema.ts +50 -1
  80. package/src/types/compression.ts +29 -0
  81. package/src/types/config.ts +40 -0
  82. package/src/types/dashboard.ts +22 -1
  83. package/src/types/hook-input.ts +4 -0
  84. package/src/types/token-ledger.ts +55 -0
  85. package/dashboard/out/_next/static/UWfkbJY4zr9fSt7O-CAge/_buildManifest.js +0 -1
  86. package/dashboard/out/_next/static/chunks/app/(panels)/activity/page-096a97ba539d5323.js +0 -1
  87. package/dashboard/out/_next/static/chunks/app/(panels)/bugs/page-449d31c133432458.js +0 -1
  88. package/dashboard/out/_next/static/chunks/app/(panels)/capture/page-c6617aa0a8a7333e.js +0 -1
  89. package/dashboard/out/_next/static/chunks/app/(panels)/config/page-aa0a0623b3fdd0d8.js +0 -1
  90. package/dashboard/out/_next/static/chunks/app/(panels)/daemon/page-7cd3fac2f5d87a0d.js +0 -1
  91. package/dashboard/out/_next/static/chunks/app/(panels)/design/page-5304675c96b6793b.js +0 -1
  92. package/dashboard/out/_next/static/chunks/app/(panels)/discord/page-9940dde80ba2a69e.js +0 -1
  93. package/dashboard/out/_next/static/chunks/app/(panels)/file-index/page-ecd8a753614e981e.js +0 -1
  94. package/dashboard/out/_next/static/chunks/app/(panels)/insights/page-7909d8beb8d8ef7a.js +0 -1
  95. package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-7a9e86dcde67d6a9.js +0 -1
  96. package/dashboard/out/_next/static/chunks/app/(panels)/scheduler/page-a88f93204c9742a1.js +0 -1
  97. package/dashboard/out/_next/static/chunks/app/(panels)/sync/page-8a9ad4c36aa6cb65.js +0 -1
  98. package/dashboard/out/_next/static/chunks/app/(panels)/tokens/page-8dac7d50d4db2756.js +0 -1
  99. package/dashboard/out/_next/static/chunks/app/(panels)/waste/page-bcf56144faf7d133.js +0 -1
  100. package/dashboard/out/_next/static/chunks/app/(panels)/wiki/page-a32fdbd0bf58b30b.js +0 -1
  101. package/dashboard/out/_next/static/chunks/app/layout-782cd26e0ccc4514.js +0 -1
  102. package/src/core/agent-detect.ts +0 -88
  103. package/src/core/agent-pi.ts +0 -314
  104. package/src/core/prompt.ts +0 -27
  105. /package/dashboard/out/_next/static/{UWfkbJY4zr9fSt7O-CAge → U9AeObddt4LmJkKRZpEfy}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.13.0-beta.1",
3
+ "version": "0.13.0-beta.3",
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
@@ -16,24 +16,8 @@ switch (command) {
16
16
  break;
17
17
 
18
18
  case "init": {
19
- const { init, resolveTargetsFromFlag } = await import("./commands/init");
20
- const args = process.argv.slice(3);
21
- const agentFlagIndex = args.findIndex(
22
- (a) => a === "--agent" || a.startsWith("--agent=")
23
- );
24
- let agentValue: string | undefined;
25
- if (agentFlagIndex !== -1) {
26
- const a = args[agentFlagIndex];
27
- agentValue = a.includes("=") ? a.split("=").slice(1).join("=") : args[agentFlagIndex + 1];
28
- }
29
- const yes = args.includes("--yes") || args.includes("-y");
30
- const targets = agentValue ? resolveTargetsFromFlag(agentValue) : undefined;
31
- if (agentValue && (!targets || targets.length === 0)) {
32
- console.error(`[mink] unknown --agent value: ${agentValue}`);
33
- console.error(" Valid: claude, pi, all (or a comma-separated list)");
34
- process.exit(1);
35
- }
36
- await init(cwd, { targets, interactive: !yes });
19
+ const { init } = await import("./commands/init");
20
+ await init(cwd);
37
21
  break;
38
22
  }
39
23
 
@@ -69,6 +53,12 @@ switch (command) {
69
53
  break;
70
54
  }
71
55
 
56
+ case "post-tool": {
57
+ const { postTool } = await import("./commands/post-tool");
58
+ await postTool(cwd);
59
+ break;
60
+ }
61
+
72
62
  case "pre-write": {
73
63
  const { preWrite } = await import("./commands/pre-write");
74
64
  await preWrite(cwd);
@@ -87,6 +77,12 @@ switch (command) {
87
77
  break;
88
78
  }
89
79
 
80
+ case "retrieve": {
81
+ const { retrieve } = await import("./commands/retrieve");
82
+ retrieve(cwd, process.argv.slice(3));
83
+ break;
84
+ }
85
+
90
86
  case "cron": {
91
87
  const { cron } = await import("./commands/cron");
92
88
  await cron(cwd, process.argv.slice(3));
@@ -237,8 +233,7 @@ switch (command) {
237
233
  console.log("Usage: mink <command> [options]");
238
234
  console.log();
239
235
  console.log("Commands:");
240
- console.log(" init [--agent X] [--yes] Initialize Mink in the current project");
241
- console.log(" --agent claude|pi|all (default: detect & prompt)");
236
+ console.log(" init Initialize Mink in the current project");
242
237
  console.log(" status Display project health at a glance");
243
238
  console.log(" scan [--check] Force a full file index rescan");
244
239
  console.log(" config [key] [value] Manage global user settings");
@@ -280,6 +275,7 @@ switch (command) {
280
275
  console.log(" restore [backup] Restore state from a backup");
281
276
  console.log(" bug search <term> Search the bug log");
282
277
  console.log(" detect-waste Detect and flag wasteful patterns");
278
+ console.log(" retrieve <token> Return a compressed tool output's original (spec 21)");
283
279
  console.log(" reflect Generate learning memory reflections");
284
280
  console.log(" designqc [target] Capture design screenshots (spec 13)");
285
281
  console.log(" framework-advisor Generate framework advisor knowledge file (spec 14)");
@@ -289,6 +285,7 @@ switch (command) {
289
285
  console.log(" session-stop Finalize session and log data");
290
286
  console.log(" pre-read / post-read File read hooks");
291
287
  console.log(" pre-write / post-write File write hooks");
288
+ console.log(" post-tool Tool-output compression hook (Bash/Grep/MCP, spec 21)");
292
289
  break;
293
290
 
294
291
  default:
@@ -12,23 +12,6 @@ import {
12
12
  isInsideVault,
13
13
  vaultProjects,
14
14
  } from "../core/vault";
15
- import {
16
- type AgentId,
17
- AGENTS,
18
- detectAgents,
19
- resolveTargetsFromFlag,
20
- } from "../core/agent-detect";
21
- import { installPi } from "../core/agent-pi";
22
- import { ask, stdinIsInteractive } from "../core/prompt";
23
-
24
- export { resolveTargetsFromFlag };
25
-
26
- export interface InitOptions {
27
- /** Explicit set of hosts to wire. When omitted, Mink detects and/or prompts. */
28
- targets?: AgentId[];
29
- /** Allow an interactive agent-selection prompt when stdin is a TTY. */
30
- interactive?: boolean;
31
- }
32
15
 
33
16
  interface HookCommand {
34
17
  type: "command";
@@ -108,6 +91,9 @@ export function buildHooksConfig(cliPath: string): HooksConfig {
108
91
  { matcher: "Read", hooks: hook(`${prefix} post-read`) },
109
92
  { matcher: "Edit", hooks: hook(`${prefix} post-write`) },
110
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`) },
111
97
  ],
112
98
  };
113
99
  }
@@ -119,7 +105,8 @@ function isMinkCommand(cmd: string): boolean {
119
105
  cmd.includes("pre-read") ||
120
106
  cmd.includes("post-read") ||
121
107
  cmd.includes("pre-write") ||
122
- cmd.includes("post-write");
108
+ cmd.includes("post-write") ||
109
+ cmd.includes("post-tool");
123
110
  if (!hasMinkSubcommand) return false;
124
111
  // Match the new bin-shim format (`mink <subcmd>` or `/abs/path/to/mink <subcmd>`)
125
112
  // as well as legacy formats (`bun run .../cli.js ...`, `node .../cli.js ...`,
@@ -185,80 +172,17 @@ export function mergeHooksIntoSettings(
185
172
  atomicWriteJson(settingsPath, existing);
186
173
  }
187
174
 
188
- /** Wire Mink into Claude Code: settings.json hooks + the project rule file. */
189
- export function installClaude(
190
- cwd: string,
191
- cliPath: string
192
- ): { settingsPath: string; rulePath: string } {
193
- const settingsPath = resolve(cwd, ".claude", "settings.json");
194
- mergeHooksIntoSettings(settingsPath, buildHooksConfig(cliPath));
195
- const rulePath = writeMinkRule(cwd);
196
- return { settingsPath, rulePath };
197
- }
198
-
199
- /**
200
- * Decide which hosts to wire. Explicit `targets` win; otherwise Mink detects
201
- * installed hosts and — only when running interactively at a TTY — asks the
202
- * user to confirm. Non-interactive runs fall back to the detected set, and to
203
- * Claude Code when nothing is detected (preserving Mink's original behavior).
204
- */
205
- export async function resolveTargets(
206
- cwd: string,
207
- opts: InitOptions
208
- ): Promise<AgentId[]> {
209
- if (opts.targets && opts.targets.length > 0) return opts.targets;
210
-
211
- const detected = detectAgents(cwd);
212
- const detectedIds = detected.filter((a) => a.detected).map((a) => a.id);
213
-
214
- if (opts.interactive && stdinIsInteractive()) {
215
- return promptForAgents(detected, detectedIds);
216
- }
217
-
218
- return detectedIds.length > 0 ? detectedIds : ["claude"];
219
- }
220
-
221
- async function promptForAgents(
222
- detected: ReturnType<typeof detectAgents>,
223
- defaults: AgentId[]
224
- ): Promise<AgentId[]> {
225
- const fallback: AgentId[] = defaults.length > 0 ? defaults : ["claude"];
226
-
227
- console.log("Which assistant(s) should Mink work with?");
228
- detected.forEach((a, i) => {
229
- const tag = a.detected ? ` (detected — ${a.signals.join(", ")})` : "";
230
- console.log(` ${i + 1}) ${a.label}${tag}`);
231
- });
232
-
233
- const answer = (
234
- await ask(
235
- `Enter numbers (comma-separated), 'a' for all [default: ${fallback.join(", ")}]: `
236
- )
237
- )
238
- .trim()
239
- .toLowerCase();
240
-
241
- if (answer === "") return fallback;
242
- if (answer === "a" || answer === "all") return AGENTS.map((a) => a.id);
243
-
244
- const picked = answer
245
- .split(",")
246
- .map((s) => parseInt(s.trim(), 10))
247
- .filter((n) => Number.isInteger(n) && n >= 1 && n <= detected.length)
248
- .map((n) => detected[n - 1].id);
249
-
250
- return picked.length > 0 ? picked : fallback;
251
- }
252
-
253
175
  function isExistingInstallation(cwd: string): boolean {
254
176
  const dir = projectDir(cwd);
255
177
  if (!existsSync(dir)) return false;
256
178
  return existsSync(join(dir, "file-index.json"));
257
179
  }
258
180
 
259
- export async function init(cwd: string, opts: InitOptions = {}): Promise<void> {
181
+ export async function init(cwd: string): Promise<void> {
260
182
  const runtime = detectRuntime();
261
183
  const cliPath = resolveCliPath();
184
+ const hooks = buildHooksConfig(cliPath);
185
+ const settingsPath = resolve(cwd, ".claude", "settings.json");
262
186
  const dir = projectDir(cwd);
263
187
  const upgrading = isExistingInstallation(cwd);
264
188
 
@@ -269,25 +193,8 @@ export async function init(cwd: string, opts: InitOptions = {}): Promise<void> {
269
193
  console.log(` backup: ${backupName}`);
270
194
  }
271
195
 
272
- const targets = await resolveTargets(cwd, opts);
273
-
274
- // Wire each selected host. Each installer is idempotent — re-running replaces
275
- // Mink's prior entries rather than duplicating them — and touches only that
276
- // host's configuration.
277
- const wired: Record<string, string[]> = {};
278
- for (const target of targets) {
279
- if (target === "claude") {
280
- const { settingsPath, rulePath } = installClaude(cwd, cliPath);
281
- wired.claude = [`hooks: ${settingsPath}`, `rule: ${rulePath}`];
282
- } else if (target === "pi") {
283
- const r = installPi(cwd, cliPath);
284
- wired.pi = [
285
- `extension: ${r.extensionPath}`,
286
- `guidance: ${r.guidancePath}`,
287
- ...(r.notePath ? [`note skill: ${r.notePath}`] : []),
288
- ];
289
- }
290
- }
196
+ mergeHooksIntoSettings(settingsPath, hooks);
197
+ const rulePath = writeMinkRule(cwd);
291
198
 
292
199
  mkdirSync(dir, { recursive: true });
293
200
 
@@ -310,13 +217,6 @@ export async function init(cwd: string, opts: InitOptions = {}): Promise<void> {
310
217
  !Array.isArray(existingMeta.pathsByDevice)
311
218
  ? (existingMeta.pathsByDevice as Record<string, string>)
312
219
  : {};
313
- // Record the set of wired hosts as the authoritative source of truth, unioned
314
- // with any previously wired host so a single-target re-init never silently
315
- // unwires the other.
316
- const priorAgents = Array.isArray(existingMeta?.agents)
317
- ? (existingMeta!.agents as string[])
318
- : [];
319
- const agents = Array.from(new Set([...priorAgents, ...targets]));
320
220
  atomicWriteJson(metaPath, {
321
221
  ...(existingMeta ?? {}),
322
222
  cwd,
@@ -324,30 +224,21 @@ export async function init(cwd: string, opts: InitOptions = {}): Promise<void> {
324
224
  initTimestamp: existingMeta?.initTimestamp ?? new Date().toISOString(),
325
225
  version: "0.1.0",
326
226
  pathsByDevice: { ...existingPathsByDevice, [deviceId]: cwd },
327
- agents,
328
227
  ...(isNotesProject ? { projectType: "notes" } : {}),
329
228
  });
330
229
 
331
- const printWiring = () => {
332
- for (const id of Object.keys(wired)) {
333
- const label = AGENTS.find((a) => a.id === id)?.label ?? id;
334
- console.log(` ${label}:`);
335
- for (const line of wired[id]) console.log(` ${line}`);
336
- }
337
- };
338
-
339
230
  if (upgrading) {
340
231
  console.log(`[mink] upgrade complete`);
341
232
  console.log(` project: ${projectId}`);
342
- console.log(` agents: ${agents.join(", ")}`);
343
- printWiring();
233
+ console.log(` hooks: ${settingsPath}`);
234
+ console.log(` rule: ${rulePath}`);
344
235
  } else {
345
236
  console.log(`[mink] initialized`);
346
237
  console.log(` project: ${projectId} (${identity.source})`);
347
238
  console.log(` state: ${dir}`);
348
239
  console.log(` runtime: ${runtime}`);
349
- console.log(` agents: ${agents.join(", ")}`);
350
- printWiring();
240
+ console.log(` hooks: ${settingsPath}`);
241
+ console.log(` rule: ${rulePath}`);
351
242
  }
352
243
 
353
244
  // Surface a one-time hint when the project is in a git repo with no remote
@@ -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
+ }
@@ -149,7 +149,19 @@ export function status(cwd: string): void {
149
149
  console.log(` Sessions: ${lt.totalSessions}`);
150
150
  console.log(` Total tokens: ${lt.totalTokens.toLocaleString()}`);
151
151
  console.log(` Reads: ${lt.totalReads} Writes: ${lt.totalWrites}`);
152
- console.log(` Estimated savings: ${lt.totalEstimatedSavings.toLocaleString()} tokens`);
152
+ console.log(` Estimated savings (heuristic): ${lt.totalEstimatedSavings.toLocaleString()} tokens`);
153
+ const comp = ledger.compression;
154
+ if (comp && comp.totalEvents > 0) {
155
+ const ratio =
156
+ comp.totalOriginalTokens > 0
157
+ ? Math.round((comp.totalMeasuredSavings / comp.totalOriginalTokens) * 100)
158
+ : 0;
159
+ console.log(
160
+ ` Measured compression savings: ${comp.totalMeasuredSavings.toLocaleString()} tokens` +
161
+ ` (${ratio}% over ${comp.totalEvents} event${comp.totalEvents === 1 ? "" : "s"}` +
162
+ `, ${comp.totalHoldoutEvents} held out)`
163
+ );
164
+ }
153
165
  } catch {
154
166
  console.log(" Token ledger: error reading");
155
167
  }
@@ -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
+ }