clawmem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +660 -0
  2. package/CLAUDE.md +660 -0
  3. package/LICENSE +21 -0
  4. package/README.md +993 -0
  5. package/SKILL.md +717 -0
  6. package/bin/clawmem +75 -0
  7. package/package.json +72 -0
  8. package/src/amem.ts +797 -0
  9. package/src/beads.ts +263 -0
  10. package/src/clawmem.ts +1849 -0
  11. package/src/collections.ts +405 -0
  12. package/src/config.ts +178 -0
  13. package/src/consolidation.ts +123 -0
  14. package/src/directory-context.ts +248 -0
  15. package/src/errors.ts +41 -0
  16. package/src/formatter.ts +427 -0
  17. package/src/graph-traversal.ts +247 -0
  18. package/src/hooks/context-surfacing.ts +317 -0
  19. package/src/hooks/curator-nudge.ts +89 -0
  20. package/src/hooks/decision-extractor.ts +639 -0
  21. package/src/hooks/feedback-loop.ts +214 -0
  22. package/src/hooks/handoff-generator.ts +345 -0
  23. package/src/hooks/postcompact-inject.ts +226 -0
  24. package/src/hooks/precompact-extract.ts +314 -0
  25. package/src/hooks/pretool-inject.ts +79 -0
  26. package/src/hooks/session-bootstrap.ts +324 -0
  27. package/src/hooks/staleness-check.ts +130 -0
  28. package/src/hooks.ts +367 -0
  29. package/src/indexer.ts +327 -0
  30. package/src/intent.ts +294 -0
  31. package/src/limits.ts +26 -0
  32. package/src/llm.ts +1175 -0
  33. package/src/mcp.ts +2138 -0
  34. package/src/memory.ts +336 -0
  35. package/src/mmr.ts +93 -0
  36. package/src/observer.ts +269 -0
  37. package/src/openclaw/engine.ts +283 -0
  38. package/src/openclaw/index.ts +221 -0
  39. package/src/openclaw/plugin.json +83 -0
  40. package/src/openclaw/shell.ts +207 -0
  41. package/src/openclaw/tools.ts +304 -0
  42. package/src/profile.ts +346 -0
  43. package/src/promptguard.ts +218 -0
  44. package/src/retrieval-gate.ts +106 -0
  45. package/src/search-utils.ts +127 -0
  46. package/src/server.ts +783 -0
  47. package/src/splitter.ts +325 -0
  48. package/src/store.ts +4062 -0
  49. package/src/validation.ts +67 -0
  50. package/src/watcher.ts +58 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * PostCompact inject hook — re-injects ClawMem context after compaction.
3
+ *
4
+ * Fires via SessionStart with matcher "compact". Reads the precompact-state.md
5
+ * file (written by precompact-extract), loads recent decisions from the vault,
6
+ * and injects authoritative context to compensate for summarization losses.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from "fs";
10
+ import { join, resolve } from "path";
11
+ import {
12
+ type HookInput,
13
+ type HookOutput,
14
+ makeContextOutput,
15
+ makeEmptyOutput,
16
+ estimateTokens,
17
+ smartTruncate,
18
+ } from "../hooks.ts";
19
+ import type { Store } from "../store.ts";
20
+ import { extractSnippet } from "../store.ts";
21
+ import { sanitizeSnippet } from "../promptguard.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const MAX_TOKEN_BUDGET = 1200;
28
+ const PRECOMPACT_STATE_BUDGET = 600;
29
+ const DECISIONS_BUDGET = 400;
30
+ const VAULT_CONTEXT_BUDGET = 200;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Auto-memory path discovery (same logic as precompact-extract)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function getAutoMemoryDir(transcriptPath?: string): string | null {
37
+ // Derive from transcript_path: ~/.claude/projects/<project-dir>/<session>.jsonl
38
+ if (transcriptPath) {
39
+ const projectDir = resolve(transcriptPath, "..");
40
+ const memDir = join(projectDir, "memory");
41
+ if (existsSync(memDir)) return memDir;
42
+ }
43
+
44
+ // Fallback: CWD-based lookup
45
+ const cwd = process.cwd();
46
+ const sanitized = cwd.replace(/\//g, "-").replace(/^-/, "");
47
+ const memDir = join(
48
+ process.env.HOME || "/tmp",
49
+ ".claude",
50
+ "projects",
51
+ sanitized,
52
+ "memory"
53
+ );
54
+ if (existsSync(memDir)) return memDir;
55
+
56
+ return null;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Main hook
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export async function postcompactInject(
64
+ store: Store,
65
+ input: HookInput
66
+ ): Promise<HookOutput> {
67
+ const sections: string[] = [];
68
+ let totalTokens = 0;
69
+
70
+ // Section 1: Precompact state (if available)
71
+ const memDir = getAutoMemoryDir(input.transcriptPath);
72
+ if (memDir) {
73
+ const statePath = join(memDir, "precompact-state.md");
74
+ if (existsSync(statePath)) {
75
+ try {
76
+ let stateContent = readFileSync(statePath, "utf-8").trim();
77
+ const stateTokens = estimateTokens(stateContent);
78
+
79
+ if (stateTokens > PRECOMPACT_STATE_BUDGET) {
80
+ stateContent = smartTruncate(stateContent, PRECOMPACT_STATE_BUDGET * 4);
81
+ }
82
+
83
+ if (stateContent.length > 0) {
84
+ sections.push(stateContent);
85
+ totalTokens += Math.min(stateTokens, PRECOMPACT_STATE_BUDGET);
86
+ }
87
+ } catch {
88
+ // ignore read errors
89
+ }
90
+ }
91
+ }
92
+
93
+ // Section 2: Recent decisions from vault (last 7 days)
94
+ if (totalTokens < MAX_TOKEN_BUDGET) {
95
+ try {
96
+ const cutoff = new Date();
97
+ cutoff.setDate(cutoff.getDate() - 7);
98
+ const recentDocs = store.getDocumentsByType("decision", 5);
99
+
100
+ const recentDecisions = recentDocs.filter(
101
+ (d) => d.modifiedAt && d.modifiedAt >= cutoff.toISOString()
102
+ );
103
+
104
+ if (recentDecisions.length > 0) {
105
+ const decisionLines: string[] = ["## Recent Decisions (from vault)", ""];
106
+
107
+ let budgetLeft = DECISIONS_BUDGET;
108
+ for (const doc of recentDecisions) {
109
+ const line = `- **${doc.title}** (${doc.modifiedAt?.slice(0, 10)})`;
110
+ const lineTokens = estimateTokens(line);
111
+ if (budgetLeft - lineTokens < 0) break;
112
+ decisionLines.push(line);
113
+ budgetLeft -= lineTokens;
114
+ }
115
+
116
+ if (decisionLines.length > 2) {
117
+ sections.push(decisionLines.join("\n"));
118
+ totalTokens += DECISIONS_BUDGET - budgetLeft;
119
+ }
120
+ }
121
+ } catch {
122
+ // non-critical
123
+ }
124
+ }
125
+
126
+ // Section 2b: Recent antipatterns (last 7 days)
127
+ if (totalTokens < MAX_TOKEN_BUDGET) {
128
+ try {
129
+ const cutoff = new Date();
130
+ cutoff.setDate(cutoff.getDate() - 7);
131
+ const recentAnti = store.getDocumentsByType("antipattern", 3);
132
+ const filteredAnti = recentAnti.filter(
133
+ (d) => d.modifiedAt && d.modifiedAt >= cutoff.toISOString()
134
+ );
135
+
136
+ if (filteredAnti.length > 0) {
137
+ const antiLines: string[] = ["## Recent Antipatterns (avoid these)", ""];
138
+ let budgetLeft = 150; // small budget for antipatterns
139
+ for (const doc of filteredAnti) {
140
+ const line = `- **Avoid:** ${doc.title} (${doc.modifiedAt?.slice(0, 10)})`;
141
+ const lineTokens = estimateTokens(line);
142
+ if (budgetLeft - lineTokens < 0) break;
143
+ antiLines.push(line);
144
+ budgetLeft -= lineTokens;
145
+ }
146
+ if (antiLines.length > 2) {
147
+ sections.push(antiLines.join("\n"));
148
+ totalTokens += 150 - budgetLeft;
149
+ }
150
+ }
151
+ } catch {
152
+ // non-critical
153
+ }
154
+ }
155
+
156
+ // Section 3: Vault context search (if we have a last request to search for)
157
+ if (totalTokens < MAX_TOKEN_BUDGET && memDir) {
158
+ try {
159
+ const statePath = join(memDir, "precompact-state.md");
160
+ if (existsSync(statePath)) {
161
+ const stateContent = readFileSync(statePath, "utf-8");
162
+ // Extract the last user request from the state file
163
+ const requestMatch = stateContent.match(
164
+ /## Last User Request\n\n([\s\S]*?)(?:\n##|\n$)/
165
+ );
166
+ if (requestMatch?.[1]) {
167
+ const query = requestMatch[1].trim().slice(0, 200);
168
+ if (query.length > 10) {
169
+ const results = store.searchFTS(query, 3);
170
+ if (results.length > 0) {
171
+ const contextLines: string[] = ["## Relevant Vault Context", ""];
172
+ let budgetLeft = VAULT_CONTEXT_BUDGET;
173
+
174
+ for (const r of results) {
175
+ const snippet = sanitizeSnippet(extractSnippet(r.body || "", query, 150).snippet);
176
+ const line = `- **${sanitizeSnippet(r.title)}** (${r.displayPath}): ${snippet}`;
177
+ const lineTokens = estimateTokens(line);
178
+ if (budgetLeft - lineTokens < 0) break;
179
+ contextLines.push(line);
180
+ budgetLeft -= lineTokens;
181
+ }
182
+
183
+ if (contextLines.length > 2) {
184
+ sections.push(contextLines.join("\n"));
185
+ totalTokens += VAULT_CONTEXT_BUDGET - budgetLeft;
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ } catch {
192
+ // non-critical
193
+ }
194
+ }
195
+
196
+ // Nothing to inject
197
+ if (sections.length === 0) {
198
+ return makeEmptyOutput("postcompact-inject");
199
+ }
200
+
201
+ // Build final output with authoritative framing
202
+ const context = [
203
+ `<vault-postcompact>`,
204
+ `IMPORTANT: Context was just compacted. The following is authoritative`,
205
+ `and takes precedence over any paraphrased version in the compacted summary.`,
206
+ ``,
207
+ sections.join("\n\n---\n\n"),
208
+ `</vault-postcompact>`,
209
+ ].join("\n");
210
+
211
+ // Audit trail
212
+ try {
213
+ store.insertUsage({
214
+ sessionId: input.sessionId || "unknown",
215
+ timestamp: new Date().toISOString(),
216
+ hookName: "postcompact-inject",
217
+ injectedPaths: [],
218
+ estimatedTokens: estimateTokens(context),
219
+ wasReferenced: 0,
220
+ });
221
+ } catch {
222
+ // non-critical
223
+ }
224
+
225
+ return makeContextOutput("postcompact-inject", context);
226
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * PreCompact hook — extracts session state before auto-compaction.
3
+ *
4
+ * Reads the full uncompressed transcript, extracts decisions and working
5
+ * state via regex (no LLM calls), and writes a handoff file to Claude Code's
6
+ * auto-memory directory. This file survives compaction and is automatically
7
+ * reloaded by Claude Code.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
11
+ import { join, resolve } from "path";
12
+ import {
13
+ type HookInput,
14
+ type HookOutput,
15
+ makeEmptyOutput,
16
+ readTranscript,
17
+ validateTranscriptPath,
18
+ estimateTokens,
19
+ } from "../hooks.ts";
20
+ import type { Store } from "../store.ts";
21
+ import { extractDecisions } from "./decision-extractor.ts";
22
+ import { indexCollection } from "../indexer.ts";
23
+ import { loadConfig } from "../collections.ts";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Auto-memory path discovery
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function getAutoMemoryDir(transcriptPath?: string): string | null {
30
+ // Best source: derive from transcript_path which is already
31
+ // ~/.claude/projects/<project-dir>/<session>.jsonl
32
+ if (transcriptPath) {
33
+ const projectDir = resolve(transcriptPath, "..");
34
+ const memDir = join(projectDir, "memory");
35
+ if (existsSync(memDir)) return memDir;
36
+ // Create it if the project dir exists
37
+ if (existsSync(projectDir)) {
38
+ try {
39
+ mkdirSync(memDir, { recursive: true });
40
+ return memDir;
41
+ } catch { /* fall through */ }
42
+ }
43
+ }
44
+
45
+ // Fallback: CWD-based lookup
46
+ const cwd = process.cwd();
47
+ const sanitized = cwd.replace(/\//g, "-").replace(/^-/, "");
48
+ const memDir = join(
49
+ process.env.HOME || "/tmp",
50
+ ".claude",
51
+ "projects",
52
+ sanitized,
53
+ "memory"
54
+ );
55
+ if (existsSync(memDir)) return memDir;
56
+
57
+ return null;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // File path extraction from transcript
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const FILE_OP_PATTERNS = [
65
+ /(?:Read|Edit|Write|NotebookEdit)\s+(?:tool\s+)?(?:on\s+)?['"`]?([/~][^\s'"`,;]+)/gi,
66
+ /file_path['":\s]+([/~][^\s'"`,;]+)/gi,
67
+ /(?:Created|Modified|Wrote|Updated|Edited)\s+['"`]?([/~][^\s'"`,;]+)/gi,
68
+ ];
69
+
70
+ function extractFilePaths(messages: { role: string; content: string }[]): string[] {
71
+ const paths = new Set<string>();
72
+
73
+ for (const msg of messages) {
74
+ if (msg.role !== "assistant") continue;
75
+ for (const pattern of FILE_OP_PATTERNS) {
76
+ pattern.lastIndex = 0;
77
+ let match;
78
+ while ((match = pattern.exec(msg.content)) !== null) {
79
+ const p = match[1]!;
80
+ if (p.length > 10 && p.length < 300 && !p.includes("*")) {
81
+ paths.add(p);
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ return [...paths].slice(0, 30); // cap at 30
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Last user request extraction
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function getLastUserRequest(messages: { role: string; content: string }[]): string {
95
+ for (let i = messages.length - 1; i >= 0; i--) {
96
+ const msg = messages[i]!;
97
+ if (msg.role === "user" && msg.content.length > 10) {
98
+ return msg.content.slice(0, 500);
99
+ }
100
+ }
101
+ return "";
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Tool output pruning — strip verbose tool results from messages
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Prune verbose tool output blocks from message content.
110
+ * Keeps tool invocation lines but strips their large result payloads.
111
+ */
112
+ function pruneToolOutputs(content: string): string {
113
+ // Strip Read/Grep/Glob/Bash tool result blocks (multi-line outputs)
114
+ let pruned = content;
115
+
116
+ // Remove large indented tool output blocks (lines starting with spaces/tabs after tool mention)
117
+ pruned = pruned.replace(
118
+ /(?:Result of (?:calling |)(?:the )?(?:Read|Grep|Glob|Bash|Write|Edit|NotebookEdit) tool[^\n]*\n)(?:[ \t]+[^\n]*\n)*/gi,
119
+ ""
120
+ );
121
+
122
+ // Remove file content dumps (numbered lines like " 1→...")
123
+ pruned = pruned.replace(/(?:^ *\d+→[^\n]*\n){5,}/gm, "[file content pruned]\n");
124
+
125
+ return pruned;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Open question extraction (simple heuristics)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const QUESTION_PATTERNS = [
133
+ /\b(?:should we|do you want|which (?:approach|option)|how should|what about)\b[^.!]*\?/gi,
134
+ /\b(?:TODO|FIXME|HACK|open question|unresolved|needs?\s+(?:investigation|decision))\b[^.!]*/gi,
135
+ ];
136
+
137
+ function extractOpenQuestions(messages: { role: string; content: string }[]): string[] {
138
+ const questions: string[] = [];
139
+ const seen = new Set<string>();
140
+
141
+ // Look at last 20 messages for recency
142
+ const recent = messages.slice(-20);
143
+
144
+ for (const msg of recent) {
145
+ for (const pattern of QUESTION_PATTERNS) {
146
+ pattern.lastIndex = 0;
147
+ let match;
148
+ while ((match = pattern.exec(msg.content)) !== null) {
149
+ const q = match[0].trim();
150
+ const key = q.slice(0, 60).toLowerCase();
151
+ if (!seen.has(key) && q.length > 15 && q.length < 300) {
152
+ seen.add(key);
153
+ questions.push(q);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ return questions.slice(0, 10);
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Query-aware decision ranking (E9)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Rank decisions by relevance to the last user request.
168
+ * Decisions mentioning terms from the active task get priority.
169
+ */
170
+ function rankDecisionsByRelevance(
171
+ decisions: { text: string; context: string }[],
172
+ lastRequest: string
173
+ ): { text: string; context: string }[] {
174
+ if (!lastRequest || decisions.length <= 1) return decisions;
175
+
176
+ const queryTerms = lastRequest
177
+ .toLowerCase()
178
+ .split(/\s+/)
179
+ .filter(t => t.length > 3);
180
+
181
+ if (queryTerms.length === 0) return decisions;
182
+
183
+ return [...decisions].sort((a, b) => {
184
+ const aText = a.text.toLowerCase();
185
+ const bText = b.text.toLowerCase();
186
+ const aScore = queryTerms.filter(t => aText.includes(t)).length;
187
+ const bScore = queryTerms.filter(t => bText.includes(t)).length;
188
+ return bScore - aScore;
189
+ });
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Main hook
194
+ // ---------------------------------------------------------------------------
195
+
196
+ export async function precompactExtract(
197
+ store: Store,
198
+ input: HookInput
199
+ ): Promise<HookOutput> {
200
+ const transcriptPath = validateTranscriptPath(input.transcriptPath ?? "");
201
+ if (!transcriptPath) {
202
+ return makeEmptyOutput("precompact-extract");
203
+ }
204
+
205
+ const messages = readTranscript(transcriptPath, 200);
206
+ if (messages.length === 0) {
207
+ return makeEmptyOutput("precompact-extract");
208
+ }
209
+
210
+ // Prune verbose tool outputs before extraction (keeps messages focused)
211
+ const prunedMessages = messages.map(m => ({
212
+ ...m,
213
+ content: m.role === "assistant" ? pruneToolOutputs(m.content) : m.content,
214
+ }));
215
+
216
+ // Extract components (use pruned messages for decisions/questions, raw for file paths)
217
+ let decisions = extractDecisions(prunedMessages);
218
+ const lastRequest = getLastUserRequest(messages); // raw — need full user request
219
+ const filePaths = extractFilePaths(messages); // raw — need exact paths
220
+ const openQuestions = extractOpenQuestions(prunedMessages);
221
+
222
+ // Query-aware ranking: prioritize decisions relevant to the active task (E9)
223
+ decisions = rankDecisionsByRelevance(decisions, lastRequest);
224
+
225
+ // Skip if nothing meaningful extracted
226
+ if (decisions.length === 0 && !lastRequest && filePaths.length === 0) {
227
+ return makeEmptyOutput("precompact-extract");
228
+ }
229
+
230
+ // Build the handoff document
231
+ const now = new Date().toISOString();
232
+ const sections: string[] = [
233
+ `# Pre-Compaction State`,
234
+ ``,
235
+ `_Extracted ${now.slice(0, 19)} before auto-compaction. This is authoritative._`,
236
+ ``,
237
+ ];
238
+
239
+ if (lastRequest) {
240
+ sections.push(`## Last User Request`, ``, lastRequest, ``);
241
+ }
242
+
243
+ if (decisions.length > 0) {
244
+ sections.push(`## Key Decisions This Session`, ``);
245
+ for (const d of decisions.slice(0, 15)) {
246
+ sections.push(`- ${d.text}`);
247
+ if (d.context) {
248
+ sections.push(` > Context: ${d.context.slice(0, 150)}`);
249
+ }
250
+ }
251
+ sections.push(``);
252
+ }
253
+
254
+ if (openQuestions.length > 0) {
255
+ sections.push(`## Open Questions / Unresolved`, ``);
256
+ for (const q of openQuestions) {
257
+ sections.push(`- ${q}`);
258
+ }
259
+ sections.push(``);
260
+ }
261
+
262
+ if (filePaths.length > 0) {
263
+ sections.push(`## Files Modified This Session`, ``);
264
+ for (const p of filePaths) {
265
+ sections.push(`- ${p}`);
266
+ }
267
+ sections.push(``);
268
+ }
269
+
270
+ const content = sections.join("\n");
271
+
272
+ // Write to auto-memory
273
+ const memDir = getAutoMemoryDir(input.transcriptPath);
274
+ if (memDir) {
275
+ const statePath = join(memDir, "precompact-state.md");
276
+ try {
277
+ writeFileSync(statePath, content, "utf-8");
278
+ } catch (e) {
279
+ process.stderr.write(`precompact-extract: failed to write state: ${e}\n`);
280
+ }
281
+
282
+ // Reindex the auto-memory collection so extracted memories are immediately searchable
283
+ try {
284
+ const config = loadConfig();
285
+ const collectionsMap = config.collections || {};
286
+ // Find collection covering this memory dir
287
+ const memEntry = Object.entries(collectionsMap).find(([, c]) =>
288
+ memDir.startsWith(c.path) || c.path.startsWith(memDir)
289
+ );
290
+ if (memEntry) {
291
+ const [colName, col] = memEntry;
292
+ await indexCollection(store, colName, col.path, col.pattern || "**/*.md");
293
+ }
294
+ } catch (e) {
295
+ process.stderr.write(`precompact-extract: archive reindex failed (non-fatal): ${e}\n`);
296
+ }
297
+ }
298
+
299
+ // Audit trail
300
+ try {
301
+ store.insertUsage({
302
+ sessionId: input.sessionId || "unknown",
303
+ timestamp: now,
304
+ hookName: "precompact-extract",
305
+ injectedPaths: [],
306
+ estimatedTokens: estimateTokens(content),
307
+ wasReferenced: 0,
308
+ });
309
+ } catch {
310
+ // non-critical
311
+ }
312
+
313
+ return makeEmptyOutput("precompact-extract");
314
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PreToolUse inject hook — injects file-specific vault context before Read/Edit/Write.
3
+ *
4
+ * Fires via PreToolUse with matcher "Read|Edit|Write". Searches the vault for
5
+ * context related to the target file path and injects relevant decisions,
6
+ * antipatterns, and notes about that specific file.
7
+ */
8
+
9
+ import type { Store } from "../store.ts";
10
+ import { extractSnippet } from "../store.ts";
11
+ import { sanitizeSnippet } from "../promptguard.ts";
12
+ import {
13
+ type HookInput,
14
+ type HookOutput,
15
+ makeEmptyOutput,
16
+ estimateTokens,
17
+ } from "../hooks.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const MAX_TOKEN_BUDGET = 200;
24
+ const MAX_RESULTS = 3;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Main hook
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export async function pretoolInject(
31
+ store: Store,
32
+ input: HookInput
33
+ ): Promise<HookOutput> {
34
+ // Extract file_path from tool input
35
+ const toolInput = input.toolInput as { file_path?: string } | undefined;
36
+ if (!toolInput?.file_path) return makeEmptyOutput("pretool-inject");
37
+
38
+ const filePath = toolInput.file_path;
39
+
40
+ // Skip very short paths or non-file paths
41
+ if (filePath.length < 5) return makeEmptyOutput("pretool-inject");
42
+
43
+ // Search vault for context about this specific file
44
+ let results;
45
+ try {
46
+ results = store.searchFTS(filePath, MAX_RESULTS);
47
+ } catch {
48
+ return makeEmptyOutput("pretool-inject");
49
+ }
50
+
51
+ if (!results || results.length === 0) return makeEmptyOutput("pretool-inject");
52
+
53
+ // Build compact context within budget
54
+ const lines: string[] = [];
55
+ let totalTokens = 0;
56
+
57
+ for (const r of results) {
58
+ if (totalTokens >= MAX_TOKEN_BUDGET) break;
59
+
60
+ const snippet = sanitizeSnippet(extractSnippet(r.body || "", filePath, 100).snippet);
61
+ const line = `- **${sanitizeSnippet(r.title)}**: ${snippet}`;
62
+ const lineTokens = estimateTokens(line);
63
+
64
+ if (totalTokens + lineTokens > MAX_TOKEN_BUDGET && lines.length > 0) break;
65
+ lines.push(line);
66
+ totalTokens += lineTokens;
67
+ }
68
+
69
+ if (lines.length === 0) return makeEmptyOutput("pretool-inject");
70
+
71
+ // PreToolUse hooks cannot inject additionalContext (only UserPromptSubmit can).
72
+ // Use the `reason` field to surface file-specific vault context.
73
+ const context = lines.join("\n");
74
+ return {
75
+ continue: true,
76
+ suppressOutput: false,
77
+ reason: `vault-file-context: ${context}`,
78
+ };
79
+ }