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,324 @@
1
+ /**
2
+ * Session Bootstrap Hook - SessionStart
3
+ *
4
+ * Fires at the start of each Claude Code session. Surfaces:
5
+ * 0. User profile (highest priority, ~800 tokens)
6
+ * 1. Latest handoff note (~600 tokens)
7
+ * 2. Recent decisions (~400 tokens)
8
+ * 3. Stale notes reminder (~200 tokens)
9
+ *
10
+ * Total budget: ~2000 tokens (~8000 chars).
11
+ */
12
+
13
+ import type { Store } from "../store.ts";
14
+ import type { HookInput, HookOutput } from "../hooks.ts";
15
+ import {
16
+ makeEmptyOutput,
17
+ smartTruncate,
18
+ estimateTokens,
19
+ logInjection,
20
+ } from "../hooks.ts";
21
+ import { sanitizeSnippet } from "../promptguard.ts";
22
+ import { getProfile } from "../profile.ts";
23
+ import { hostname } from "os";
24
+
25
+ // =============================================================================
26
+ // Config
27
+ // =============================================================================
28
+
29
+ const TOTAL_TOKEN_BUDGET = 2000;
30
+ const PROFILE_TOKEN_BUDGET = 800;
31
+ const HANDOFF_TOKEN_BUDGET = 600;
32
+ const DECISION_TOKEN_BUDGET = 400;
33
+ const STALE_TOKEN_BUDGET = 200;
34
+ const DECISION_LOOKBACK_DAYS = 7;
35
+ const STALE_LOOKBACK_DAYS = 30;
36
+
37
+ // =============================================================================
38
+ // Handler
39
+ // =============================================================================
40
+
41
+ export async function sessionBootstrap(
42
+ store: Store,
43
+ input: HookInput
44
+ ): Promise<HookOutput> {
45
+ const sessionId = input.sessionId || `session-${Date.now()}`;
46
+ const now = new Date().toISOString();
47
+
48
+ // Register the session
49
+ try {
50
+ store.insertSession(sessionId, now, hostname());
51
+ } catch {
52
+ // Session may already exist (duplicate hook fire)
53
+ }
54
+
55
+ const sections: string[] = [];
56
+ const paths: string[] = [];
57
+ let totalTokens = 0;
58
+
59
+ // 0. User profile (highest priority)
60
+ const profileSection = getProfileSection(store, PROFILE_TOKEN_BUDGET);
61
+ if (profileSection) {
62
+ const tokens = estimateTokens(profileSection.text);
63
+ if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
64
+ sections.push(profileSection.text);
65
+ paths.push(...profileSection.paths);
66
+ totalTokens += tokens;
67
+ }
68
+ }
69
+
70
+ // 1. Latest handoff
71
+ const handoffSection = getLatestHandoff(store, HANDOFF_TOKEN_BUDGET);
72
+ if (handoffSection) {
73
+ const tokens = estimateTokens(handoffSection.text);
74
+ if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
75
+ sections.push(handoffSection.text);
76
+ paths.push(...handoffSection.paths);
77
+ totalTokens += tokens;
78
+ }
79
+ }
80
+
81
+ // 2. Recent decisions
82
+ const decisionSection = getRecentDecisions(store, DECISION_TOKEN_BUDGET);
83
+ if (decisionSection) {
84
+ const tokens = estimateTokens(decisionSection.text);
85
+ if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
86
+ sections.push(decisionSection.text);
87
+ paths.push(...decisionSection.paths);
88
+ totalTokens += tokens;
89
+ }
90
+ }
91
+
92
+ // 3. Stale notes reminder
93
+ const staleSection = getStaleNotes(store, STALE_TOKEN_BUDGET);
94
+ if (staleSection) {
95
+ const tokens = estimateTokens(staleSection.text);
96
+ if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
97
+ sections.push(staleSection.text);
98
+ paths.push(...staleSection.paths);
99
+ totalTokens += tokens;
100
+ }
101
+ }
102
+
103
+ if (sections.length === 0) return makeEmptyOutput("session-bootstrap");
104
+
105
+ // Log the injection
106
+ logInjection(store, sessionId, "session-bootstrap", paths, totalTokens);
107
+
108
+ // C4b fix: makeContextOutput drops content for SessionStart hooks (null in HOOK_EVENT_MAP).
109
+ // Return direct output with io6-bootstrap event name to ensure content reaches cmdSurface.
110
+ const sessionContext = `<vault-session>\n${sections.join("\n\n---\n\n")}\n</vault-session>`;
111
+ return {
112
+ continue: true,
113
+ suppressOutput: false,
114
+ hookSpecificOutput: {
115
+ hookEventName: "io6-bootstrap",
116
+ additionalContext: sessionContext,
117
+ },
118
+ };
119
+ }
120
+
121
+ // =============================================================================
122
+ // Section Builders
123
+ // =============================================================================
124
+
125
+ function getProfileSection(
126
+ store: Store,
127
+ maxTokens: number
128
+ ): { text: string; paths: string[] } | null {
129
+ const profile = getProfile(store);
130
+ if (!profile) return null;
131
+ if (profile.static.length === 0 && profile.dynamic.length === 0) return null;
132
+
133
+ const maxChars = maxTokens * 4;
134
+ const lines: string[] = ["### User Profile"];
135
+ let charCount = 20;
136
+
137
+ if (profile.static.length > 0) {
138
+ lines.push("**Known Context:**");
139
+ charCount += 20;
140
+ for (const fact of profile.static) {
141
+ if (charCount + fact.length + 4 > maxChars) break;
142
+ lines.push(`- ${fact}`);
143
+ charCount += fact.length + 4;
144
+ }
145
+ }
146
+
147
+ if (profile.dynamic.length > 0) {
148
+ lines.push("", "**Current Focus:**");
149
+ charCount += 22;
150
+ for (const item of profile.dynamic) {
151
+ if (charCount + item.length + 4 > maxChars) break;
152
+ lines.push(`- ${item}`);
153
+ charCount += item.length + 4;
154
+ }
155
+ }
156
+
157
+ return { text: lines.join("\n"), paths: ["_clawmem/profile.md"] };
158
+ }
159
+
160
+ function getLatestHandoff(
161
+ store: Store,
162
+ maxTokens: number
163
+ ): { text: string; paths: string[] } | null {
164
+ // Get most recent session with a handoff
165
+ const sessions = store.getRecentSessions(5);
166
+ const withHandoff = sessions.find(s => s.handoffPath);
167
+ if (!withHandoff?.handoffPath) {
168
+ // Fall back: get most recent handoff-type document
169
+ const handoffs = store.getDocumentsByType("handoff", 1);
170
+ if (handoffs.length === 0) return null;
171
+
172
+ const doc = handoffs[0]!;
173
+ const body = store.getDocumentBody({ filepath: `${doc.collection}/${doc.path}`, displayPath: `${doc.collection}/${doc.path}` } as any);
174
+ if (!body) return null;
175
+
176
+ const text = formatHandoff(doc.title, body, maxTokens);
177
+ return { text, paths: [`${doc.collection}/${doc.path}`] };
178
+ }
179
+
180
+ // Try to read the handoff note from the DB
181
+ const handoffPath = withHandoff.handoffPath;
182
+ const parts = handoffPath.split("/");
183
+ if (parts.length >= 2) {
184
+ const collection = parts[0]!;
185
+ const path = parts.slice(1).join("/");
186
+ const docInfo = store.findActiveDocument(collection, path);
187
+ if (docInfo) {
188
+ const body = store.getDocumentBody({ filepath: handoffPath, displayPath: handoffPath } as any);
189
+ if (body) {
190
+ const text = formatHandoff(docInfo.title, body, maxTokens);
191
+ return { text, paths: [handoffPath] };
192
+ }
193
+ }
194
+ }
195
+
196
+ // Fall back to session summary
197
+ if (withHandoff.summary) {
198
+ const text = `### Last Session\n${smartTruncate(withHandoff.summary, maxTokens * 4)}`;
199
+ return { text, paths: [] };
200
+ }
201
+
202
+ return null;
203
+ }
204
+
205
+ function formatHandoff(title: string, body: string, maxTokens: number): string {
206
+ // Prompt injection guard
207
+ body = sanitizeSnippet(body);
208
+ if (body === "[content filtered for security]") return "";
209
+ const maxChars = maxTokens * 4;
210
+
211
+ // Extract key sections in priority order
212
+ const prioritySections = [
213
+ "Next Session Should",
214
+ "Next Session",
215
+ "Next Steps",
216
+ "Current State",
217
+ "Request",
218
+ "What Was Done",
219
+ "What Was Learned",
220
+ "What Was Investigated",
221
+ "Accomplishments",
222
+ "Decisions Made",
223
+ "Files Changed",
224
+ ];
225
+
226
+ const extracted: string[] = [];
227
+ let remaining = maxChars;
228
+
229
+ for (const sectionName of prioritySections) {
230
+ if (remaining <= 100) break;
231
+ const section = extractSection(body, sectionName);
232
+ if (section) {
233
+ const truncated = smartTruncate(section, remaining);
234
+ extracted.push(truncated);
235
+ remaining -= truncated.length;
236
+ }
237
+ }
238
+
239
+ if (extracted.length > 0) {
240
+ return `### Last Handoff: ${title}\n${extracted.join("\n\n")}`;
241
+ }
242
+
243
+ // No structured sections, use raw content
244
+ return `### Last Handoff: ${title}\n${smartTruncate(body, maxChars)}`;
245
+ }
246
+
247
+ function extractSection(body: string, sectionName: string): string | null {
248
+ const regex = new RegExp(`^#{1,3}\\s+${escapeRegex(sectionName)}\\b[^\n]*\n([\\s\\S]*?)(?=^#{1,3}\\s|$)`, "mi");
249
+ const match = body.match(regex);
250
+ if (!match?.[1]) return null;
251
+ const text = match[1].trim();
252
+ return text.length > 10 ? `**${sectionName}:**\n${text}` : null;
253
+ }
254
+
255
+ function getRecentDecisions(
256
+ store: Store,
257
+ maxTokens: number
258
+ ): { text: string; paths: string[] } | null {
259
+ const decisions = store.getDocumentsByType("decision", 5);
260
+ if (decisions.length === 0) return null;
261
+
262
+ const cutoff = new Date();
263
+ cutoff.setDate(cutoff.getDate() - DECISION_LOOKBACK_DAYS);
264
+ const cutoffStr = cutoff.toISOString();
265
+
266
+ // Filter to recent decisions
267
+ const recent = decisions.filter(d => d.modifiedAt >= cutoffStr);
268
+ if (recent.length === 0) return null;
269
+
270
+ const maxChars = maxTokens * 4;
271
+ const lines: string[] = ["### Recent Decisions"];
272
+ const paths: string[] = [];
273
+ let charCount = 25; // header
274
+
275
+ for (const d of recent) {
276
+ if (charCount >= maxChars) break;
277
+ let body = store.getDocumentBody({ filepath: `${d.collection}/${d.path}`, displayPath: `${d.collection}/${d.path}` } as any);
278
+ if (body) body = sanitizeSnippet(body);
279
+ if (body === "[content filtered for security]") continue;
280
+ const snippet = body ? smartTruncate(body, 200) : d.title;
281
+ const entry = `- **${d.title}** (${d.modifiedAt.slice(0, 10)})\n ${snippet}`;
282
+ const entryLen = entry.length;
283
+ if (charCount + entryLen > maxChars && lines.length > 1) break;
284
+ lines.push(entry);
285
+ paths.push(`${d.collection}/${d.path}`);
286
+ charCount += entryLen;
287
+ }
288
+
289
+ return lines.length > 1 ? { text: lines.join("\n"), paths } : null;
290
+ }
291
+
292
+ function getStaleNotes(
293
+ store: Store,
294
+ maxTokens: number
295
+ ): { text: string; paths: string[] } | null {
296
+ const cutoff = new Date();
297
+ cutoff.setDate(cutoff.getDate() - STALE_LOOKBACK_DAYS);
298
+ const stale = store.getStaleDocuments(cutoff.toISOString());
299
+
300
+ if (stale.length === 0) return null;
301
+
302
+ const maxChars = maxTokens * 4;
303
+ const lines: string[] = ["### Notes to Review"];
304
+ const paths: string[] = [];
305
+ let charCount = 25;
306
+
307
+ for (const d of stale.slice(0, 5)) {
308
+ const entry = `- ${d.title} (${d.collection}/${d.path}) — last modified ${d.modifiedAt.slice(0, 10)}`;
309
+ if (charCount + entry.length > maxChars && lines.length > 1) break;
310
+ lines.push(entry);
311
+ paths.push(`${d.collection}/${d.path}`);
312
+ charCount += entry.length;
313
+ }
314
+
315
+ return lines.length > 1 ? { text: lines.join("\n"), paths } : null;
316
+ }
317
+
318
+ // =============================================================================
319
+ // Utilities
320
+ // =============================================================================
321
+
322
+ function escapeRegex(str: string): string {
323
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
324
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Staleness Check Hook - SessionStart
3
+ *
4
+ * Fires at session start. Finds documents that have a review_by date
5
+ * in the past and haven't been updated recently. Injects a gentle
6
+ * reminder to review them.
7
+ */
8
+
9
+ import type { Store } from "../store.ts";
10
+ import type { HookInput, HookOutput } from "../hooks.ts";
11
+ import {
12
+ makeContextOutput,
13
+ makeEmptyOutput,
14
+ estimateTokens,
15
+ logInjection,
16
+ } from "../hooks.ts";
17
+
18
+ // =============================================================================
19
+ // Config
20
+ // =============================================================================
21
+
22
+ const MAX_STALE_NOTES = 5;
23
+ const MAX_TOKEN_BUDGET = 250;
24
+ const STALE_DAYS = 30;
25
+
26
+ // =============================================================================
27
+ // Handler
28
+ // =============================================================================
29
+
30
+ export async function stalenessCheck(
31
+ store: Store,
32
+ input: HookInput
33
+ ): Promise<HookOutput> {
34
+ const now = new Date();
35
+
36
+ // Find documents with review_by in the past
37
+ const reviewDue = findReviewDue(store, now);
38
+
39
+ // Find documents not modified in STALE_DAYS
40
+ const stale = findStaleByAge(store, now);
41
+
42
+ // Merge and deduplicate
43
+ const allStale = new Map<string, { title: string; path: string; reason: string }>();
44
+
45
+ for (const d of reviewDue) {
46
+ allStale.set(d.path, d);
47
+ }
48
+ for (const d of stale) {
49
+ if (!allStale.has(d.path)) {
50
+ allStale.set(d.path, d);
51
+ }
52
+ }
53
+
54
+ if (allStale.size === 0) return makeEmptyOutput("staleness-check");
55
+
56
+ // Build context within budget
57
+ const entries = [...allStale.values()].slice(0, MAX_STALE_NOTES);
58
+ const lines = ["**Notes needing review:**"];
59
+ const paths: string[] = [];
60
+ let tokens = estimateTokens(lines[0]!);
61
+
62
+ for (const entry of entries) {
63
+ const line = `- ${entry.title} (${entry.path}) — ${entry.reason}`;
64
+ const lineTokens = estimateTokens(line);
65
+ if (tokens + lineTokens > MAX_TOKEN_BUDGET && lines.length > 1) break;
66
+ lines.push(line);
67
+ paths.push(entry.path);
68
+ tokens += lineTokens;
69
+ }
70
+
71
+ if (lines.length <= 1) return makeEmptyOutput("staleness-check");
72
+
73
+ // Log injection
74
+ if (input.sessionId) {
75
+ logInjection(store, input.sessionId, "staleness-check", paths, tokens);
76
+ }
77
+
78
+ return makeContextOutput(
79
+ "staleness-check",
80
+ `<vault-staleness>\n${lines.join("\n")}\n</vault-staleness>`
81
+ );
82
+ }
83
+
84
+ // =============================================================================
85
+ // Finders
86
+ // =============================================================================
87
+
88
+ function findReviewDue(
89
+ store: Store,
90
+ now: Date
91
+ ): { title: string; path: string; reason: string }[] {
92
+ const nowStr = now.toISOString();
93
+
94
+ try {
95
+ const rows = store.db.prepare(`
96
+ SELECT collection, path, title, review_by
97
+ FROM documents
98
+ WHERE active = 1
99
+ AND review_by IS NOT NULL
100
+ AND review_by != ''
101
+ AND review_by <= ?
102
+ ORDER BY review_by ASC
103
+ LIMIT ?
104
+ `).all(nowStr, MAX_STALE_NOTES) as { collection: string; path: string; title: string; review_by: string }[];
105
+
106
+ return rows.map(r => ({
107
+ title: r.title,
108
+ path: `${r.collection}/${r.path}`,
109
+ reason: `review due ${r.review_by.slice(0, 10)}`,
110
+ }));
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ function findStaleByAge(
117
+ store: Store,
118
+ now: Date
119
+ ): { title: string; path: string; reason: string }[] {
120
+ const cutoff = new Date(now);
121
+ cutoff.setDate(cutoff.getDate() - STALE_DAYS);
122
+
123
+ const stale = store.getStaleDocuments(cutoff.toISOString());
124
+
125
+ return stale.slice(0, MAX_STALE_NOTES).map(d => ({
126
+ title: d.title,
127
+ path: `${d.collection}/${d.path}`,
128
+ reason: `not modified since ${d.modifiedAt.slice(0, 10)}`,
129
+ }));
130
+ }