laminark 2.21.6

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +182 -0
  3. package/package.json +63 -0
  4. package/plugin/.claude-plugin/plugin.json +13 -0
  5. package/plugin/.mcp.json +12 -0
  6. package/plugin/dist/analysis/worker.d.ts +1 -0
  7. package/plugin/dist/analysis/worker.js +233 -0
  8. package/plugin/dist/analysis/worker.js.map +1 -0
  9. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  10. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  11. package/plugin/dist/hooks/handler.d.ts +284 -0
  12. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  13. package/plugin/dist/hooks/handler.js +2125 -0
  14. package/plugin/dist/hooks/handler.js.map +1 -0
  15. package/plugin/dist/index.d.ts +445 -0
  16. package/plugin/dist/index.d.ts.map +1 -0
  17. package/plugin/dist/index.js +5831 -0
  18. package/plugin/dist/index.js.map +1 -0
  19. package/plugin/dist/observations-Ch0nc47i.d.mts +170 -0
  20. package/plugin/dist/observations-Ch0nc47i.d.mts.map +1 -0
  21. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs +2655 -0
  22. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +1 -0
  23. package/plugin/hooks/hooks.json +78 -0
  24. package/plugin/scripts/README.md +47 -0
  25. package/plugin/scripts/bump-version.sh +44 -0
  26. package/plugin/scripts/ensure-deps.sh +12 -0
  27. package/plugin/scripts/install.sh +63 -0
  28. package/plugin/scripts/local-install.sh +103 -0
  29. package/plugin/scripts/setup-tmpdir.sh +65 -0
  30. package/plugin/scripts/uninstall.sh +95 -0
  31. package/plugin/scripts/update.sh +88 -0
  32. package/plugin/scripts/verify-install.sh +43 -0
  33. package/plugin/ui/activity.js +185 -0
  34. package/plugin/ui/app.js +1642 -0
  35. package/plugin/ui/graph.js +2333 -0
  36. package/plugin/ui/help.js +228 -0
  37. package/plugin/ui/index.html +492 -0
  38. package/plugin/ui/settings.js +650 -0
  39. package/plugin/ui/styles.css +2910 -0
  40. package/plugin/ui/timeline.js +652 -0
@@ -0,0 +1,2125 @@
1
+ import { i as getProjectHash, n as getDatabaseConfig } from "../config-t8LZeB-u.mjs";
2
+ import { C as rowToObservation, D as debug, S as ObservationRepository, _ as SaveGuard, a as ResearchBufferRepository, b as SearchEngine, c as inferToolType, d as getNodeByNameAndType, h as traverseFrom, i as NotificationStore, n as PathRepository, o as extractServerName, r as initPathSchema, s as inferScope, t as ToolRegistryRepository, v as jaccardSimilarity, w as openDatabase, x as SessionRepository } from "../tool-registry-CZ3mJ4iR.mjs";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { homedir } from "node:os";
6
+
7
+ //#region src/hooks/self-referential.ts
8
+ /**
9
+ * Self-referential tool detection for Laminark.
10
+ *
11
+ * Laminark's MCP tools appear with different prefixes depending on
12
+ * how Claude Code discovers the server:
13
+ *
14
+ * - Project-scoped (.mcp.json): `mcp__laminark__<tool>`
15
+ * - Global plugin (~/.claude/plugins/): `mcp__plugin_laminark_laminark__<tool>`
16
+ *
17
+ * Both prefixes must be detected to prevent Laminark from capturing
18
+ * its own tool calls as observations, which would create a feedback loop.
19
+ */
20
+ /**
21
+ * All known prefixes for Laminark's own MCP tools.
22
+ * Order: project-scoped first (most common), plugin-scoped second.
23
+ */
24
+ const LAMINARK_PREFIXES = ["mcp__laminark__", "mcp__plugin_laminark_laminark__"];
25
+ /**
26
+ * Returns true if the given tool name belongs to Laminark.
27
+ *
28
+ * Checks against all known Laminark MCP prefixes to detect self-referential
29
+ * tool calls regardless of installation method (project-scoped or global plugin).
30
+ */
31
+ function isLaminarksOwnTool(toolName) {
32
+ return LAMINARK_PREFIXES.some((prefix) => toolName.startsWith(prefix));
33
+ }
34
+
35
+ //#endregion
36
+ //#region src/hooks/capture.ts
37
+ /**
38
+ * Truncates a string to maxLength, appending '...' if truncated.
39
+ */
40
+ function truncate$2(text, maxLength) {
41
+ if (text.length <= maxLength) return text;
42
+ return text.slice(0, maxLength) + "...";
43
+ }
44
+ /**
45
+ * Extracts a semantic observation summary from a PostToolUse payload.
46
+ * Returns null if no meaningful observation can be derived.
47
+ *
48
+ * Summaries are human-readable, not raw tool output. Each tool type
49
+ * gets a format optimized for later search and recall.
50
+ */
51
+ function extractObservation(payload) {
52
+ const { tool_name, tool_input, tool_response } = payload;
53
+ switch (tool_name) {
54
+ case "Write": return `[Write] Created ${tool_input.file_path}\n${truncate$2(String(tool_input.content ?? ""), 200)}`;
55
+ case "Edit": return `[Edit] Modified ${tool_input.file_path}: replaced "${truncate$2(String(tool_input.old_string ?? ""), 80)}" with "${truncate$2(String(tool_input.new_string ?? ""), 80)}"`;
56
+ case "Bash": return `[Bash] $ ${truncate$2(String(tool_input.command ?? ""), 100)}\n${truncate$2(JSON.stringify(tool_response ?? ""), 200)}`;
57
+ case "Read":
58
+ case "Glob":
59
+ case "Grep": return null;
60
+ case "WebFetch": return `[WebFetch] ${String(tool_input.url ?? "")}\nPrompt: ${truncate$2(String(tool_input.prompt ?? ""), 100)}\n${truncate$2(JSON.stringify(tool_response ?? ""), 300)}`;
61
+ case "WebSearch": return `[WebSearch] "${String(tool_input.query ?? "")}"\n${truncate$2(JSON.stringify(tool_response ?? ""), 300)}`;
62
+ default: return `[${tool_name}] ${truncate$2(JSON.stringify(tool_input), 200)}`;
63
+ }
64
+ }
65
+
66
+ //#endregion
67
+ //#region src/curation/summarizer.ts
68
+ /**
69
+ * Groups observations by their kind field.
70
+ */
71
+ function groupByKind(observations) {
72
+ const groups = {
73
+ change: [],
74
+ reference: [],
75
+ finding: [],
76
+ decision: [],
77
+ verification: []
78
+ };
79
+ for (const obs of observations) {
80
+ const kind = obs.kind ?? "finding";
81
+ if (groups[kind]) groups[kind].push(obs);
82
+ else groups.finding.push(obs);
83
+ }
84
+ return groups;
85
+ }
86
+ /**
87
+ * Extracts a snippet from observation content (first line, max 120 chars).
88
+ */
89
+ function snippet(content, maxLen = 120) {
90
+ const firstLine = content.split("\n")[0].trim();
91
+ if (firstLine.length <= maxLen) return firstLine;
92
+ return firstLine.slice(0, maxLen - 3) + "...";
93
+ }
94
+ /**
95
+ * Compresses an array of session observations into a structured text summary.
96
+ *
97
+ * Kind-aware: groups observations by their `kind` field instead of heuristic
98
+ * keyword matching. Produces structured sections:
99
+ * - Changes (kind='change'): file modifications
100
+ * - Decisions (kind='decision'): choices made
101
+ * - Verifications (kind='verification'): test/build results
102
+ * - References (kind='reference'): external resources consulted
103
+ * - Findings (kind='finding'): manual saves and insights
104
+ *
105
+ * Target output: under 500 tokens (~2000 characters).
106
+ * If the raw extraction exceeds this budget, sections are trimmed by priority:
107
+ * references first, then findings, then verifications, then changes.
108
+ */
109
+ function compressObservations(observations) {
110
+ if (observations.length === 0) return "";
111
+ const groups = groupByKind(observations);
112
+ const sections = [];
113
+ sections.push("## Session Summary");
114
+ const sorted = [...observations].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
115
+ const startedAt = sorted[0].createdAt;
116
+ const endedAt = sorted[sorted.length - 1].createdAt;
117
+ sections.push(`**Duration:** ${startedAt} to ${endedAt}`);
118
+ sections.push(`**Observations:** ${observations.length}`);
119
+ if (groups.change.length > 0) {
120
+ sections.push("");
121
+ sections.push("### Changes");
122
+ for (const obs of groups.change.slice(0, 10)) sections.push(`- ${snippet(obs.content)}`);
123
+ }
124
+ if (groups.decision.length > 0) {
125
+ sections.push("");
126
+ sections.push("### Decisions");
127
+ for (const obs of groups.decision.slice(0, 5)) sections.push(`- ${snippet(obs.content)}`);
128
+ }
129
+ if (groups.verification.length > 0) {
130
+ sections.push("");
131
+ sections.push("### Verifications");
132
+ for (const obs of groups.verification.slice(0, 5)) sections.push(`- ${snippet(obs.content)}`);
133
+ }
134
+ if (groups.reference.length > 0) {
135
+ sections.push("");
136
+ sections.push("### References");
137
+ for (const obs of groups.reference.slice(0, 3)) sections.push(`- ${snippet(obs.content)}`);
138
+ }
139
+ if (groups.finding.length > 0) {
140
+ sections.push("");
141
+ sections.push("### Findings");
142
+ for (const obs of groups.finding.slice(0, 5)) sections.push(`- ${snippet(obs.content)}`);
143
+ }
144
+ let result = sections.join("\n");
145
+ if (result.length > 2e3) {
146
+ const trimSections = [];
147
+ trimSections.push("## Session Summary");
148
+ trimSections.push(`**Duration:** ${startedAt} to ${endedAt}`);
149
+ trimSections.push(`**Observations:** ${observations.length}`);
150
+ if (groups.change.length > 0) {
151
+ trimSections.push("");
152
+ trimSections.push("### Changes");
153
+ for (const obs of groups.change.slice(0, 5)) trimSections.push(`- ${snippet(obs.content)}`);
154
+ }
155
+ if (groups.decision.length > 0) {
156
+ trimSections.push("");
157
+ trimSections.push("### Decisions");
158
+ for (const obs of groups.decision.slice(0, 3)) trimSections.push(`- ${snippet(obs.content)}`);
159
+ }
160
+ if (groups.verification.length > 0) {
161
+ trimSections.push("");
162
+ trimSections.push("### Verifications");
163
+ for (const obs of groups.verification.slice(0, 3)) trimSections.push(`- ${snippet(obs.content)}`);
164
+ }
165
+ result = trimSections.join("\n");
166
+ }
167
+ return result;
168
+ }
169
+ /**
170
+ * Generates a session summary by reading all observations for the given session,
171
+ * compressing them into a concise summary, and storing it back on the session row.
172
+ *
173
+ * Returns null if the session has zero observations (graceful no-op).
174
+ *
175
+ * @param sessionId - The session ID to summarize
176
+ * @param obsRepo - Repository for reading observations
177
+ * @param sessionRepo - Repository for updating the session summary
178
+ * @returns SessionSummary or null if no observations
179
+ */
180
+ function generateSessionSummary(sessionId, obsRepo, sessionRepo) {
181
+ debug("curation", "Generating session summary", { sessionId });
182
+ const observations = obsRepo.list({
183
+ sessionId,
184
+ limit: 1e3
185
+ });
186
+ if (observations.length === 0) {
187
+ debug("curation", "No observations for session, skipping summary", { sessionId });
188
+ return null;
189
+ }
190
+ const summary = compressObservations(observations);
191
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
192
+ sessionRepo.updateSessionSummary(sessionId, summary);
193
+ debug("curation", "Session summary generated", {
194
+ sessionId,
195
+ observationCount: observations.length,
196
+ summaryLength: summary.length
197
+ });
198
+ return {
199
+ sessionId,
200
+ summary,
201
+ observationCount: observations.length,
202
+ generatedAt
203
+ };
204
+ }
205
+
206
+ //#endregion
207
+ //#region src/context/injection.ts
208
+ /**
209
+ * Maximum character budget for injected context (~2000 tokens at ~3 chars/token).
210
+ * If the assembled context exceeds this, observations are truncated.
211
+ */
212
+ const MAX_CONTEXT_CHARS = 6e3;
213
+ /**
214
+ * Maximum number of characters to show per observation in the index.
215
+ */
216
+ const OBSERVATION_CONTENT_LIMIT = 120;
217
+ /**
218
+ * Maximum character budget for the "## Available Tools" section.
219
+ * Prevents tool listings from consuming too much of the 6000-char overall budget.
220
+ */
221
+ const TOOL_SECTION_BUDGET = 500;
222
+ /**
223
+ * Welcome message for first-ever session (no prior sessions or observations).
224
+ */
225
+ const WELCOME_MESSAGE = `[Laminark] First session detected. Memory system is active and capturing observations.
226
+ Use /laminark:remember to save important context. Use /laminark:recall to search memories.`;
227
+ /**
228
+ * Formats an ISO 8601 timestamp into a human-readable relative time string.
229
+ *
230
+ * @param isoDate - ISO 8601 timestamp string
231
+ * @returns Relative time string (e.g., "2 hours ago", "yesterday", "3 days ago")
232
+ */
233
+ function formatRelativeTime(isoDate) {
234
+ const diffMs = Date.now() - new Date(isoDate).getTime();
235
+ if (diffMs < 0) return "just now";
236
+ const seconds = Math.floor(diffMs / 1e3);
237
+ const minutes = Math.floor(seconds / 60);
238
+ const hours = Math.floor(minutes / 60);
239
+ const days = Math.floor(hours / 24);
240
+ const weeks = Math.floor(days / 7);
241
+ if (minutes < 1) return "just now";
242
+ if (minutes === 1) return "1 minute ago";
243
+ if (minutes < 60) return `${minutes} minutes ago`;
244
+ if (hours === 1) return "1 hour ago";
245
+ if (hours < 24) return `${hours} hours ago`;
246
+ if (days === 1) return "yesterday";
247
+ if (days < 7) return `${days} days ago`;
248
+ if (weeks === 1) return "1 week ago";
249
+ return `${weeks} weeks ago`;
250
+ }
251
+ /**
252
+ * Truncates a string to `maxLen` characters, appending "..." if truncated.
253
+ */
254
+ function truncate$1(text, maxLen) {
255
+ const normalized = text.replace(/\s+/g, " ").trim();
256
+ if (normalized.length <= maxLen) return normalized;
257
+ return normalized.slice(0, maxLen) + "...";
258
+ }
259
+ /**
260
+ * Queries recent observations filtered by kind with a time window.
261
+ */
262
+ function getRecentByKind(db, projectHash, kind, limit, sinceDays) {
263
+ const since = (/* @__PURE__ */ new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1e3)).toISOString();
264
+ return db.prepare(`SELECT * FROM observations
265
+ WHERE project_hash = ? AND kind = ? AND deleted_at IS NULL
266
+ AND created_at >= ?
267
+ ORDER BY created_at DESC, rowid DESC
268
+ LIMIT ?`).all(projectHash, kind, since, limit).map(rowToObservation);
269
+ }
270
+ /**
271
+ * Formats the context using structured kind-aware sections.
272
+ *
273
+ * Produces a compact index suitable for Claude's context window:
274
+ * - Last session summary
275
+ * - Recent changes (with provenance context)
276
+ * - Active decisions
277
+ * - Reference docs
278
+ * - Findings
279
+ */
280
+ function formatContextIndex(lastSession, sections) {
281
+ if (!(lastSession?.summary || sections.changes.length > 0 || sections.decisions.length > 0 || sections.findings.length > 0 || sections.references.length > 0)) return WELCOME_MESSAGE;
282
+ const lines = ["[Laminark - Session Context]", ""];
283
+ if (lastSession && lastSession.summary) {
284
+ lines.push("## Previous Session");
285
+ lines.push(lastSession.summary);
286
+ lines.push("");
287
+ }
288
+ if (sections.changes.length > 0) {
289
+ lines.push("## Recent Changes");
290
+ for (const obs of sections.changes) {
291
+ const content = truncate$1(obs.content, OBSERVATION_CONTENT_LIMIT);
292
+ const relTime = formatRelativeTime(obs.createdAt);
293
+ lines.push(`- ${content} (${relTime})`);
294
+ }
295
+ lines.push("");
296
+ }
297
+ if (sections.decisions.length > 0) {
298
+ lines.push("## Active Decisions");
299
+ for (const obs of sections.decisions) {
300
+ const content = truncate$1(obs.content, OBSERVATION_CONTENT_LIMIT);
301
+ lines.push(`- ${content}`);
302
+ }
303
+ lines.push("");
304
+ }
305
+ if (sections.references.length > 0) {
306
+ lines.push("## Reference Docs");
307
+ for (const obs of sections.references) {
308
+ const content = truncate$1(obs.content, OBSERVATION_CONTENT_LIMIT);
309
+ lines.push(`- ${content}`);
310
+ }
311
+ lines.push("");
312
+ }
313
+ if (sections.findings.length > 0) {
314
+ lines.push("## Recent Findings");
315
+ for (const obs of sections.findings) {
316
+ const shortId = obs.id.slice(0, 8);
317
+ const content = truncate$1(obs.content, OBSERVATION_CONTENT_LIMIT);
318
+ lines.push(`- [${shortId}] ${content}`);
319
+ }
320
+ }
321
+ return lines.join("\n");
322
+ }
323
+ /**
324
+ * Gets the most recent completed session with a non-null summary.
325
+ */
326
+ function getLastCompletedSession(db, projectHash) {
327
+ const row = db.prepare(`SELECT * FROM sessions
328
+ WHERE project_hash = ? AND summary IS NOT NULL AND ended_at IS NOT NULL
329
+ ORDER BY ended_at DESC, rowid DESC
330
+ LIMIT 1`).get(projectHash);
331
+ if (!row) return null;
332
+ return {
333
+ id: row.id,
334
+ projectHash: row.project_hash,
335
+ startedAt: row.started_at,
336
+ endedAt: row.ended_at,
337
+ summary: row.summary
338
+ };
339
+ }
340
+ /**
341
+ * Ranks tools by relevance using a weighted combination of recent usage
342
+ * frequency and recency. Tools with no recent usage score 0.
343
+ *
344
+ * Formula: score = eventCount / totalEvents (frequency share among peers)
345
+ *
346
+ * Uses event-count-based window (last N events) instead of time-based decay.
347
+ * This is immune to usage gaps — if you don't use the app for a week,
348
+ * your usage patterns are preserved because the window slides by event
349
+ * count, not calendar time.
350
+ *
351
+ * MCP server entries aggregate usage stats from their individual tool events
352
+ * to ensure accurate scoring.
353
+ */
354
+ function rankToolsByRelevance(tools, usageStats) {
355
+ if (usageStats.length === 0) return tools;
356
+ const statsMap = /* @__PURE__ */ new Map();
357
+ for (const stat of usageStats) statsMap.set(stat.tool_name, stat);
358
+ const serverStats = /* @__PURE__ */ new Map();
359
+ for (const stat of usageStats) {
360
+ const match = stat.tool_name.match(/^mcp__([^_]+(?:_[^_]+)*)__/);
361
+ if (match) {
362
+ const serverName = match[1];
363
+ const existing = serverStats.get(serverName);
364
+ if (existing) existing.usage_count += stat.usage_count;
365
+ else serverStats.set(serverName, { usage_count: stat.usage_count });
366
+ }
367
+ }
368
+ const totalEvents = Math.max(1, [...statsMap.values()].reduce((sum, s) => sum + s.usage_count, 0));
369
+ const scored = tools.map((row) => {
370
+ let count = statsMap.get(row.name)?.usage_count;
371
+ if (count === void 0 && row.tool_type === "mcp_server" && row.server_name) count = serverStats.get(row.server_name)?.usage_count;
372
+ if (count === void 0) return {
373
+ row,
374
+ score: 0
375
+ };
376
+ let score = count / totalEvents;
377
+ if (row.status === "stale" || row.status === "demoted") score *= .25;
378
+ const lastUsed = row.last_used_at || row.discovered_at;
379
+ const lastSeen = new Date(Math.max(new Date(lastUsed).getTime(), new Date(row.updated_at).getTime()));
380
+ if ((Date.now() - lastSeen.getTime()) / (1e3 * 60 * 60 * 24) > 30) score *= .5;
381
+ return {
382
+ row,
383
+ score
384
+ };
385
+ });
386
+ scored.sort((a, b) => {
387
+ if (b.score !== a.score) return b.score - a.score;
388
+ return b.row.usage_count - a.row.usage_count;
389
+ });
390
+ return scored.map((s) => s.row);
391
+ }
392
+ /**
393
+ * Formats available tools as a compact section for session context.
394
+ *
395
+ * Deduplicates MCP servers vs individual MCP tools (prefers server entries).
396
+ * Excludes built-in tools (Claude already knows Read, Write, Edit, Bash, etc.).
397
+ * Enforces a 500-character sub-budget via incremental line checking.
398
+ */
399
+ function formatToolSection(tools) {
400
+ if (tools.length === 0) return "";
401
+ const seenServers = /* @__PURE__ */ new Set();
402
+ const deduped = [];
403
+ for (const tool of tools) if (tool.tool_type === "mcp_server") {
404
+ seenServers.add(tool.server_name ?? tool.name);
405
+ deduped.push(tool);
406
+ }
407
+ for (const tool of tools) if (tool.tool_type !== "mcp_server") {
408
+ if (tool.tool_type === "mcp_tool" && tool.server_name && seenServers.has(tool.server_name)) continue;
409
+ deduped.push(tool);
410
+ }
411
+ const displayable = deduped.filter((t) => t.tool_type !== "builtin");
412
+ if (displayable.length === 0) return "";
413
+ const lines = ["## Available Tools"];
414
+ for (const tool of displayable) {
415
+ const scopeTag = tool.scope === "project" ? "project" : "global";
416
+ const usageStr = tool.usage_count > 0 ? `, ${tool.usage_count}x` : "";
417
+ let candidateLine;
418
+ if (tool.tool_type === "mcp_server") candidateLine = `- MCP: ${tool.server_name ?? tool.name} (${scopeTag}${usageStr})`;
419
+ else if (tool.tool_type === "slash_command") candidateLine = `- ${tool.name} (${scopeTag}${usageStr})`;
420
+ else if (tool.tool_type === "skill") {
421
+ const desc = tool.description ? ` - ${tool.description}` : "";
422
+ candidateLine = `- skill: ${tool.name} (${scopeTag})${desc}`;
423
+ } else if (tool.tool_type === "plugin") candidateLine = `- plugin: ${tool.name} (${scopeTag})`;
424
+ else candidateLine = `- ${tool.name} (${scopeTag}${usageStr})`;
425
+ if ([...lines, candidateLine].join("\n").length > TOOL_SECTION_BUDGET) break;
426
+ lines.push(candidateLine);
427
+ }
428
+ const added = lines.length - 1;
429
+ if (displayable.length > added && added > 0) {
430
+ const overflow = `(${displayable.length - added} more available)`;
431
+ if ((lines.join("\n") + "\n" + overflow).length <= TOOL_SECTION_BUDGET) lines.push(overflow);
432
+ }
433
+ return lines.join("\n");
434
+ }
435
+ /**
436
+ * Assembles the complete context string for SessionStart injection.
437
+ *
438
+ * Kind-aware: queries changes (last 24h), decisions (last 7d),
439
+ * findings (last 7d), and references (last 3d) separately,
440
+ * then assembles them into structured sections.
441
+ *
442
+ * Token budget: Total output stays under 2000 tokens (~6000 characters).
443
+ */
444
+ function assembleSessionContext(db, projectHash, toolRegistry) {
445
+ debug("context", "Assembling session context", { projectHash });
446
+ const lastSession = getLastCompletedSession(db, projectHash);
447
+ const changes = getRecentByKind(db, projectHash, "change", 10, 1);
448
+ const decisions = getRecentByKind(db, projectHash, "decision", 5, 7);
449
+ const findings = getRecentByKind(db, projectHash, "finding", 5, 7);
450
+ const references = getRecentByKind(db, projectHash, "reference", 3, 3);
451
+ let toolSection = "";
452
+ if (toolRegistry) try {
453
+ toolSection = formatToolSection(rankToolsByRelevance(toolRegistry.getAvailableForSession(projectHash), toolRegistry.getRecentUsage(projectHash, 200)));
454
+ } catch {}
455
+ let context = formatContextIndex(lastSession, {
456
+ changes,
457
+ decisions,
458
+ findings,
459
+ references
460
+ });
461
+ if (toolSection) context = context + "\n\n" + toolSection;
462
+ if (context.length > MAX_CONTEXT_CHARS) {
463
+ debug("context", "Context exceeds budget, trimming", {
464
+ length: context.length,
465
+ budget: MAX_CONTEXT_CHARS
466
+ });
467
+ if (toolSection) {
468
+ context = formatContextIndex(lastSession, {
469
+ changes,
470
+ decisions,
471
+ findings,
472
+ references
473
+ });
474
+ toolSection = "";
475
+ }
476
+ }
477
+ if (context.length > MAX_CONTEXT_CHARS) {
478
+ let trimmedRefs = references.slice();
479
+ let trimmedFindings = findings.slice();
480
+ let trimmedChanges = changes.slice();
481
+ while (context.length > MAX_CONTEXT_CHARS && trimmedRefs.length > 0) {
482
+ trimmedRefs = trimmedRefs.slice(0, -1);
483
+ context = formatContextIndex(lastSession, {
484
+ changes: trimmedChanges,
485
+ decisions,
486
+ findings: trimmedFindings,
487
+ references: trimmedRefs
488
+ });
489
+ }
490
+ if (context.length > MAX_CONTEXT_CHARS) while (context.length > MAX_CONTEXT_CHARS && trimmedFindings.length > 0) {
491
+ trimmedFindings = trimmedFindings.slice(0, -1);
492
+ context = formatContextIndex(lastSession, {
493
+ changes: trimmedChanges,
494
+ decisions,
495
+ findings: trimmedFindings,
496
+ references: trimmedRefs
497
+ });
498
+ }
499
+ if (context.length > MAX_CONTEXT_CHARS) while (context.length > MAX_CONTEXT_CHARS && trimmedChanges.length > 0) {
500
+ trimmedChanges = trimmedChanges.slice(0, -1);
501
+ context = formatContextIndex(lastSession, {
502
+ changes: trimmedChanges,
503
+ decisions,
504
+ findings: trimmedFindings,
505
+ references: trimmedRefs
506
+ });
507
+ }
508
+ }
509
+ debug("context", "Session context assembled", { length: context.length });
510
+ return context;
511
+ }
512
+
513
+ //#endregion
514
+ //#region src/hooks/config-scanner.ts
515
+ /**
516
+ * Extracts a description from YAML frontmatter in a Markdown file.
517
+ * Reads only the first 500 bytes for performance.
518
+ */
519
+ function extractDescription(filePath) {
520
+ try {
521
+ const fmMatch = readFileSync(filePath, {
522
+ encoding: "utf-8",
523
+ flag: "r"
524
+ }).slice(0, 500).match(/^---\n([\s\S]*?)\n---/);
525
+ if (!fmMatch) return null;
526
+ const descMatch = fmMatch[1].match(/description:\s*(.+)/);
527
+ return descMatch ? descMatch[1].trim() : null;
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+ /**
533
+ * Scans an .mcp.json file for MCP server entries.
534
+ * Each server key becomes a wildcard tool entry (individual tool names are not in config).
535
+ */
536
+ function scanMcpJson(filePath, scope, projectHash, tools) {
537
+ try {
538
+ if (!existsSync(filePath)) return;
539
+ const raw = readFileSync(filePath, "utf-8");
540
+ const mcpServers = JSON.parse(raw).mcpServers;
541
+ if (!mcpServers || typeof mcpServers !== "object") return;
542
+ for (const serverName of Object.keys(mcpServers)) tools.push({
543
+ name: `mcp__${serverName}__*`,
544
+ toolType: "mcp_server",
545
+ scope,
546
+ source: `config:${filePath}`,
547
+ projectHash,
548
+ description: null,
549
+ serverName
550
+ });
551
+ } catch (err) {
552
+ debug("scanner", "Failed to scan MCP config", {
553
+ filePath,
554
+ error: String(err)
555
+ });
556
+ }
557
+ }
558
+ /**
559
+ * Scans ~/.claude.json for MCP servers (top-level and per-project).
560
+ */
561
+ function scanClaudeJson(filePath, tools) {
562
+ try {
563
+ if (!existsSync(filePath)) return;
564
+ const raw = readFileSync(filePath, "utf-8");
565
+ const config = JSON.parse(raw);
566
+ const topServers = config.mcpServers;
567
+ if (topServers && typeof topServers === "object") for (const serverName of Object.keys(topServers)) tools.push({
568
+ name: `mcp__${serverName}__*`,
569
+ toolType: "mcp_server",
570
+ scope: "global",
571
+ source: "config:~/.claude.json",
572
+ projectHash: null,
573
+ description: null,
574
+ serverName
575
+ });
576
+ const projects = config.projects;
577
+ if (projects && typeof projects === "object") for (const projectEntry of Object.values(projects)) {
578
+ const projServers = projectEntry.mcpServers;
579
+ if (projServers && typeof projServers === "object") for (const serverName of Object.keys(projServers)) tools.push({
580
+ name: `mcp__${serverName}__*`,
581
+ toolType: "mcp_server",
582
+ scope: "global",
583
+ source: "config:~/.claude.json",
584
+ projectHash: null,
585
+ description: null,
586
+ serverName
587
+ });
588
+ }
589
+ } catch (err) {
590
+ debug("scanner", "Failed to scan claude.json", {
591
+ filePath,
592
+ error: String(err)
593
+ });
594
+ }
595
+ }
596
+ /**
597
+ * Scans a commands directory for slash command .md files.
598
+ * Supports one level of subdirectory nesting for namespaced commands.
599
+ */
600
+ function scanCommands(dirPath, scope, projectHash, tools) {
601
+ try {
602
+ if (!existsSync(dirPath)) return;
603
+ const entries = readdirSync(dirPath, { withFileTypes: true });
604
+ for (const entry of entries) if (entry.isFile() && entry.name.endsWith(".md")) {
605
+ const cmdName = `/${basename(entry.name, ".md")}`;
606
+ const description = extractDescription(join(dirPath, entry.name));
607
+ tools.push({
608
+ name: cmdName,
609
+ toolType: "slash_command",
610
+ scope,
611
+ source: `config:${dirPath}`,
612
+ projectHash,
613
+ description,
614
+ serverName: null
615
+ });
616
+ } else if (entry.isDirectory()) {
617
+ const subDir = join(dirPath, entry.name);
618
+ try {
619
+ const subEntries = readdirSync(subDir, { withFileTypes: true });
620
+ for (const subEntry of subEntries) if (subEntry.isFile() && subEntry.name.endsWith(".md")) {
621
+ const cmdName = `/${entry.name}:${basename(subEntry.name, ".md")}`;
622
+ const description = extractDescription(join(subDir, subEntry.name));
623
+ tools.push({
624
+ name: cmdName,
625
+ toolType: "slash_command",
626
+ scope,
627
+ source: `config:${dirPath}`,
628
+ projectHash,
629
+ description,
630
+ serverName: null
631
+ });
632
+ }
633
+ } catch {}
634
+ }
635
+ } catch (err) {
636
+ debug("scanner", "Failed to scan commands directory", {
637
+ dirPath,
638
+ error: String(err)
639
+ });
640
+ }
641
+ }
642
+ /**
643
+ * Scans a skills directory for skill subdirectories containing SKILL.md.
644
+ */
645
+ function scanSkills(dirPath, scope, projectHash, tools) {
646
+ try {
647
+ if (!existsSync(dirPath)) return;
648
+ const entries = readdirSync(dirPath, { withFileTypes: true });
649
+ for (const entry of entries) if (entry.isDirectory()) {
650
+ const skillMdPath = join(dirPath, entry.name, "SKILL.md");
651
+ if (existsSync(skillMdPath)) {
652
+ const description = extractDescription(skillMdPath);
653
+ tools.push({
654
+ name: entry.name,
655
+ toolType: "skill",
656
+ scope,
657
+ source: `config:${dirPath}`,
658
+ projectHash,
659
+ description,
660
+ serverName: null
661
+ });
662
+ }
663
+ }
664
+ } catch (err) {
665
+ debug("scanner", "Failed to scan skills directory", {
666
+ dirPath,
667
+ error: String(err)
668
+ });
669
+ }
670
+ }
671
+ /**
672
+ * Scans installed_plugins.json for installed Claude plugins.
673
+ * Version 2 format: { version: 2, plugins: { "name@marketplace": [{ scope, installPath, version }] } }
674
+ */
675
+ function scanInstalledPlugins(filePath, tools) {
676
+ try {
677
+ if (!existsSync(filePath)) return;
678
+ const raw = readFileSync(filePath, "utf-8");
679
+ const plugins = JSON.parse(raw).plugins;
680
+ if (!plugins || typeof plugins !== "object") return;
681
+ for (const [key, installations] of Object.entries(plugins)) {
682
+ const pluginName = key.split("@")[0];
683
+ if (!Array.isArray(installations)) continue;
684
+ for (const install of installations) {
685
+ const inst = install;
686
+ const instScope = inst.scope === "user" ? "global" : "project";
687
+ tools.push({
688
+ name: pluginName,
689
+ toolType: "plugin",
690
+ scope: instScope,
691
+ source: "config:installed_plugins.json",
692
+ projectHash: null,
693
+ description: null,
694
+ serverName: null
695
+ });
696
+ if (typeof inst.installPath === "string") scanMcpJson(join(inst.installPath, ".mcp.json"), "plugin", null, tools);
697
+ }
698
+ }
699
+ } catch (err) {
700
+ debug("scanner", "Failed to scan installed plugins", {
701
+ filePath,
702
+ error: String(err)
703
+ });
704
+ }
705
+ }
706
+ /**
707
+ * Scans all Claude Code config surfaces for tool discovery.
708
+ * Called during SessionStart to proactively populate the tool registry.
709
+ *
710
+ * All filesystem operations are synchronous (SessionStart hook is synchronous).
711
+ * Every scanner is wrapped in try/catch -- malformed configs never crash the hook.
712
+ *
713
+ * Config surfaces scanned:
714
+ * DISC-01: .mcp.json (project) + ~/.claude.json (global)
715
+ * DISC-02: .claude/commands (project) + ~/.claude/commands (global)
716
+ * DISC-03: .claude/skills (project) + ~/.claude/skills (global)
717
+ * DISC-04: installed_plugins.json (global plugins)
718
+ */
719
+ function scanConfigForTools(cwd, projectHash) {
720
+ const tools = [];
721
+ const home = homedir();
722
+ scanMcpJson(join(cwd, ".mcp.json"), "project", projectHash, tools);
723
+ scanClaudeJson(join(home, ".claude.json"), tools);
724
+ scanCommands(join(cwd, ".claude", "commands"), "project", projectHash, tools);
725
+ scanCommands(join(home, ".claude", "commands"), "global", null, tools);
726
+ scanSkills(join(cwd, ".claude", "skills"), "project", projectHash, tools);
727
+ scanSkills(join(home, ".claude", "skills"), "global", null, tools);
728
+ scanInstalledPlugins(join(home, ".claude", "plugins", "installed_plugins.json"), tools);
729
+ return tools;
730
+ }
731
+
732
+ //#endregion
733
+ //#region src/routing/intent-patterns.ts
734
+ /**
735
+ * Extracts tool sequence patterns from historical tool_usage_events.
736
+ *
737
+ * Scans all successful tool usage events for the project, groups them by session,
738
+ * and identifies recurring sliding-window patterns where a specific sequence of
739
+ * preceding tool calls led to a target tool activation.
740
+ *
741
+ * Runs at SessionStart and stores results in the routing_patterns table for
742
+ * cheap PostToolUse lookup.
743
+ *
744
+ * @param db - Database connection
745
+ * @param projectHash - Project identifier
746
+ * @param windowSize - Number of preceding tools to consider (default 5)
747
+ * @returns Extracted patterns sorted by frequency descending
748
+ */
749
+ function extractPatterns(db, projectHash, windowSize = 5) {
750
+ const events = db.prepare(`
751
+ SELECT tool_name, session_id
752
+ FROM tool_usage_events
753
+ WHERE project_hash = ? AND success = 1
754
+ ORDER BY session_id, created_at
755
+ `).all(projectHash);
756
+ const sessions = /* @__PURE__ */ new Map();
757
+ for (const evt of events) {
758
+ if (!sessions.has(evt.session_id)) sessions.set(evt.session_id, []);
759
+ sessions.get(evt.session_id).push(evt.tool_name);
760
+ }
761
+ const patternCounts = /* @__PURE__ */ new Map();
762
+ for (const [, toolSequence] of sessions) for (let i = windowSize; i < toolSequence.length; i++) {
763
+ const target = toolSequence[i];
764
+ const preceding = toolSequence.slice(i - windowSize, i);
765
+ if (inferToolType(target) === "builtin") continue;
766
+ if (isLaminarksOwnTool(target)) continue;
767
+ const key = `${target}:${preceding.join(",")}`;
768
+ const existing = patternCounts.get(key);
769
+ if (existing) existing.count++;
770
+ else patternCounts.set(key, {
771
+ target,
772
+ preceding,
773
+ count: 1
774
+ });
775
+ }
776
+ return Array.from(patternCounts.values()).filter((p) => p.count >= 2).map((p) => ({
777
+ targetTool: p.target,
778
+ precedingTools: p.preceding,
779
+ frequency: p.count
780
+ })).sort((a, b) => b.frequency - a.frequency);
781
+ }
782
+ /**
783
+ * Stores pre-computed routing patterns in the routing_patterns table.
784
+ *
785
+ * Creates the table inline (CREATE TABLE IF NOT EXISTS), deletes old patterns
786
+ * for the project, and inserts new ones in a transaction.
787
+ *
788
+ * @param db - Database connection
789
+ * @param projectHash - Project identifier
790
+ * @param patterns - Pre-computed patterns from extractPatterns()
791
+ */
792
+ function storePrecomputedPatterns(db, projectHash, patterns) {
793
+ db.exec(`
794
+ CREATE TABLE IF NOT EXISTS routing_patterns (
795
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
796
+ project_hash TEXT NOT NULL,
797
+ target_tool TEXT NOT NULL,
798
+ preceding_tools TEXT NOT NULL,
799
+ frequency INTEGER NOT NULL,
800
+ computed_at TEXT NOT NULL DEFAULT (datetime('now'))
801
+ )
802
+ `);
803
+ db.exec(`
804
+ CREATE INDEX IF NOT EXISTS idx_routing_patterns_project ON routing_patterns(project_hash)
805
+ `);
806
+ const deleteStmt = db.prepare("DELETE FROM routing_patterns WHERE project_hash = ?");
807
+ const insertStmt = db.prepare("INSERT INTO routing_patterns (project_hash, target_tool, preceding_tools, frequency) VALUES (?, ?, ?, ?)");
808
+ db.transaction(() => {
809
+ deleteStmt.run(projectHash);
810
+ for (const pattern of patterns) insertStmt.run(projectHash, pattern.targetTool, JSON.stringify(pattern.precedingTools), pattern.frequency);
811
+ })();
812
+ debug("routing", "Stored pre-computed patterns", {
813
+ projectHash,
814
+ count: patterns.length
815
+ });
816
+ }
817
+ /**
818
+ * Evaluates the current session's recent tool sequence against pre-computed patterns.
819
+ *
820
+ * Queries the current session's recent tool names, compares against stored patterns,
821
+ * and returns the best match if it exceeds the confidence threshold and the target
822
+ * tool is in the suggestable set.
823
+ *
824
+ * @param db - Database connection
825
+ * @param sessionId - Current session identifier
826
+ * @param projectHash - Project identifier
827
+ * @param suggestableToolNames - Set of tool names available for suggestion (availability gate)
828
+ * @param confidenceThreshold - Minimum confidence to return a suggestion
829
+ * @returns Best matching suggestion, or null if none qualifies
830
+ */
831
+ function evaluateLearnedPatterns(db, sessionId, projectHash, suggestableToolNames, confidenceThreshold) {
832
+ const currentTools = db.prepare(`
833
+ SELECT tool_name FROM tool_usage_events
834
+ WHERE session_id = ? AND project_hash = ?
835
+ ORDER BY created_at DESC
836
+ LIMIT 10
837
+ `).all(sessionId, projectHash).map((e) => e.tool_name).reverse();
838
+ if (currentTools.length === 0) return null;
839
+ const storedPatterns = db.prepare(`
840
+ SELECT target_tool, preceding_tools, frequency
841
+ FROM routing_patterns
842
+ WHERE project_hash = ?
843
+ ORDER BY frequency DESC
844
+ `).all(projectHash);
845
+ if (storedPatterns.length === 0) return null;
846
+ let bestMatch = null;
847
+ for (const row of storedPatterns) {
848
+ if (!suggestableToolNames.has(row.target_tool)) continue;
849
+ const overlap = computeSequenceOverlap(currentTools, JSON.parse(row.preceding_tools));
850
+ if (overlap > (bestMatch?.confidence ?? 0)) bestMatch = {
851
+ targetTool: row.target_tool,
852
+ confidence: overlap,
853
+ frequency: row.frequency
854
+ };
855
+ }
856
+ if (!bestMatch || bestMatch.confidence < confidenceThreshold) return null;
857
+ return {
858
+ toolName: bestMatch.targetTool,
859
+ toolDescription: null,
860
+ confidence: bestMatch.confidence,
861
+ tier: "learned",
862
+ reason: `Tool sequence pattern match (seen ${bestMatch.frequency}x in similar contexts)`
863
+ };
864
+ }
865
+ /**
866
+ * Computes Jaccard-like overlap between the current session's recent tool set
867
+ * and a pattern's preceding tools set.
868
+ *
869
+ * Takes the last N tools from the current sequence (where N = pattern length),
870
+ * converts both to sets, and counts how many pattern tools appear in the current set.
871
+ *
872
+ * @param currentTools - Current session's recent tool names (chronological order)
873
+ * @param patternTools - Pattern's preceding tools
874
+ * @returns Overlap score from 0.0 to 1.0
875
+ */
876
+ function computeSequenceOverlap(currentTools, patternTools) {
877
+ if (patternTools.length === 0) return 0;
878
+ const current = new Set(currentTools.slice(-patternTools.length));
879
+ const pattern = new Set(patternTools);
880
+ let matches = 0;
881
+ for (const tool of pattern) if (current.has(tool)) matches++;
882
+ return matches / pattern.size;
883
+ }
884
+
885
+ //#endregion
886
+ //#region src/hooks/session-lifecycle.ts
887
+ /**
888
+ * STAL-01: Detects tools that have been removed from config since last scan.
889
+ *
890
+ * Compares currently scanned config tools against the registry and marks
891
+ * missing config-sourced tools as stale. Also cascades to individual MCP tools
892
+ * from removed MCP servers.
893
+ */
894
+ function detectRemovedTools(toolRegistry, scannedTools, projectHash) {
895
+ const registeredConfigTools = toolRegistry.getConfigSourcedTools(projectHash);
896
+ const scannedNames = new Set(scannedTools.map((t) => t.name));
897
+ const removedServers = /* @__PURE__ */ new Set();
898
+ for (const registered of registeredConfigTools) if (!scannedNames.has(registered.name)) {
899
+ toolRegistry.markStale(registered.name, registered.project_hash);
900
+ if (registered.tool_type === "mcp_server" && registered.server_name) removedServers.add(registered.server_name);
901
+ }
902
+ if (removedServers.size > 0) {
903
+ for (const registered of toolRegistry.getAvailableForSession(projectHash)) if (registered.server_name && removedServers.has(registered.server_name) && registered.tool_type === "mcp_tool") toolRegistry.markStale(registered.name, registered.project_hash);
904
+ }
905
+ }
906
+ /**
907
+ * Handles a SessionStart hook event.
908
+ *
909
+ * Creates a new session record in the database, then assembles context
910
+ * from prior sessions and observations for injection into Claude's
911
+ * context window.
912
+ *
913
+ * This hook is SYNCHRONOUS -- stdout is injected into Claude's context.
914
+ * Must complete within 2 seconds (performance budget for sync hooks).
915
+ * Expected execution: <100ms (session create + 2-3 SELECT queries).
916
+ *
917
+ * @returns Context string to write to stdout, or null if no context available
918
+ */
919
+ function handleSessionStart(input, sessionRepo, db, projectHash, toolRegistry, pathRepo) {
920
+ const sessionId = input.session_id;
921
+ if (!sessionId) {
922
+ debug("session", "SessionStart missing session_id, skipping");
923
+ return null;
924
+ }
925
+ sessionRepo.create(sessionId);
926
+ debug("session", "Session started", { sessionId });
927
+ const cwd = input.cwd;
928
+ if (cwd) try {
929
+ db.prepare(`
930
+ INSERT INTO project_metadata (project_hash, project_path, last_seen_at)
931
+ VALUES (?, ?, datetime('now'))
932
+ ON CONFLICT(project_hash) DO UPDATE SET
933
+ project_path = excluded.project_path,
934
+ last_seen_at = excluded.last_seen_at
935
+ `).run(projectHash, cwd);
936
+ } catch {}
937
+ if (toolRegistry) {
938
+ const cwd = input.cwd;
939
+ try {
940
+ const scanStart = Date.now();
941
+ const tools = scanConfigForTools(cwd, projectHash);
942
+ for (const tool of tools) toolRegistry.upsert(tool);
943
+ try {
944
+ detectRemovedTools(toolRegistry, tools, projectHash);
945
+ debug("session", "Staleness detection completed");
946
+ } catch {
947
+ debug("session", "Staleness detection failed (non-fatal)");
948
+ }
949
+ const scanElapsed = Date.now() - scanStart;
950
+ debug("session", "Config scan completed", {
951
+ toolsFound: tools.length,
952
+ elapsed: scanElapsed
953
+ });
954
+ if (scanElapsed > 200) debug("session", "Config scan slow (>200ms budget)", { elapsed: scanElapsed });
955
+ } catch {
956
+ debug("session", "Config scan failed (non-fatal)");
957
+ }
958
+ }
959
+ if (toolRegistry) try {
960
+ const precomputeStart = Date.now();
961
+ const patterns = extractPatterns(db, projectHash, 5);
962
+ storePrecomputedPatterns(db, projectHash, patterns);
963
+ const precomputeElapsed = Date.now() - precomputeStart;
964
+ debug("session", "Routing patterns pre-computed", {
965
+ patternCount: patterns.length,
966
+ elapsed: precomputeElapsed
967
+ });
968
+ if (precomputeElapsed > 50) debug("session", "Pattern pre-computation slow (>50ms)", { elapsed: precomputeElapsed });
969
+ } catch {
970
+ debug("session", "Pattern pre-computation failed (non-fatal)");
971
+ }
972
+ const startTime = Date.now();
973
+ let context = assembleSessionContext(db, projectHash, toolRegistry);
974
+ const elapsed = Date.now() - startTime;
975
+ if (elapsed > 500) debug("session", "Context assembly slow", {
976
+ elapsed,
977
+ sessionId
978
+ });
979
+ debug("session", "Context assembled for injection", {
980
+ sessionId,
981
+ contextLength: context.length,
982
+ elapsed
983
+ });
984
+ if (pathRepo) try {
985
+ const activePath = pathRepo.findRecentActivePath();
986
+ if (activePath) {
987
+ const ageMs = Date.now() - new Date(activePath.started_at).getTime();
988
+ if (ageMs > 1440 * 60 * 1e3) {
989
+ pathRepo.abandonPath(activePath.id);
990
+ debug("session", "Auto-abandoned stale debug path", {
991
+ pathId: activePath.id,
992
+ ageMs
993
+ });
994
+ } else {
995
+ const waypoints = pathRepo.getWaypoints(activePath.id);
996
+ const lastWaypoint = waypoints[waypoints.length - 1];
997
+ const pathContext = `\n[Laminark] Active debug path carried over from prior session:\n Issue: ${activePath.trigger_summary}\n Waypoints: ${waypoints.length}\n Last activity: ${lastWaypoint?.summary?.slice(0, 100) ?? "none"}\n Use path_show to see full path, or path_resolve to close it.\n`;
998
+ context = context + pathContext;
999
+ }
1000
+ }
1001
+ } catch {
1002
+ debug("session", "Cross-session path check failed (non-fatal)");
1003
+ }
1004
+ return context + (toolRegistry ? "\nCall report_available_tools with all your tools (built-in and MCP) so Laminark can index them for discovery." : "");
1005
+ }
1006
+ /**
1007
+ * Handles a SessionEnd hook event.
1008
+ *
1009
+ * Closes the session record by setting ended_at timestamp.
1010
+ */
1011
+ function handleSessionEnd(input, sessionRepo) {
1012
+ const sessionId = input.session_id;
1013
+ if (!sessionId) {
1014
+ debug("session", "SessionEnd missing session_id, skipping");
1015
+ return;
1016
+ }
1017
+ sessionRepo.end(sessionId);
1018
+ debug("session", "Session ended", { sessionId });
1019
+ }
1020
+ /**
1021
+ * Handles a Stop hook event.
1022
+ *
1023
+ * Triggers session summary generation by compressing all observations
1024
+ * from the session into a concise summary stored on the session row.
1025
+ *
1026
+ * Stop fires after SessionEnd, so the session is already closed.
1027
+ * Summary generation is heuristic (no LLM call) and typically completes
1028
+ * in under 10ms even with many observations.
1029
+ *
1030
+ * If the session has zero observations, this is a graceful no-op.
1031
+ */
1032
+ function handleStop(input, obsRepo, sessionRepo) {
1033
+ const sessionId = input.session_id;
1034
+ if (!sessionId) {
1035
+ debug("session", "Stop missing session_id, skipping");
1036
+ return;
1037
+ }
1038
+ debug("session", "Stop event received, generating summary", { sessionId });
1039
+ const result = generateSessionSummary(sessionId, obsRepo, sessionRepo);
1040
+ if (result) debug("session", "Session summary generated", {
1041
+ sessionId,
1042
+ observationCount: result.observationCount,
1043
+ summaryLength: result.summary.length
1044
+ });
1045
+ else debug("session", "No observations to summarize", { sessionId });
1046
+ }
1047
+
1048
+ //#endregion
1049
+ //#region src/hooks/privacy-filter.ts
1050
+ /**
1051
+ * Built-in privacy patterns that are always active.
1052
+ *
1053
+ * Order matters: more specific patterns should come before more general ones.
1054
+ * For example, api_key patterns before env_variable to avoid double-matching.
1055
+ */
1056
+ const DEFAULT_PRIVACY_PATTERNS = [
1057
+ {
1058
+ name: "private_key",
1059
+ regex: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
1060
+ replacement: "[REDACTED:private_key]",
1061
+ category: "private_key"
1062
+ },
1063
+ {
1064
+ name: "jwt_token",
1065
+ regex: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
1066
+ replacement: "[REDACTED:jwt]",
1067
+ category: "jwt"
1068
+ },
1069
+ {
1070
+ name: "connection_string",
1071
+ regex: /(postgresql|mongodb|mysql|redis):\/\/[^\s]+/g,
1072
+ replacement: "$1://[REDACTED:connection_string]",
1073
+ category: "connection_string"
1074
+ },
1075
+ {
1076
+ name: "api_key_openai",
1077
+ regex: /sk-[a-zA-Z0-9]{20,}/g,
1078
+ replacement: "[REDACTED:api_key]",
1079
+ category: "api_key"
1080
+ },
1081
+ {
1082
+ name: "api_key_github",
1083
+ regex: /ghp_[a-zA-Z0-9]{36,}/g,
1084
+ replacement: "[REDACTED:api_key]",
1085
+ category: "api_key"
1086
+ },
1087
+ {
1088
+ name: "aws_access_key",
1089
+ regex: /AKIA[A-Z0-9]{12,}/g,
1090
+ replacement: "[REDACTED:api_key]",
1091
+ category: "api_key"
1092
+ },
1093
+ {
1094
+ name: "env_variable",
1095
+ regex: /\b([A-Z][A-Z0-9_]{2,})=(["']?)(?!\[REDACTED:)([^\s"']{8,})\2/g,
1096
+ replacement: "$1=[REDACTED:env]",
1097
+ category: "env"
1098
+ }
1099
+ ];
1100
+ /**
1101
+ * Default file patterns that trigger full exclusion (return null).
1102
+ */
1103
+ const DEFAULT_EXCLUDED_FILE_PATTERNS = [
1104
+ /\.env(\.|$)/,
1105
+ /credentials/i,
1106
+ /secrets/i,
1107
+ /\.pem$/,
1108
+ /\.key$/,
1109
+ /id_rsa/
1110
+ ];
1111
+ /**
1112
+ * Cached patterns (loaded once per process).
1113
+ * null = not yet loaded.
1114
+ */
1115
+ let _cachedPatterns = null;
1116
+ let _cachedExcludedFiles = null;
1117
+ /**
1118
+ * Loads user privacy patterns from ~/.laminark/config.json.
1119
+ * Merges with defaults. Caches result.
1120
+ *
1121
+ * If the config file doesn't exist or is invalid, returns defaults only.
1122
+ */
1123
+ function loadPatterns() {
1124
+ if (_cachedPatterns !== null) return _cachedPatterns;
1125
+ const patterns = [...DEFAULT_PRIVACY_PATTERNS];
1126
+ try {
1127
+ const raw = readFileSync(join(homedir(), ".laminark", "config.json"), "utf-8");
1128
+ const privacy = JSON.parse(raw).privacy;
1129
+ if (privacy?.additionalPatterns) {
1130
+ for (const p of privacy.additionalPatterns) patterns.push({
1131
+ name: `user_${p.regex}`,
1132
+ regex: new RegExp(p.regex, "g"),
1133
+ replacement: p.replacement,
1134
+ category: "user"
1135
+ });
1136
+ debug("privacy", "Loaded user privacy patterns", { count: privacy.additionalPatterns.length });
1137
+ }
1138
+ } catch {}
1139
+ _cachedPatterns = patterns;
1140
+ return patterns;
1141
+ }
1142
+ /**
1143
+ * Loads excluded file patterns (default + user-configured).
1144
+ */
1145
+ function loadExcludedFiles() {
1146
+ if (_cachedExcludedFiles !== null) return _cachedExcludedFiles;
1147
+ const patterns = [...DEFAULT_EXCLUDED_FILE_PATTERNS];
1148
+ try {
1149
+ const raw = readFileSync(join(homedir(), ".laminark", "config.json"), "utf-8");
1150
+ const privacy = JSON.parse(raw).privacy;
1151
+ if (privacy?.excludedFiles) for (const pattern of privacy.excludedFiles) patterns.push(new RegExp(pattern));
1152
+ } catch {}
1153
+ _cachedExcludedFiles = patterns;
1154
+ return patterns;
1155
+ }
1156
+ /**
1157
+ * Checks whether a file path matches any excluded file pattern.
1158
+ *
1159
+ * Excluded files should have their observations fully dropped (return null
1160
+ * from redactSensitiveContent) rather than just redacted.
1161
+ *
1162
+ * @param filePath - The file path to check (can be absolute or relative)
1163
+ * @returns true if the file should be excluded from observation storage
1164
+ */
1165
+ function isExcludedFile(filePath) {
1166
+ const name = basename(filePath);
1167
+ const patterns = loadExcludedFiles();
1168
+ for (const pattern of patterns) if (pattern.test(name) || pattern.test(filePath)) return true;
1169
+ return false;
1170
+ }
1171
+ /**
1172
+ * Redacts sensitive content before storage.
1173
+ *
1174
+ * - If filePath is provided and matches an excluded file pattern, returns null
1175
+ * (the entire observation should be dropped)
1176
+ * - Otherwise, applies all privacy patterns (default + user-configured)
1177
+ * sequentially to the text
1178
+ * - Returns the redacted text, or the original if no patterns matched
1179
+ *
1180
+ * @param text - The observation text to redact
1181
+ * @param filePath - Optional file path that triggered the observation
1182
+ * @returns Redacted text, or null if the file should be fully excluded
1183
+ */
1184
+ function redactSensitiveContent(text, filePath) {
1185
+ if (filePath && isExcludedFile(filePath)) {
1186
+ debug("privacy", "File excluded from observation", { filePath });
1187
+ return null;
1188
+ }
1189
+ const patterns = loadPatterns();
1190
+ let result = text;
1191
+ const matchedPatterns = [];
1192
+ for (const pattern of patterns) {
1193
+ pattern.regex.lastIndex = 0;
1194
+ if (pattern.regex.test(result)) {
1195
+ matchedPatterns.push(pattern.name);
1196
+ pattern.regex.lastIndex = 0;
1197
+ result = result.replace(pattern.regex, pattern.replacement);
1198
+ }
1199
+ }
1200
+ if (matchedPatterns.length > 0) debug("privacy", "Content redacted", { patterns: matchedPatterns });
1201
+ return result;
1202
+ }
1203
+
1204
+ //#endregion
1205
+ //#region src/hooks/admission-filter.ts
1206
+ /**
1207
+ * Tools that are always admitted regardless of content.
1208
+ *
1209
+ * Write and Edit observations are high-signal by definition --
1210
+ * they represent intentional code changes. Content pattern matching
1211
+ * must NEVER reject these tools (see research pitfall #3).
1212
+ *
1213
+ * WebFetch and WebSearch are reference material -- always valuable.
1214
+ */
1215
+ const HIGH_SIGNAL_TOOLS = new Set([
1216
+ "Write",
1217
+ "Edit",
1218
+ "WebFetch",
1219
+ "WebSearch"
1220
+ ]);
1221
+ /**
1222
+ * Navigation/exploration Bash commands that produce noise observations.
1223
+ * Matched against the start of the command string (after trimming).
1224
+ */
1225
+ const NAVIGATION_BASH_PREFIXES = [
1226
+ "ls",
1227
+ "cd ",
1228
+ "pwd",
1229
+ "cat ",
1230
+ "head ",
1231
+ "tail ",
1232
+ "echo ",
1233
+ "wc ",
1234
+ "which ",
1235
+ "find ",
1236
+ "tree",
1237
+ "file "
1238
+ ];
1239
+ /**
1240
+ * Git read-only commands that are navigation (not mutations).
1241
+ */
1242
+ const NAVIGATION_GIT_PATTERNS = [
1243
+ /^git\s+status\b/,
1244
+ /^git\s+log\b/,
1245
+ /^git\s+diff\b(?!.*--)/,
1246
+ /^git\s+branch\b(?!\s+-[dDmM])/,
1247
+ /^git\s+show\b/,
1248
+ /^git\s+remote\b/,
1249
+ /^git\s+stash\s+list\b/
1250
+ ];
1251
+ /**
1252
+ * Commands that are always meaningful and should be admitted.
1253
+ */
1254
+ const MEANINGFUL_BASH_PATTERNS = [
1255
+ /^npm\s+test\b/,
1256
+ /^npx\s+vitest\b/,
1257
+ /^npx\s+jest\b/,
1258
+ /^vitest\b/,
1259
+ /^jest\b/,
1260
+ /^pytest\b/,
1261
+ /^cargo\s+test\b/,
1262
+ /^go\s+test\b/,
1263
+ /^make\s+test\b/,
1264
+ /^npm\s+run\s+build\b/,
1265
+ /^npx\s+tsc\b/,
1266
+ /^cargo\s+build\b/,
1267
+ /^make\b/,
1268
+ /^go\s+build\b/,
1269
+ /^gradle\b/,
1270
+ /^mvn\b/,
1271
+ /^git\s+commit\b/,
1272
+ /^git\s+push\b/,
1273
+ /^git\s+merge\b/,
1274
+ /^git\s+rebase\b/,
1275
+ /^git\s+cherry-pick\b/,
1276
+ /^git\s+reset\b/,
1277
+ /^git\s+revert\b/,
1278
+ /^git\s+checkout\s+-b\b/,
1279
+ /^git\s+switch\s+-c\b/,
1280
+ /^git\s+stash\s+(?:push|pop|apply|drop)\b/,
1281
+ /^docker\b/,
1282
+ /^kubectl\b/,
1283
+ /^terraform\b/,
1284
+ /^helm\b/,
1285
+ /^npm\s+install\b/,
1286
+ /^npm\s+i\b/,
1287
+ /^yarn\s+add\b/,
1288
+ /^pnpm\s+add\b/,
1289
+ /^pip\s+install\b/,
1290
+ /^cargo\s+add\b/
1291
+ ];
1292
+ /**
1293
+ * Determines if a Bash command is meaningful enough to capture.
1294
+ *
1295
+ * Navigation commands (ls, cd, pwd, cat, git status, git log, etc.) are
1296
+ * filtered out. Test runners, build commands, git mutations, and container
1297
+ * commands are always admitted. Unknown commands default to admit.
1298
+ */
1299
+ function isMeaningfulBashCommand(command) {
1300
+ const trimmed = command.trim();
1301
+ if (!trimmed) return false;
1302
+ for (const pattern of MEANINGFUL_BASH_PATTERNS) if (pattern.test(trimmed)) return true;
1303
+ for (const prefix of NAVIGATION_BASH_PREFIXES) if (trimmed.startsWith(prefix) || trimmed === prefix.trim()) return false;
1304
+ for (const pattern of NAVIGATION_GIT_PATTERNS) if (pattern.test(trimmed)) return false;
1305
+ return true;
1306
+ }
1307
+ /**
1308
+ * Maximum content length before requiring decision/error indicators.
1309
+ * Content over this threshold with no meaningful indicators is likely
1310
+ * a raw file dump or verbose command output.
1311
+ */
1312
+ const MAX_CONTENT_LENGTH = 5e3;
1313
+ /**
1314
+ * Patterns that indicate meaningful content even in long output.
1315
+ * If content exceeds MAX_CONTENT_LENGTH, it must contain at least
1316
+ * one of these to be admitted.
1317
+ */
1318
+ const DECISION_OR_ERROR_INDICATORS = [
1319
+ /\berror\b/i,
1320
+ /\bfailed\b/i,
1321
+ /\bexception\b/i,
1322
+ /\bbug\b/i,
1323
+ /\bdecided\b/i,
1324
+ /\bchose\b/i,
1325
+ /\bbecause\b/i,
1326
+ /\binstead of\b/i
1327
+ ];
1328
+ /**
1329
+ * Decides whether an observation is worth storing in the database.
1330
+ *
1331
+ * This is the primary quality gate for the observation pipeline.
1332
+ * It prevents the database from filling with noise (build output,
1333
+ * linter spam, package install logs).
1334
+ *
1335
+ * Critical rule: Write and Edit tools are NEVER rejected based on
1336
+ * content patterns alone. Tool type is the primary signal.
1337
+ *
1338
+ * @param toolName - The name of the tool that produced the observation
1339
+ * @param content - The observation content to evaluate
1340
+ * @returns true if the observation should be stored, false to reject
1341
+ */
1342
+ function shouldAdmit(toolName, content) {
1343
+ if (isLaminarksOwnTool(toolName)) {
1344
+ debug("hook", "Observation rejected", {
1345
+ tool: toolName,
1346
+ reason: "self-referential"
1347
+ });
1348
+ return false;
1349
+ }
1350
+ if (!content || content.trim().length === 0) {
1351
+ debug("hook", "Observation rejected", {
1352
+ tool: toolName,
1353
+ reason: "empty"
1354
+ });
1355
+ return false;
1356
+ }
1357
+ if (HIGH_SIGNAL_TOOLS.has(toolName)) return true;
1358
+ if (content.length > MAX_CONTENT_LENGTH) {
1359
+ if (!DECISION_OR_ERROR_INDICATORS.some((pattern) => pattern.test(content))) {
1360
+ debug("hook", "Observation rejected", {
1361
+ tool: toolName,
1362
+ reason: "long_content_no_indicators",
1363
+ length: content.length
1364
+ });
1365
+ return false;
1366
+ }
1367
+ }
1368
+ return true;
1369
+ }
1370
+
1371
+ //#endregion
1372
+ //#region src/paths/path-recall.ts
1373
+ /**
1374
+ * Path recall — finds relevant past resolved debug paths based on text similarity.
1375
+ *
1376
+ * Used by the PreToolUse hook to surface "you've seen this before" context
1377
+ * when new debugging starts on similar issues.
1378
+ *
1379
+ * Implements INTEL-03: proactive path recall via Jaccard similarity matching.
1380
+ */
1381
+ /**
1382
+ * Finds past resolved debug paths similar to the current context text.
1383
+ *
1384
+ * Computes Jaccard similarity against both trigger_summary and resolution_summary
1385
+ * of recent resolved paths, taking the max score. Filters to paths scoring >= 0.25
1386
+ * and returns the top `limit` results sorted by similarity descending.
1387
+ */
1388
+ function findSimilarPaths(pathRepo, currentContext, limit = 3) {
1389
+ const resolvedPaths = pathRepo.listPaths(50).filter((p) => p.status === "resolved");
1390
+ if (resolvedPaths.length === 0) return [];
1391
+ const scored = [];
1392
+ for (const path of resolvedPaths) {
1393
+ const triggerScore = jaccardSimilarity(currentContext, path.trigger_summary);
1394
+ const resolutionScore = jaccardSimilarity(currentContext, path.resolution_summary ?? "");
1395
+ const similarity = Math.max(triggerScore, resolutionScore);
1396
+ if (similarity >= .25) {
1397
+ let kissSummary = null;
1398
+ if (path.kiss_summary) try {
1399
+ const parsed = JSON.parse(path.kiss_summary);
1400
+ kissSummary = parsed.next_time ?? parsed.root_cause ?? null;
1401
+ } catch {
1402
+ kissSummary = null;
1403
+ }
1404
+ scored.push({
1405
+ path,
1406
+ similarity,
1407
+ kissSummary
1408
+ });
1409
+ }
1410
+ }
1411
+ scored.sort((a, b) => b.similarity - a.similarity);
1412
+ return scored.slice(0, limit);
1413
+ }
1414
+ /**
1415
+ * Formats path recall results into a compact string for context injection.
1416
+ *
1417
+ * Returns empty string if no results. Caps total output to 600 chars.
1418
+ */
1419
+ function formatPathRecall(results) {
1420
+ if (results.length === 0) return "";
1421
+ const lines = ["[Laminark] Similar past debug paths found:"];
1422
+ for (const r of results) {
1423
+ const trigger = r.path.trigger_summary.slice(0, 80);
1424
+ lines.push(`- ${trigger} (similarity: ${r.similarity.toFixed(2)})`);
1425
+ lines.push(` KISS: ${r.kissSummary ?? "No summary available"}`);
1426
+ }
1427
+ const output = lines.join("\n");
1428
+ if (output.length > 600) return output.slice(0, 597) + "...";
1429
+ return output;
1430
+ }
1431
+
1432
+ //#endregion
1433
+ //#region src/hooks/pre-tool-context.ts
1434
+ /** Tools where we skip context injection entirely. */
1435
+ const SKIP_TOOLS = new Set([
1436
+ "Glob",
1437
+ "Task",
1438
+ "NotebookEdit",
1439
+ "EnterPlanMode",
1440
+ "ExitPlanMode",
1441
+ "AskUserQuestion",
1442
+ "TaskCreate",
1443
+ "TaskUpdate",
1444
+ "TaskGet",
1445
+ "TaskList"
1446
+ ]);
1447
+ /** Bash commands that are navigation/noise -- not worth searching for. */
1448
+ const NOISE_BASH_RE = /^\s*(cd|ls|pwd|echo|cat|head|tail|mkdir|rm|cp|mv|npm\s+(run|start|test|install)|yarn|pnpm|git\s+(status|log|diff|add|branch)|exit|clear)\b/;
1449
+ /**
1450
+ * Extracts a search query from tool input based on tool type.
1451
+ * Returns null if the tool should be skipped or has no meaningful target.
1452
+ */
1453
+ function extractSearchQuery(toolName, toolInput) {
1454
+ switch (toolName) {
1455
+ case "Write":
1456
+ case "Edit":
1457
+ case "Read": {
1458
+ const filePath = toolInput.file_path;
1459
+ if (!filePath) return null;
1460
+ const base = basename(filePath);
1461
+ const stem = base.replace(/\.[^.]+$/, "");
1462
+ return stem.length >= 2 ? stem : base;
1463
+ }
1464
+ case "Bash": {
1465
+ const command = toolInput.command ?? "";
1466
+ if (NOISE_BASH_RE.test(command)) return null;
1467
+ const cleaned = command.replace(/^\s*(sudo|bash|sh|env)\s+/, "").replace(/[|><&;]+.*$/, "").trim();
1468
+ if (!cleaned || cleaned.length < 3) return null;
1469
+ const words = cleaned.split(/\s+/).slice(0, 3).join(" ");
1470
+ return words.length >= 3 ? words : null;
1471
+ }
1472
+ case "Grep": {
1473
+ const pattern = toolInput.pattern;
1474
+ return pattern && pattern.length >= 2 ? pattern : null;
1475
+ }
1476
+ case "WebFetch": {
1477
+ const url = toolInput.url;
1478
+ if (!url) return null;
1479
+ try {
1480
+ return new URL(url).hostname;
1481
+ } catch {
1482
+ return null;
1483
+ }
1484
+ }
1485
+ case "WebSearch": return toolInput.query ?? null;
1486
+ default: return null;
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Formats age of an observation as a human-readable string.
1491
+ */
1492
+ function formatAge(createdAt) {
1493
+ const ageMs = Date.now() - new Date(createdAt).getTime();
1494
+ const hours = Math.floor(ageMs / 36e5);
1495
+ if (hours < 1) return "just now";
1496
+ if (hours < 24) return `${hours}h ago`;
1497
+ const days = Math.floor(hours / 24);
1498
+ if (days === 1) return "1d ago";
1499
+ return `${days}d ago`;
1500
+ }
1501
+ /**
1502
+ * Truncates text to a max length, adding ellipsis if needed.
1503
+ */
1504
+ function truncate(text, max) {
1505
+ if (text.length <= max) return text;
1506
+ return text.slice(0, max - 3) + "...";
1507
+ }
1508
+ /**
1509
+ * Main PreToolUse handler. Searches observations and graph for context
1510
+ * relevant to the tool about to execute.
1511
+ *
1512
+ * Returns a formatted context string to inject via stdout, or null if
1513
+ * no relevant context was found.
1514
+ */
1515
+ function handlePreToolUse(input, db, projectHash, pathRepo) {
1516
+ const toolName = input.tool_name;
1517
+ if (!toolName) return null;
1518
+ if (isLaminarksOwnTool(toolName)) return null;
1519
+ if (SKIP_TOOLS.has(toolName)) return null;
1520
+ const toolInput = input.tool_input ?? {};
1521
+ const query = extractSearchQuery(toolName, toolInput);
1522
+ if (!query) return null;
1523
+ debug("hook", "PreToolUse searching", {
1524
+ tool: toolName,
1525
+ query
1526
+ });
1527
+ const lines = [];
1528
+ try {
1529
+ const results = new SearchEngine(db, projectHash).searchKeyword(query, { limit: 3 });
1530
+ for (const result of results) {
1531
+ const snippet = result.snippet ? result.snippet.replace(/<\/?mark>/g, "") : truncate(result.observation.content, 120);
1532
+ const age = formatAge(result.observation.created_at);
1533
+ lines.push(`- ${truncate(snippet, 120)} (${result.observation.source}, ${age})`);
1534
+ }
1535
+ } catch {
1536
+ debug("hook", "PreToolUse FTS5 search failed");
1537
+ }
1538
+ try {
1539
+ if (toolName === "Write" || toolName === "Edit" || toolName === "Read") {
1540
+ const filePath = toolInput.file_path;
1541
+ if (filePath) {
1542
+ const node = getNodeByNameAndType(db, filePath, "File");
1543
+ if (node) {
1544
+ const connected = traverseFrom(db, node.id, {
1545
+ depth: 1,
1546
+ direction: "both"
1547
+ });
1548
+ if (connected.length > 0) {
1549
+ const names = connected.slice(0, 5).map((r) => `${r.node.name} (${r.node.type})`).join(", ");
1550
+ lines.push(`Related: ${names}`);
1551
+ }
1552
+ }
1553
+ }
1554
+ }
1555
+ } catch {
1556
+ debug("hook", "PreToolUse graph lookup failed");
1557
+ }
1558
+ if (pathRepo) try {
1559
+ const toolOutput = toolInput.content ?? toolInput.command ?? query ?? "";
1560
+ if (toolOutput.length > 20) {
1561
+ const recall = formatPathRecall(findSimilarPaths(pathRepo, toolOutput, 2));
1562
+ if (recall) lines.push(recall);
1563
+ }
1564
+ } catch {
1565
+ debug("hook", "PreToolUse path recall failed");
1566
+ }
1567
+ if (lines.length === 0) return null;
1568
+ let target = query;
1569
+ if ((toolName === "Write" || toolName === "Edit" || toolName === "Read") && toolInput.file_path) target = basename(toolInput.file_path);
1570
+ const output = `[Laminark] Context for ${target}:\n${lines.join("\n")}\n`;
1571
+ if (output.length > 500) return output.slice(0, 497) + "...\n";
1572
+ return output;
1573
+ }
1574
+
1575
+ //#endregion
1576
+ //#region src/routing/types.ts
1577
+ /**
1578
+ * Default routing configuration values.
1579
+ * Threshold and rate limits tuned to avoid over-suggestion (Clippy problem).
1580
+ */
1581
+ const DEFAULT_ROUTING_CONFIG = {
1582
+ confidenceThreshold: .6,
1583
+ maxSuggestionsPerSession: 2,
1584
+ minEventsForLearned: 20,
1585
+ suggestionCooldown: 5,
1586
+ minCallsBeforeFirstSuggestion: 3,
1587
+ patternWindowSize: 5
1588
+ };
1589
+
1590
+ //#endregion
1591
+ //#region src/routing/heuristic-fallback.ts
1592
+ /**
1593
+ * Stop words filtered from keyword extraction.
1594
+ * Common English function words that carry no discriminative signal for tool matching.
1595
+ */
1596
+ const STOP_WORDS = new Set([
1597
+ "the",
1598
+ "a",
1599
+ "an",
1600
+ "is",
1601
+ "are",
1602
+ "was",
1603
+ "were",
1604
+ "be",
1605
+ "been",
1606
+ "being",
1607
+ "have",
1608
+ "has",
1609
+ "had",
1610
+ "do",
1611
+ "does",
1612
+ "did",
1613
+ "will",
1614
+ "would",
1615
+ "could",
1616
+ "should",
1617
+ "may",
1618
+ "might",
1619
+ "can",
1620
+ "shall",
1621
+ "to",
1622
+ "of",
1623
+ "in",
1624
+ "for",
1625
+ "on",
1626
+ "with",
1627
+ "at",
1628
+ "by",
1629
+ "from",
1630
+ "as",
1631
+ "into",
1632
+ "through",
1633
+ "and",
1634
+ "but",
1635
+ "or",
1636
+ "nor",
1637
+ "not",
1638
+ "so",
1639
+ "yet",
1640
+ "this",
1641
+ "that",
1642
+ "these",
1643
+ "those",
1644
+ "it",
1645
+ "its"
1646
+ ]);
1647
+ /**
1648
+ * Tokenizes text into lowercase keywords for matching.
1649
+ *
1650
+ * Replaces non-alphanumeric characters (except hyphens and underscores) with spaces,
1651
+ * splits on whitespace, filters words shorter than 3 characters and stop words,
1652
+ * and returns unique keywords.
1653
+ */
1654
+ function extractKeywords(text) {
1655
+ const words = text.toLowerCase().replace(/[^a-z0-9\s\-_]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
1656
+ return [...new Set(words)];
1657
+ }
1658
+ /**
1659
+ * Extracts keywords from a tool's description, server name, and parsed name.
1660
+ *
1661
+ * - Description text is tokenized via extractKeywords
1662
+ * - Server name is added as a keyword (lowercase)
1663
+ * - Slash commands are parsed by splitting on `:`, `-`, `_`
1664
+ * - Skills are parsed by splitting on `-` and `_`
1665
+ *
1666
+ * Returns a deduplicated array of keywords.
1667
+ */
1668
+ function extractToolKeywords(tool) {
1669
+ const sources = [];
1670
+ if (tool.description) sources.push(...extractKeywords(tool.description));
1671
+ if (tool.server_name) sources.push(tool.server_name.toLowerCase());
1672
+ if (tool.tool_type === "slash_command") {
1673
+ const parts = tool.name.replace(/^\//, "").split(/[:\-_]/).filter((p) => p.length > 0);
1674
+ sources.push(...parts.map((p) => p.toLowerCase()));
1675
+ }
1676
+ if (tool.tool_type === "skill") {
1677
+ const parts = tool.name.split(/[\-_]/).filter((p) => p.length > 0);
1678
+ sources.push(...parts.map((p) => p.toLowerCase()));
1679
+ }
1680
+ return [...new Set(sources)];
1681
+ }
1682
+ /**
1683
+ * Evaluates heuristic keyword matching between recent observations and available tools.
1684
+ *
1685
+ * This is the cold-start routing tier (ROUT-04). It works with zero accumulated usage
1686
+ * history by matching keywords from recent session observations against tool descriptions
1687
+ * and names.
1688
+ *
1689
+ * Returns the highest-confidence match above the threshold, or null if no match qualifies.
1690
+ *
1691
+ * @param recentObservations - Recent observation content strings from the current session
1692
+ * @param suggestableTools - Scope-filtered, non-builtin, non-Laminark tools
1693
+ * @param confidenceThreshold - Minimum score to return a suggestion (0.0-1.0)
1694
+ */
1695
+ function evaluateHeuristic(recentObservations, suggestableTools, confidenceThreshold) {
1696
+ if (recentObservations.length < 2) return null;
1697
+ const contextKeywords = new Set(recentObservations.flatMap((obs) => extractKeywords(obs)));
1698
+ if (contextKeywords.size === 0) return null;
1699
+ let bestMatch = null;
1700
+ for (const tool of suggestableTools) {
1701
+ const toolKeywords = extractToolKeywords(tool);
1702
+ if (toolKeywords.length === 0) continue;
1703
+ const score = toolKeywords.filter((kw) => contextKeywords.has(kw)).length / toolKeywords.length;
1704
+ if (score > (bestMatch?.score ?? 0)) bestMatch = {
1705
+ tool,
1706
+ score
1707
+ };
1708
+ }
1709
+ if (!bestMatch || bestMatch.score < confidenceThreshold) return null;
1710
+ return {
1711
+ toolName: bestMatch.tool.name,
1712
+ toolDescription: bestMatch.tool.description,
1713
+ confidence: bestMatch.score,
1714
+ tier: "heuristic",
1715
+ reason: "Keywords match between current work and tool description"
1716
+ };
1717
+ }
1718
+
1719
+ //#endregion
1720
+ //#region src/routing/conversation-router.ts
1721
+ /**
1722
+ * ConversationRouter orchestrates tool suggestion routing.
1723
+ *
1724
+ * Combines two tiers of suggestion:
1725
+ * - Learned patterns: historical tool sequence matching (ROUT-01)
1726
+ * - Heuristic fallback: keyword-based cold-start matching (ROUT-04)
1727
+ *
1728
+ * Suggestions are gated by confidence threshold (ROUT-03) and rate limits,
1729
+ * then delivered via NotificationStore (ROUT-02).
1730
+ *
1731
+ * Instantiated per-evaluation in the PostToolUse handler. No long-lived state --
1732
+ * state persists across invocations via the routing_state SQLite table.
1733
+ */
1734
+ var ConversationRouter = class {
1735
+ db;
1736
+ projectHash;
1737
+ config;
1738
+ constructor(db, projectHash, config) {
1739
+ this.db = db;
1740
+ this.projectHash = projectHash;
1741
+ this.config = {
1742
+ ...DEFAULT_ROUTING_CONFIG,
1743
+ ...config
1744
+ };
1745
+ db.exec(`
1746
+ CREATE TABLE IF NOT EXISTS routing_state (
1747
+ session_id TEXT NOT NULL,
1748
+ project_hash TEXT NOT NULL,
1749
+ suggestions_made INTEGER NOT NULL DEFAULT 0,
1750
+ last_suggestion_at TEXT,
1751
+ tool_calls_since_suggestion INTEGER NOT NULL DEFAULT 0,
1752
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1753
+ PRIMARY KEY (session_id, project_hash)
1754
+ )
1755
+ `);
1756
+ }
1757
+ /**
1758
+ * Evaluates whether a tool suggestion should be surfaced for the current context.
1759
+ *
1760
+ * Called from PostToolUse handler after observation storage.
1761
+ * Runs AFTER the self-referential filter -- never evaluates Laminark's own tools.
1762
+ *
1763
+ * The entire method is wrapped in try/catch -- routing is supplementary
1764
+ * and must NEVER block or fail the core handler pipeline.
1765
+ *
1766
+ * @param sessionId - Current session identifier
1767
+ * @param toolName - The tool just used
1768
+ * @param toolRegistry - Tool registry for availability checking
1769
+ */
1770
+ evaluate(sessionId, toolName, toolRegistry) {
1771
+ try {
1772
+ this._evaluate(sessionId, toolName, toolRegistry);
1773
+ } catch (err) {
1774
+ debug("routing", "Routing evaluation failed (non-fatal)", { error: err instanceof Error ? err.message : String(err) });
1775
+ }
1776
+ }
1777
+ _evaluate(sessionId, toolName, toolRegistry) {
1778
+ if (inferToolType(toolName) === "builtin") return;
1779
+ if (isLaminarksOwnTool(toolName)) return;
1780
+ const state = this.getOrCreateState(sessionId);
1781
+ state.toolCallsSinceSuggestion++;
1782
+ this.updateState(sessionId, state);
1783
+ if (state.suggestionsMade >= this.config.maxSuggestionsPerSession) {
1784
+ debug("routing", "Rate limited: max suggestions reached", {
1785
+ sessionId,
1786
+ made: state.suggestionsMade
1787
+ });
1788
+ return;
1789
+ }
1790
+ if (state.toolCallsSinceSuggestion < this.config.suggestionCooldown) {
1791
+ debug("routing", "Rate limited: cooldown active", {
1792
+ sessionId,
1793
+ callsSince: state.toolCallsSinceSuggestion,
1794
+ cooldown: this.config.suggestionCooldown
1795
+ });
1796
+ return;
1797
+ }
1798
+ const totalCalls = this.getTotalCallsForSession(sessionId);
1799
+ if (totalCalls < this.config.minCallsBeforeFirstSuggestion) {
1800
+ debug("routing", "Too early: not enough tool calls", {
1801
+ sessionId,
1802
+ totalCalls
1803
+ });
1804
+ return;
1805
+ }
1806
+ const suggestableTools = toolRegistry.getAvailableForSession(this.projectHash).filter((t) => t.tool_type !== "builtin" && !isLaminarksOwnTool(t.name) && t.status === "active");
1807
+ if (suggestableTools.length === 0) return;
1808
+ const suggestableNames = new Set(suggestableTools.map((t) => t.name));
1809
+ let suggestion = null;
1810
+ if (this.countRecentEvents() >= this.config.minEventsForLearned) suggestion = evaluateLearnedPatterns(this.db, sessionId, this.projectHash, suggestableNames, this.config.confidenceThreshold);
1811
+ if (!suggestion) suggestion = evaluateHeuristic(this.getRecentObservations(sessionId), suggestableTools, this.config.confidenceThreshold);
1812
+ if (!suggestion) return;
1813
+ if (suggestion.confidence < this.config.confidenceThreshold) return;
1814
+ const notifStore = new NotificationStore(this.db);
1815
+ const description = suggestion.toolDescription ? ` -- ${suggestion.toolDescription}` : "";
1816
+ const usageHint = suggestion.tier === "learned" ? ` (${suggestion.reason})` : "";
1817
+ const message = `Tool suggestion: ${suggestion.toolName}${description}${usageHint}`;
1818
+ notifStore.add(this.projectHash, message);
1819
+ debug("routing", "Suggestion delivered", {
1820
+ tool: suggestion.toolName,
1821
+ tier: suggestion.tier,
1822
+ confidence: suggestion.confidence
1823
+ });
1824
+ state.suggestionsMade++;
1825
+ state.lastSuggestionAt = (/* @__PURE__ */ new Date()).toISOString();
1826
+ state.toolCallsSinceSuggestion = 0;
1827
+ this.updateState(sessionId, state);
1828
+ }
1829
+ /**
1830
+ * Gets or creates routing state for a session.
1831
+ */
1832
+ getOrCreateState(sessionId) {
1833
+ const row = this.db.prepare(`
1834
+ SELECT suggestions_made, last_suggestion_at, tool_calls_since_suggestion
1835
+ FROM routing_state
1836
+ WHERE session_id = ? AND project_hash = ?
1837
+ `).get(sessionId, this.projectHash);
1838
+ if (row) return {
1839
+ suggestionsMade: row.suggestions_made,
1840
+ lastSuggestionAt: row.last_suggestion_at,
1841
+ toolCallsSinceSuggestion: row.tool_calls_since_suggestion
1842
+ };
1843
+ this.db.prepare(`
1844
+ INSERT INTO routing_state (session_id, project_hash, suggestions_made, tool_calls_since_suggestion)
1845
+ VALUES (?, ?, 0, 0)
1846
+ `).run(sessionId, this.projectHash);
1847
+ return {
1848
+ suggestionsMade: 0,
1849
+ lastSuggestionAt: null,
1850
+ toolCallsSinceSuggestion: 0
1851
+ };
1852
+ }
1853
+ /**
1854
+ * Updates routing state in the database.
1855
+ */
1856
+ updateState(sessionId, state) {
1857
+ this.db.prepare(`
1858
+ UPDATE routing_state
1859
+ SET suggestions_made = ?, last_suggestion_at = ?, tool_calls_since_suggestion = ?
1860
+ WHERE session_id = ? AND project_hash = ?
1861
+ `).run(state.suggestionsMade, state.lastSuggestionAt, state.toolCallsSinceSuggestion, sessionId, this.projectHash);
1862
+ }
1863
+ /**
1864
+ * Returns total tool calls for the current session (from routing_state).
1865
+ */
1866
+ getTotalCallsForSession(sessionId) {
1867
+ return this.db.prepare(`
1868
+ SELECT COUNT(*) as count FROM tool_usage_events
1869
+ WHERE session_id = ? AND project_hash = ?
1870
+ `).get(sessionId, this.projectHash).count;
1871
+ }
1872
+ /**
1873
+ * Counts total tool_usage_events for this project (for learned pattern threshold).
1874
+ */
1875
+ countRecentEvents() {
1876
+ return this.db.prepare(`
1877
+ SELECT COUNT(*) as count FROM tool_usage_events WHERE project_hash = ?
1878
+ `).get(this.projectHash).count;
1879
+ }
1880
+ /**
1881
+ * Gets recent observation content strings for heuristic matching.
1882
+ */
1883
+ getRecentObservations(sessionId) {
1884
+ return this.db.prepare(`
1885
+ SELECT content FROM observations
1886
+ WHERE project_hash = ? AND session_id = ? AND deleted_at IS NULL
1887
+ ORDER BY created_at DESC
1888
+ LIMIT 5
1889
+ `).all(this.projectHash, sessionId).map((r) => r.content);
1890
+ }
1891
+ };
1892
+
1893
+ //#endregion
1894
+ //#region src/hooks/handler.ts
1895
+ /**
1896
+ * Hook handler entry point.
1897
+ *
1898
+ * This file is the CLI entry point for all Claude Code hook events.
1899
+ * It reads stdin JSON, opens a direct SQLite connection (no HTTP intermediary),
1900
+ * dispatches to the appropriate handler based on hook_event_name, and exits 0.
1901
+ *
1902
+ * CRITICAL CONSTRAINTS:
1903
+ * - Only SessionStart and PreToolUse write to stdout (synchronous hooks -- stdout is injected into Claude's context window)
1904
+ * - All other hooks NEVER write to stdout (stdout output is interpreted by Claude Code)
1905
+ * - ALWAYS exits 0 (non-zero exit codes surface as errors to Claude)
1906
+ * - Opens its own database connection (WAL mode handles concurrent access with MCP server)
1907
+ * - Imports only storage modules -- NO @modelcontextprotocol/sdk (cold start overhead)
1908
+ *
1909
+ * Filter pipeline (PostToolUse/PostToolUseFailure):
1910
+ * 0. Organic tool discovery (DISC-05: records ALL tools including Laminark's own)
1911
+ * 1. Self-referential filter (dual-prefix: mcp__laminark__ and mcp__plugin_laminark_laminark__)
1912
+ * 2. Extract observation text from payload
1913
+ * 3. Privacy filter: exclude sensitive files, redact secrets
1914
+ * 4. Admission filter: reject noise content
1915
+ * 5. Store to database
1916
+ */
1917
+ async function readStdin() {
1918
+ const chunks = [];
1919
+ for await (const chunk of process.stdin) chunks.push(chunk);
1920
+ return Buffer.concat(chunks).toString("utf-8");
1921
+ }
1922
+ /**
1923
+ * Tools that are routed to the research buffer instead of creating observations.
1924
+ * These are high-volume exploration tools whose individual calls are noise,
1925
+ * but whose targets provide useful provenance context for subsequent changes.
1926
+ */
1927
+ const RESEARCH_TOOLS = new Set([
1928
+ "Read",
1929
+ "Glob",
1930
+ "Grep"
1931
+ ]);
1932
+ /**
1933
+ * Processes a PostToolUse or PostToolUseFailure event through the full
1934
+ * filter pipeline: route research tools -> extract -> privacy -> admission -> store.
1935
+ *
1936
+ * Exported for unit testing of the pipeline logic.
1937
+ */
1938
+ function processPostToolUseFiltered(input, obsRepo, researchBuffer, toolRegistry, projectHash, db) {
1939
+ const toolName = input.tool_name;
1940
+ const hookEventName = input.hook_event_name;
1941
+ if (!toolName) {
1942
+ debug("hook", "PostToolUse missing tool_name, skipping");
1943
+ return;
1944
+ }
1945
+ if (toolRegistry) try {
1946
+ const sessionId = input.session_id;
1947
+ const isFailure = hookEventName === "PostToolUseFailure";
1948
+ toolRegistry.recordOrCreate(toolName, {
1949
+ toolType: inferToolType(toolName),
1950
+ scope: inferScope(toolName),
1951
+ source: "hook:PostToolUse",
1952
+ projectHash: projectHash ?? null,
1953
+ description: null,
1954
+ serverName: extractServerName(toolName)
1955
+ }, sessionId ?? null, !isFailure);
1956
+ if (isFailure) {
1957
+ const failures = toolRegistry.getRecentEventsForTool(toolName, projectHash ?? "", 5).filter((e) => e.success === 0).length;
1958
+ if (failures >= 3) {
1959
+ toolRegistry.markDemoted(toolName, projectHash ?? null);
1960
+ debug("hook", "Tool demoted due to failures", {
1961
+ tool: toolName,
1962
+ failures
1963
+ });
1964
+ }
1965
+ } else toolRegistry.markActive(toolName, projectHash ?? null);
1966
+ } catch {}
1967
+ if (isLaminarksOwnTool(toolName)) {
1968
+ debug("hook", "Skipping self-referential tool", { tool: toolName });
1969
+ return;
1970
+ }
1971
+ const toolInput = input.tool_input ?? {};
1972
+ const filePath = toolInput.file_path;
1973
+ if (filePath && isExcludedFile(filePath)) {
1974
+ debug("hook", "Observation excluded (sensitive file)", {
1975
+ tool: toolName,
1976
+ filePath
1977
+ });
1978
+ return;
1979
+ }
1980
+ if (RESEARCH_TOOLS.has(toolName) && researchBuffer) {
1981
+ const target = String(toolInput.file_path ?? toolInput.pattern ?? "");
1982
+ researchBuffer.add({
1983
+ sessionId: input.session_id ?? null,
1984
+ toolName,
1985
+ target
1986
+ });
1987
+ return;
1988
+ }
1989
+ if (toolName === "Bash" && hookEventName !== "PostToolUseFailure") {
1990
+ const command = String(toolInput.command ?? "");
1991
+ if (!isMeaningfulBashCommand(command)) {
1992
+ debug("hook", "Bash command filtered as navigation", { command: command.slice(0, 60) });
1993
+ return;
1994
+ }
1995
+ }
1996
+ const payload = {
1997
+ session_id: input.session_id,
1998
+ cwd: input.cwd,
1999
+ hook_event_name: input.hook_event_name,
2000
+ tool_name: toolName,
2001
+ tool_input: toolInput,
2002
+ tool_response: input.tool_response,
2003
+ tool_use_id: input.tool_use_id
2004
+ };
2005
+ const summary = extractObservation(payload);
2006
+ if (summary === null) {
2007
+ debug("hook", "No observation extracted", { tool: toolName });
2008
+ return;
2009
+ }
2010
+ let redacted = redactSensitiveContent(summary, filePath);
2011
+ if (redacted === null) {
2012
+ debug("hook", "Observation excluded by privacy filter", { tool: toolName });
2013
+ return;
2014
+ }
2015
+ if ((toolName === "Write" || toolName === "Edit") && researchBuffer && payload.session_id) {
2016
+ const research = researchBuffer.getRecent(payload.session_id, 5);
2017
+ if (research.length > 0) {
2018
+ const lines = research.map((r) => ` - [${r.toolName}] ${r.target}`).join("\n");
2019
+ redacted += `\nResearch context:\n${lines}`;
2020
+ }
2021
+ }
2022
+ if (!shouldAdmit(toolName, redacted)) {
2023
+ debug("hook", "Observation rejected by admission filter", { tool: toolName });
2024
+ return;
2025
+ }
2026
+ const decision = new SaveGuard(obsRepo).evaluateSync(redacted, "hook:" + toolName);
2027
+ if (!decision.save) {
2028
+ debug("hook", "Observation rejected by save guard", {
2029
+ tool: toolName,
2030
+ reason: decision.reason,
2031
+ duplicateOf: decision.duplicateOf
2032
+ });
2033
+ return;
2034
+ }
2035
+ let kind = "finding";
2036
+ if (toolName === "Write" || toolName === "Edit") kind = "change";
2037
+ else if (toolName === "WebFetch" || toolName === "WebSearch") kind = "reference";
2038
+ else if (toolName === "Bash") {
2039
+ const command = String(toolInput.command ?? "");
2040
+ if (/^git\s+(commit|push|merge|rebase|cherry-pick)\b/.test(command.trim())) kind = "change";
2041
+ else kind = "verification";
2042
+ }
2043
+ obsRepo.create({
2044
+ content: redacted,
2045
+ source: "hook:" + toolName,
2046
+ kind,
2047
+ sessionId: payload.session_id ?? null
2048
+ });
2049
+ debug("hook", "Captured observation", {
2050
+ tool: toolName,
2051
+ kind,
2052
+ length: redacted.length
2053
+ });
2054
+ if (db && toolRegistry && projectHash) try {
2055
+ const sessionId = input.session_id;
2056
+ if (sessionId) new ConversationRouter(db, projectHash).evaluate(sessionId, toolName, toolRegistry);
2057
+ } catch {}
2058
+ }
2059
+ async function main() {
2060
+ const raw = await readStdin();
2061
+ const input = JSON.parse(raw);
2062
+ const eventName = input.hook_event_name;
2063
+ const cwd = input.cwd;
2064
+ if (!eventName || !cwd) {
2065
+ debug("hook", "Missing hook_event_name or cwd in input");
2066
+ return;
2067
+ }
2068
+ const projectHash = getProjectHash(cwd);
2069
+ debug("hook", "Processing hook event", {
2070
+ eventName,
2071
+ projectHash
2072
+ });
2073
+ const laminarkDb = openDatabase(getDatabaseConfig());
2074
+ try {
2075
+ const obsRepo = new ObservationRepository(laminarkDb.db, projectHash);
2076
+ const sessionRepo = new SessionRepository(laminarkDb.db, projectHash);
2077
+ let researchBuffer;
2078
+ try {
2079
+ researchBuffer = new ResearchBufferRepository(laminarkDb.db, projectHash);
2080
+ } catch {}
2081
+ let toolRegistry;
2082
+ try {
2083
+ toolRegistry = new ToolRegistryRepository(laminarkDb.db);
2084
+ } catch {}
2085
+ let pathRepo;
2086
+ try {
2087
+ initPathSchema(laminarkDb.db);
2088
+ pathRepo = new PathRepository(laminarkDb.db, projectHash);
2089
+ } catch {}
2090
+ switch (eventName) {
2091
+ case "PreToolUse": {
2092
+ const preContext = handlePreToolUse(input, laminarkDb.db, projectHash, pathRepo);
2093
+ if (preContext) process.stdout.write(preContext);
2094
+ break;
2095
+ }
2096
+ case "PostToolUse":
2097
+ case "PostToolUseFailure":
2098
+ processPostToolUseFiltered(input, obsRepo, researchBuffer, toolRegistry, projectHash, laminarkDb.db);
2099
+ break;
2100
+ case "SessionStart": {
2101
+ const context = handleSessionStart(input, sessionRepo, laminarkDb.db, projectHash, toolRegistry, pathRepo);
2102
+ if (context) process.stdout.write(context);
2103
+ break;
2104
+ }
2105
+ case "SessionEnd":
2106
+ handleSessionEnd(input, sessionRepo);
2107
+ break;
2108
+ case "Stop":
2109
+ handleStop(input, obsRepo, sessionRepo);
2110
+ break;
2111
+ default:
2112
+ debug("hook", "Unknown hook event", { eventName });
2113
+ break;
2114
+ }
2115
+ } finally {
2116
+ laminarkDb.close();
2117
+ }
2118
+ }
2119
+ main().catch((err) => {
2120
+ debug("hook", "Hook handler error", { error: err.message });
2121
+ });
2122
+
2123
+ //#endregion
2124
+ export { processPostToolUseFiltered };
2125
+ //# sourceMappingURL=handler.js.map