@vkmikc/create-vkm-kit 4.2.0
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/LICENSE.md +44 -0
- package/README.md +148 -0
- package/package.json +57 -0
- package/src/asset-install.mjs +109 -0
- package/src/claude-native-memory.mjs +507 -0
- package/src/file-perms.mjs +53 -0
- package/src/hooks/_transcript-cache.mjs +223 -0
- package/src/hooks/compact-mcp-output.mjs +87 -0
- package/src/hooks/compact-tool-output.mjs +177 -0
- package/src/hooks/ensure-otel-sink.mjs +51 -0
- package/src/hooks/guard-effort-gate.mjs +209 -0
- package/src/hooks/guard-native-memory-write.mjs +129 -0
- package/src/hooks/session-start-vault-context.mjs +200 -0
- package/src/hooks/stop-vault-close-reminder.mjs +150 -0
- package/src/index.js +1547 -0
- package/src/mcp-merge.mjs +279 -0
- package/src/memory-rules.mjs +205 -0
- package/src/obscura-setup.mjs +272 -0
- package/src/ollama-setup.mjs +126 -0
- package/src/rules-merge.mjs +106 -0
- package/src/settings-io.mjs +193 -0
- package/src/settings-writers.mjs +150 -0
- package/src/skills-install.mjs +96 -0
- package/src/telemetry.mjs +154 -0
- package/src/token-saver.mjs +248 -0
- package/templates/agents/vkm-implementer.md +23 -0
- package/templates/output-styles/vkm-terse.md +23 -0
- package/templates/skills/vkm-discipline/SKILL.md +77 -0
- package/templates/skills/vkm-discipline/domains/coding.md +39 -0
- package/templates/skills/vkm-discipline/domains/data.md +37 -0
- package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
- package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
- package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
- package/templates/skills/vkm-discipline/domains/infra.md +35 -0
- package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
- package/templates/skills/vkm-discipline/domains/security.md +37 -0
- package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
- package/templates/skills/vkm-discipline/domains/writing.md +32 -0
- package/templates/skills/vkm-spec/SKILL.md +33 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared incremental-scan engine for the transcript-reading hooks (`guard-effort-gate.mjs`,
|
|
4
|
+
* `stop-vault-close-reminder.mjs`). Installed by create-obsidian-memory — the vkm-kit
|
|
5
|
+
* installer — alongside them into `~/.claude/hooks/` — NOT a hook itself (Claude Code never
|
|
6
|
+
* invokes this file directly), just a sibling module the other two import.
|
|
7
|
+
*
|
|
8
|
+
* Why: both hooks re-read + re-parse the ENTIRE JSONL transcript on every single gated tool
|
|
9
|
+
* call. Measured ~75ms on a real 25.8MB/1878-line transcript (41ms read + 34ms parse);
|
|
10
|
+
* `guard-effort-gate` pays this on every substantive Write/Edit/MultiEdit/NotebookEdit, so a
|
|
11
|
+
* long session with dozens of edits accumulates real, worsening latency (ADR-0030/0031
|
|
12
|
+
* explicitly deferred this for v1: "zero extra state to manage"). Since each hook invocation
|
|
13
|
+
* is a FRESH subprocess (no persistent in-memory state possible), this adds a small sidecar
|
|
14
|
+
* cache file in the OS temp dir, keyed by the transcript's own path + which hook is asking,
|
|
15
|
+
* storing the last-consumed byte offset plus the caller's derived accumulator state — so a
|
|
16
|
+
* later invocation only reads+parses the NEW suffix appended since the last call.
|
|
17
|
+
*
|
|
18
|
+
* Correctness over speed on any doubt: a missing, corrupt, or stale (transcript shrank —
|
|
19
|
+
* detected by comparing the cache's recorded size/mtime against the current stat) cache always
|
|
20
|
+
* falls back to a full rescan from byte 0. Size/mtime monotonicity alone cannot catch a
|
|
21
|
+
* transcript that was TRUNCATED AND REPLACED with unrelated content, then appended-to past its
|
|
22
|
+
* original size before the next call — same-or-larger size, same-or-newer mtime, wrong bytes.
|
|
23
|
+
* A content fingerprint (hash of the bytes immediately before the cached offset) closes that
|
|
24
|
+
* gap: it is re-verified against the CURRENT file before trusting a resume, so replaced content
|
|
25
|
+
* always falls back to a full rescan instead of resuming into the middle of unrelated bytes. A
|
|
26
|
+
* trailing PARTIAL line (the transcript is being actively appended to mid-write) is never
|
|
27
|
+
* consumed — only complete lines (ending in `"\n"`) advance the committed offset, so a line can
|
|
28
|
+
* never be parsed truncated or silently dropped. This is a PERFORMANCE change only: the derived
|
|
29
|
+
* state for any given transcript content is byte-for-byte identical to what a full rescan would
|
|
30
|
+
* produce.
|
|
31
|
+
*/
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import crypto from "node:crypto";
|
|
36
|
+
|
|
37
|
+
/** Test-only instrumentation: how many actual file reads / bytes {@link getIncrementalState}
|
|
38
|
+
* performed since the last reset. Lets tests assert a resumed scan reads LESS than a full
|
|
39
|
+
* one — not just that the final derived state ends up correct. Never consulted by the hooks
|
|
40
|
+
* themselves; purely observational. */
|
|
41
|
+
export const _debugStats = { reads: 0, bytesRead: 0 };
|
|
42
|
+
export function _resetDebugStats() {
|
|
43
|
+
_debugStats.reads = 0;
|
|
44
|
+
_debugStats.bytesRead = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cacheDir() {
|
|
48
|
+
return path.join(os.tmpdir(), "obsidian-memory-kit-hook-cache");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One cache file per (transcript, hook) pair — two different hooks scanning the SAME
|
|
52
|
+
* transcript (or the same hook across different sessions/transcripts) never collide. */
|
|
53
|
+
function cacheFilePath(transcriptPath, cacheKey) {
|
|
54
|
+
const digest = crypto.createHash("sha1").update(path.resolve(transcriptPath)).digest("hex");
|
|
55
|
+
return path.join(cacheDir(), `${digest}.${cacheKey}.json`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How many bytes immediately before the cached offset get hashed into the resume
|
|
59
|
+
* fingerprint. Small enough to be cheap on every gated call; large enough that a
|
|
60
|
+
* transcript replacement is astronomically unlikely to hash-collide by chance. */
|
|
61
|
+
const FINGERPRINT_WINDOW = 64;
|
|
62
|
+
|
|
63
|
+
/** Hash of the up-to-{@link FINGERPRINT_WINDOW} bytes immediately before `offset` in
|
|
64
|
+
* `transcriptPath`, as currently on disk. `null` if that region can't be read (missing
|
|
65
|
+
* file, offset beyond EOF, etc.) — treated as "can't verify, don't resume". Offset 0 has
|
|
66
|
+
* nothing before it to fingerprint, so it hashes the empty buffer (deterministic, always
|
|
67
|
+
* "matches" — resuming from byte 0 is always safe, there's nothing to have been replaced). */
|
|
68
|
+
function fingerprintBefore(transcriptPath, offset) {
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
fd = fs.openSync(transcriptPath, "r");
|
|
72
|
+
const start = Math.max(0, offset - FINGERPRINT_WINDOW);
|
|
73
|
+
const length = offset - start;
|
|
74
|
+
const buf = Buffer.alloc(length);
|
|
75
|
+
if (length > 0) fs.readSync(fd, buf, 0, length, start);
|
|
76
|
+
return crypto.createHash("sha1").update(buf).digest("hex");
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
} finally {
|
|
80
|
+
if (fd !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
} catch {
|
|
84
|
+
/* ignore */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Load + validate a cache file. Returns `null` (triggering a full rescan) on anything that
|
|
91
|
+
* doesn't look exactly like a cache we could have written for THIS transcript — missing
|
|
92
|
+
* file, corrupt JSON, wrong shape, or a `transcriptPath` mismatch. Never throws. */
|
|
93
|
+
function readCache(cacheFp, transcriptPath) {
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(cacheFp, "utf8");
|
|
96
|
+
const cache = JSON.parse(raw);
|
|
97
|
+
if (
|
|
98
|
+
cache &&
|
|
99
|
+
typeof cache === "object" &&
|
|
100
|
+
cache.transcriptPath === transcriptPath &&
|
|
101
|
+
typeof cache.offset === "number" &&
|
|
102
|
+
typeof cache.size === "number" &&
|
|
103
|
+
typeof cache.mtimeMs === "number" &&
|
|
104
|
+
typeof cache.fingerprint === "string" &&
|
|
105
|
+
cache.state &&
|
|
106
|
+
typeof cache.state === "object"
|
|
107
|
+
) {
|
|
108
|
+
return cache;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* missing/corrupt cache -> full rescan */
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Best-effort: a failed cache write just means the NEXT call does a full rescan instead of
|
|
117
|
+
* resuming — never lets a permissions/disk issue break the hook itself. */
|
|
118
|
+
function writeCache(cacheFp, cache) {
|
|
119
|
+
try {
|
|
120
|
+
fs.mkdirSync(path.dirname(cacheFp), { recursive: true });
|
|
121
|
+
fs.writeFileSync(cacheFp, JSON.stringify(cache));
|
|
122
|
+
} catch {
|
|
123
|
+
/* best-effort */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read `transcriptPath` from byte offset `fromOffset` to EOF, fold each COMPLETE line into
|
|
129
|
+
* `state` via `foldLine(state, line)`, and return the byte offset right after the last
|
|
130
|
+
* complete line consumed (NOT necessarily EOF — a trailing partial line is left for next
|
|
131
|
+
* time). Never throws: an unreadable file leaves `state`/offset unchanged.
|
|
132
|
+
* @param {string} transcriptPath
|
|
133
|
+
* @param {number} fromOffset
|
|
134
|
+
* @param {object} state
|
|
135
|
+
* @param {(state: object, line: string) => object} foldLine
|
|
136
|
+
*/
|
|
137
|
+
function scanFromOffset(transcriptPath, fromOffset, state, foldLine) {
|
|
138
|
+
let fd;
|
|
139
|
+
try {
|
|
140
|
+
fd = fs.openSync(transcriptPath, "r");
|
|
141
|
+
const size = fs.fstatSync(fd).size;
|
|
142
|
+
if (size <= fromOffset) return { offset: fromOffset, state };
|
|
143
|
+
const length = size - fromOffset;
|
|
144
|
+
const buf = Buffer.alloc(length);
|
|
145
|
+
fs.readSync(fd, buf, 0, length, fromOffset);
|
|
146
|
+
_debugStats.reads++;
|
|
147
|
+
_debugStats.bytesRead += length;
|
|
148
|
+
const chunk = buf.toString("utf8");
|
|
149
|
+
const lastNewline = chunk.lastIndexOf("\n");
|
|
150
|
+
if (lastNewline === -1) return { offset: fromOffset, state }; // no complete line yet
|
|
151
|
+
for (const line of chunk.slice(0, lastNewline).split("\n")) {
|
|
152
|
+
state = foldLine(state, line);
|
|
153
|
+
}
|
|
154
|
+
return { offset: fromOffset + lastNewline + 1, state };
|
|
155
|
+
} catch {
|
|
156
|
+
return { offset: fromOffset, state };
|
|
157
|
+
} finally {
|
|
158
|
+
if (fd !== undefined) {
|
|
159
|
+
try {
|
|
160
|
+
fs.closeSync(fd);
|
|
161
|
+
} catch {
|
|
162
|
+
/* ignore */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get the up-to-date derived state for `transcriptPath`, resuming from the cached offset
|
|
170
|
+
* when it's SAFE to (cache present, matches this transcript + hook, the file has only grown
|
|
171
|
+
* since — same-or-larger size, same-or-later mtime — AND the content fingerprint just before
|
|
172
|
+
* the cached offset still matches what's currently on disk), otherwise doing a full rescan
|
|
173
|
+
* from byte 0. The fingerprint check is what catches a transcript truncated and replaced with
|
|
174
|
+
* unrelated content that then grew past its original cached size — size/mtime alone cannot.
|
|
175
|
+
* Always persists the new offset + state + fingerprint for next time (best-effort, never
|
|
176
|
+
* fatal). Missing transcript -> the caller's `initialState()`, matching the old
|
|
177
|
+
* always-full-rescan behavior's fail-open result for that case.
|
|
178
|
+
* @param {string} transcriptPath
|
|
179
|
+
* @param {string} cacheKey - identifies which hook this cache belongs to (e.g.
|
|
180
|
+
* "effort-gate", "stop-reminder") — the same transcript is scanned by multiple hooks with
|
|
181
|
+
* independent accumulator shapes, so each needs its own cache.
|
|
182
|
+
* @param {() => object} initialState - factory for a fresh accumulator (called on a full
|
|
183
|
+
* rescan / cache miss — a fresh object each time, never shared/mutated across calls).
|
|
184
|
+
* @param {(state: object, line: string) => object} foldLine - folds ONE raw JSONL line into
|
|
185
|
+
* `state` (mutate-and-return is fine); must have no cross-line lookback beyond `state`
|
|
186
|
+
* itself, so resuming partway through is equivalent to having scanned from the start.
|
|
187
|
+
* @returns {object} the derived state, fully caught up with the transcript's current
|
|
188
|
+
* contents — identical to what a full rescan from byte 0 would produce.
|
|
189
|
+
*/
|
|
190
|
+
export function getIncrementalState(transcriptPath, cacheKey, initialState, foldLine) {
|
|
191
|
+
let stat;
|
|
192
|
+
try {
|
|
193
|
+
stat = fs.statSync(transcriptPath);
|
|
194
|
+
} catch {
|
|
195
|
+
return initialState(); // missing transcript -> fail open, same as a full-rescan miss
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const cacheFp = cacheFilePath(transcriptPath, cacheKey);
|
|
199
|
+
const cache = readCache(cacheFp, transcriptPath);
|
|
200
|
+
const sizeAndTimeOk =
|
|
201
|
+
cache !== null &&
|
|
202
|
+
cache.size <= stat.size &&
|
|
203
|
+
cache.offset <= stat.size &&
|
|
204
|
+
cache.mtimeMs <= stat.mtimeMs;
|
|
205
|
+
const canResume =
|
|
206
|
+
sizeAndTimeOk && fingerprintBefore(transcriptPath, cache.offset) === cache.fingerprint;
|
|
207
|
+
|
|
208
|
+
const startState = canResume ? cache.state : initialState();
|
|
209
|
+
const startOffset = canResume ? cache.offset : 0;
|
|
210
|
+
|
|
211
|
+
const { offset, state } = scanFromOffset(transcriptPath, startOffset, startState, foldLine);
|
|
212
|
+
|
|
213
|
+
writeCache(cacheFp, {
|
|
214
|
+
transcriptPath,
|
|
215
|
+
size: stat.size,
|
|
216
|
+
mtimeMs: stat.mtimeMs,
|
|
217
|
+
offset,
|
|
218
|
+
fingerprint: fingerprintBefore(transcriptPath, offset),
|
|
219
|
+
state
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return state;
|
|
223
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook (matcher: mcp__.*) shipped by create-obsidian-memory — the vkm-kit
|
|
3
|
+
// token-saver (ADR-0043), MCP flavor. Pretty-printed JSON responses from MCP servers waste
|
|
4
|
+
// input tokens on pure indentation; this hook re-serializes any text block that parses as
|
|
5
|
+
// JSON into its compact form. STRICTLY whitespace-level: the parsed value is re-emitted
|
|
6
|
+
// unchanged (same keys, same order as JSON.parse preserves, same data), so security
|
|
7
|
+
// envelopes like the vault's `_trust` field survive byte-comparable at the semantic level.
|
|
8
|
+
// Non-JSON text, small payloads, and anything that fails to parse are left untouched; any
|
|
9
|
+
// internal failure fails open (prints nothing → Claude Code keeps the original).
|
|
10
|
+
//
|
|
11
|
+
// Kill switch: set VKM_TOKEN_SAVER=0 to disable without uninstalling.
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
|
|
15
|
+
/** Below this many saved characters the hook stays silent. */
|
|
16
|
+
const MIN_SAVED_CHARS = 200;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compact one text payload IFF it parses as JSON; returns null when unchanged/not JSON.
|
|
20
|
+
* @param {string} text
|
|
21
|
+
*/
|
|
22
|
+
export function compactJsonText(text) {
|
|
23
|
+
if (typeof text !== "string") return null;
|
|
24
|
+
const trimmed = text.trim();
|
|
25
|
+
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return null;
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(trimmed);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const compact = JSON.stringify(parsed);
|
|
33
|
+
return text.length - compact.length >= MIN_SAVED_CHARS ? compact : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compact an MCP `tool_response`, preserving its shape: a bare string is compacted
|
|
38
|
+
* directly; an object with a `content` array gets each `{type:"text", text}` block
|
|
39
|
+
* compacted in place (other block types and fields pass through verbatim). Returns null
|
|
40
|
+
* when nothing changed.
|
|
41
|
+
* @param {unknown} toolResponse
|
|
42
|
+
*/
|
|
43
|
+
export function compactMcpResponse(toolResponse) {
|
|
44
|
+
if (typeof toolResponse === "string") return compactJsonText(toolResponse);
|
|
45
|
+
if (!toolResponse || typeof toolResponse !== "object" || Array.isArray(toolResponse)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(toolResponse.content)) return null;
|
|
49
|
+
let changed = false;
|
|
50
|
+
const content = toolResponse.content.map((block) => {
|
|
51
|
+
if (block && typeof block === "object" && block.type === "text") {
|
|
52
|
+
const compact = compactJsonText(block.text);
|
|
53
|
+
if (compact !== null) {
|
|
54
|
+
changed = true;
|
|
55
|
+
return { ...block, text: compact };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return block;
|
|
59
|
+
});
|
|
60
|
+
return changed ? { ...toolResponse, content } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function main() {
|
|
64
|
+
if (process.env.VKM_TOKEN_SAVER === "0") return;
|
|
65
|
+
let payload;
|
|
66
|
+
try {
|
|
67
|
+
payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return; // fail open
|
|
70
|
+
}
|
|
71
|
+
const updated = compactMcpResponse(payload?.tool_response);
|
|
72
|
+
if (updated === null || updated === undefined) return;
|
|
73
|
+
process.stdout.write(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: updated }
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isEntryPoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
81
|
+
if (isEntryPoint) {
|
|
82
|
+
try {
|
|
83
|
+
main();
|
|
84
|
+
} catch {
|
|
85
|
+
// fail open — a hook crash must never break the tool call
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook (matcher: Bash) shipped by create-obsidian-memory — the vkm-kit
|
|
3
|
+
// token-saver (ADR-0043). Compacts noisy shell output BEFORE it enters Claude's context:
|
|
4
|
+
// strips ANSI color/cursor codes, keeps only the final repaint of carriage-return progress
|
|
5
|
+
// lines, collapses runs of identical lines, and windows very long logs to head+tail with an
|
|
6
|
+
// elision marker — while HARD-GUARANTEEING that diagnostic lines (error/warn/fail/exception/
|
|
7
|
+
// exit-code patterns) and the final lines of output always survive verbatim. Conservative by
|
|
8
|
+
// design: small or already-clean outputs are left untouched (the hook prints nothing, and
|
|
9
|
+
// Claude Code keeps the original), and ANY internal failure fails open the same way.
|
|
10
|
+
//
|
|
11
|
+
// Kill switch: set VKM_TOKEN_SAVER=0 to disable without uninstalling.
|
|
12
|
+
//
|
|
13
|
+
// stdin: PostToolUse JSON ({ tool_name, tool_input, tool_response, ... }).
|
|
14
|
+
// stdout: { hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput } } —
|
|
15
|
+
// same TYPE as the incoming tool_response (string in → string out; object in →
|
|
16
|
+
// object out with its text fields compacted, other fields preserved verbatim), so a
|
|
17
|
+
// schema mismatch can never corrupt the tool result (Claude Code ignores values that
|
|
18
|
+
// don't match the tool's output schema).
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
/** Diagnostic lines that must NEVER be dropped, whatever else gets compacted. Note the
|
|
23
|
+
* punctuation-ending alternatives (`err!`, `npm ERR!`) sit OUTSIDE the trailing `\b` group —
|
|
24
|
+
* `!` followed by a space is not a word boundary — and `\w*` before `error|exception`
|
|
25
|
+
* catches joined names like `TypeError` / `HttpException`. */
|
|
26
|
+
export const MUST_KEEP_RE =
|
|
27
|
+
/\b\w*(?:error|exception)s?\b|\berr!|npm ERR!|\b(?:warn|warning|fail|failed|failure|fatal|panic|traceback|assert|denied|refused|timeout|timed out|exit code|exited with)\b|✖|✗|⨯/i;
|
|
28
|
+
|
|
29
|
+
/** ANSI CSI/OSC escape sequences (colors, cursor movement, titles). */
|
|
30
|
+
const ANSI_RE = new RegExp(
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
"[\\u001b\\u009b](?:\\[[0-9;?]*[ -/]*[@-~]|\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\))",
|
|
33
|
+
"g"
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const DEFAULTS = {
|
|
37
|
+
/** Outputs at or under this many lines (after cleaning) are never windowed. */
|
|
38
|
+
maxLines: 200,
|
|
39
|
+
/** Head/tail window sizes when windowing kicks in. */
|
|
40
|
+
headLines: 120,
|
|
41
|
+
tailLines: 60,
|
|
42
|
+
/** Identical-line runs longer than this collapse to one line + a repeat marker. */
|
|
43
|
+
repeatThreshold: 3,
|
|
44
|
+
/** Cap on rescued diagnostic lines re-inserted from an elided middle section. */
|
|
45
|
+
maxRescued: 40,
|
|
46
|
+
/** Below this many saved characters the hook stays silent (not worth a rewrite). */
|
|
47
|
+
minSavedChars: 500
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pure compaction: returns `{ text, changed, stats }`. Never throws on string input.
|
|
52
|
+
* @param {string} input
|
|
53
|
+
* @param {Partial<typeof DEFAULTS>} [opts]
|
|
54
|
+
*/
|
|
55
|
+
export function compactText(input, opts = {}) {
|
|
56
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
57
|
+
const original = String(input ?? "");
|
|
58
|
+
if (!original) return { text: original, changed: false, stats: null };
|
|
59
|
+
|
|
60
|
+
// 1. ANSI escapes out; progress repaints: a physical line that was redrawn with `\r`
|
|
61
|
+
// only ever shows its final segment in a terminal — keep exactly that.
|
|
62
|
+
const cleaned = original
|
|
63
|
+
.replace(ANSI_RE, "")
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((line) => {
|
|
66
|
+
const i = line.lastIndexOf("\r");
|
|
67
|
+
return i >= 0 ? line.slice(i + 1) : line;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 2. Collapse runs of identical (trimmed-equal) lines.
|
|
71
|
+
const collapsed = [];
|
|
72
|
+
let run = 0;
|
|
73
|
+
for (let i = 0; i < cleaned.length; i++) {
|
|
74
|
+
const line = cleaned[i];
|
|
75
|
+
if (i > 0 && line.trim() === cleaned[i - 1].trim()) {
|
|
76
|
+
run += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (run >= cfg.repeatThreshold) {
|
|
80
|
+
collapsed.push(` [... repeated ${run} more times]`);
|
|
81
|
+
} else {
|
|
82
|
+
for (let r = 0; r < run; r++) collapsed.push(cleaned[i - 1]);
|
|
83
|
+
}
|
|
84
|
+
run = 0;
|
|
85
|
+
collapsed.push(line);
|
|
86
|
+
}
|
|
87
|
+
if (run >= cfg.repeatThreshold) collapsed.push(` [... repeated ${run} more times]`);
|
|
88
|
+
else for (let r = 0; r < run; r++) collapsed.push(cleaned[cleaned.length - 1]);
|
|
89
|
+
|
|
90
|
+
// 3. Head+tail windowing, rescuing diagnostic lines from the elided middle.
|
|
91
|
+
let windowed = collapsed;
|
|
92
|
+
if (collapsed.length > cfg.maxLines) {
|
|
93
|
+
const head = collapsed.slice(0, cfg.headLines);
|
|
94
|
+
const tail = collapsed.slice(-cfg.tailLines);
|
|
95
|
+
const middle = collapsed.slice(cfg.headLines, -cfg.tailLines);
|
|
96
|
+
const rescued = middle.filter((l) => MUST_KEEP_RE.test(l)).slice(0, cfg.maxRescued);
|
|
97
|
+
const elided = middle.length - rescued.length;
|
|
98
|
+
windowed = [
|
|
99
|
+
...head,
|
|
100
|
+
`[vkm token-saver: ${elided} low-signal lines elided; ${rescued.length} diagnostic lines preserved below]`,
|
|
101
|
+
...rescued,
|
|
102
|
+
...tail
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const text = windowed.join("\n");
|
|
107
|
+
const saved = original.length - text.length;
|
|
108
|
+
if (saved < cfg.minSavedChars) {
|
|
109
|
+
return { text: original, changed: false, stats: null };
|
|
110
|
+
}
|
|
111
|
+
const stats = {
|
|
112
|
+
fromChars: original.length,
|
|
113
|
+
toChars: text.length,
|
|
114
|
+
fromLines: cleaned.length,
|
|
115
|
+
toLines: windowed.length
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
text: `${text}\n[vkm token-saver: compacted ${stats.fromChars}→${stats.toChars} chars (${stats.fromLines}→${stats.toLines} lines); diagnostics preserved]`,
|
|
119
|
+
changed: true,
|
|
120
|
+
stats
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compact a PostToolUse `tool_response`, preserving its shape: strings are compacted
|
|
126
|
+
* directly; plain objects get their known text-bearing fields (`stdout`, `stderr`,
|
|
127
|
+
* `output`, `text`) compacted and every other field passed through verbatim. Returns
|
|
128
|
+
* `null` when nothing changed enough to be worth a rewrite.
|
|
129
|
+
* @param {unknown} toolResponse
|
|
130
|
+
*/
|
|
131
|
+
export function compactToolResponse(toolResponse) {
|
|
132
|
+
if (typeof toolResponse === "string") {
|
|
133
|
+
const { text, changed } = compactText(toolResponse);
|
|
134
|
+
return changed ? text : null;
|
|
135
|
+
}
|
|
136
|
+
if (toolResponse && typeof toolResponse === "object" && !Array.isArray(toolResponse)) {
|
|
137
|
+
const out = { ...toolResponse };
|
|
138
|
+
let changed = false;
|
|
139
|
+
for (const field of ["stdout", "stderr", "output", "text"]) {
|
|
140
|
+
if (typeof out[field] === "string") {
|
|
141
|
+
const result = compactText(out[field]);
|
|
142
|
+
if (result.changed) {
|
|
143
|
+
out[field] = result.text;
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return changed ? out : null;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function main() {
|
|
154
|
+
if (process.env.VKM_TOKEN_SAVER === "0") return;
|
|
155
|
+
let payload;
|
|
156
|
+
try {
|
|
157
|
+
payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
158
|
+
} catch {
|
|
159
|
+
return; // fail open: unreadable stdin → leave the tool output untouched
|
|
160
|
+
}
|
|
161
|
+
const updated = compactToolResponse(payload?.tool_response);
|
|
162
|
+
if (updated === null || updated === undefined) return;
|
|
163
|
+
process.stdout.write(
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: updated }
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isEntryPoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
171
|
+
if (isEntryPoint) {
|
|
172
|
+
try {
|
|
173
|
+
main();
|
|
174
|
+
} catch {
|
|
175
|
+
// fail open — a hook crash must never break the tool call
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook shipped by create-obsidian-memory (ADR-0044): guarantees the vkm-kit
|
|
3
|
+
// OTLP telemetry sink is alive whenever a Claude Code session starts, with no OS service or
|
|
4
|
+
// scheduled task. Checks the sink lockfile (pid liveness) and, when absent/stale, spawns
|
|
5
|
+
// `vkm-otel-sink` detached and exits immediately — the fast path is a single stat+kill(0)
|
|
6
|
+
// well under 50ms. Fail-open: ANY error exits 0 silently (a telemetry hiccup must never
|
|
7
|
+
// block a session). argv: node <hook> <sink-script-path>.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
|
|
14
|
+
function dataDir() {
|
|
15
|
+
return process.env.VKM_TELEMETRY_DIR || path.join(os.homedir(), ".vkm", "telemetry");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** True if a live sink already holds the lock. */
|
|
19
|
+
export function sinkAlive(dir = dataDir()) {
|
|
20
|
+
try {
|
|
21
|
+
const lock = JSON.parse(fs.readFileSync(path.join(dir, "sink.lock"), "utf8"));
|
|
22
|
+
process.kill(lock.pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function main() {
|
|
30
|
+
const sinkScript = process.argv[2];
|
|
31
|
+
if (!sinkScript || sinkAlive()) return;
|
|
32
|
+
try {
|
|
33
|
+
const child = spawn(process.execPath, [sinkScript], {
|
|
34
|
+
detached: true,
|
|
35
|
+
stdio: "ignore",
|
|
36
|
+
windowsHide: true
|
|
37
|
+
});
|
|
38
|
+
child.unref();
|
|
39
|
+
} catch {
|
|
40
|
+
// fail open
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isEntryPoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
45
|
+
if (isEntryPoint) {
|
|
46
|
+
try {
|
|
47
|
+
main();
|
|
48
|
+
} catch {
|
|
49
|
+
// fail open — never block a session over telemetry
|
|
50
|
+
}
|
|
51
|
+
}
|