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.
- package/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- 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
|
+
}
|