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,317 @@
1
+ /**
2
+ * Context Surfacing Hook - UserPromptSubmit
3
+ *
4
+ * Fires on every user message. Searches the vault for relevant context,
5
+ * applies SAME composite scoring, enforces a token budget, and injects
6
+ * the most relevant notes as additional context for Claude.
7
+ */
8
+
9
+ import type { Store, SearchResult } from "../store.ts";
10
+ import { DEFAULT_EMBED_MODEL, extractSnippet } from "../store.ts";
11
+ import type { HookInput, HookOutput } from "../hooks.ts";
12
+ import {
13
+ makeContextOutput,
14
+ makeEmptyOutput,
15
+ smartTruncate,
16
+ estimateTokens,
17
+ logInjection,
18
+ isHeartbeatPrompt,
19
+ wasPromptSeenRecently,
20
+ } from "../hooks.ts";
21
+ import {
22
+ applyCompositeScoring,
23
+ hasRecencyIntent,
24
+ inferMemoryType,
25
+ type EnrichedResult,
26
+ type ScoredResult,
27
+ } from "../memory.ts";
28
+ import { enrichResults } from "../search-utils.ts";
29
+ import { sanitizeSnippet } from "../promptguard.ts";
30
+ import { shouldSkipRetrieval, isRetrievedNoise } from "../retrieval-gate.ts";
31
+ import { MAX_QUERY_LENGTH } from "../limits.ts";
32
+ import { getActiveProfile } from "../config.ts";
33
+
34
+ // =============================================================================
35
+ // Config
36
+ // =============================================================================
37
+
38
+ // Profile-driven defaults (overridden by CLAWMEM_PROFILE env var)
39
+ const DEFAULT_TOKEN_BUDGET = 800;
40
+ const DEFAULT_MAX_RESULTS = 10;
41
+ const DEFAULT_MIN_SCORE = 0.45;
42
+ const MIN_COMPOSITE_SCORE_RECENCY = 0.35;
43
+ const MIN_PROMPT_LENGTH = 20;
44
+
45
+ // Tiered injection: HOT gets full snippets, WARM gets shorter, COLD gets title-only
46
+ function getTierConfig(score: number): { snippetLen: number; showMeta: boolean; tier: string } {
47
+ if (score > 0.8) return { snippetLen: 300, showMeta: true, tier: "HOT" };
48
+ if (score > 0.6) return { snippetLen: 150, showMeta: false, tier: "WARM" };
49
+ return { snippetLen: 0, showMeta: false, tier: "COLD" };
50
+ }
51
+
52
+ // Directories to never surface
53
+ const FILTERED_PATHS = ["_PRIVATE/", "experiments/", "_clawmem/"];
54
+
55
+ // File path patterns to extract from prompts (E13: file-aware UserPromptSubmit)
56
+ const FILE_PATH_RE = /(?:^|\s)((?:\/[\w.@-]+)+(?:\.\w+)?|[\w.@-]+\.(?:ts|js|py|md|sh|yaml|yml|json|toml|rs|go|tsx|jsx|css|html))\b/g;
57
+
58
+ // =============================================================================
59
+ // Handler
60
+ // =============================================================================
61
+
62
+ export async function contextSurfacing(
63
+ store: Store,
64
+ input: HookInput
65
+ ): Promise<HookOutput> {
66
+ let prompt = input.prompt?.trim();
67
+ if (!prompt || prompt.length < MIN_PROMPT_LENGTH) return makeEmptyOutput("context-surfacing");
68
+
69
+ // Bound query length to prevent DoS on search indices
70
+ if (prompt.length > MAX_QUERY_LENGTH) prompt = prompt.slice(0, MAX_QUERY_LENGTH);
71
+
72
+ // Skip slash commands
73
+ if (prompt.startsWith("/")) return makeEmptyOutput("context-surfacing");
74
+
75
+ // Adaptive retrieval gate: skip greetings, shell commands, affirmations, etc.
76
+ if (shouldSkipRetrieval(prompt)) return makeEmptyOutput("context-surfacing");
77
+
78
+ // Heartbeat / duplicate suppression (IO4)
79
+ if (isHeartbeatPrompt(prompt)) return makeEmptyOutput("context-surfacing");
80
+ if (wasPromptSeenRecently(store, "context-surfacing", prompt)) {
81
+ return makeEmptyOutput("context-surfacing");
82
+ }
83
+
84
+ // Load active performance profile
85
+ const profile = getActiveProfile();
86
+ const maxResults = profile.maxResults;
87
+ const tokenBudget = profile.tokenBudget;
88
+
89
+ const isRecency = hasRecencyIntent(prompt);
90
+ const minScore = isRecency ? MIN_COMPOSITE_SCORE_RECENCY : profile.minScore;
91
+
92
+ // Search: try vector first (if profile allows), fall back to BM25
93
+ // When vector succeeds, also supplement with FTS for keyword-exact recall
94
+ let results: SearchResult[] = [];
95
+ if (profile.useVector) {
96
+ try {
97
+ const vectorPromise = store.searchVec(prompt, DEFAULT_EMBED_MODEL, maxResults);
98
+ const timeoutPromise = new Promise<SearchResult[]>((_, reject) =>
99
+ setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout)
100
+ );
101
+ results = await Promise.race([vectorPromise, timeoutPromise]);
102
+ } catch {
103
+ // Vector search unavailable, timed out, or errored — fall back to BM25
104
+ }
105
+ }
106
+
107
+ if (results.length === 0) {
108
+ results = store.searchFTS(prompt, maxResults);
109
+ } else {
110
+ // Supplement vector results with FTS for keyword-exact matches (<10ms)
111
+ const seen = new Set(results.map(r => r.filepath));
112
+ const ftsSupplemental = store.searchFTS(prompt, 5);
113
+ for (const r of ftsSupplemental) {
114
+ if (!seen.has(r.filepath)) {
115
+ seen.add(r.filepath);
116
+ results.push(r);
117
+ }
118
+ }
119
+ }
120
+
121
+ // File-aware supplemental search (E13): extract file paths/names from prompt
122
+ // and run targeted FTS queries to surface file-specific vault context
123
+ const fileMatches = [...prompt.matchAll(FILE_PATH_RE)].map(m => m[1]!.trim()).filter(Boolean);
124
+ if (fileMatches.length > 0) {
125
+ const seen = new Set(results.map(r => r.filepath));
126
+ for (const fp of fileMatches.slice(0, 3)) {
127
+ try {
128
+ const fileResults = store.searchFTS(fp, 2);
129
+ for (const r of fileResults) {
130
+ if (!seen.has(r.filepath)) {
131
+ seen.add(r.filepath);
132
+ results.push(r);
133
+ }
134
+ }
135
+ } catch { /* non-fatal */ }
136
+ }
137
+ }
138
+
139
+ if (results.length === 0) return makeEmptyOutput("context-surfacing");
140
+
141
+ // Filter out private/excluded paths
142
+ results = results.filter(r =>
143
+ !FILTERED_PATHS.some(p => r.displayPath.includes(p))
144
+ );
145
+
146
+ if (results.length === 0) return makeEmptyOutput("context-surfacing");
147
+
148
+ // Filter out snoozed documents
149
+ const now = new Date();
150
+ results = results.filter(r => {
151
+ const parsed = r.filepath.startsWith('clawmem://') ? r.filepath.replace(/^clawmem:\/\/[^/]+\/?/, '') : r.filepath;
152
+ const doc = store.findActiveDocument(r.collectionName, parsed);
153
+ if (!doc) return true;
154
+ if (doc.snoozed_until && new Date(doc.snoozed_until) > now) return false;
155
+ return true;
156
+ });
157
+
158
+ if (results.length === 0) return makeEmptyOutput("context-surfacing");
159
+
160
+ // Deduplicate by filepath (keep best score per path)
161
+ const deduped = new Map<string, SearchResult>();
162
+ for (const r of results) {
163
+ const existing = deduped.get(r.filepath);
164
+ if (!existing || r.score > existing.score) {
165
+ deduped.set(r.filepath, r);
166
+ }
167
+ }
168
+ results = [...deduped.values()];
169
+
170
+ // Filter out noise results (agent denials, too-short snippets) before enrichment
171
+ results = results.filter(r => !r.body || !isRetrievedNoise(r.body));
172
+
173
+ // Enrich with SAME metadata
174
+ const enriched = enrichResults(store, results, prompt);
175
+
176
+ // Apply composite scoring
177
+ const scored = applyCompositeScoring(enriched, prompt)
178
+ .filter(r => r.compositeScore >= minScore);
179
+
180
+ if (scored.length === 0) return makeEmptyOutput("context-surfacing");
181
+
182
+ // Spreading activation (E11): boost results co-activated with top HOT results
183
+ if (scored.length > 3) {
184
+ const hotPaths = scored.slice(0, 3)
185
+ .filter(r => r.compositeScore > 0.8)
186
+ .map(r => r.displayPath);
187
+
188
+ for (const hotPath of hotPaths) {
189
+ try {
190
+ const coActs = store.getCoActivated(hotPath, 3);
191
+ for (const ca of coActs) {
192
+ const existing = scored.find(r => r.displayPath === ca.path);
193
+ if (existing && existing.compositeScore <= 0.8) {
194
+ existing.compositeScore += Math.min(0.2, 0.1 * Math.min(ca.count, 2));
195
+ }
196
+ }
197
+ } catch {
198
+ // co_activations table may not exist yet
199
+ }
200
+ }
201
+ scored.sort((a, b) => b.compositeScore - a.compositeScore);
202
+ }
203
+
204
+ // Memory type diversification (E10): ensure procedural results aren't crowded out
205
+ if (scored.length > 3) {
206
+ const top3Types = scored.slice(0, 3).map(r => inferMemoryType(r.displayPath, r.contentType, r.body));
207
+ const hasProc = top3Types.includes("procedural");
208
+ if (!hasProc) {
209
+ const procIdx = scored.findIndex(r => inferMemoryType(r.displayPath, r.contentType, r.body) === "procedural");
210
+ if (procIdx > 3) {
211
+ const [proc] = scored.splice(procIdx, 1);
212
+ scored.splice(3, 0, proc!);
213
+ }
214
+ }
215
+ }
216
+
217
+ // Build context within token budget (profile-driven)
218
+ const { context, paths, tokens } = buildContext(scored, prompt, tokenBudget);
219
+
220
+ if (!context) return makeEmptyOutput("context-surfacing");
221
+
222
+ // Log the injection
223
+ if (input.sessionId) {
224
+ logInjection(store, input.sessionId, "context-surfacing", paths, tokens);
225
+ }
226
+
227
+ // Routing hint: detect query intent signals and prepend a tool routing directive
228
+ const routingHint = detectRoutingHint(prompt);
229
+
230
+ return makeContextOutput(
231
+ "context-surfacing",
232
+ routingHint
233
+ ? `<vault-routing>${routingHint}</vault-routing>\n<vault-context>\n${context}\n</vault-context>`
234
+ : `<vault-context>\n${context}\n</vault-context>`
235
+ );
236
+ }
237
+
238
+ // =============================================================================
239
+ // Helpers
240
+ // =============================================================================
241
+
242
+ /**
243
+ * Detect causal/temporal/discovery signals in the prompt and return a
244
+ * routing hint that makes the correct tool choice salient at the moment
245
+ * of tool selection. Returns null for general queries (no hint needed).
246
+ */
247
+ function detectRoutingHint(prompt: string): string | null {
248
+ const q = prompt.toLowerCase();
249
+
250
+ if (/\b(last session|yesterday|prior session|previous session|last time we|handoff|what happened last|what did we do|cross.session|earlier today|what we discussed|when we last)\b/i.test(q)) {
251
+ return "If searching memory for this: use session_log or memory_retrieve, NOT query.";
252
+ }
253
+
254
+ if (/\b(why did|why was|why were|what caused|what led to|reason for|decided to|decision about|trade.?off|instead of|chose to)\b/i.test(q) || /^why\b/i.test(q)) {
255
+ return "If searching memory for this: use intent_search or memory_retrieve, NOT query.";
256
+ }
257
+
258
+ if (/\b(similar to|related to|what else|what other|reminds? me of|like this)\b/i.test(q)) {
259
+ return "If searching memory for this: use find_similar or memory_retrieve, NOT query.";
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ function buildContext(
266
+ scored: ScoredResult[],
267
+ query: string,
268
+ budget: number = DEFAULT_TOKEN_BUDGET
269
+ ): { context: string; paths: string[]; tokens: number } {
270
+ const lines: string[] = [];
271
+ const paths: string[] = [];
272
+ let totalTokens = 0;
273
+
274
+ for (const r of scored) {
275
+ if (totalTokens >= budget) break;
276
+
277
+ // Tiered injection: allocate snippet length by composite score
278
+ const tier = getTierConfig(r.compositeScore);
279
+
280
+ // Sanitize title and displayPath to prevent injection via metadata fields
281
+ const safeTitle = sanitizeSnippet(r.title);
282
+ const safePath = sanitizeSnippet(r.displayPath);
283
+ if (safeTitle === "[content filtered for security]" || safePath === "[content filtered for security]") continue;
284
+
285
+ const typeTag = r.contentType !== "note" ? ` (${r.contentType})` : "";
286
+ let entry: string;
287
+
288
+ if (tier.snippetLen > 0) {
289
+ // HOT or WARM: include snippet
290
+ const bodyStr = r.body || "";
291
+ const sanitized = sanitizeSnippet(bodyStr);
292
+ if (sanitized === "[content filtered for security]") continue;
293
+
294
+ const snippet = smartTruncate(
295
+ extractSnippet(sanitized, query, tier.snippetLen, r.chunkPos).snippet,
296
+ tier.snippetLen
297
+ );
298
+ entry = `**${safeTitle}**${typeTag}\n${safePath}\n${snippet}`;
299
+ } else {
300
+ // COLD: title + path only, no snippet
301
+ entry = `**${safeTitle}**${typeTag}\n${safePath}`;
302
+ }
303
+
304
+ const entryTokens = estimateTokens(entry);
305
+ if (totalTokens + entryTokens > budget && lines.length > 0) break;
306
+
307
+ lines.push(entry);
308
+ paths.push(r.displayPath);
309
+ totalTokens += entryTokens;
310
+ }
311
+
312
+ return {
313
+ context: lines.join("\n\n---\n\n"),
314
+ paths,
315
+ tokens: totalTokens,
316
+ };
317
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Curator Nudge Hook - SessionStart
3
+ *
4
+ * Reads the curator report JSON and surfaces actionable items.
5
+ * If the report is stale (>7 days), nudges to run curator.
6
+ * Budget: ~200 tokens. Fail-open.
7
+ */
8
+
9
+ import { resolve as pathResolve } from "path";
10
+ import { existsSync, readFileSync } from "fs";
11
+ import type { Store } from "../store.ts";
12
+ import type { HookInput, HookOutput } from "../hooks.ts";
13
+ import {
14
+ makeContextOutput,
15
+ makeEmptyOutput,
16
+ estimateTokens,
17
+ logInjection,
18
+ } from "../hooks.ts";
19
+
20
+ const MAX_TOKEN_BUDGET = 200;
21
+ const STALE_DAYS = 7;
22
+ const REPORT_PATH = pathResolve(process.env.HOME || "~", ".cache", "clawmem", "curator-report.json");
23
+
24
+ interface CuratorReport {
25
+ timestamp: string;
26
+ actions: string[];
27
+ health: { active: number; embeddingBacklog: number; infrastructure: string };
28
+ sweep: { candidates: number };
29
+ consolidation: { candidates: number };
30
+ }
31
+
32
+ export async function curatorNudge(
33
+ store: Store,
34
+ input: HookInput
35
+ ): Promise<HookOutput> {
36
+ if (!existsSync(REPORT_PATH)) {
37
+ // No report yet — nudge to run curator
38
+ return makeContextOutput(
39
+ "curator-nudge",
40
+ `<vault-curator>\nCurator has never run. Consider: \`clawmem curate\` or "run curator" agent.\n</vault-curator>`
41
+ );
42
+ }
43
+
44
+ let report: CuratorReport;
45
+ try {
46
+ report = JSON.parse(readFileSync(REPORT_PATH, "utf-8"));
47
+ } catch {
48
+ return makeEmptyOutput("curator-nudge");
49
+ }
50
+
51
+ const reportAge = Date.now() - new Date(report.timestamp).getTime();
52
+ const reportDays = Math.floor(reportAge / (86400 * 1000));
53
+
54
+ // If report is stale, just nudge
55
+ if (reportDays > STALE_DAYS) {
56
+ return makeContextOutput(
57
+ "curator-nudge",
58
+ `<vault-curator>\nCurator report is ${reportDays}d old. Consider: \`clawmem curate\` or "run curator" agent.\n</vault-curator>`
59
+ );
60
+ }
61
+
62
+ // If no actions, stay silent
63
+ if (!report.actions || report.actions.length === 0) {
64
+ return makeEmptyOutput("curator-nudge");
65
+ }
66
+
67
+ // Build compact action summary within budget
68
+ const lines = [`**Curator (${report.timestamp.slice(0, 10)}):**`];
69
+ let tokens = estimateTokens(lines[0]!);
70
+
71
+ for (const action of report.actions) {
72
+ const line = `- ${action}`;
73
+ const lineTokens = estimateTokens(line);
74
+ if (tokens + lineTokens > MAX_TOKEN_BUDGET && lines.length > 1) break;
75
+ lines.push(line);
76
+ tokens += lineTokens;
77
+ }
78
+
79
+ if (lines.length <= 1) return makeEmptyOutput("curator-nudge");
80
+
81
+ if (input.sessionId) {
82
+ logInjection(store, input.sessionId, "curator-nudge", [], tokens);
83
+ }
84
+
85
+ return makeContextOutput(
86
+ "curator-nudge",
87
+ `<vault-curator>\n${lines.join("\n")}\n</vault-curator>`
88
+ );
89
+ }