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