@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.
Files changed (39) hide show
  1. package/LICENSE.md +44 -0
  2. package/README.md +148 -0
  3. package/package.json +57 -0
  4. package/src/asset-install.mjs +109 -0
  5. package/src/claude-native-memory.mjs +507 -0
  6. package/src/file-perms.mjs +53 -0
  7. package/src/hooks/_transcript-cache.mjs +223 -0
  8. package/src/hooks/compact-mcp-output.mjs +87 -0
  9. package/src/hooks/compact-tool-output.mjs +177 -0
  10. package/src/hooks/ensure-otel-sink.mjs +51 -0
  11. package/src/hooks/guard-effort-gate.mjs +209 -0
  12. package/src/hooks/guard-native-memory-write.mjs +129 -0
  13. package/src/hooks/session-start-vault-context.mjs +200 -0
  14. package/src/hooks/stop-vault-close-reminder.mjs +150 -0
  15. package/src/index.js +1547 -0
  16. package/src/mcp-merge.mjs +279 -0
  17. package/src/memory-rules.mjs +205 -0
  18. package/src/obscura-setup.mjs +272 -0
  19. package/src/ollama-setup.mjs +126 -0
  20. package/src/rules-merge.mjs +106 -0
  21. package/src/settings-io.mjs +193 -0
  22. package/src/settings-writers.mjs +150 -0
  23. package/src/skills-install.mjs +96 -0
  24. package/src/telemetry.mjs +154 -0
  25. package/src/token-saver.mjs +248 -0
  26. package/templates/agents/vkm-implementer.md +23 -0
  27. package/templates/output-styles/vkm-terse.md +23 -0
  28. package/templates/skills/vkm-discipline/SKILL.md +77 -0
  29. package/templates/skills/vkm-discipline/domains/coding.md +39 -0
  30. package/templates/skills/vkm-discipline/domains/data.md +37 -0
  31. package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
  32. package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
  33. package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
  34. package/templates/skills/vkm-discipline/domains/infra.md +35 -0
  35. package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
  36. package/templates/skills/vkm-discipline/domains/security.md +37 -0
  37. package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
  38. package/templates/skills/vkm-discipline/domains/writing.md +32 -0
  39. 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
+ }