@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.
- package/README.md +22 -4
- package/dashboard/out/404.html +1 -1
- package/dashboard/out/_next/static/chunks/157-7bbe4894a18a8332.js +1 -0
- package/dashboard/out/_next/static/chunks/447-8cfdad14e7559c07.js +1 -0
- package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-141f456cd7141815.js +1 -0
- package/dashboard/out/_next/static/chunks/app/{layout-70a6d18f8e464960.js → layout-99d1a82956bc0f56.js} +1 -1
- package/dashboard/out/_next/static/css/0e381ead1d090c3f.css +1 -0
- package/dashboard/out/action-log.html +1 -1
- package/dashboard/out/action-log.txt +5 -5
- package/dashboard/out/activity.html +1 -1
- package/dashboard/out/activity.txt +5 -5
- package/dashboard/out/bugs.html +1 -1
- package/dashboard/out/bugs.txt +5 -5
- package/dashboard/out/capture.html +1 -1
- package/dashboard/out/capture.txt +5 -5
- package/dashboard/out/compression.html +1 -1
- package/dashboard/out/compression.txt +5 -5
- package/dashboard/out/config.html +1 -1
- package/dashboard/out/config.txt +5 -5
- package/dashboard/out/daemon.html +1 -1
- package/dashboard/out/daemon.txt +5 -5
- package/dashboard/out/design.html +1 -1
- package/dashboard/out/design.txt +5 -5
- package/dashboard/out/discord.html +1 -1
- package/dashboard/out/discord.txt +5 -5
- package/dashboard/out/file-index.html +1 -1
- package/dashboard/out/file-index.txt +5 -5
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +5 -5
- package/dashboard/out/insights.html +1 -1
- package/dashboard/out/insights.txt +5 -5
- package/dashboard/out/learning.html +1 -1
- package/dashboard/out/learning.txt +5 -5
- package/dashboard/out/overview.html +1 -1
- package/dashboard/out/overview.txt +6 -6
- package/dashboard/out/scheduler.html +1 -1
- package/dashboard/out/scheduler.txt +5 -5
- package/dashboard/out/sync.html +1 -1
- package/dashboard/out/sync.txt +5 -5
- package/dashboard/out/tokens.html +1 -1
- package/dashboard/out/tokens.txt +5 -5
- package/dashboard/out/waste.html +1 -1
- package/dashboard/out/waste.txt +5 -5
- package/dashboard/out/wiki.html +1 -1
- package/dashboard/out/wiki.txt +5 -5
- package/dist/cli.bun.js +3961 -3354
- package/dist/cli.node.js +4347 -3535
- package/package.json +1 -1
- package/src/cli.ts +29 -5
- package/src/commands/init.ts +132 -10
- package/src/commands/post-read.ts +1 -1
- package/src/commands/post-tool.ts +1 -1
- package/src/commands/refresh-hooks.ts +42 -0
- package/src/commands/retrieve.ts +1 -1
- package/src/commands/session-start.ts +11 -0
- package/src/core/agent-detect.ts +88 -0
- package/src/core/agent-pi.ts +383 -0
- package/src/core/code-skeleton.ts +1 -1
- package/src/core/compress-tool-output.ts +4 -4
- package/src/core/compression.ts +6 -7
- package/src/core/dashboard-api.ts +1 -1
- package/src/core/hook-output.ts +1 -1
- package/src/core/hook-refresh.ts +81 -0
- package/src/core/output-compression.ts +2 -2
- package/src/core/prompt.ts +27 -0
- package/src/core/self-update.ts +15 -0
- package/src/repositories/compression-cache-repo.ts +1 -1
- package/src/repositories/token-ledger-repo.ts +1 -1
- package/src/storage/schema.ts +2 -2
- package/src/types/compression.ts +1 -1
- package/src/types/config.ts +3 -2
- package/src/types/dashboard.ts +2 -2
- package/src/types/hook-input.ts +1 -1
- package/src/types/token-ledger.ts +2 -2
- package/dashboard/out/_next/static/chunks/189-fe789442321eb5eb.js +0 -1
- package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-38b8430b5c56e807.js +0 -1
- package/dashboard/out/_next/static/css/5e43917ea49c5b3e.css +0 -1
- /package/dashboard/out/_next/static/{U9AeObddt4LmJkKRZpEfy → Yov5CTLEIMMDdQaCwuG1a}/_buildManifest.js +0 -0
- /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
|
|
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
|
|
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
|
-
// -
|
|
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
|
|
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
|
|
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++) {
|
package/src/core/compression.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// Tool-output compression — configuration and decision logic (spec
|
|
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.
|
|
6
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
package/src/core/hook-output.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Helpers for PostToolUse hooks that replace a tool's result (spec
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/core/self-update.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/storage/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/types/compression.ts
CHANGED