laminark 0.1.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 (55) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/plugin/.claude-plugin/plugin.json +13 -0
  4. package/plugin/.mcp.json +12 -0
  5. package/plugin/CLAUDE.md +10 -0
  6. package/plugin/commands/recall.md +55 -0
  7. package/plugin/commands/remember.md +34 -0
  8. package/plugin/commands/resume.md +45 -0
  9. package/plugin/commands/stash.md +34 -0
  10. package/plugin/commands/status.md +33 -0
  11. package/plugin/dist/analysis/worker.d.ts +1 -0
  12. package/plugin/dist/analysis/worker.js +233 -0
  13. package/plugin/dist/analysis/worker.js.map +1 -0
  14. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  15. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  16. package/plugin/dist/hooks/handler.d.ts +286 -0
  17. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  18. package/plugin/dist/hooks/handler.js +2413 -0
  19. package/plugin/dist/hooks/handler.js.map +1 -0
  20. package/plugin/dist/index.d.ts +447 -0
  21. package/plugin/dist/index.d.ts.map +1 -0
  22. package/plugin/dist/index.js +7334 -0
  23. package/plugin/dist/index.js.map +1 -0
  24. package/plugin/dist/observations-CorAAc1A.d.mts +192 -0
  25. package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
  26. package/plugin/dist/tool-registry-e710BvXq.mjs +3574 -0
  27. package/plugin/dist/tool-registry-e710BvXq.mjs.map +1 -0
  28. package/plugin/hooks/hooks.json +78 -0
  29. package/plugin/laminark.db +0 -0
  30. package/plugin/package.json +17 -0
  31. package/plugin/scripts/README.md +65 -0
  32. package/plugin/scripts/bump-version.sh +42 -0
  33. package/plugin/scripts/dev-sync.sh +58 -0
  34. package/plugin/scripts/ensure-deps.sh +15 -0
  35. package/plugin/scripts/install.sh +139 -0
  36. package/plugin/scripts/local-install.sh +138 -0
  37. package/plugin/scripts/uninstall.sh +133 -0
  38. package/plugin/scripts/update.sh +39 -0
  39. package/plugin/scripts/verify-install.sh +87 -0
  40. package/plugin/skills/status/SKILL.md +6 -0
  41. package/plugin/ui/activity.js +197 -0
  42. package/plugin/ui/app.js +1612 -0
  43. package/plugin/ui/graph.js +2560 -0
  44. package/plugin/ui/help/activity-feed.png +0 -0
  45. package/plugin/ui/help/analysis-panel.png +0 -0
  46. package/plugin/ui/help/graph-toolbar.png +0 -0
  47. package/plugin/ui/help/graph-view.png +0 -0
  48. package/plugin/ui/help/settings.png +0 -0
  49. package/plugin/ui/help/timeline.png +0 -0
  50. package/plugin/ui/help.js +932 -0
  51. package/plugin/ui/index.html +756 -0
  52. package/plugin/ui/settings.js +1414 -0
  53. package/plugin/ui/styles.css +3856 -0
  54. package/plugin/ui/timeline.js +652 -0
  55. package/plugin/ui/tools.js +826 -0
@@ -0,0 +1,2413 @@
1
+ import { i as getProjectHash, n as getDatabaseConfig } from "../config-t8LZeB-u.mjs";
2
+ import { E as traverseFrom, F as openDatabase, M as SessionRepository, N as ObservationRepository, O as SaveGuard, P as rowToObservation, R as debug, S as getNodeByNameAndType, a as ResearchBufferRepository, c as inferScope, i as NotificationStore, j as SearchEngine, k as jaccardSimilarity, l as inferToolType, n as PathRepository, o as BranchRepository, p as runAutoCleanup, r as initPathSchema, s as extractServerName, t as ToolRegistryRepository } from "../tool-registry-e710BvXq.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 2000 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, 2e3).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
+ * Extracts trigger hints from a command/skill file for proactive suggestion matching.
534
+ * Reads YAML frontmatter `description` + content from `<objective>` blocks.
535
+ * Returns a concatenated string or null if nothing found.
536
+ */
537
+ function extractTriggerHints(filePath) {
538
+ try {
539
+ const content = readFileSync(filePath, {
540
+ encoding: "utf-8",
541
+ flag: "r"
542
+ });
543
+ const head = content.slice(0, 2e3);
544
+ const parts = [];
545
+ const fmMatch = head.match(/^---\n([\s\S]*?)\n---/);
546
+ if (fmMatch) {
547
+ const descMatch = fmMatch[1].match(/description:\s*(.+)/);
548
+ if (descMatch) parts.push(descMatch[1].trim());
549
+ }
550
+ const objMatch = content.match(/<objective>([\s\S]*?)<\/objective>/);
551
+ if (objMatch) parts.push(objMatch[1].trim());
552
+ return parts.length > 0 ? parts.join(" ") : null;
553
+ } catch {
554
+ return null;
555
+ }
556
+ }
557
+ /**
558
+ * Scans an .mcp.json file for MCP server entries.
559
+ * Each server key becomes a wildcard tool entry (individual tool names are not in config).
560
+ */
561
+ function scanMcpJson(filePath, scope, projectHash, tools) {
562
+ try {
563
+ if (!existsSync(filePath)) return;
564
+ const raw = readFileSync(filePath, "utf-8");
565
+ const mcpServers = JSON.parse(raw).mcpServers;
566
+ if (!mcpServers || typeof mcpServers !== "object") return;
567
+ for (const serverName of Object.keys(mcpServers)) tools.push({
568
+ name: `mcp__${serverName}__*`,
569
+ toolType: "mcp_server",
570
+ scope,
571
+ source: `config:${filePath}`,
572
+ projectHash,
573
+ description: null,
574
+ serverName,
575
+ triggerHints: null
576
+ });
577
+ } catch (err) {
578
+ debug("scanner", "Failed to scan MCP config", {
579
+ filePath,
580
+ error: String(err)
581
+ });
582
+ }
583
+ }
584
+ /**
585
+ * Scans ~/.claude.json for MCP servers (top-level and per-project).
586
+ */
587
+ function scanClaudeJson(filePath, tools) {
588
+ try {
589
+ if (!existsSync(filePath)) return;
590
+ const raw = readFileSync(filePath, "utf-8");
591
+ const config = JSON.parse(raw);
592
+ const topServers = config.mcpServers;
593
+ if (topServers && typeof topServers === "object") for (const serverName of Object.keys(topServers)) tools.push({
594
+ name: `mcp__${serverName}__*`,
595
+ toolType: "mcp_server",
596
+ scope: "global",
597
+ source: "config:~/.claude.json",
598
+ projectHash: null,
599
+ description: null,
600
+ serverName,
601
+ triggerHints: null
602
+ });
603
+ const projects = config.projects;
604
+ if (projects && typeof projects === "object") for (const projectEntry of Object.values(projects)) {
605
+ const projServers = projectEntry.mcpServers;
606
+ if (projServers && typeof projServers === "object") for (const serverName of Object.keys(projServers)) tools.push({
607
+ name: `mcp__${serverName}__*`,
608
+ toolType: "mcp_server",
609
+ scope: "global",
610
+ source: "config:~/.claude.json",
611
+ projectHash: null,
612
+ description: null,
613
+ serverName,
614
+ triggerHints: null
615
+ });
616
+ }
617
+ } catch (err) {
618
+ debug("scanner", "Failed to scan claude.json", {
619
+ filePath,
620
+ error: String(err)
621
+ });
622
+ }
623
+ }
624
+ /**
625
+ * Scans a commands directory for slash command .md files.
626
+ * Supports one level of subdirectory nesting for namespaced commands.
627
+ */
628
+ function scanCommands(dirPath, scope, projectHash, tools) {
629
+ try {
630
+ if (!existsSync(dirPath)) return;
631
+ const entries = readdirSync(dirPath, { withFileTypes: true });
632
+ for (const entry of entries) if (entry.isFile() && entry.name.endsWith(".md")) {
633
+ const cmdName = `/${basename(entry.name, ".md")}`;
634
+ const filePath = join(dirPath, entry.name);
635
+ const description = extractDescription(filePath);
636
+ const triggerHints = extractTriggerHints(filePath);
637
+ tools.push({
638
+ name: cmdName,
639
+ toolType: "slash_command",
640
+ scope,
641
+ source: `config:${dirPath}`,
642
+ projectHash,
643
+ description,
644
+ serverName: null,
645
+ triggerHints
646
+ });
647
+ } else if (entry.isDirectory()) {
648
+ const subDir = join(dirPath, entry.name);
649
+ try {
650
+ const subEntries = readdirSync(subDir, { withFileTypes: true });
651
+ for (const subEntry of subEntries) if (subEntry.isFile() && subEntry.name.endsWith(".md")) {
652
+ const cmdName = `/${entry.name}:${basename(subEntry.name, ".md")}`;
653
+ const subFilePath = join(subDir, subEntry.name);
654
+ const description = extractDescription(subFilePath);
655
+ const triggerHints = extractTriggerHints(subFilePath);
656
+ tools.push({
657
+ name: cmdName,
658
+ toolType: "slash_command",
659
+ scope,
660
+ source: `config:${dirPath}`,
661
+ projectHash,
662
+ description,
663
+ serverName: null,
664
+ triggerHints
665
+ });
666
+ }
667
+ } catch {}
668
+ }
669
+ } catch (err) {
670
+ debug("scanner", "Failed to scan commands directory", {
671
+ dirPath,
672
+ error: String(err)
673
+ });
674
+ }
675
+ }
676
+ /**
677
+ * Scans a skills directory for skill subdirectories containing SKILL.md.
678
+ */
679
+ function scanSkills(dirPath, scope, projectHash, tools) {
680
+ try {
681
+ if (!existsSync(dirPath)) return;
682
+ const entries = readdirSync(dirPath, { withFileTypes: true });
683
+ for (const entry of entries) if (entry.isDirectory()) {
684
+ const skillMdPath = join(dirPath, entry.name, "SKILL.md");
685
+ if (existsSync(skillMdPath)) {
686
+ const description = extractDescription(skillMdPath);
687
+ const triggerHints = extractTriggerHints(skillMdPath);
688
+ tools.push({
689
+ name: entry.name,
690
+ toolType: "skill",
691
+ scope,
692
+ source: `config:${dirPath}`,
693
+ projectHash,
694
+ description,
695
+ serverName: null,
696
+ triggerHints
697
+ });
698
+ }
699
+ }
700
+ } catch (err) {
701
+ debug("scanner", "Failed to scan skills directory", {
702
+ dirPath,
703
+ error: String(err)
704
+ });
705
+ }
706
+ }
707
+ /**
708
+ * Scans installed_plugins.json for installed Claude plugins.
709
+ * Version 2 format: { version: 2, plugins: { "name@marketplace": [{ scope, installPath, version }] } }
710
+ */
711
+ function scanInstalledPlugins(filePath, tools) {
712
+ try {
713
+ if (!existsSync(filePath)) return;
714
+ const raw = readFileSync(filePath, "utf-8");
715
+ const plugins = JSON.parse(raw).plugins;
716
+ if (!plugins || typeof plugins !== "object") return;
717
+ for (const [key, installations] of Object.entries(plugins)) {
718
+ const pluginName = key.split("@")[0];
719
+ if (!Array.isArray(installations)) continue;
720
+ for (const install of installations) {
721
+ const inst = install;
722
+ const instScope = inst.scope === "user" ? "global" : "project";
723
+ tools.push({
724
+ name: pluginName,
725
+ toolType: "plugin",
726
+ scope: instScope,
727
+ source: "config:installed_plugins.json",
728
+ projectHash: null,
729
+ description: null,
730
+ serverName: null,
731
+ triggerHints: null
732
+ });
733
+ if (typeof inst.installPath === "string") scanMcpJson(join(inst.installPath, ".mcp.json"), "plugin", null, tools);
734
+ }
735
+ }
736
+ } catch (err) {
737
+ debug("scanner", "Failed to scan installed plugins", {
738
+ filePath,
739
+ error: String(err)
740
+ });
741
+ }
742
+ }
743
+ /**
744
+ * Scans all Claude Code config surfaces for tool discovery.
745
+ * Called during SessionStart to proactively populate the tool registry.
746
+ *
747
+ * All filesystem operations are synchronous (SessionStart hook is synchronous).
748
+ * Every scanner is wrapped in try/catch -- malformed configs never crash the hook.
749
+ *
750
+ * Config surfaces scanned:
751
+ * DISC-01: .mcp.json (project) + ~/.claude.json (global)
752
+ * DISC-02: .claude/commands (project) + ~/.claude/commands (global)
753
+ * DISC-03: .claude/skills (project) + ~/.claude/skills (global)
754
+ * DISC-04: installed_plugins.json (global plugins)
755
+ */
756
+ function scanConfigForTools(cwd, projectHash) {
757
+ const tools = [];
758
+ const home = homedir();
759
+ scanMcpJson(join(cwd, ".mcp.json"), "project", projectHash, tools);
760
+ scanClaudeJson(join(home, ".claude.json"), tools);
761
+ scanCommands(join(cwd, ".claude", "commands"), "project", projectHash, tools);
762
+ scanCommands(join(home, ".claude", "commands"), "global", null, tools);
763
+ scanSkills(join(cwd, ".claude", "skills"), "project", projectHash, tools);
764
+ scanSkills(join(home, ".claude", "skills"), "global", null, tools);
765
+ scanInstalledPlugins(join(home, ".claude", "plugins", "installed_plugins.json"), tools);
766
+ return tools;
767
+ }
768
+
769
+ //#endregion
770
+ //#region src/routing/intent-patterns.ts
771
+ /**
772
+ * Extracts tool sequence patterns from historical tool_usage_events.
773
+ *
774
+ * Scans all successful tool usage events for the project, groups them by session,
775
+ * and identifies recurring sliding-window patterns where a specific sequence of
776
+ * preceding tool calls led to a target tool activation.
777
+ *
778
+ * Runs at SessionStart and stores results in the routing_patterns table for
779
+ * cheap PostToolUse lookup.
780
+ *
781
+ * @param db - Database connection
782
+ * @param projectHash - Project identifier
783
+ * @param windowSize - Number of preceding tools to consider (default 5)
784
+ * @returns Extracted patterns sorted by frequency descending
785
+ */
786
+ function extractPatterns(db, projectHash, windowSize = 5) {
787
+ const events = db.prepare(`
788
+ SELECT tool_name, session_id
789
+ FROM tool_usage_events
790
+ WHERE project_hash = ? AND success = 1
791
+ ORDER BY session_id, created_at
792
+ `).all(projectHash);
793
+ const sessions = /* @__PURE__ */ new Map();
794
+ for (const evt of events) {
795
+ if (!sessions.has(evt.session_id)) sessions.set(evt.session_id, []);
796
+ sessions.get(evt.session_id).push(evt.tool_name);
797
+ }
798
+ const patternCounts = /* @__PURE__ */ new Map();
799
+ for (const [, toolSequence] of sessions) for (let i = windowSize; i < toolSequence.length; i++) {
800
+ const target = toolSequence[i];
801
+ const preceding = toolSequence.slice(i - windowSize, i);
802
+ if (inferToolType(target) === "builtin") continue;
803
+ if (isLaminarksOwnTool(target)) continue;
804
+ const key = `${target}:${preceding.join(",")}`;
805
+ const existing = patternCounts.get(key);
806
+ if (existing) existing.count++;
807
+ else patternCounts.set(key, {
808
+ target,
809
+ preceding,
810
+ count: 1
811
+ });
812
+ }
813
+ return Array.from(patternCounts.values()).filter((p) => p.count >= 2).map((p) => ({
814
+ targetTool: p.target,
815
+ precedingTools: p.preceding,
816
+ frequency: p.count
817
+ })).sort((a, b) => b.frequency - a.frequency);
818
+ }
819
+ /**
820
+ * Stores pre-computed routing patterns in the routing_patterns table.
821
+ *
822
+ * Creates the table inline (CREATE TABLE IF NOT EXISTS), deletes old patterns
823
+ * for the project, and inserts new ones in a transaction.
824
+ *
825
+ * @param db - Database connection
826
+ * @param projectHash - Project identifier
827
+ * @param patterns - Pre-computed patterns from extractPatterns()
828
+ */
829
+ function storePrecomputedPatterns(db, projectHash, patterns) {
830
+ db.exec(`
831
+ CREATE TABLE IF NOT EXISTS routing_patterns (
832
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
833
+ project_hash TEXT NOT NULL,
834
+ target_tool TEXT NOT NULL,
835
+ preceding_tools TEXT NOT NULL,
836
+ frequency INTEGER NOT NULL,
837
+ computed_at TEXT NOT NULL DEFAULT (datetime('now'))
838
+ )
839
+ `);
840
+ db.exec(`
841
+ CREATE INDEX IF NOT EXISTS idx_routing_patterns_project ON routing_patterns(project_hash)
842
+ `);
843
+ const deleteStmt = db.prepare("DELETE FROM routing_patterns WHERE project_hash = ?");
844
+ const insertStmt = db.prepare("INSERT INTO routing_patterns (project_hash, target_tool, preceding_tools, frequency) VALUES (?, ?, ?, ?)");
845
+ db.transaction(() => {
846
+ deleteStmt.run(projectHash);
847
+ for (const pattern of patterns) insertStmt.run(projectHash, pattern.targetTool, JSON.stringify(pattern.precedingTools), pattern.frequency);
848
+ })();
849
+ debug("routing", "Stored pre-computed patterns", {
850
+ projectHash,
851
+ count: patterns.length
852
+ });
853
+ }
854
+ /**
855
+ * Evaluates the current session's recent tool sequence against pre-computed patterns.
856
+ *
857
+ * Queries the current session's recent tool names, compares against stored patterns,
858
+ * and returns the best match if it exceeds the confidence threshold and the target
859
+ * tool is in the suggestable set.
860
+ *
861
+ * @param db - Database connection
862
+ * @param sessionId - Current session identifier
863
+ * @param projectHash - Project identifier
864
+ * @param suggestableToolNames - Set of tool names available for suggestion (availability gate)
865
+ * @param confidenceThreshold - Minimum confidence to return a suggestion
866
+ * @returns Best matching suggestion, or null if none qualifies
867
+ */
868
+ function evaluateLearnedPatterns(db, sessionId, projectHash, suggestableToolNames, confidenceThreshold) {
869
+ const currentTools = db.prepare(`
870
+ SELECT tool_name FROM tool_usage_events
871
+ WHERE session_id = ? AND project_hash = ?
872
+ ORDER BY created_at DESC
873
+ LIMIT 10
874
+ `).all(sessionId, projectHash).map((e) => e.tool_name).reverse();
875
+ if (currentTools.length === 0) return null;
876
+ const storedPatterns = db.prepare(`
877
+ SELECT target_tool, preceding_tools, frequency
878
+ FROM routing_patterns
879
+ WHERE project_hash = ?
880
+ ORDER BY frequency DESC
881
+ `).all(projectHash);
882
+ if (storedPatterns.length === 0) return null;
883
+ let bestMatch = null;
884
+ for (const row of storedPatterns) {
885
+ if (!suggestableToolNames.has(row.target_tool)) continue;
886
+ const overlap = computeSequenceOverlap(currentTools, JSON.parse(row.preceding_tools));
887
+ if (overlap > (bestMatch?.confidence ?? 0)) bestMatch = {
888
+ targetTool: row.target_tool,
889
+ confidence: overlap,
890
+ frequency: row.frequency
891
+ };
892
+ }
893
+ if (!bestMatch || bestMatch.confidence < confidenceThreshold) return null;
894
+ return {
895
+ toolName: bestMatch.targetTool,
896
+ toolDescription: null,
897
+ confidence: bestMatch.confidence,
898
+ tier: "learned",
899
+ reason: `Tool sequence pattern match (seen ${bestMatch.frequency}x in similar contexts)`
900
+ };
901
+ }
902
+ /**
903
+ * Computes Jaccard-like overlap between the current session's recent tool set
904
+ * and a pattern's preceding tools set.
905
+ *
906
+ * Takes the last N tools from the current sequence (where N = pattern length),
907
+ * converts both to sets, and counts how many pattern tools appear in the current set.
908
+ *
909
+ * @param currentTools - Current session's recent tool names (chronological order)
910
+ * @param patternTools - Pattern's preceding tools
911
+ * @returns Overlap score from 0.0 to 1.0
912
+ */
913
+ function computeSequenceOverlap(currentTools, patternTools) {
914
+ if (patternTools.length === 0) return 0;
915
+ const current = new Set(currentTools.slice(-patternTools.length));
916
+ const pattern = new Set(patternTools);
917
+ let matches = 0;
918
+ for (const tool of pattern) if (current.has(tool)) matches++;
919
+ return matches / pattern.size;
920
+ }
921
+
922
+ //#endregion
923
+ //#region src/hooks/session-lifecycle.ts
924
+ /**
925
+ * STAL-01: Detects tools that have been removed from config since last scan.
926
+ *
927
+ * Compares currently scanned config tools against the registry and marks
928
+ * missing config-sourced tools as stale. Also cascades to individual MCP tools
929
+ * from removed MCP servers.
930
+ */
931
+ function detectRemovedTools(toolRegistry, scannedTools, projectHash) {
932
+ const registeredConfigTools = toolRegistry.getConfigSourcedTools(projectHash);
933
+ const scannedNames = new Set(scannedTools.map((t) => t.name));
934
+ const removedServers = /* @__PURE__ */ new Set();
935
+ for (const registered of registeredConfigTools) if (!scannedNames.has(registered.name)) {
936
+ toolRegistry.markStale(registered.name, registered.project_hash);
937
+ if (registered.tool_type === "mcp_server" && registered.server_name) removedServers.add(registered.server_name);
938
+ }
939
+ if (removedServers.size > 0) {
940
+ 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);
941
+ }
942
+ }
943
+ /**
944
+ * Handles a SessionStart hook event.
945
+ *
946
+ * Creates a new session record in the database, then assembles context
947
+ * from prior sessions and observations for injection into Claude's
948
+ * context window.
949
+ *
950
+ * This hook is SYNCHRONOUS -- stdout is injected into Claude's context.
951
+ * Must complete within 2 seconds (performance budget for sync hooks).
952
+ * Expected execution: <100ms (session create + 2-3 SELECT queries).
953
+ *
954
+ * @returns Context string to write to stdout, or null if no context available
955
+ */
956
+ function handleSessionStart(input, sessionRepo, db, projectHash, toolRegistry, pathRepo, branchRepo) {
957
+ const sessionId = input.session_id;
958
+ if (!sessionId) {
959
+ debug("session", "SessionStart missing session_id, skipping");
960
+ return null;
961
+ }
962
+ sessionRepo.create(sessionId);
963
+ debug("session", "Session started", { sessionId });
964
+ const cwd = input.cwd;
965
+ if (cwd) try {
966
+ db.prepare(`
967
+ INSERT INTO project_metadata (project_hash, project_path, last_seen_at)
968
+ VALUES (?, ?, datetime('now'))
969
+ ON CONFLICT(project_hash) DO UPDATE SET
970
+ project_path = excluded.project_path,
971
+ last_seen_at = excluded.last_seen_at
972
+ `).run(projectHash, cwd);
973
+ } catch {}
974
+ if (toolRegistry) {
975
+ const cwd = input.cwd;
976
+ try {
977
+ const scanStart = Date.now();
978
+ const tools = scanConfigForTools(cwd, projectHash);
979
+ for (const tool of tools) toolRegistry.upsert(tool);
980
+ try {
981
+ detectRemovedTools(toolRegistry, tools, projectHash);
982
+ debug("session", "Staleness detection completed");
983
+ } catch {
984
+ debug("session", "Staleness detection failed (non-fatal)");
985
+ }
986
+ const scanElapsed = Date.now() - scanStart;
987
+ debug("session", "Config scan completed", {
988
+ toolsFound: tools.length,
989
+ elapsed: scanElapsed
990
+ });
991
+ if (scanElapsed > 200) debug("session", "Config scan slow (>200ms budget)", { elapsed: scanElapsed });
992
+ } catch {
993
+ debug("session", "Config scan failed (non-fatal)");
994
+ }
995
+ }
996
+ if (toolRegistry) try {
997
+ const precomputeStart = Date.now();
998
+ const patterns = extractPatterns(db, projectHash, 5);
999
+ storePrecomputedPatterns(db, projectHash, patterns);
1000
+ const precomputeElapsed = Date.now() - precomputeStart;
1001
+ debug("session", "Routing patterns pre-computed", {
1002
+ patternCount: patterns.length,
1003
+ elapsed: precomputeElapsed
1004
+ });
1005
+ if (precomputeElapsed > 50) debug("session", "Pattern pre-computation slow (>50ms)", { elapsed: precomputeElapsed });
1006
+ } catch {
1007
+ debug("session", "Pattern pre-computation failed (non-fatal)");
1008
+ }
1009
+ const startTime = Date.now();
1010
+ let context = assembleSessionContext(db, projectHash, toolRegistry);
1011
+ const elapsed = Date.now() - startTime;
1012
+ if (elapsed > 500) debug("session", "Context assembly slow", {
1013
+ elapsed,
1014
+ sessionId
1015
+ });
1016
+ debug("session", "Context assembled for injection", {
1017
+ sessionId,
1018
+ contextLength: context.length,
1019
+ elapsed
1020
+ });
1021
+ if (pathRepo) try {
1022
+ const activePath = pathRepo.findRecentActivePath();
1023
+ if (activePath) {
1024
+ const ageMs = Date.now() - new Date(activePath.started_at).getTime();
1025
+ if (ageMs > 1440 * 60 * 1e3) {
1026
+ pathRepo.abandonPath(activePath.id);
1027
+ debug("session", "Auto-abandoned stale debug path", {
1028
+ pathId: activePath.id,
1029
+ ageMs
1030
+ });
1031
+ } else {
1032
+ const waypoints = pathRepo.getWaypoints(activePath.id);
1033
+ const lastWaypoint = waypoints[waypoints.length - 1];
1034
+ 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`;
1035
+ context = context + pathContext;
1036
+ }
1037
+ }
1038
+ } catch {
1039
+ debug("session", "Cross-session path check failed (non-fatal)");
1040
+ }
1041
+ if (branchRepo) try {
1042
+ const activeBranch = branchRepo.findRecentActiveBranch();
1043
+ if (activeBranch) {
1044
+ const branchContext = `\n[Laminark] Active work branch carried over:\n ${activeBranch.title ?? activeBranch.id.slice(0, 12)} (${activeBranch.branch_type})\n Stage: ${activeBranch.arc_stage} | Observations: ${activeBranch.observation_count}\n Use query_branches to see all branches.\n`;
1045
+ context = context + branchContext;
1046
+ }
1047
+ } catch {
1048
+ debug("session", "Cross-session branch check failed (non-fatal)");
1049
+ }
1050
+ return context + (toolRegistry ? "\nCall report_available_tools with all your tools (built-in and MCP) so Laminark can index them for discovery." : "");
1051
+ }
1052
+ /**
1053
+ * Handles a SessionEnd hook event.
1054
+ *
1055
+ * Closes the session record by setting ended_at timestamp.
1056
+ */
1057
+ function handleSessionEnd(input, sessionRepo) {
1058
+ const sessionId = input.session_id;
1059
+ if (!sessionId) {
1060
+ debug("session", "SessionEnd missing session_id, skipping");
1061
+ return;
1062
+ }
1063
+ sessionRepo.end(sessionId);
1064
+ debug("session", "Session ended", { sessionId });
1065
+ }
1066
+ /**
1067
+ * Handles a Stop hook event.
1068
+ *
1069
+ * Triggers session summary generation by compressing all observations
1070
+ * from the session into a concise summary stored on the session row.
1071
+ *
1072
+ * Stop fires after SessionEnd, so the session is already closed.
1073
+ * Summary generation is heuristic (no LLM call) and typically completes
1074
+ * in under 10ms even with many observations.
1075
+ *
1076
+ * If the session has zero observations, this is a graceful no-op.
1077
+ */
1078
+ function handleStop(input, obsRepo, sessionRepo, db, projectHash) {
1079
+ const sessionId = input.session_id;
1080
+ if (!sessionId) {
1081
+ debug("session", "Stop missing session_id, skipping");
1082
+ return;
1083
+ }
1084
+ debug("session", "Stop event received, generating summary", { sessionId });
1085
+ const result = generateSessionSummary(sessionId, obsRepo, sessionRepo);
1086
+ if (result) debug("session", "Session summary generated", {
1087
+ sessionId,
1088
+ observationCount: result.observationCount,
1089
+ summaryLength: result.summary.length
1090
+ });
1091
+ else debug("session", "No observations to summarize", { sessionId });
1092
+ if (db && projectHash) try {
1093
+ const cleanup = runAutoCleanup(db, projectHash);
1094
+ if (!cleanup.skipped && (cleanup.observationsPurged > 0 || cleanup.orphanNodesRemoved > 0)) debug("session", "Auto-cleanup ran at session end", {
1095
+ observationsPurged: cleanup.observationsPurged,
1096
+ orphanNodesRemoved: cleanup.orphanNodesRemoved
1097
+ });
1098
+ } catch {
1099
+ debug("session", "Auto-cleanup failed (non-fatal)");
1100
+ }
1101
+ }
1102
+
1103
+ //#endregion
1104
+ //#region src/hooks/privacy-filter.ts
1105
+ /**
1106
+ * Built-in privacy patterns that are always active.
1107
+ *
1108
+ * Order matters: more specific patterns should come before more general ones.
1109
+ * For example, api_key patterns before env_variable to avoid double-matching.
1110
+ */
1111
+ const DEFAULT_PRIVACY_PATTERNS = [
1112
+ {
1113
+ name: "private_key",
1114
+ regex: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
1115
+ replacement: "[REDACTED:private_key]",
1116
+ category: "private_key"
1117
+ },
1118
+ {
1119
+ name: "jwt_token",
1120
+ regex: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
1121
+ replacement: "[REDACTED:jwt]",
1122
+ category: "jwt"
1123
+ },
1124
+ {
1125
+ name: "connection_string",
1126
+ regex: /(postgresql|mongodb|mysql|redis):\/\/[^\s]+/g,
1127
+ replacement: "$1://[REDACTED:connection_string]",
1128
+ category: "connection_string"
1129
+ },
1130
+ {
1131
+ name: "api_key_openai",
1132
+ regex: /sk-[a-zA-Z0-9]{20,}/g,
1133
+ replacement: "[REDACTED:api_key]",
1134
+ category: "api_key"
1135
+ },
1136
+ {
1137
+ name: "api_key_github",
1138
+ regex: /ghp_[a-zA-Z0-9]{36,}/g,
1139
+ replacement: "[REDACTED:api_key]",
1140
+ category: "api_key"
1141
+ },
1142
+ {
1143
+ name: "aws_access_key",
1144
+ regex: /AKIA[A-Z0-9]{12,}/g,
1145
+ replacement: "[REDACTED:api_key]",
1146
+ category: "api_key"
1147
+ },
1148
+ {
1149
+ name: "env_variable",
1150
+ regex: /\b([A-Z][A-Z0-9_]{2,})=(["']?)(?!\[REDACTED:)([^\s"']{8,})\2/g,
1151
+ replacement: "$1=[REDACTED:env]",
1152
+ category: "env"
1153
+ }
1154
+ ];
1155
+ /**
1156
+ * Default file patterns that trigger full exclusion (return null).
1157
+ */
1158
+ const DEFAULT_EXCLUDED_FILE_PATTERNS = [
1159
+ /\.env(\.|$)/,
1160
+ /credentials/i,
1161
+ /secrets/i,
1162
+ /\.pem$/,
1163
+ /\.key$/,
1164
+ /id_rsa/
1165
+ ];
1166
+ /**
1167
+ * Cached patterns (loaded once per process).
1168
+ * null = not yet loaded.
1169
+ */
1170
+ let _cachedPatterns = null;
1171
+ let _cachedExcludedFiles = null;
1172
+ /**
1173
+ * Loads user privacy patterns from ~/.laminark/config.json.
1174
+ * Merges with defaults. Caches result.
1175
+ *
1176
+ * If the config file doesn't exist or is invalid, returns defaults only.
1177
+ */
1178
+ function loadPatterns() {
1179
+ if (_cachedPatterns !== null) return _cachedPatterns;
1180
+ const patterns = [...DEFAULT_PRIVACY_PATTERNS];
1181
+ try {
1182
+ const raw = readFileSync(join(homedir(), ".laminark", "config.json"), "utf-8");
1183
+ const privacy = JSON.parse(raw).privacy;
1184
+ if (privacy?.additionalPatterns) {
1185
+ for (const p of privacy.additionalPatterns) patterns.push({
1186
+ name: `user_${p.regex}`,
1187
+ regex: new RegExp(p.regex, "g"),
1188
+ replacement: p.replacement,
1189
+ category: "user"
1190
+ });
1191
+ debug("privacy", "Loaded user privacy patterns", { count: privacy.additionalPatterns.length });
1192
+ }
1193
+ } catch {}
1194
+ _cachedPatterns = patterns;
1195
+ return patterns;
1196
+ }
1197
+ /**
1198
+ * Loads excluded file patterns (default + user-configured).
1199
+ */
1200
+ function loadExcludedFiles() {
1201
+ if (_cachedExcludedFiles !== null) return _cachedExcludedFiles;
1202
+ const patterns = [...DEFAULT_EXCLUDED_FILE_PATTERNS];
1203
+ try {
1204
+ const raw = readFileSync(join(homedir(), ".laminark", "config.json"), "utf-8");
1205
+ const privacy = JSON.parse(raw).privacy;
1206
+ if (privacy?.excludedFiles) for (const pattern of privacy.excludedFiles) patterns.push(new RegExp(pattern));
1207
+ } catch {}
1208
+ _cachedExcludedFiles = patterns;
1209
+ return patterns;
1210
+ }
1211
+ /**
1212
+ * Checks whether a file path matches any excluded file pattern.
1213
+ *
1214
+ * Excluded files should have their observations fully dropped (return null
1215
+ * from redactSensitiveContent) rather than just redacted.
1216
+ *
1217
+ * @param filePath - The file path to check (can be absolute or relative)
1218
+ * @returns true if the file should be excluded from observation storage
1219
+ */
1220
+ function isExcludedFile(filePath) {
1221
+ const name = basename(filePath);
1222
+ const patterns = loadExcludedFiles();
1223
+ for (const pattern of patterns) if (pattern.test(name) || pattern.test(filePath)) return true;
1224
+ return false;
1225
+ }
1226
+ /**
1227
+ * Redacts sensitive content before storage.
1228
+ *
1229
+ * - If filePath is provided and matches an excluded file pattern, returns null
1230
+ * (the entire observation should be dropped)
1231
+ * - Otherwise, applies all privacy patterns (default + user-configured)
1232
+ * sequentially to the text
1233
+ * - Returns the redacted text, or the original if no patterns matched
1234
+ *
1235
+ * @param text - The observation text to redact
1236
+ * @param filePath - Optional file path that triggered the observation
1237
+ * @returns Redacted text, or null if the file should be fully excluded
1238
+ */
1239
+ function redactSensitiveContent(text, filePath) {
1240
+ if (filePath && isExcludedFile(filePath)) {
1241
+ debug("privacy", "File excluded from observation", { filePath });
1242
+ return null;
1243
+ }
1244
+ const patterns = loadPatterns();
1245
+ let result = text;
1246
+ const matchedPatterns = [];
1247
+ for (const pattern of patterns) {
1248
+ pattern.regex.lastIndex = 0;
1249
+ if (pattern.regex.test(result)) {
1250
+ matchedPatterns.push(pattern.name);
1251
+ pattern.regex.lastIndex = 0;
1252
+ result = result.replace(pattern.regex, pattern.replacement);
1253
+ }
1254
+ }
1255
+ if (matchedPatterns.length > 0) debug("privacy", "Content redacted", { patterns: matchedPatterns });
1256
+ return result;
1257
+ }
1258
+
1259
+ //#endregion
1260
+ //#region src/hooks/admission-filter.ts
1261
+ /**
1262
+ * Tools that are always admitted regardless of content.
1263
+ *
1264
+ * Write and Edit observations are high-signal by definition --
1265
+ * they represent intentional code changes. Content pattern matching
1266
+ * must NEVER reject these tools (see research pitfall #3).
1267
+ *
1268
+ * WebFetch and WebSearch are reference material -- always valuable.
1269
+ */
1270
+ const HIGH_SIGNAL_TOOLS = new Set([
1271
+ "Write",
1272
+ "Edit",
1273
+ "WebFetch",
1274
+ "WebSearch"
1275
+ ]);
1276
+ /**
1277
+ * Navigation/exploration Bash commands that produce noise observations.
1278
+ * Matched against the start of the command string (after trimming).
1279
+ */
1280
+ const NAVIGATION_BASH_PREFIXES = [
1281
+ "ls",
1282
+ "cd ",
1283
+ "pwd",
1284
+ "cat ",
1285
+ "head ",
1286
+ "tail ",
1287
+ "echo ",
1288
+ "wc ",
1289
+ "which ",
1290
+ "find ",
1291
+ "tree",
1292
+ "file "
1293
+ ];
1294
+ /**
1295
+ * Git read-only commands that are navigation (not mutations).
1296
+ */
1297
+ const NAVIGATION_GIT_PATTERNS = [
1298
+ /^git\s+status\b/,
1299
+ /^git\s+log\b/,
1300
+ /^git\s+diff\b(?!.*--)/,
1301
+ /^git\s+branch\b(?!\s+-[dDmM])/,
1302
+ /^git\s+show\b/,
1303
+ /^git\s+remote\b/,
1304
+ /^git\s+stash\s+list\b/
1305
+ ];
1306
+ /**
1307
+ * Commands that are always meaningful and should be admitted.
1308
+ */
1309
+ const MEANINGFUL_BASH_PATTERNS = [
1310
+ /^npm\s+test\b/,
1311
+ /^npx\s+vitest\b/,
1312
+ /^npx\s+jest\b/,
1313
+ /^vitest\b/,
1314
+ /^jest\b/,
1315
+ /^pytest\b/,
1316
+ /^cargo\s+test\b/,
1317
+ /^go\s+test\b/,
1318
+ /^make\s+test\b/,
1319
+ /^npm\s+run\s+build\b/,
1320
+ /^npx\s+tsc\b/,
1321
+ /^cargo\s+build\b/,
1322
+ /^make\b/,
1323
+ /^go\s+build\b/,
1324
+ /^gradle\b/,
1325
+ /^mvn\b/,
1326
+ /^git\s+commit\b/,
1327
+ /^git\s+push\b/,
1328
+ /^git\s+merge\b/,
1329
+ /^git\s+rebase\b/,
1330
+ /^git\s+cherry-pick\b/,
1331
+ /^git\s+reset\b/,
1332
+ /^git\s+revert\b/,
1333
+ /^git\s+checkout\s+-b\b/,
1334
+ /^git\s+switch\s+-c\b/,
1335
+ /^git\s+stash\s+(?:push|pop|apply|drop)\b/,
1336
+ /^docker\b/,
1337
+ /^kubectl\b/,
1338
+ /^terraform\b/,
1339
+ /^helm\b/,
1340
+ /^npm\s+install\b/,
1341
+ /^npm\s+i\b/,
1342
+ /^yarn\s+add\b/,
1343
+ /^pnpm\s+add\b/,
1344
+ /^pip\s+install\b/,
1345
+ /^cargo\s+add\b/
1346
+ ];
1347
+ /**
1348
+ * Determines if a Bash command is meaningful enough to capture.
1349
+ *
1350
+ * Navigation commands (ls, cd, pwd, cat, git status, git log, etc.) are
1351
+ * filtered out. Test runners, build commands, git mutations, and container
1352
+ * commands are always admitted. Unknown commands default to admit.
1353
+ */
1354
+ function isMeaningfulBashCommand(command) {
1355
+ const trimmed = command.trim();
1356
+ if (!trimmed) return false;
1357
+ for (const pattern of MEANINGFUL_BASH_PATTERNS) if (pattern.test(trimmed)) return true;
1358
+ for (const prefix of NAVIGATION_BASH_PREFIXES) if (trimmed.startsWith(prefix) || trimmed === prefix.trim()) return false;
1359
+ for (const pattern of NAVIGATION_GIT_PATTERNS) if (pattern.test(trimmed)) return false;
1360
+ return true;
1361
+ }
1362
+ /**
1363
+ * Maximum content length before requiring decision/error indicators.
1364
+ * Content over this threshold with no meaningful indicators is likely
1365
+ * a raw file dump or verbose command output.
1366
+ */
1367
+ const MAX_CONTENT_LENGTH = 5e3;
1368
+ /**
1369
+ * Patterns that indicate meaningful content even in long output.
1370
+ * If content exceeds MAX_CONTENT_LENGTH, it must contain at least
1371
+ * one of these to be admitted.
1372
+ */
1373
+ const DECISION_OR_ERROR_INDICATORS = [
1374
+ /\berror\b/i,
1375
+ /\bfailed\b/i,
1376
+ /\bexception\b/i,
1377
+ /\bbug\b/i,
1378
+ /\bdecided\b/i,
1379
+ /\bchose\b/i,
1380
+ /\bbecause\b/i,
1381
+ /\binstead of\b/i
1382
+ ];
1383
+ /**
1384
+ * Decides whether an observation is worth storing in the database.
1385
+ *
1386
+ * This is the primary quality gate for the observation pipeline.
1387
+ * It prevents the database from filling with noise (build output,
1388
+ * linter spam, package install logs).
1389
+ *
1390
+ * Critical rule: Write and Edit tools are NEVER rejected based on
1391
+ * content patterns alone. Tool type is the primary signal.
1392
+ *
1393
+ * @param toolName - The name of the tool that produced the observation
1394
+ * @param content - The observation content to evaluate
1395
+ * @returns true if the observation should be stored, false to reject
1396
+ */
1397
+ function shouldAdmit(toolName, content) {
1398
+ if (isLaminarksOwnTool(toolName)) {
1399
+ debug("hook", "Observation rejected", {
1400
+ tool: toolName,
1401
+ reason: "self-referential"
1402
+ });
1403
+ return false;
1404
+ }
1405
+ if (!content || content.trim().length === 0) {
1406
+ debug("hook", "Observation rejected", {
1407
+ tool: toolName,
1408
+ reason: "empty"
1409
+ });
1410
+ return false;
1411
+ }
1412
+ if (HIGH_SIGNAL_TOOLS.has(toolName)) return true;
1413
+ if (content.length > MAX_CONTENT_LENGTH) {
1414
+ if (!DECISION_OR_ERROR_INDICATORS.some((pattern) => pattern.test(content))) {
1415
+ debug("hook", "Observation rejected", {
1416
+ tool: toolName,
1417
+ reason: "long_content_no_indicators",
1418
+ length: content.length
1419
+ });
1420
+ return false;
1421
+ }
1422
+ }
1423
+ return true;
1424
+ }
1425
+
1426
+ //#endregion
1427
+ //#region src/paths/path-recall.ts
1428
+ /**
1429
+ * Path recall — finds relevant past resolved debug paths based on text similarity.
1430
+ *
1431
+ * Used by the PreToolUse hook to surface "you've seen this before" context
1432
+ * when new debugging starts on similar issues.
1433
+ *
1434
+ * Implements INTEL-03: proactive path recall via Jaccard similarity matching.
1435
+ */
1436
+ /**
1437
+ * Finds past resolved debug paths similar to the current context text.
1438
+ *
1439
+ * Computes Jaccard similarity against both trigger_summary and resolution_summary
1440
+ * of recent resolved paths, taking the max score. Filters to paths scoring >= 0.25
1441
+ * and returns the top `limit` results sorted by similarity descending.
1442
+ */
1443
+ function findSimilarPaths(pathRepo, currentContext, limit = 3) {
1444
+ const resolvedPaths = pathRepo.listPaths(50).filter((p) => p.status === "resolved");
1445
+ if (resolvedPaths.length === 0) return [];
1446
+ const scored = [];
1447
+ for (const path of resolvedPaths) {
1448
+ const triggerScore = jaccardSimilarity(currentContext, path.trigger_summary);
1449
+ const resolutionScore = jaccardSimilarity(currentContext, path.resolution_summary ?? "");
1450
+ const similarity = Math.max(triggerScore, resolutionScore);
1451
+ if (similarity >= .25) {
1452
+ let kissSummary = null;
1453
+ if (path.kiss_summary) try {
1454
+ const parsed = JSON.parse(path.kiss_summary);
1455
+ kissSummary = parsed.next_time ?? parsed.root_cause ?? null;
1456
+ } catch {
1457
+ kissSummary = null;
1458
+ }
1459
+ scored.push({
1460
+ path,
1461
+ similarity,
1462
+ kissSummary
1463
+ });
1464
+ }
1465
+ }
1466
+ scored.sort((a, b) => b.similarity - a.similarity);
1467
+ return scored.slice(0, limit);
1468
+ }
1469
+ /**
1470
+ * Formats path recall results into a compact string for context injection.
1471
+ *
1472
+ * Returns empty string if no results. Caps total output to 600 chars.
1473
+ */
1474
+ function formatPathRecall(results) {
1475
+ if (results.length === 0) return "";
1476
+ const lines = ["[Laminark] Similar past debug paths found:"];
1477
+ for (const r of results) {
1478
+ const trigger = r.path.trigger_summary.slice(0, 80);
1479
+ lines.push(`- ${trigger} (similarity: ${r.similarity.toFixed(2)})`);
1480
+ lines.push(` KISS: ${r.kissSummary ?? "No summary available"}`);
1481
+ }
1482
+ const output = lines.join("\n");
1483
+ if (output.length > 600) return output.slice(0, 597) + "...";
1484
+ return output;
1485
+ }
1486
+
1487
+ //#endregion
1488
+ //#region src/hooks/pre-tool-context.ts
1489
+ /** Tools where we skip context injection entirely. */
1490
+ const SKIP_TOOLS = new Set([
1491
+ "Glob",
1492
+ "Task",
1493
+ "NotebookEdit",
1494
+ "EnterPlanMode",
1495
+ "ExitPlanMode",
1496
+ "AskUserQuestion",
1497
+ "TaskCreate",
1498
+ "TaskUpdate",
1499
+ "TaskGet",
1500
+ "TaskList"
1501
+ ]);
1502
+ /** Bash commands that are navigation/noise -- not worth searching for. */
1503
+ 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/;
1504
+ /**
1505
+ * Extracts a search query from tool input based on tool type.
1506
+ * Returns null if the tool should be skipped or has no meaningful target.
1507
+ */
1508
+ function extractSearchQuery(toolName, toolInput) {
1509
+ switch (toolName) {
1510
+ case "Write":
1511
+ case "Edit":
1512
+ case "Read": {
1513
+ const filePath = toolInput.file_path;
1514
+ if (!filePath) return null;
1515
+ const base = basename(filePath);
1516
+ const stem = base.replace(/\.[^.]+$/, "");
1517
+ return stem.length >= 2 ? stem : base;
1518
+ }
1519
+ case "Bash": {
1520
+ const command = toolInput.command ?? "";
1521
+ if (NOISE_BASH_RE.test(command)) return null;
1522
+ const cleaned = command.replace(/^\s*(sudo|bash|sh|env)\s+/, "").replace(/[|><&;]+.*$/, "").trim();
1523
+ if (!cleaned || cleaned.length < 3) return null;
1524
+ const words = cleaned.split(/\s+/).slice(0, 3).join(" ");
1525
+ return words.length >= 3 ? words : null;
1526
+ }
1527
+ case "Grep": {
1528
+ const pattern = toolInput.pattern;
1529
+ return pattern && pattern.length >= 2 ? pattern : null;
1530
+ }
1531
+ case "WebFetch": {
1532
+ const url = toolInput.url;
1533
+ if (!url) return null;
1534
+ try {
1535
+ return new URL(url).hostname;
1536
+ } catch {
1537
+ return null;
1538
+ }
1539
+ }
1540
+ case "WebSearch": return toolInput.query ?? null;
1541
+ default: return null;
1542
+ }
1543
+ }
1544
+ /**
1545
+ * Formats age of an observation as a human-readable string.
1546
+ */
1547
+ function formatAge(createdAt) {
1548
+ const ageMs = Date.now() - new Date(createdAt).getTime();
1549
+ const hours = Math.floor(ageMs / 36e5);
1550
+ if (hours < 1) return "just now";
1551
+ if (hours < 24) return `${hours}h ago`;
1552
+ const days = Math.floor(hours / 24);
1553
+ if (days === 1) return "1d ago";
1554
+ return `${days}d ago`;
1555
+ }
1556
+ /**
1557
+ * Truncates text to a max length, adding ellipsis if needed.
1558
+ */
1559
+ function truncate(text, max) {
1560
+ if (text.length <= max) return text;
1561
+ return text.slice(0, max - 3) + "...";
1562
+ }
1563
+ /**
1564
+ * Main PreToolUse handler. Searches observations and graph for context
1565
+ * relevant to the tool about to execute.
1566
+ *
1567
+ * Returns a formatted context string to inject via stdout, or null if
1568
+ * no relevant context was found.
1569
+ */
1570
+ function handlePreToolUse(input, db, projectHash, pathRepo) {
1571
+ const toolName = input.tool_name;
1572
+ if (!toolName) return null;
1573
+ if (isLaminarksOwnTool(toolName)) return null;
1574
+ if (SKIP_TOOLS.has(toolName)) return null;
1575
+ const toolInput = input.tool_input ?? {};
1576
+ const query = extractSearchQuery(toolName, toolInput);
1577
+ if (!query) return null;
1578
+ debug("hook", "PreToolUse searching", {
1579
+ tool: toolName,
1580
+ query
1581
+ });
1582
+ const lines = [];
1583
+ try {
1584
+ const results = new SearchEngine(db, projectHash).searchKeyword(query, { limit: 3 });
1585
+ for (const result of results) {
1586
+ const snippet = result.snippet ? result.snippet.replace(/<\/?mark>/g, "") : truncate(result.observation.content, 120);
1587
+ const age = formatAge(result.observation.created_at);
1588
+ lines.push(`- ${truncate(snippet, 120)} (${result.observation.source}, ${age})`);
1589
+ }
1590
+ } catch {
1591
+ debug("hook", "PreToolUse FTS5 search failed");
1592
+ }
1593
+ try {
1594
+ if (toolName === "Write" || toolName === "Edit" || toolName === "Read") {
1595
+ const filePath = toolInput.file_path;
1596
+ if (filePath) {
1597
+ const node = getNodeByNameAndType(db, filePath, "File");
1598
+ if (node) {
1599
+ const connected = traverseFrom(db, node.id, {
1600
+ depth: 1,
1601
+ direction: "both"
1602
+ });
1603
+ if (connected.length > 0) {
1604
+ const names = connected.slice(0, 5).map((r) => `${r.node.name} (${r.node.type})`).join(", ");
1605
+ lines.push(`Related: ${names}`);
1606
+ }
1607
+ }
1608
+ }
1609
+ }
1610
+ } catch {
1611
+ debug("hook", "PreToolUse graph lookup failed");
1612
+ }
1613
+ if (pathRepo) try {
1614
+ const toolOutput = toolInput.content ?? toolInput.command ?? query ?? "";
1615
+ if (toolOutput.length > 20) {
1616
+ const recall = formatPathRecall(findSimilarPaths(pathRepo, toolOutput, 2));
1617
+ if (recall) lines.push(recall);
1618
+ }
1619
+ } catch {
1620
+ debug("hook", "PreToolUse path recall failed");
1621
+ }
1622
+ if (lines.length === 0) return null;
1623
+ let target = query;
1624
+ if ((toolName === "Write" || toolName === "Edit" || toolName === "Read") && toolInput.file_path) target = basename(toolInput.file_path);
1625
+ const output = `[Laminark] Context for ${target}:\n${lines.join("\n")}\n`;
1626
+ if (output.length > 500) return output.slice(0, 497) + "...\n";
1627
+ return output;
1628
+ }
1629
+
1630
+ //#endregion
1631
+ //#region src/routing/types.ts
1632
+ /**
1633
+ * Default routing configuration values.
1634
+ * Threshold and rate limits tuned to avoid over-suggestion (Clippy problem).
1635
+ */
1636
+ const DEFAULT_ROUTING_CONFIG = {
1637
+ confidenceThreshold: .6,
1638
+ maxSuggestionsPerSession: 2,
1639
+ minEventsForLearned: 20,
1640
+ suggestionCooldown: 5,
1641
+ minCallsBeforeFirstSuggestion: 3,
1642
+ patternWindowSize: 5
1643
+ };
1644
+
1645
+ //#endregion
1646
+ //#region src/routing/proactive-suggestions.ts
1647
+ /**
1648
+ * Loads a lightweight snapshot of current session context.
1649
+ * Three small queries, each <3ms on a typical database.
1650
+ */
1651
+ function loadContextSnapshot(db, projectHash, sessionId) {
1652
+ let branch = null;
1653
+ try {
1654
+ const row = db.prepare(`
1655
+ SELECT arc_stage, branch_type, observation_count, tool_pattern
1656
+ FROM thought_branches
1657
+ WHERE project_hash = ? AND session_id = ? AND status = 'active'
1658
+ ORDER BY started_at DESC LIMIT 1
1659
+ `).get(projectHash, sessionId);
1660
+ if (row) {
1661
+ let toolPattern = {};
1662
+ try {
1663
+ toolPattern = JSON.parse(row.tool_pattern);
1664
+ } catch {}
1665
+ branch = {
1666
+ arcStage: row.arc_stage,
1667
+ branchType: row.branch_type,
1668
+ observationCount: row.observation_count,
1669
+ toolPattern
1670
+ };
1671
+ }
1672
+ } catch {}
1673
+ let debugPath = null;
1674
+ try {
1675
+ const pathRow = db.prepare(`
1676
+ SELECT dp.status,
1677
+ (SELECT COUNT(*) FROM path_waypoints pw WHERE pw.path_id = dp.id) AS waypoint_count,
1678
+ (SELECT COUNT(*) FROM path_waypoints pw WHERE pw.path_id = dp.id AND pw.waypoint_type = 'error') AS error_count
1679
+ FROM debug_paths dp
1680
+ WHERE dp.project_hash = ? AND dp.status = 'active'
1681
+ ORDER BY dp.started_at DESC LIMIT 1
1682
+ `).get(projectHash);
1683
+ if (pathRow) debugPath = {
1684
+ status: pathRow.status,
1685
+ waypointCount: pathRow.waypoint_count,
1686
+ errorCount: pathRow.error_count
1687
+ };
1688
+ } catch {}
1689
+ let recentClassifications = [];
1690
+ try {
1691
+ recentClassifications = db.prepare(`
1692
+ SELECT classification FROM observations
1693
+ WHERE project_hash = ? AND session_id = ? AND deleted_at IS NULL AND classification IS NOT NULL
1694
+ ORDER BY created_at DESC LIMIT 5
1695
+ `).all(projectHash, sessionId).map((r) => r.classification);
1696
+ } catch {}
1697
+ return {
1698
+ branch,
1699
+ debugPath,
1700
+ recentClassifications
1701
+ };
1702
+ }
1703
+ /**
1704
+ * Rules map context patterns to keyword categories, NOT tool names.
1705
+ * The engine then searches the tool registry for matching tools.
1706
+ */
1707
+ const CONTEXT_RULES = [
1708
+ {
1709
+ id: "debug-session",
1710
+ searchKeywords: [
1711
+ "debug",
1712
+ "error tracking",
1713
+ "issue investigation",
1714
+ "systematic debugging"
1715
+ ],
1716
+ confidence: .8,
1717
+ reason: "Diagnosis stage detected with problems but no active debug path",
1718
+ matches(ctx) {
1719
+ if (!ctx.branch) return false;
1720
+ const inDiagnosis = ctx.branch.arcStage === "diagnosis" || ctx.branch.arcStage === "investigation";
1721
+ const hasProblems = ctx.recentClassifications.some((c) => c === "problem" || c === "error");
1722
+ const noActivePath = !ctx.debugPath;
1723
+ return inDiagnosis && hasProblems && noActivePath;
1724
+ }
1725
+ },
1726
+ {
1727
+ id: "planning-needed",
1728
+ searchKeywords: [
1729
+ "plan",
1730
+ "design",
1731
+ "architecture",
1732
+ "implementation strategy"
1733
+ ],
1734
+ confidence: .7,
1735
+ reason: "Investigation phase with 5+ observations suggests planning would help",
1736
+ matches(ctx) {
1737
+ if (!ctx.branch) return false;
1738
+ const inInvestigation = ctx.branch.arcStage === "investigation";
1739
+ const enoughObservations = ctx.branch.observationCount >= 5;
1740
+ const readTools = (ctx.branch.toolPattern["Read"] ?? 0) + (ctx.branch.toolPattern["Grep"] ?? 0) + (ctx.branch.toolPattern["Glob"] ?? 0);
1741
+ const totalTools = Object.values(ctx.branch.toolPattern).reduce((a, b) => a + b, 0);
1742
+ const mostlyReads = totalTools > 0 && readTools / totalTools > .6;
1743
+ return inInvestigation && enoughObservations && mostlyReads;
1744
+ }
1745
+ },
1746
+ {
1747
+ id: "ready-to-commit",
1748
+ searchKeywords: [
1749
+ "commit",
1750
+ "save changes",
1751
+ "checkpoint"
1752
+ ],
1753
+ confidence: .75,
1754
+ reason: "Execution stage with recent resolutions — good time to commit",
1755
+ matches(ctx) {
1756
+ if (!ctx.branch) return false;
1757
+ const inExecution = ctx.branch.arcStage === "execution";
1758
+ const hasResolutions = ctx.recentClassifications.some((c) => c === "resolution" || c === "success");
1759
+ const recentSuccesses = ctx.recentClassifications.filter((c) => c === "success" || c === "resolution").length;
1760
+ return inExecution && hasResolutions && recentSuccesses >= 2;
1761
+ }
1762
+ },
1763
+ {
1764
+ id: "verify-work",
1765
+ searchKeywords: [
1766
+ "verify",
1767
+ "validate",
1768
+ "test",
1769
+ "acceptance",
1770
+ "UAT"
1771
+ ],
1772
+ confidence: .7,
1773
+ reason: "Feature branch in verification stage",
1774
+ matches(ctx) {
1775
+ if (!ctx.branch) return false;
1776
+ return ctx.branch.branchType === "feature" && ctx.branch.arcStage === "verification";
1777
+ }
1778
+ },
1779
+ {
1780
+ id: "resume-debugging",
1781
+ searchKeywords: [
1782
+ "debug",
1783
+ "continue debugging",
1784
+ "resume investigation"
1785
+ ],
1786
+ confidence: .75,
1787
+ reason: "Active debug path with multiple errors detected",
1788
+ matches(ctx) {
1789
+ if (!ctx.branch || !ctx.debugPath) return false;
1790
+ const inInvestigation = ctx.branch.arcStage === "investigation" || ctx.branch.arcStage === "diagnosis";
1791
+ return ctx.debugPath.status === "active" && inInvestigation && ctx.debugPath.errorCount >= 2;
1792
+ }
1793
+ },
1794
+ {
1795
+ id: "check-progress",
1796
+ searchKeywords: [
1797
+ "progress",
1798
+ "status",
1799
+ "milestone",
1800
+ "overview"
1801
+ ],
1802
+ confidence: .65,
1803
+ reason: "Extended execution — consider reviewing progress",
1804
+ matches(ctx) {
1805
+ if (!ctx.branch) return false;
1806
+ return ctx.branch.arcStage === "execution" && ctx.branch.observationCount >= 10;
1807
+ }
1808
+ }
1809
+ ];
1810
+ /**
1811
+ * Searches suggestable tools for the best match against a set of keywords.
1812
+ * Checks trigger_hints, description, and name for substring matches.
1813
+ *
1814
+ * This is a lightweight in-memory scan, not a DB query.
1815
+ */
1816
+ function findMatchingTool(keywords, suggestableTools) {
1817
+ let best = null;
1818
+ for (const tool of suggestableTools) {
1819
+ const searchText = [
1820
+ tool.trigger_hints ?? "",
1821
+ tool.description ?? "",
1822
+ tool.name
1823
+ ].join(" ").toLowerCase();
1824
+ let matchCount = 0;
1825
+ for (const keyword of keywords) if (searchText.includes(keyword.toLowerCase())) matchCount++;
1826
+ if (matchCount === 0) continue;
1827
+ const relevance = matchCount / keywords.length;
1828
+ if (!best || relevance > best.relevance) best = {
1829
+ tool,
1830
+ relevance
1831
+ };
1832
+ }
1833
+ return best;
1834
+ }
1835
+ /**
1836
+ * Evaluates proactive suggestions by matching context rules against available tools.
1837
+ *
1838
+ * Returns the highest-confidence match (rule confidence * tool relevance) that
1839
+ * exceeds the threshold, or null if nothing qualifies.
1840
+ */
1841
+ function evaluateProactiveSuggestions(ctx, suggestableTools, threshold) {
1842
+ let bestSuggestion = null;
1843
+ let bestScore = 0;
1844
+ for (const rule of CONTEXT_RULES) try {
1845
+ if (!rule.matches(ctx)) continue;
1846
+ const toolMatch = findMatchingTool(rule.searchKeywords, suggestableTools);
1847
+ if (!toolMatch) continue;
1848
+ const combinedScore = rule.confidence * toolMatch.relevance;
1849
+ if (combinedScore > bestScore && combinedScore >= threshold) {
1850
+ bestScore = combinedScore;
1851
+ bestSuggestion = {
1852
+ toolName: toolMatch.tool.name,
1853
+ toolDescription: toolMatch.tool.description,
1854
+ confidence: combinedScore,
1855
+ tier: "proactive",
1856
+ reason: rule.reason
1857
+ };
1858
+ }
1859
+ } catch (err) {
1860
+ debug("proactive", `Rule ${rule.id} failed`, { error: String(err) });
1861
+ }
1862
+ return bestSuggestion;
1863
+ }
1864
+
1865
+ //#endregion
1866
+ //#region src/routing/heuristic-fallback.ts
1867
+ /**
1868
+ * Stop words filtered from keyword extraction.
1869
+ * Common English function words that carry no discriminative signal for tool matching.
1870
+ */
1871
+ const STOP_WORDS = new Set([
1872
+ "the",
1873
+ "a",
1874
+ "an",
1875
+ "is",
1876
+ "are",
1877
+ "was",
1878
+ "were",
1879
+ "be",
1880
+ "been",
1881
+ "being",
1882
+ "have",
1883
+ "has",
1884
+ "had",
1885
+ "do",
1886
+ "does",
1887
+ "did",
1888
+ "will",
1889
+ "would",
1890
+ "could",
1891
+ "should",
1892
+ "may",
1893
+ "might",
1894
+ "can",
1895
+ "shall",
1896
+ "to",
1897
+ "of",
1898
+ "in",
1899
+ "for",
1900
+ "on",
1901
+ "with",
1902
+ "at",
1903
+ "by",
1904
+ "from",
1905
+ "as",
1906
+ "into",
1907
+ "through",
1908
+ "and",
1909
+ "but",
1910
+ "or",
1911
+ "nor",
1912
+ "not",
1913
+ "so",
1914
+ "yet",
1915
+ "this",
1916
+ "that",
1917
+ "these",
1918
+ "those",
1919
+ "it",
1920
+ "its"
1921
+ ]);
1922
+ /**
1923
+ * Tokenizes text into lowercase keywords for matching.
1924
+ *
1925
+ * Replaces non-alphanumeric characters (except hyphens and underscores) with spaces,
1926
+ * splits on whitespace, filters words shorter than 3 characters and stop words,
1927
+ * and returns unique keywords.
1928
+ */
1929
+ function extractKeywords(text) {
1930
+ const words = text.toLowerCase().replace(/[^a-z0-9\s\-_]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
1931
+ return [...new Set(words)];
1932
+ }
1933
+ /**
1934
+ * Extracts keywords from a tool's description, server name, and parsed name.
1935
+ *
1936
+ * - Description text is tokenized via extractKeywords
1937
+ * - Server name is added as a keyword (lowercase)
1938
+ * - Slash commands are parsed by splitting on `:`, `-`, `_`
1939
+ * - Skills are parsed by splitting on `-` and `_`
1940
+ *
1941
+ * Returns a deduplicated array of keywords.
1942
+ */
1943
+ function extractToolKeywords(tool) {
1944
+ const sources = [];
1945
+ if (tool.description) sources.push(...extractKeywords(tool.description));
1946
+ if (tool.server_name) sources.push(tool.server_name.toLowerCase());
1947
+ if (tool.tool_type === "slash_command") {
1948
+ const parts = tool.name.replace(/^\//, "").split(/[:\-_]/).filter((p) => p.length > 0);
1949
+ sources.push(...parts.map((p) => p.toLowerCase()));
1950
+ }
1951
+ if (tool.tool_type === "skill") {
1952
+ const parts = tool.name.split(/[\-_]/).filter((p) => p.length > 0);
1953
+ sources.push(...parts.map((p) => p.toLowerCase()));
1954
+ }
1955
+ return [...new Set(sources)];
1956
+ }
1957
+ /**
1958
+ * Evaluates heuristic keyword matching between recent observations and available tools.
1959
+ *
1960
+ * This is the cold-start routing tier (ROUT-04). It works with zero accumulated usage
1961
+ * history by matching keywords from recent session observations against tool descriptions
1962
+ * and names.
1963
+ *
1964
+ * Returns the highest-confidence match above the threshold, or null if no match qualifies.
1965
+ *
1966
+ * @param recentObservations - Recent observation content strings from the current session
1967
+ * @param suggestableTools - Scope-filtered, non-builtin, non-Laminark tools
1968
+ * @param confidenceThreshold - Minimum score to return a suggestion (0.0-1.0)
1969
+ */
1970
+ function evaluateHeuristic(recentObservations, suggestableTools, confidenceThreshold) {
1971
+ if (recentObservations.length < 2) return null;
1972
+ const contextKeywords = new Set(recentObservations.flatMap((obs) => extractKeywords(obs)));
1973
+ if (contextKeywords.size === 0) return null;
1974
+ let bestMatch = null;
1975
+ for (const tool of suggestableTools) {
1976
+ const toolKeywords = extractToolKeywords(tool);
1977
+ if (toolKeywords.length === 0) continue;
1978
+ const score = toolKeywords.filter((kw) => contextKeywords.has(kw)).length / toolKeywords.length;
1979
+ if (score > (bestMatch?.score ?? 0)) bestMatch = {
1980
+ tool,
1981
+ score
1982
+ };
1983
+ }
1984
+ if (!bestMatch || bestMatch.score < confidenceThreshold) return null;
1985
+ return {
1986
+ toolName: bestMatch.tool.name,
1987
+ toolDescription: bestMatch.tool.description,
1988
+ confidence: bestMatch.score,
1989
+ tier: "heuristic",
1990
+ reason: "Keywords match between current work and tool description"
1991
+ };
1992
+ }
1993
+
1994
+ //#endregion
1995
+ //#region src/routing/conversation-router.ts
1996
+ /**
1997
+ * ConversationRouter orchestrates tool suggestion routing.
1998
+ *
1999
+ * Combines three tiers of suggestion:
2000
+ * - Proactive suggestions: context-aware trigger hint matching (ROUT-05)
2001
+ * - Learned patterns: historical tool sequence matching (ROUT-01)
2002
+ * - Heuristic fallback: keyword-based cold-start matching (ROUT-04)
2003
+ *
2004
+ * Suggestions are gated by confidence threshold (ROUT-03) and rate limits,
2005
+ * then delivered via NotificationStore (ROUT-02).
2006
+ *
2007
+ * Instantiated per-evaluation in the PostToolUse handler. No long-lived state --
2008
+ * state persists across invocations via the routing_state SQLite table.
2009
+ */
2010
+ var ConversationRouter = class {
2011
+ db;
2012
+ projectHash;
2013
+ config;
2014
+ constructor(db, projectHash, config) {
2015
+ this.db = db;
2016
+ this.projectHash = projectHash;
2017
+ this.config = {
2018
+ ...DEFAULT_ROUTING_CONFIG,
2019
+ ...config
2020
+ };
2021
+ db.exec(`
2022
+ CREATE TABLE IF NOT EXISTS routing_state (
2023
+ session_id TEXT NOT NULL,
2024
+ project_hash TEXT NOT NULL,
2025
+ suggestions_made INTEGER NOT NULL DEFAULT 0,
2026
+ last_suggestion_at TEXT,
2027
+ tool_calls_since_suggestion INTEGER NOT NULL DEFAULT 0,
2028
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2029
+ PRIMARY KEY (session_id, project_hash)
2030
+ )
2031
+ `);
2032
+ }
2033
+ /**
2034
+ * Evaluates whether a tool suggestion should be surfaced for the current context.
2035
+ *
2036
+ * Called from PostToolUse handler after observation storage.
2037
+ * Runs AFTER the self-referential filter -- never evaluates Laminark's own tools.
2038
+ *
2039
+ * The entire method is wrapped in try/catch -- routing is supplementary
2040
+ * and must NEVER block or fail the core handler pipeline.
2041
+ *
2042
+ * @param sessionId - Current session identifier
2043
+ * @param toolName - The tool just used
2044
+ * @param toolRegistry - Tool registry for availability checking
2045
+ */
2046
+ evaluate(sessionId, toolName, toolRegistry) {
2047
+ try {
2048
+ this._evaluate(sessionId, toolName, toolRegistry);
2049
+ } catch (err) {
2050
+ debug("routing", "Routing evaluation failed (non-fatal)", { error: err instanceof Error ? err.message : String(err) });
2051
+ }
2052
+ }
2053
+ _evaluate(sessionId, toolName, toolRegistry) {
2054
+ if (inferToolType(toolName) === "builtin") return;
2055
+ if (isLaminarksOwnTool(toolName)) return;
2056
+ const state = this.getOrCreateState(sessionId);
2057
+ state.toolCallsSinceSuggestion++;
2058
+ this.updateState(sessionId, state);
2059
+ if (state.suggestionsMade >= this.config.maxSuggestionsPerSession) {
2060
+ debug("routing", "Rate limited: max suggestions reached", {
2061
+ sessionId,
2062
+ made: state.suggestionsMade
2063
+ });
2064
+ return;
2065
+ }
2066
+ if (state.toolCallsSinceSuggestion < this.config.suggestionCooldown) {
2067
+ debug("routing", "Rate limited: cooldown active", {
2068
+ sessionId,
2069
+ callsSince: state.toolCallsSinceSuggestion,
2070
+ cooldown: this.config.suggestionCooldown
2071
+ });
2072
+ return;
2073
+ }
2074
+ const totalCalls = this.getTotalCallsForSession(sessionId);
2075
+ if (totalCalls < this.config.minCallsBeforeFirstSuggestion) {
2076
+ debug("routing", "Too early: not enough tool calls", {
2077
+ sessionId,
2078
+ totalCalls
2079
+ });
2080
+ return;
2081
+ }
2082
+ const suggestableTools = toolRegistry.getAvailableForSession(this.projectHash).filter((t) => t.tool_type !== "builtin" && !isLaminarksOwnTool(t.name) && t.status === "active");
2083
+ if (suggestableTools.length === 0) return;
2084
+ const suggestableNames = new Set(suggestableTools.map((t) => t.name));
2085
+ let suggestion = null;
2086
+ suggestion = evaluateProactiveSuggestions(loadContextSnapshot(this.db, this.projectHash, sessionId), suggestableTools, this.config.confidenceThreshold);
2087
+ if (!suggestion) {
2088
+ if (this.countRecentEvents() >= this.config.minEventsForLearned) suggestion = evaluateLearnedPatterns(this.db, sessionId, this.projectHash, suggestableNames, this.config.confidenceThreshold);
2089
+ }
2090
+ if (!suggestion) suggestion = evaluateHeuristic(this.getRecentObservations(sessionId), suggestableTools, this.config.confidenceThreshold);
2091
+ if (!suggestion) return;
2092
+ if (suggestion.confidence < this.config.confidenceThreshold) return;
2093
+ const notifStore = new NotificationStore(this.db);
2094
+ let message;
2095
+ if (suggestion.tier === "proactive") message = `[Laminark suggests] ${suggestion.reason} -- try ${suggestion.toolName}`;
2096
+ else {
2097
+ const description = suggestion.toolDescription ? ` -- ${suggestion.toolDescription}` : "";
2098
+ const usageHint = suggestion.tier === "learned" ? ` (${suggestion.reason})` : "";
2099
+ message = `Tool suggestion: ${suggestion.toolName}${description}${usageHint}`;
2100
+ }
2101
+ notifStore.add(this.projectHash, message);
2102
+ debug("routing", "Suggestion delivered", {
2103
+ tool: suggestion.toolName,
2104
+ tier: suggestion.tier,
2105
+ confidence: suggestion.confidence
2106
+ });
2107
+ state.suggestionsMade++;
2108
+ state.lastSuggestionAt = (/* @__PURE__ */ new Date()).toISOString();
2109
+ state.toolCallsSinceSuggestion = 0;
2110
+ this.updateState(sessionId, state);
2111
+ }
2112
+ /**
2113
+ * Gets or creates routing state for a session.
2114
+ */
2115
+ getOrCreateState(sessionId) {
2116
+ const row = this.db.prepare(`
2117
+ SELECT suggestions_made, last_suggestion_at, tool_calls_since_suggestion
2118
+ FROM routing_state
2119
+ WHERE session_id = ? AND project_hash = ?
2120
+ `).get(sessionId, this.projectHash);
2121
+ if (row) return {
2122
+ suggestionsMade: row.suggestions_made,
2123
+ lastSuggestionAt: row.last_suggestion_at,
2124
+ toolCallsSinceSuggestion: row.tool_calls_since_suggestion
2125
+ };
2126
+ this.db.prepare(`
2127
+ INSERT INTO routing_state (session_id, project_hash, suggestions_made, tool_calls_since_suggestion)
2128
+ VALUES (?, ?, 0, 0)
2129
+ `).run(sessionId, this.projectHash);
2130
+ return {
2131
+ suggestionsMade: 0,
2132
+ lastSuggestionAt: null,
2133
+ toolCallsSinceSuggestion: 0
2134
+ };
2135
+ }
2136
+ /**
2137
+ * Updates routing state in the database.
2138
+ */
2139
+ updateState(sessionId, state) {
2140
+ this.db.prepare(`
2141
+ UPDATE routing_state
2142
+ SET suggestions_made = ?, last_suggestion_at = ?, tool_calls_since_suggestion = ?
2143
+ WHERE session_id = ? AND project_hash = ?
2144
+ `).run(state.suggestionsMade, state.lastSuggestionAt, state.toolCallsSinceSuggestion, sessionId, this.projectHash);
2145
+ }
2146
+ /**
2147
+ * Returns total tool calls for the current session (from routing_state).
2148
+ */
2149
+ getTotalCallsForSession(sessionId) {
2150
+ return this.db.prepare(`
2151
+ SELECT COUNT(*) as count FROM tool_usage_events
2152
+ WHERE session_id = ? AND project_hash = ?
2153
+ `).get(sessionId, this.projectHash).count;
2154
+ }
2155
+ /**
2156
+ * Counts total tool_usage_events for this project (for learned pattern threshold).
2157
+ */
2158
+ countRecentEvents() {
2159
+ return this.db.prepare(`
2160
+ SELECT COUNT(*) as count FROM tool_usage_events WHERE project_hash = ?
2161
+ `).get(this.projectHash).count;
2162
+ }
2163
+ /**
2164
+ * Gets recent observation content strings for heuristic matching.
2165
+ */
2166
+ getRecentObservations(sessionId) {
2167
+ return this.db.prepare(`
2168
+ SELECT content FROM observations
2169
+ WHERE project_hash = ? AND session_id = ? AND deleted_at IS NULL
2170
+ ORDER BY created_at DESC
2171
+ LIMIT 5
2172
+ `).all(this.projectHash, sessionId).map((r) => r.content);
2173
+ }
2174
+ };
2175
+
2176
+ //#endregion
2177
+ //#region src/hooks/handler.ts
2178
+ /**
2179
+ * Hook handler entry point.
2180
+ *
2181
+ * This file is the CLI entry point for all Claude Code hook events.
2182
+ * It reads stdin JSON, opens a direct SQLite connection (no HTTP intermediary),
2183
+ * dispatches to the appropriate handler based on hook_event_name, and exits 0.
2184
+ *
2185
+ * CRITICAL CONSTRAINTS:
2186
+ * - Only SessionStart and PreToolUse write to stdout (synchronous hooks -- stdout is injected into Claude's context window)
2187
+ * - All other hooks NEVER write to stdout (stdout output is interpreted by Claude Code)
2188
+ * - ALWAYS exits 0 (non-zero exit codes surface as errors to Claude)
2189
+ * - Opens its own database connection (WAL mode handles concurrent access with MCP server)
2190
+ * - Imports only storage modules -- NO @modelcontextprotocol/sdk (cold start overhead)
2191
+ *
2192
+ * Filter pipeline (PostToolUse/PostToolUseFailure):
2193
+ * 0. Organic tool discovery (DISC-05: records ALL tools including Laminark's own)
2194
+ * 1. Self-referential filter (dual-prefix: mcp__laminark__ and mcp__plugin_laminark_laminark__)
2195
+ * 2. Extract observation text from payload
2196
+ * 3. Privacy filter: exclude sensitive files, redact secrets
2197
+ * 4. Admission filter: reject noise content
2198
+ * 5. Store to database
2199
+ */
2200
+ async function readStdin() {
2201
+ const chunks = [];
2202
+ for await (const chunk of process.stdin) chunks.push(chunk);
2203
+ return Buffer.concat(chunks).toString("utf-8");
2204
+ }
2205
+ /**
2206
+ * Tools that are routed to the research buffer instead of creating observations.
2207
+ * These are high-volume exploration tools whose individual calls are noise,
2208
+ * but whose targets provide useful provenance context for subsequent changes.
2209
+ */
2210
+ const RESEARCH_TOOLS = new Set([
2211
+ "Read",
2212
+ "Glob",
2213
+ "Grep"
2214
+ ]);
2215
+ /**
2216
+ * Processes a PostToolUse or PostToolUseFailure event through the full
2217
+ * filter pipeline: route research tools -> extract -> privacy -> admission -> store.
2218
+ *
2219
+ * Exported for unit testing of the pipeline logic.
2220
+ */
2221
+ function processPostToolUseFiltered(input, obsRepo, researchBuffer, toolRegistry, projectHash, db) {
2222
+ const toolName = input.tool_name;
2223
+ const hookEventName = input.hook_event_name;
2224
+ if (!toolName) {
2225
+ debug("hook", "PostToolUse missing tool_name, skipping");
2226
+ return;
2227
+ }
2228
+ if (toolRegistry) try {
2229
+ const sessionId = input.session_id;
2230
+ const isFailure = hookEventName === "PostToolUseFailure";
2231
+ toolRegistry.recordOrCreate(toolName, {
2232
+ toolType: inferToolType(toolName),
2233
+ scope: inferScope(toolName),
2234
+ source: "hook:PostToolUse",
2235
+ projectHash: projectHash ?? null,
2236
+ description: null,
2237
+ serverName: extractServerName(toolName),
2238
+ triggerHints: null
2239
+ }, sessionId ?? null, !isFailure);
2240
+ if (isFailure) {
2241
+ const failures = toolRegistry.getRecentEventsForTool(toolName, projectHash ?? "", 5).filter((e) => e.success === 0).length;
2242
+ if (failures >= 3) {
2243
+ toolRegistry.markDemoted(toolName, projectHash ?? null);
2244
+ debug("hook", "Tool demoted due to failures", {
2245
+ tool: toolName,
2246
+ failures
2247
+ });
2248
+ }
2249
+ } else toolRegistry.markActive(toolName, projectHash ?? null);
2250
+ } catch {}
2251
+ if (isLaminarksOwnTool(toolName)) {
2252
+ debug("hook", "Skipping self-referential tool", { tool: toolName });
2253
+ return;
2254
+ }
2255
+ const toolInput = input.tool_input ?? {};
2256
+ const filePath = toolInput.file_path;
2257
+ if (filePath && isExcludedFile(filePath)) {
2258
+ debug("hook", "Observation excluded (sensitive file)", {
2259
+ tool: toolName,
2260
+ filePath
2261
+ });
2262
+ return;
2263
+ }
2264
+ if (RESEARCH_TOOLS.has(toolName) && researchBuffer) {
2265
+ const target = String(toolInput.file_path ?? toolInput.pattern ?? "");
2266
+ researchBuffer.add({
2267
+ sessionId: input.session_id ?? null,
2268
+ toolName,
2269
+ target
2270
+ });
2271
+ return;
2272
+ }
2273
+ if (toolName === "Bash" && hookEventName !== "PostToolUseFailure") {
2274
+ const command = String(toolInput.command ?? "");
2275
+ if (!isMeaningfulBashCommand(command)) {
2276
+ debug("hook", "Bash command filtered as navigation", { command: command.slice(0, 60) });
2277
+ return;
2278
+ }
2279
+ }
2280
+ const payload = {
2281
+ session_id: input.session_id,
2282
+ cwd: input.cwd,
2283
+ hook_event_name: input.hook_event_name,
2284
+ tool_name: toolName,
2285
+ tool_input: toolInput,
2286
+ tool_response: input.tool_response,
2287
+ tool_use_id: input.tool_use_id
2288
+ };
2289
+ const summary = extractObservation(payload);
2290
+ if (summary === null) {
2291
+ debug("hook", "No observation extracted", { tool: toolName });
2292
+ return;
2293
+ }
2294
+ let redacted = redactSensitiveContent(summary, filePath);
2295
+ if (redacted === null) {
2296
+ debug("hook", "Observation excluded by privacy filter", { tool: toolName });
2297
+ return;
2298
+ }
2299
+ if ((toolName === "Write" || toolName === "Edit") && researchBuffer && payload.session_id) {
2300
+ const research = researchBuffer.getRecent(payload.session_id, 5);
2301
+ if (research.length > 0) {
2302
+ const lines = research.map((r) => ` - [${r.toolName}] ${r.target}`).join("\n");
2303
+ redacted += `\nResearch context:\n${lines}`;
2304
+ }
2305
+ }
2306
+ if (!shouldAdmit(toolName, redacted)) {
2307
+ debug("hook", "Observation rejected by admission filter", { tool: toolName });
2308
+ return;
2309
+ }
2310
+ const decision = new SaveGuard(obsRepo).evaluateSync(redacted, "hook:" + toolName);
2311
+ if (!decision.save) {
2312
+ debug("hook", "Observation rejected by save guard", {
2313
+ tool: toolName,
2314
+ reason: decision.reason,
2315
+ duplicateOf: decision.duplicateOf
2316
+ });
2317
+ return;
2318
+ }
2319
+ let kind = "finding";
2320
+ if (toolName === "Write" || toolName === "Edit") kind = "change";
2321
+ else if (toolName === "WebFetch" || toolName === "WebSearch") kind = "reference";
2322
+ else if (toolName === "Bash") {
2323
+ const command = String(toolInput.command ?? "");
2324
+ if (/^git\s+(commit|push|merge|rebase|cherry-pick)\b/.test(command.trim())) kind = "change";
2325
+ else kind = "verification";
2326
+ }
2327
+ obsRepo.create({
2328
+ content: redacted,
2329
+ source: "hook:" + toolName,
2330
+ kind,
2331
+ sessionId: payload.session_id ?? null
2332
+ });
2333
+ debug("hook", "Captured observation", {
2334
+ tool: toolName,
2335
+ kind,
2336
+ length: redacted.length
2337
+ });
2338
+ if (db && toolRegistry && projectHash) try {
2339
+ const sessionId = input.session_id;
2340
+ if (sessionId) new ConversationRouter(db, projectHash).evaluate(sessionId, toolName, toolRegistry);
2341
+ } catch {}
2342
+ }
2343
+ async function main() {
2344
+ const raw = await readStdin();
2345
+ const input = JSON.parse(raw);
2346
+ const eventName = input.hook_event_name;
2347
+ const cwd = input.cwd;
2348
+ if (!eventName || !cwd) {
2349
+ debug("hook", "Missing hook_event_name or cwd in input");
2350
+ return;
2351
+ }
2352
+ const projectHash = getProjectHash(cwd);
2353
+ debug("hook", "Processing hook event", {
2354
+ eventName,
2355
+ projectHash
2356
+ });
2357
+ const laminarkDb = openDatabase(getDatabaseConfig());
2358
+ try {
2359
+ const obsRepo = new ObservationRepository(laminarkDb.db, projectHash);
2360
+ const sessionRepo = new SessionRepository(laminarkDb.db, projectHash);
2361
+ let researchBuffer;
2362
+ try {
2363
+ researchBuffer = new ResearchBufferRepository(laminarkDb.db, projectHash);
2364
+ } catch {}
2365
+ let toolRegistry;
2366
+ try {
2367
+ toolRegistry = new ToolRegistryRepository(laminarkDb.db);
2368
+ } catch {}
2369
+ let pathRepo;
2370
+ try {
2371
+ initPathSchema(laminarkDb.db);
2372
+ pathRepo = new PathRepository(laminarkDb.db, projectHash);
2373
+ } catch {}
2374
+ let branchRepo;
2375
+ try {
2376
+ branchRepo = new BranchRepository(laminarkDb.db, projectHash);
2377
+ } catch {}
2378
+ switch (eventName) {
2379
+ case "PreToolUse": {
2380
+ const preContext = handlePreToolUse(input, laminarkDb.db, projectHash, pathRepo);
2381
+ if (preContext) process.stdout.write(preContext);
2382
+ break;
2383
+ }
2384
+ case "PostToolUse":
2385
+ case "PostToolUseFailure":
2386
+ processPostToolUseFiltered(input, obsRepo, researchBuffer, toolRegistry, projectHash, laminarkDb.db);
2387
+ break;
2388
+ case "SessionStart": {
2389
+ const context = handleSessionStart(input, sessionRepo, laminarkDb.db, projectHash, toolRegistry, pathRepo, branchRepo);
2390
+ if (context) process.stdout.write(context);
2391
+ break;
2392
+ }
2393
+ case "SessionEnd":
2394
+ handleSessionEnd(input, sessionRepo);
2395
+ break;
2396
+ case "Stop":
2397
+ handleStop(input, obsRepo, sessionRepo, laminarkDb.db, projectHash);
2398
+ break;
2399
+ default:
2400
+ debug("hook", "Unknown hook event", { eventName });
2401
+ break;
2402
+ }
2403
+ } finally {
2404
+ laminarkDb.close();
2405
+ }
2406
+ }
2407
+ main().catch((err) => {
2408
+ debug("hook", "Hook handler error", { error: err.message });
2409
+ });
2410
+
2411
+ //#endregion
2412
+ export { processPostToolUseFiltered };
2413
+ //# sourceMappingURL=handler.js.map