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

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 (79) hide show
  1. package/README.md +22 -4
  2. package/dashboard/out/404.html +1 -1
  3. package/dashboard/out/_next/static/chunks/157-7bbe4894a18a8332.js +1 -0
  4. package/dashboard/out/_next/static/chunks/447-8cfdad14e7559c07.js +1 -0
  5. package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-141f456cd7141815.js +1 -0
  6. package/dashboard/out/_next/static/chunks/app/{layout-70a6d18f8e464960.js → layout-99d1a82956bc0f56.js} +1 -1
  7. package/dashboard/out/_next/static/css/0e381ead1d090c3f.css +1 -0
  8. package/dashboard/out/action-log.html +1 -1
  9. package/dashboard/out/action-log.txt +5 -5
  10. package/dashboard/out/activity.html +1 -1
  11. package/dashboard/out/activity.txt +5 -5
  12. package/dashboard/out/bugs.html +1 -1
  13. package/dashboard/out/bugs.txt +5 -5
  14. package/dashboard/out/capture.html +1 -1
  15. package/dashboard/out/capture.txt +5 -5
  16. package/dashboard/out/compression.html +1 -1
  17. package/dashboard/out/compression.txt +5 -5
  18. package/dashboard/out/config.html +1 -1
  19. package/dashboard/out/config.txt +5 -5
  20. package/dashboard/out/daemon.html +1 -1
  21. package/dashboard/out/daemon.txt +5 -5
  22. package/dashboard/out/design.html +1 -1
  23. package/dashboard/out/design.txt +5 -5
  24. package/dashboard/out/discord.html +1 -1
  25. package/dashboard/out/discord.txt +5 -5
  26. package/dashboard/out/file-index.html +1 -1
  27. package/dashboard/out/file-index.txt +5 -5
  28. package/dashboard/out/index.html +1 -1
  29. package/dashboard/out/index.txt +5 -5
  30. package/dashboard/out/insights.html +1 -1
  31. package/dashboard/out/insights.txt +5 -5
  32. package/dashboard/out/learning.html +1 -1
  33. package/dashboard/out/learning.txt +5 -5
  34. package/dashboard/out/overview.html +1 -1
  35. package/dashboard/out/overview.txt +6 -6
  36. package/dashboard/out/scheduler.html +1 -1
  37. package/dashboard/out/scheduler.txt +5 -5
  38. package/dashboard/out/sync.html +1 -1
  39. package/dashboard/out/sync.txt +5 -5
  40. package/dashboard/out/tokens.html +1 -1
  41. package/dashboard/out/tokens.txt +5 -5
  42. package/dashboard/out/waste.html +1 -1
  43. package/dashboard/out/waste.txt +5 -5
  44. package/dashboard/out/wiki.html +1 -1
  45. package/dashboard/out/wiki.txt +5 -5
  46. package/dist/cli.bun.js +3961 -3354
  47. package/dist/cli.node.js +4347 -3535
  48. package/package.json +1 -1
  49. package/src/cli.ts +29 -5
  50. package/src/commands/init.ts +132 -10
  51. package/src/commands/post-read.ts +1 -1
  52. package/src/commands/post-tool.ts +1 -1
  53. package/src/commands/refresh-hooks.ts +42 -0
  54. package/src/commands/retrieve.ts +1 -1
  55. package/src/commands/session-start.ts +11 -0
  56. package/src/core/agent-detect.ts +88 -0
  57. package/src/core/agent-pi.ts +383 -0
  58. package/src/core/code-skeleton.ts +1 -1
  59. package/src/core/compress-tool-output.ts +4 -4
  60. package/src/core/compression.ts +6 -7
  61. package/src/core/dashboard-api.ts +1 -1
  62. package/src/core/hook-output.ts +1 -1
  63. package/src/core/hook-refresh.ts +81 -0
  64. package/src/core/output-compression.ts +2 -2
  65. package/src/core/prompt.ts +27 -0
  66. package/src/core/self-update.ts +15 -0
  67. package/src/repositories/compression-cache-repo.ts +1 -1
  68. package/src/repositories/token-ledger-repo.ts +1 -1
  69. package/src/storage/schema.ts +2 -2
  70. package/src/types/compression.ts +1 -1
  71. package/src/types/config.ts +3 -2
  72. package/src/types/dashboard.ts +2 -2
  73. package/src/types/hook-input.ts +1 -1
  74. package/src/types/token-ledger.ts +2 -2
  75. package/dashboard/out/_next/static/chunks/189-fe789442321eb5eb.js +0 -1
  76. package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-38b8430b5c56e807.js +0 -1
  77. package/dashboard/out/_next/static/css/5e43917ea49c5b3e.css +0 -1
  78. /package/dashboard/out/_next/static/{U9AeObddt4LmJkKRZpEfy → Yov5CTLEIMMDdQaCwuG1a}/_buildManifest.js +0 -0
  79. /package/dashboard/out/_next/static/{U9AeObddt4LmJkKRZpEfy → Yov5CTLEIMMDdQaCwuG1a}/_ssgManifest.js +0 -0
@@ -0,0 +1,383 @@
1
+ import { join, resolve, dirname } from "path";
2
+ import { existsSync, mkdirSync, copyFileSync, rmSync } from "fs";
3
+ import { atomicWriteText } from "./fs-utils";
4
+
5
+ // ── Paths ───────────────────────────────────────────────────────────────────
6
+ // Pi auto-discovers extensions from `.pi/extensions/*.ts` and skills from
7
+ // `.pi/skills/*/SKILL.md`, so simply writing these files wires Mink in — no
8
+ // `.pi/settings.json` edit required, which keeps us from clobbering the user's
9
+ // own Pi configuration.
10
+
11
+ export function piExtensionPath(cwd: string): string {
12
+ return join(cwd, ".pi", "extensions", "mink.ts");
13
+ }
14
+
15
+ export function piGuidanceSkillPath(cwd: string): string {
16
+ return join(cwd, ".pi", "skills", "mink", "SKILL.md");
17
+ }
18
+
19
+ export function piNoteSkillPath(cwd: string): string {
20
+ return join(cwd, ".pi", "skills", "mink-note", "SKILL.md");
21
+ }
22
+
23
+ // ── Extension source ─────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Generate the Pi adapter extension. Like the Claude hook wiring, the `mink`
27
+ * invocation is templated: an installed package resolves the portable `mink`
28
+ * bin shim (so a committed `.pi/` works across machines), while source-dev mode
29
+ * falls back to `bun run <abs cli.ts>`.
30
+ *
31
+ * The adapter shells out to the same `mink` lifecycle commands every other host
32
+ * uses, translating Pi's event/tool shapes into Mink's canonical payload. It is
33
+ * deliberately defensive: it never throws into Pi, never blocks a tool, and
34
+ * times out fast — matching the safety contract of the Claude hooks.
35
+ */
36
+ export function buildPiExtension(cliPath: string): string {
37
+ const isTsSource = cliPath.endsWith(".ts");
38
+ const cmd = isTsSource ? "bun" : "mink";
39
+ const baseArgs = isTsSource ? JSON.stringify(["run", cliPath]) : "[]";
40
+
41
+ return `// AUTO-GENERATED by \`mink init\`. Do not edit — re-run \`mink init\` to refresh.
42
+ //
43
+ // Mink adapter for the Pi coding agent. Routes Pi lifecycle and tool events
44
+ // into the \`mink\` CLI so Pi shares the same ~/.mink state, file index, ledger,
45
+ // and wiki as every other assistant wired to this project.
46
+ //
47
+ // Field shapes confirmed against pi source (earendil-works/pi,
48
+ // packages/coding-agent): read params {path, offset, limit}; write {path,
49
+ // content}; edit {path, edits:[{oldText,newText}]} (with legacy file_path /
50
+ // top-level oldText,newText). tool_call/tool_result events expose toolName,
51
+ // toolCallId, input; tool_result also exposes content (a content-block array),
52
+ // details, isError. Advisories are surfaced by returning a modified result from
53
+ // the tool_result handler — the documented mechanism; that same return path
54
+ // SUBSTITUTES a reversible compressed result whenever a hook prints Claude
55
+ // Code's { hookSpecificOutput: { updatedToolOutput } } envelope on stdout
56
+ // (tool-output compression, spec 22 — Bash/Grep/Glob/MCP via post-tool, large
57
+ // whole-file reads via post-read; the original is retrievable with
58
+ // \`mink retrieve\`). pi.exec has no stdin, so the canonical payload is piped to
59
+ // \`mink\` via a spawned child process. Every host-API access is defensive: the
60
+ // adapter never throws into Pi or blocks a tool — a failure degrades to "no
61
+ // advisory" and the original, uncompressed output.
62
+
63
+ import { spawn } from "node:child_process";
64
+
65
+ const MINK_CMD = ${JSON.stringify(cmd)};
66
+ const MINK_BASE_ARGS = ${baseArgs};
67
+ const TIMEOUT_MS = 5000;
68
+
69
+ // Spawn a \`mink\` subcommand, pipe the canonical payload to its stdin, and
70
+ // collect BOTH streams: stdout carries a compression replacement
71
+ // (Claude Code's { hookSpecificOutput: { updatedToolOutput } } envelope) and
72
+ // stderr carries human-visible advisories. Always resolves to { out, err } —
73
+ // any failure or timeout degrades to empty strings, never a rejection.
74
+ function runMink(sub, payload, cwd) {
75
+ return new Promise((res) => {
76
+ let done = false;
77
+ const finish = (out, err) => {
78
+ if (done) return;
79
+ done = true;
80
+ res({ out: (out || "").trim(), err: (err || "").trim() });
81
+ };
82
+ try {
83
+ const child = spawn(MINK_CMD, [...MINK_BASE_ARGS, sub], {
84
+ cwd,
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ });
87
+ let stdout = "";
88
+ let stderr = "";
89
+ child.stdout?.on("data", (d) => {
90
+ stdout += d.toString();
91
+ });
92
+ child.stderr?.on("data", (d) => {
93
+ stderr += d.toString();
94
+ });
95
+ child.on("error", () => finish("", ""));
96
+ child.on("close", () => finish(stdout, stderr));
97
+ const timer = setTimeout(() => {
98
+ try {
99
+ child.kill();
100
+ } catch {}
101
+ finish("", "");
102
+ }, TIMEOUT_MS);
103
+ timer.unref?.();
104
+ try {
105
+ child.stdin?.end(payload ? JSON.stringify(payload) : "");
106
+ } catch {}
107
+ } catch {
108
+ finish("", "");
109
+ }
110
+ });
111
+ }
112
+
113
+ // Pi's edit tool takes an array of { oldText, newText } replacements (legacy
114
+ // inputs may put oldText/newText/new_string at the top level). Concatenate the
115
+ // replacement text so Mink's write-enforcement sees everything being written.
116
+ function editNewText(input) {
117
+ if (Array.isArray(input.edits)) {
118
+ return input.edits
119
+ .map((e) => e?.newText ?? e?.new_string ?? "")
120
+ .filter(Boolean)
121
+ .join("\\n");
122
+ }
123
+ return input.newText ?? input.new_string ?? input.replacement ?? "";
124
+ }
125
+
126
+ // Resolve Pi's tool name + arguments to Mink's canonical operation. Only the
127
+ // three file operations matter; anything else returns null and is ignored.
128
+ function toolInfo(event) {
129
+ const name = String(event?.toolName ?? event?.tool ?? event?.name ?? "").toLowerCase();
130
+ const input = event?.input ?? event?.arguments ?? {};
131
+ const filePath = input.path ?? input.file_path ?? input.filePath;
132
+ if (!filePath) return null;
133
+ if (name === "read") return { op: "read", filePath };
134
+ if (name === "write") return { op: "write", filePath, content: input.content ?? input.text ?? "" };
135
+ if (name === "edit") return { op: "edit", filePath, newString: editNewText(input) };
136
+ return null;
137
+ }
138
+
139
+ // A tool_result's content is an array of content blocks ({ type, text }); pull
140
+ // the text out so Mink's post-read can estimate tokens from real content.
141
+ function resultContent(event) {
142
+ const c = event?.content;
143
+ if (typeof c === "string") return c;
144
+ if (Array.isArray(c)) {
145
+ const text = c
146
+ .map((b) => (typeof b === "string" ? b : b?.text ?? ""))
147
+ .filter(Boolean)
148
+ .join("\\n");
149
+ return text || null;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ // Parse a hook's stdout for a reversible compression replacement. Mink's
155
+ // post-read/post-tool hooks print Claude Code's
156
+ // { hookSpecificOutput: { updatedToolOutput } } envelope on stdout; under Pi we
157
+ // read it here and substitute it into the tool result. Empty/non-JSON → null.
158
+ function parseReplacement(out) {
159
+ if (!out) return null;
160
+ try {
161
+ const j = JSON.parse(out);
162
+ const r = j?.hookSpecificOutput?.updatedToolOutput;
163
+ return typeof r === "string" ? r : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ // Map a Pi non-file tool to the canonical Mink tool name the post-tool
170
+ // compression hook accepts (Bash/Grep/Glob, or an mcp__* passthrough). Pi's
171
+ // built-in tool names aren't all pinned, so map common shell/search aliases and
172
+ // return null for anything Mink doesn't compress — those pass through untouched.
173
+ function compressibleName(event) {
174
+ const raw = String(event?.toolName ?? event?.tool ?? event?.name ?? "");
175
+ const name = raw.toLowerCase();
176
+ if (name.startsWith("mcp__")) return raw;
177
+ if (name.startsWith("mcp")) return "mcp__" + raw;
178
+ if (name === "bash" || name === "shell" || name === "exec" || name === "command" || name === "run")
179
+ return "Bash";
180
+ if (name === "grep" || name === "search" || name === "ripgrep" || name === "rg")
181
+ return "Grep";
182
+ if (name === "glob" || name === "find") return "Glob";
183
+ return null;
184
+ }
185
+
186
+ function prePayload(info) {
187
+ if (info.op === "read")
188
+ return { sub: "pre-read", payload: { tool_name: "Read", tool_input: { file_path: info.filePath } } };
189
+ if (info.op === "write")
190
+ return {
191
+ sub: "pre-write",
192
+ payload: { tool_name: "Write", tool_input: { file_path: info.filePath, content: info.content } },
193
+ };
194
+ return {
195
+ sub: "pre-write",
196
+ payload: { tool_name: "Edit", tool_input: { file_path: info.filePath, new_string: info.newString } },
197
+ };
198
+ }
199
+
200
+ function postPayload(info, content) {
201
+ if (info.op === "read")
202
+ return {
203
+ sub: "post-read",
204
+ payload: {
205
+ tool_name: "Read",
206
+ tool_input: { file_path: info.filePath },
207
+ tool_output: content == null ? undefined : { content },
208
+ },
209
+ };
210
+ if (info.op === "write")
211
+ return {
212
+ sub: "post-write",
213
+ payload: { tool_name: "Write", tool_input: { file_path: info.filePath, content: info.content } },
214
+ };
215
+ return {
216
+ sub: "post-write",
217
+ payload: { tool_name: "Edit", tool_input: { file_path: info.filePath, new_string: info.newString } },
218
+ };
219
+ }
220
+
221
+ export default function (pi) {
222
+ const cwd = pi?.ctx?.cwd || process.cwd();
223
+ const pending = new Map();
224
+ const keyOf = (event) => event?.toolCallId ?? event?.id ?? event?.callId ?? "";
225
+
226
+ pi.on?.("session_start", (event) => {
227
+ // Skip hot-reloads so an extension reload mid-task doesn't reset the
228
+ // ephemeral session state; every genuinely new/resumed session starts fresh.
229
+ if (event?.reason === "reload") return;
230
+ void runMink("session-start", null, cwd);
231
+ });
232
+ pi.on?.("agent_end", () => {
233
+ void runMink("session-stop", null, cwd);
234
+ });
235
+ pi.on?.("session_shutdown", () => {
236
+ void runMink("session-stop", null, cwd);
237
+ });
238
+
239
+ pi.on?.("tool_call", async (event) => {
240
+ const info = toolInfo(event);
241
+ if (!info) return;
242
+ const { sub, payload } = prePayload(info);
243
+ const { err } = await runMink(sub, payload, cwd);
244
+ if (err) pending.set(keyOf(event), err);
245
+ });
246
+
247
+ // Build the Pi tool_result return value. A compression \`replacement\` (a hook's
248
+ // stdout) SUBSTITUTES the tool output; \`advisoryParts\` (hook stderr) are
249
+ // appended as an extra text block — the parity of Claude feeding hook stderr
250
+ // back. Returns undefined when nothing changes, leaving Pi's result intact.
251
+ const buildResult = (event, replacement, advisoryParts) => {
252
+ const advisory = advisoryParts.filter(Boolean).join("\\n");
253
+ if (replacement == null && !advisory) return undefined;
254
+ const base =
255
+ replacement != null
256
+ ? [{ type: "text", text: replacement }]
257
+ : Array.isArray(event.content)
258
+ ? event.content
259
+ : typeof event.content === "string"
260
+ ? [{ type: "text", text: event.content }]
261
+ : [];
262
+ const content = advisory ? [...base, { type: "text", text: advisory }] : base;
263
+ return { content, details: event.details, isError: event.isError };
264
+ };
265
+
266
+ pi.on?.("tool_result", async (event) => {
267
+ const info = toolInfo(event);
268
+ if (info) {
269
+ // File ops: post-read/post-write record state; a large whole-file read may
270
+ // also emit a reversible compression replacement on stdout.
271
+ const { sub, payload } = postPayload(info, resultContent(event));
272
+ const post = await runMink(sub, payload, cwd);
273
+ const pre = pending.get(keyOf(event)) ?? "";
274
+ pending.delete(keyOf(event));
275
+ return buildResult(event, parseReplacement(post.out), [pre, post.err]);
276
+ }
277
+
278
+ // Non-file tools (Bash/Grep/Glob/MCP): route the output text through the
279
+ // post-tool compression hook and substitute its reversible replacement. The
280
+ // original is cached host-side, retrievable via \`mink retrieve\`.
281
+ const canon = compressibleName(event);
282
+ if (!canon) return;
283
+ const content = resultContent(event);
284
+ if (content == null) return;
285
+ const post = await runMink(
286
+ "post-tool",
287
+ { tool_name: canon, tool_output: { content } },
288
+ cwd
289
+ );
290
+ return buildResult(event, parseReplacement(post.out), [post.err]);
291
+ });
292
+ }
293
+ `;
294
+ }
295
+
296
+ // ── Guidance skill ───────────────────────────────────────────────────────────
297
+ // Pi has no automatic project-rules file (CLAUDE.md / AGENTS.md equivalent), so
298
+ // the guidance Mink gives the assistant is delivered as a Pi skill instead.
299
+
300
+ const PI_GUIDANCE_SKILL = `---
301
+ name: mink
302
+ description: Mink context management is active in this project. Read this to understand how Mink memory, write enforcement, and note capture work under Pi.
303
+ ---
304
+
305
+ # Mink — context management for this project
306
+
307
+ This project uses **Mink** (\`@drewpayment/mink\`) for cross-session context management.
308
+
309
+ ## How it works
310
+ - Mink runs automatically through a Pi extension at \`.pi/extensions/mink.ts\` that hooks session start/stop and every read/edit/write tool call, plus Bash/Grep/Glob/MCP results.
311
+ - Large tool outputs may be transparently replaced with a compact, reversible summary; if you need the full original, fetch it with \`mink retrieve <token>\` (the token appears in the summary).
312
+ - All state lives in \`~/.mink/\` on the user's machine — **not** in this repository. Do not create or write to any in-repo state directory (no \`.wolf/\`, \`.mink/\`, etc.).
313
+ - Read intelligence, write enforcement, bug memory, and the token ledger are handled by the extension. You do not need to manually read or update any state files.
314
+ - Mink shares one \`~/.mink/\` state across every assistant wired to this project, so history is unified whether the user runs Pi or another assistant.
315
+
316
+ ## When to act on Mink
317
+ - If the user asks to "save a note", "remember this", "log this to my wiki", or similar, use the \`mink-note\` skill (\`/skill:mink-note\`) — it captures into the user's \`~/.mink/\` vault.
318
+ - If the extension surfaces a learning, past bug, or repeat-read warning in context, treat that as authoritative project memory and follow it.
319
+ - The \`mink dashboard\` and \`mink agent\` commands are user tools — do not invoke them on the user's behalf.
320
+ `;
321
+
322
+ function resolveSkillsSourceDir(): string {
323
+ // Walk up until we find a package root that contains skills/ — works from
324
+ // src/core/agent-pi.ts (dev) and dist/cli.js (installed), which sit at
325
+ // different depths relative to the package root.
326
+ let dir = dirname(new URL(import.meta.url).pathname);
327
+ while (true) {
328
+ if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "skills"))) {
329
+ return join(dir, "skills");
330
+ }
331
+ const parent = dirname(dir);
332
+ if (parent === dir) break;
333
+ dir = parent;
334
+ }
335
+ return resolve(dirname(new URL(import.meta.url).pathname), "../../skills");
336
+ }
337
+
338
+ export interface PiInstallResult {
339
+ extensionPath: string;
340
+ guidancePath: string;
341
+ notePath: string | null;
342
+ }
343
+
344
+ export function installPi(cwd: string, cliPath: string): PiInstallResult {
345
+ const extensionPath = piExtensionPath(cwd);
346
+ mkdirSync(dirname(extensionPath), { recursive: true });
347
+ atomicWriteText(extensionPath, buildPiExtension(cliPath));
348
+
349
+ const guidancePath = piGuidanceSkillPath(cwd);
350
+ mkdirSync(dirname(guidancePath), { recursive: true });
351
+ atomicWriteText(guidancePath, PI_GUIDANCE_SKILL);
352
+
353
+ // Mirror the note-capture skill into Pi's skill directory so the single
354
+ // source of truth (skills/mink-note) is reused rather than duplicated.
355
+ let notePath: string | null = null;
356
+ try {
357
+ const src = join(resolveSkillsSourceDir(), "mink-note", "SKILL.md");
358
+ if (existsSync(src)) {
359
+ notePath = piNoteSkillPath(cwd);
360
+ mkdirSync(dirname(notePath), { recursive: true });
361
+ copyFileSync(src, notePath);
362
+ }
363
+ } catch {
364
+ // Note skill is non-critical — the extension and guidance still work.
365
+ notePath = null;
366
+ }
367
+
368
+ return { extensionPath, guidancePath, notePath };
369
+ }
370
+
371
+ export function removePi(cwd: string): void {
372
+ for (const p of [
373
+ piExtensionPath(cwd),
374
+ join(cwd, ".pi", "skills", "mink"),
375
+ join(cwd, ".pi", "skills", "mink-note"),
376
+ ]) {
377
+ try {
378
+ rmSync(p, { recursive: true, force: true });
379
+ } catch {
380
+ // best-effort
381
+ }
382
+ }
383
+ }
@@ -1,4 +1,4 @@
1
- // Deterministic, dependency-free code skeleton extraction (spec 21 phase 3).
1
+ // Deterministic, dependency-free code skeleton extraction (spec 22 phase 3).
2
2
  //
3
3
  // Produces a structural outline of source: top-level declarations and the direct
4
4
  // members of classes/interfaces, with function/method bodies elided to "{ … }".
@@ -1,12 +1,12 @@
1
- // Compression pipeline orchestrator (spec 21). Ties together the config/holdout
1
+ // Compression pipeline orchestrator (spec 22). Ties together the config/holdout
2
2
  // decisions, the pure engine, the reversible cache, and the ledger. Returns the
3
3
  // replacement text to emit, or null to pass the original through unchanged.
4
4
  //
5
5
  // Invariants:
6
- // - Disabled by default (config gate) → no-op.
6
+ // - Enabled by default; the config gate still allows opt-out → no-op when off.
7
7
  // - Reversible or nothing: the original is stored BEFORE we return a compressed
8
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).
9
+ // result is never shown without a retrievable original (spec 22 edge case).
10
10
  // - Every failure degrades to "no compression" — a hook must never throw.
11
11
  // - Holdout arms pass the original through but are still measured.
12
12
 
@@ -23,7 +23,7 @@ import { CompressionCacheRepo } from "../repositories/compression-cache-repo";
23
23
  import { TokenLedgerRepo } from "../repositories/token-ledger-repo";
24
24
 
25
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).
26
+ // output always lands in the same holdout arm (spec 22 edge case).
27
27
  function contentKey(s: string): string {
28
28
  let h = 0x811c9dc5;
29
29
  for (let i = 0; i < s.length; i++) {
@@ -1,10 +1,9 @@
1
- // Tool-output compression — configuration and decision logic (spec 21).
1
+ // Tool-output compression — configuration and decision logic (spec 22).
2
2
  //
3
3
  // This module is pure: it reads config and makes the eligibility / holdout /
4
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.
5
+ // it is trivially testable. Compression is enabled by default; users opt out
6
+ // with `mink config set compression.enabled false`.
8
7
 
9
8
  import { resolveConfigValue } from "./global-config";
10
9
  import type { ConfigKey } from "../types/config";
@@ -35,13 +34,13 @@ export function loadCompressionConfig(): CompressionConfig {
35
34
  }
36
35
 
37
36
  // An output is eligible for compression only once it crosses the size threshold;
38
- // small outputs are never touched (spec 21 §Eligibility).
37
+ // small outputs are never touched (spec 22 §Eligibility).
39
38
  export function isEligible(originalTokens: number, config: CompressionConfig): boolean {
40
39
  return config.enabled && originalTokens >= config.thresholdTokens;
41
40
  }
42
41
 
43
42
  // 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).
43
+ // fraction of tokens; otherwise the original is used (spec 22 §Thresholds).
45
44
  export function meetsMinSavings(
46
45
  originalTokens: number,
47
46
  compressedTokens: number,
@@ -58,7 +57,7 @@ export function measuredSavings(originalTokens: number, compressedTokens: number
58
57
 
59
58
  // Deterministic FNV-1a hash → a stable fraction in [0, 1) for a given key. Used
60
59
  // 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
60
+ // same arm, which keeps measurement from being double-counted (spec 22 edge
62
61
  // case "Holdout selection must be stable for a given event").
63
62
  function hashUnitInterval(key: string): number {
64
63
  let h = 0x811c9dc5;
@@ -201,7 +201,7 @@ export function loadTokenLedgerPanel(cwd: string): TokenLedgerPayload {
201
201
  };
202
202
  }
203
203
 
204
- // Dedicated Compression panel (spec 21, phase 4). Reads the measured
204
+ // Dedicated Compression panel (spec 22, phase 4). Reads the measured
205
205
  // compression aggregates, the holdout A/B split, per-kind/per-tool breakdowns,
206
206
  // and recent events, plus whether compression is currently enabled.
207
207
  export function loadCompressionPanel(cwd: string): CompressionPayload {
@@ -1,4 +1,4 @@
1
- // Helpers for PostToolUse hooks that replace a tool's result (spec 21). The
1
+ // Helpers for PostToolUse hooks that replace a tool's result (spec 22). The
2
2
  // replacement mechanism is Claude Code's `hookSpecificOutput.updatedToolOutput`
3
3
  // (verified against the hooks reference): whatever JSON we print to stdout here
4
4
  // substitutes the original output before the model sees it.
@@ -0,0 +1,81 @@
1
+ // Self-healing hook regeneration.
2
+ //
3
+ // Mink's generated wiring — `.claude/settings.json` and `.pi/extensions/mink.ts`
4
+ // — is produced by `mink init` and pinned at the version that wrote it. After the
5
+ // package upgrades, those files can go stale (e.g. they lack a newly-added hook,
6
+ // or the Pi adapter template changed) until the user re-runs `mink init`.
7
+ //
8
+ // To avoid that manual step we stamp the project metadata with the Mink version
9
+ // that generated the hooks (`hooksVersion`) and refresh when it changes:
10
+ // - lazily, per project, on `session-start` (refreshHooksIfStale), and
11
+ // - eagerly, across all registered projects, right after a successful upgrade
12
+ // (`mink refresh-hooks --all`, spawned as the freshly-installed binary).
13
+ //
14
+ // Both paths regenerate ONLY the hosts a project already uses and are defensive:
15
+ // a failure degrades to "no refresh" and the next session-start tries again.
16
+
17
+ import { projectMetaPath } from "./paths";
18
+ import { safeReadJson, atomicWriteJson } from "./fs-utils";
19
+ import { getInstallInfo } from "./self-update";
20
+
21
+ export interface HookRefreshResult {
22
+ refreshed: boolean;
23
+ /** Hosts the project is wired for (claude/pi). */
24
+ agents: string[];
25
+ /** The Mink version now stamped, or null when nothing could be resolved. */
26
+ version: string | null;
27
+ }
28
+
29
+ const SKIP: HookRefreshResult = { refreshed: false, agents: [], version: null };
30
+
31
+ /**
32
+ * Regenerate a single project's hooks for exactly the agents it already uses,
33
+ * then stamp the generating Mink version. With `force` off (session-start) it
34
+ * only acts when the stamp differs from the running version; with `force` on
35
+ * (`refresh-hooks`) it always regenerates. Never throws.
36
+ */
37
+ export function refreshProjectHooks(
38
+ cwd: string,
39
+ opts: { force?: boolean } = {}
40
+ ): HookRefreshResult {
41
+ try {
42
+ const metaPath = projectMetaPath(cwd);
43
+ const meta = safeReadJson(metaPath) as Record<string, unknown> | null;
44
+ if (!meta) return SKIP; // never initialized here → nothing to refresh
45
+ const agents = Array.isArray(meta.agents) ? (meta.agents as string[]) : [];
46
+ if (agents.length === 0) return SKIP;
47
+
48
+ const current = getInstallInfo().currentVersion;
49
+ const stamped = typeof meta.hooksVersion === "string" ? meta.hooksVersion : null;
50
+ if (!opts.force && stamped === current) {
51
+ return { refreshed: false, agents, version: current };
52
+ }
53
+
54
+ rewireAgents(cwd, agents);
55
+ atomicWriteJson(metaPath, { ...meta, hooksVersion: current });
56
+ return { refreshed: true, agents, version: current };
57
+ } catch {
58
+ return SKIP;
59
+ }
60
+ }
61
+
62
+ /** Session-start convenience: refresh only when the version stamp is stale. */
63
+ export function refreshHooksIfStale(cwd: string): HookRefreshResult {
64
+ return refreshProjectHooks(cwd);
65
+ }
66
+
67
+ function rewireAgents(cwd: string, agents: string[]): void {
68
+ // Lazy-require the installers so the common (up-to-date) path stays cheap and
69
+ // we avoid any import cycle (init.ts is heavy and pulls in the agent wiring).
70
+ const { resolveCliPath, installClaude } = require("../commands/init");
71
+ const { installPi } = require("./agent-pi");
72
+ const cliPath = resolveCliPath();
73
+ for (const agent of agents) {
74
+ try {
75
+ if (agent === "claude") installClaude(cwd, cliPath);
76
+ else if (agent === "pi") installPi(cwd, cliPath);
77
+ } catch {
78
+ // Per-agent best-effort; one host failing must not block the others.
79
+ }
80
+ }
81
+ }
@@ -1,4 +1,4 @@
1
- // Tool-output compression engine (spec 21 §Content-Aware Compression).
1
+ // Tool-output compression engine (spec 22 §Content-Aware Compression).
2
2
  //
3
3
  // Pure, deterministic, dependency-free. Each compressor takes a tool output
4
4
  // string and returns a smaller body plus a note of what was dropped, or null
@@ -8,7 +8,7 @@
8
8
  // "mink retrieve" footer. Keeping this layer pure makes every strategy trivially
9
9
  // testable and prompt-cache-stable (identical input → identical output).
10
10
  //
11
- // The "file" strategy does line-based signature extraction; spec 21's phase 3
11
+ // The "file" strategy does line-based signature extraction; spec 22's phase 3
12
12
  // upgrades it to richer AST skeletons behind this same interface.
13
13
 
14
14
  import type { ContentKind, CompressionResult } from "../types/compression";
@@ -0,0 +1,27 @@
1
+ import { createInterface } from "readline";
2
+
3
+ /**
4
+ * Whether the current process can safely prompt the user. We require a real
5
+ * interactive TTY on both stdin and stdout and honor the usual escape hatches
6
+ * (CI, an explicit opt-out) so `mink init` never blocks a script or pipeline.
7
+ */
8
+ export function stdinIsInteractive(): boolean {
9
+ const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
10
+ const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean };
11
+ return (
12
+ Boolean(stdin.isTTY) &&
13
+ Boolean(stdout.isTTY) &&
14
+ process.env.MINK_NO_PROMPT !== "1" &&
15
+ !process.env.CI
16
+ );
17
+ }
18
+
19
+ export function ask(question: string): Promise<string> {
20
+ return new Promise((resolve) => {
21
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22
+ rl.question(question, (answer) => {
23
+ rl.close();
24
+ resolve(answer);
25
+ });
26
+ });
27
+ }
@@ -232,9 +232,24 @@ function rotateLogIfNeeded(path: string): void {
232
232
  export async function runSelfUpgrade(opts: UpgradeOptions): Promise<UpgradeResult> {
233
233
  const result = await runSelfUpgradeInner(opts);
234
234
  appendLogEntry({ source: opts.source, ...result });
235
+ if (result.status === "upgraded") refreshHooksAfterUpgrade();
235
236
  return result;
236
237
  }
237
238
 
239
+ // After a successful self-upgrade the running process is still the OLD code, so
240
+ // regenerating hooks here would write stale templates. Instead spawn the
241
+ // freshly-installed `mink` to refresh every project — it runs the NEW code and
242
+ // stamps the new version. Best-effort: if it fails, each project's session-start
243
+ // self-heal is the fallback. Skipped in dev mode (no global `mink` to invoke).
244
+ function refreshHooksAfterUpgrade(): void {
245
+ try {
246
+ if (getInstallInfo().isDevMode) return;
247
+ spawnSync("mink", ["refresh-hooks", "--all"], { stdio: "ignore" });
248
+ } catch {
249
+ // Best-effort; session-start covers any project we miss here.
250
+ }
251
+ }
252
+
238
253
  async function runSelfUpgradeInner(opts: UpgradeOptions): Promise<UpgradeResult> {
239
254
  // 1. Hard kill switch.
240
255
  if (process.env.MINK_DISABLE_AUTO_UPDATE === "1" && opts.source === "scheduler") {
@@ -1,4 +1,4 @@
1
- // Reversible-compression cache repository (spec 21 §Reversibility). Stores the
1
+ // Reversible-compression cache repository (spec 22 §Reversibility). Stores the
2
2
  // byte-exact original of a compressed tool output keyed by a short retrieval
3
3
  // token, with a TTL. `get` treats an expired row as a miss and evicts it lazily,
4
4
  // so a stale token can never return partial or wrong content.
@@ -239,7 +239,7 @@ export class TokenLedgerRepo {
239
239
  });
240
240
  }
241
241
 
242
- // ── Compression measurement (spec 21) ────────────────────────────────
242
+ // ── Compression measurement (spec 22) ────────────────────────────────
243
243
 
244
244
  // Record one compression decision and fold it into this device's
245
245
  // compression-lifetime aggregates, transactionally so the row and the
@@ -177,7 +177,7 @@ CREATE TABLE IF NOT EXISTS counters (
177
177
  file_index_misses INTEGER NOT NULL DEFAULT 0
178
178
  );
179
179
 
180
- -- Tool-output compression measurement (spec 21). One row per compression
180
+ -- Tool-output compression measurement (spec 22). One row per compression
181
181
  -- decision: either a compressed arm (compressed_tokens < original_tokens) or a
182
182
  -- holdout arm (left uncompressed for control, compressed_tokens = original_tokens).
183
183
  -- These are append-only telemetry, independent of session lifecycle, written at
@@ -208,7 +208,7 @@ CREATE TABLE IF NOT EXISTS ledger_compression_lifetime (
208
208
  total_measured_savings INTEGER NOT NULL DEFAULT 0
209
209
  );
210
210
 
211
- -- Reversible-compression cache (spec 21 §Reversibility). When a tool output is
211
+ -- Reversible-compression cache (spec 22 §Reversibility). When a tool output is
212
212
  -- compressed, the original is stored here keyed by a short retrieval token and
213
213
  -- embedded in the compressed result; "mink retrieve <token>" returns it
214
214
  -- byte-exact. Rows expire after the configured retention window; an expired or
@@ -1,4 +1,4 @@
1
- // Tool-output compression types (spec 21). The decision/config types live in
1
+ // Tool-output compression types (spec 22). The decision/config types live in
2
2
  // src/core/compression.ts; these describe the reversible cache and the engine's
3
3
  // content-aware output.
4
4