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,214 @@
1
+ /**
2
+ * Feedback Loop Hook - Stop
3
+ *
4
+ * Fires when a session ends. Detects which surfaced notes were actually
5
+ * referenced by the assistant, and boosts their access counts.
6
+ * This closes the learning loop: notes that prove useful rise in confidence,
7
+ * unused notes gradually decay.
8
+ *
9
+ * Silent — does not inject context back to Claude.
10
+ */
11
+
12
+ import type { Store } from "../store.ts";
13
+ import type { HookInput, HookOutput } from "../hooks.ts";
14
+ import {
15
+ makeEmptyOutput,
16
+ readTranscript,
17
+ validateTranscriptPath,
18
+ } from "../hooks.ts";
19
+
20
+ // =============================================================================
21
+ // Handler
22
+ // =============================================================================
23
+
24
+ export async function feedbackLoop(
25
+ store: Store,
26
+ input: HookInput
27
+ ): Promise<HookOutput> {
28
+ const transcriptPath = validateTranscriptPath(input.transcriptPath);
29
+ const sessionId = input.sessionId;
30
+ if (!transcriptPath || !sessionId) return makeEmptyOutput("feedback-loop");
31
+
32
+ // Get all notes injected during this session
33
+ const usages = store.getUsageForSession(sessionId);
34
+ if (usages.length === 0) return makeEmptyOutput("feedback-loop");
35
+
36
+ // Collect all injected paths
37
+ const injectedPaths = new Set<string>();
38
+ for (const u of usages) {
39
+ try {
40
+ const paths = JSON.parse(u.injectedPaths) as string[];
41
+ for (const p of paths) injectedPaths.add(p);
42
+ } catch {
43
+ // Skip malformed
44
+ }
45
+ }
46
+
47
+ if (injectedPaths.size === 0) return makeEmptyOutput("feedback-loop");
48
+
49
+ // Read assistant messages from transcript
50
+ const assistantMessages = readTranscript(transcriptPath, 200, "assistant");
51
+ if (assistantMessages.length === 0) return makeEmptyOutput("feedback-loop");
52
+
53
+ // Build full assistant text for reference detection
54
+ const assistantText = assistantMessages.map(m => m.content).join("\n");
55
+
56
+ // Detect references: check if the assistant mentioned any injected path or title
57
+ const referencedPaths: string[] = [];
58
+
59
+ for (const path of injectedPaths) {
60
+ // Check for path reference
61
+ if (assistantText.includes(path)) {
62
+ referencedPaths.push(path);
63
+ continue;
64
+ }
65
+
66
+ // Check for filename reference
67
+ const filename = path.split("/").pop()?.replace(/\.(md|txt)$/i, "");
68
+ if (filename && filename.length > 3 && assistantText.toLowerCase().includes(filename.toLowerCase())) {
69
+ referencedPaths.push(path);
70
+ continue;
71
+ }
72
+
73
+ // Check for title reference (look up from DB)
74
+ const titleMatch = checkTitleReference(store, path, assistantText);
75
+ if (titleMatch) {
76
+ referencedPaths.push(path);
77
+ }
78
+ }
79
+
80
+ // Boost access counts for referenced notes
81
+ if (referencedPaths.length > 0) {
82
+ store.incrementAccessCount(referencedPaths);
83
+
84
+ // Mark usage records as referenced
85
+ for (const u of usages) {
86
+ try {
87
+ const paths = JSON.parse(u.injectedPaths) as string[];
88
+ if (paths.some(p => referencedPaths.includes(p))) {
89
+ store.markUsageReferenced(u.id);
90
+ }
91
+ } catch {
92
+ // Skip
93
+ }
94
+ }
95
+
96
+ // Record usage relations between co-referenced documents
97
+ if (referencedPaths.length >= 2) {
98
+ try {
99
+ const docIds = new Map<string, number>();
100
+ for (const path of referencedPaths) {
101
+ const parts = path.split("/");
102
+ if (parts.length < 2) continue;
103
+ const collection = parts[0]!;
104
+ const docPath = parts.slice(1).join("/");
105
+ const doc = store.findActiveDocument(collection, docPath);
106
+ if (doc) docIds.set(path, doc.id);
107
+ }
108
+ const ids = [...docIds.values()];
109
+ for (let i = 0; i < ids.length; i++) {
110
+ for (let j = i + 1; j < ids.length; j++) {
111
+ store.insertRelation(ids[i]!, ids[j]!, "usage");
112
+ }
113
+ }
114
+ } catch {
115
+ // Non-critical — don't block feedback loop on relation errors
116
+ }
117
+ }
118
+
119
+ // Record co-activations for the referenced paths
120
+ if (referencedPaths.length >= 2) {
121
+ store.recordCoActivation(referencedPaths);
122
+ }
123
+ }
124
+
125
+ // Utility tracking: detect pin/snooze candidates based on usage patterns
126
+ try {
127
+ trackUtilitySignals(store, injectedPaths, referencedPaths);
128
+ } catch {
129
+ // Non-critical — don't block feedback loop on utility tracking errors
130
+ }
131
+
132
+ // Silent return — feedback loop doesn't inject context
133
+ return makeEmptyOutput("feedback-loop");
134
+ }
135
+
136
+ // =============================================================================
137
+ // Utility Signal Tracking
138
+ // =============================================================================
139
+
140
+ /**
141
+ * Track utility signals for lifecycle automation (ReMe-inspired u/f ratio).
142
+ *
143
+ * For each injected path, records whether it was referenced (useful) or not (noise).
144
+ * Over time this builds a utility profile per document:
145
+ * - High utility (referenced often) → pin candidate
146
+ * - Low utility (surfaced often, never referenced) → snooze candidate
147
+ *
148
+ * Writes to `utility_signals` table (created lazily).
149
+ */
150
+ function trackUtilitySignals(
151
+ store: Store,
152
+ injectedPaths: Set<string>,
153
+ referencedPaths: string[]
154
+ ): void {
155
+ store.db.exec(`
156
+ CREATE TABLE IF NOT EXISTS utility_signals (
157
+ path TEXT NOT NULL,
158
+ surfaced_count INTEGER NOT NULL DEFAULT 0,
159
+ referenced_count INTEGER NOT NULL DEFAULT 0,
160
+ last_surfaced TEXT,
161
+ last_referenced TEXT,
162
+ PRIMARY KEY (path)
163
+ )
164
+ `);
165
+
166
+ const referencedSet = new Set(referencedPaths);
167
+ const now = new Date().toISOString();
168
+
169
+ const upsert = store.db.prepare(`
170
+ INSERT INTO utility_signals (path, surfaced_count, referenced_count, last_surfaced, last_referenced)
171
+ VALUES (?, 1, ?, ?, ?)
172
+ ON CONFLICT(path) DO UPDATE SET
173
+ surfaced_count = surfaced_count + 1,
174
+ referenced_count = referenced_count + ?,
175
+ last_surfaced = ?,
176
+ last_referenced = CASE WHEN ? > 0 THEN ? ELSE last_referenced END
177
+ `);
178
+
179
+ for (const path of injectedPaths) {
180
+ const wasReferenced = referencedSet.has(path) ? 1 : 0;
181
+ upsert.run(
182
+ path,
183
+ wasReferenced,
184
+ now,
185
+ wasReferenced > 0 ? now : null,
186
+ wasReferenced,
187
+ now,
188
+ wasReferenced,
189
+ now
190
+ );
191
+ }
192
+ }
193
+
194
+ // =============================================================================
195
+ // Reference Detection
196
+ // =============================================================================
197
+
198
+ function checkTitleReference(store: Store, path: string, text: string): boolean {
199
+ try {
200
+ const parts = path.split("/");
201
+ if (parts.length < 2) return false;
202
+ const collection = parts[0]!;
203
+ const docPath = parts.slice(1).join("/");
204
+ const doc = store.findActiveDocument(collection, docPath);
205
+ if (!doc?.title) return false;
206
+
207
+ // Skip generic titles
208
+ if (doc.title.length < 5) return false;
209
+
210
+ return text.toLowerCase().includes(doc.title.toLowerCase());
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Handoff Generator Hook - Stop
3
+ *
4
+ * Fires when a Claude Code session ends. Analyzes the transcript
5
+ * to generate a handoff note summarizing: what was done, current state,
6
+ * decisions made, and next steps. Stored in _clawmem collection.
7
+ */
8
+
9
+ import type { Store } from "../store.ts";
10
+ import type { HookInput, HookOutput } from "../hooks.ts";
11
+ import {
12
+ makeContextOutput,
13
+ makeEmptyOutput,
14
+ readTranscript,
15
+ validateTranscriptPath,
16
+ type TranscriptMessage,
17
+ } from "../hooks.ts";
18
+ import { extractSummary, type SessionSummary } from "../observer.ts";
19
+ import { updateDirectoryContext } from "../directory-context.ts";
20
+ import { loadConfig } from "../collections.ts";
21
+
22
+ // =============================================================================
23
+ // Config
24
+ // =============================================================================
25
+
26
+ const MIN_MESSAGES_FOR_HANDOFF = 4;
27
+
28
+ // =============================================================================
29
+ // Handler
30
+ // =============================================================================
31
+
32
+ export async function handoffGenerator(
33
+ store: Store,
34
+ input: HookInput
35
+ ): Promise<HookOutput> {
36
+ const transcriptPath = validateTranscriptPath(input.transcriptPath);
37
+ if (!transcriptPath) return makeEmptyOutput("handoff-generator");
38
+
39
+ const messages = readTranscript(transcriptPath, 200);
40
+ if (messages.length < MIN_MESSAGES_FOR_HANDOFF) return makeEmptyOutput("handoff-generator");
41
+
42
+ const sessionId = input.sessionId || `session-${Date.now()}`;
43
+ const now = new Date();
44
+ const timestamp = now.toISOString();
45
+ const dateStr = timestamp.slice(0, 10);
46
+
47
+ // Try observer for rich summary, fall back to regex
48
+ const summary = await extractSummary(messages);
49
+ const handoff = summary
50
+ ? buildHandoffFromSummary(summary, messages, sessionId, dateStr)
51
+ : buildHandoff(messages, sessionId, dateStr);
52
+
53
+ // Use saveMemory API with dedup protection.
54
+ // semanticPayload = session ID + core summary fields.
55
+ // Include sessionId so different sessions never dedup even if content is similar.
56
+ // SessionSummary fields: request, investigated, learned, completed, nextSteps (all strings)
57
+ const semanticPayload = summary
58
+ ? [sessionId, summary.request, summary.investigated, summary.learned, summary.completed, summary.nextSteps].filter(Boolean).join("\n")
59
+ : [sessionId, extractSummaryLine(messages) || handoff].join("\n");
60
+
61
+ const handoffPath = `handoffs/${dateStr}-${sessionId.slice(0, 8)}.md`;
62
+ const result = store.saveMemory({
63
+ collection: "_clawmem",
64
+ path: handoffPath,
65
+ title: `Handoff ${dateStr}`,
66
+ body: handoff,
67
+ contentType: "handoff",
68
+ confidence: 0.60,
69
+ semanticPayload,
70
+ });
71
+
72
+ if (result.action === 'deduplicated') {
73
+ process.stderr.write(`[handoff-generator] Dedup: existing handoff within window (doc ${result.docId}, count=${result.duplicateCount})\n`);
74
+ }
75
+
76
+ // Update session record with handoff path
77
+ try {
78
+ store.updateSession(sessionId, {
79
+ endedAt: timestamp,
80
+ handoffPath,
81
+ summary: extractSummaryLine(messages),
82
+ });
83
+ } catch {
84
+ // Non-fatal
85
+ }
86
+
87
+ // Extract files changed from transcript
88
+ const filesChanged = extractFilesChanged(messages);
89
+ if (filesChanged.length > 0) {
90
+ try {
91
+ store.updateSession(sessionId, { filesChanged });
92
+ } catch { /* non-fatal */ }
93
+
94
+ // Trigger directory context update if enabled
95
+ const config = loadConfig();
96
+ if (config.directoryContext) {
97
+ try {
98
+ updateDirectoryContext(store, filesChanged);
99
+ } catch { /* non-fatal */ }
100
+ }
101
+ }
102
+
103
+ return makeContextOutput(
104
+ "handoff-generator",
105
+ `<vault-handoff>Handoff note saved: ${handoffPath}</vault-handoff>`
106
+ );
107
+ }
108
+
109
+ // =============================================================================
110
+ // Observer-based Handoff Builder
111
+ // =============================================================================
112
+
113
+ function buildHandoffFromSummary(
114
+ summary: SessionSummary,
115
+ messages: TranscriptMessage[],
116
+ sessionId: string,
117
+ dateStr: string
118
+ ): string {
119
+ const filesChanged = extractFilesChanged(messages);
120
+
121
+ const lines = [
122
+ `---`,
123
+ `content_type: handoff`,
124
+ `tags: [auto-generated, observer]`,
125
+ `---`,
126
+ ``,
127
+ `# Session Handoff — ${dateStr}`,
128
+ ``,
129
+ `Session: \`${sessionId.slice(0, 8)}\``,
130
+ ``,
131
+ ];
132
+
133
+ if (summary.request !== "None") {
134
+ lines.push(`## Request`, ``, summary.request, ``);
135
+ }
136
+
137
+ if (summary.investigated !== "None") {
138
+ lines.push(`## What Was Investigated`, ``, summary.investigated, ``);
139
+ }
140
+
141
+ if (summary.learned !== "None") {
142
+ lines.push(`## What Was Learned`, ``, summary.learned, ``);
143
+ }
144
+
145
+ if (summary.completed !== "None") {
146
+ lines.push(`## What Was Done`, ``, summary.completed, ``);
147
+ }
148
+
149
+ if (filesChanged.length > 0) {
150
+ lines.push(`## Files Changed`, ``);
151
+ for (const f of filesChanged.slice(0, 20)) {
152
+ lines.push(`- \`${f}\``);
153
+ }
154
+ lines.push(``);
155
+ }
156
+
157
+ if (summary.nextSteps !== "None") {
158
+ lines.push(`## Next Session Should`, ``, summary.nextSteps, ``);
159
+ }
160
+
161
+ return lines.join("\n");
162
+ }
163
+
164
+ // =============================================================================
165
+ // Regex-based Handoff Builder (Fallback)
166
+ // =============================================================================
167
+
168
+ function buildHandoff(
169
+ messages: TranscriptMessage[],
170
+ sessionId: string,
171
+ dateStr: string
172
+ ): string {
173
+ const topics = extractTopics(messages);
174
+ const actions = extractActions(messages);
175
+ const nextSteps = extractNextSteps(messages);
176
+ const filesChanged = extractFilesChanged(messages);
177
+
178
+ const lines = [
179
+ `---`,
180
+ `content_type: handoff`,
181
+ `tags: [auto-generated]`,
182
+ `---`,
183
+ ``,
184
+ `# Session Handoff — ${dateStr}`,
185
+ ``,
186
+ `Session: \`${sessionId.slice(0, 8)}\``,
187
+ ``,
188
+ ];
189
+
190
+ if (topics.length > 0) {
191
+ lines.push(`## Current State`, ``);
192
+ for (const topic of topics) {
193
+ lines.push(`- ${topic}`);
194
+ }
195
+ lines.push(``);
196
+ }
197
+
198
+ if (actions.length > 0) {
199
+ lines.push(`## What Was Done`, ``);
200
+ for (const action of actions) {
201
+ lines.push(`- ${action}`);
202
+ }
203
+ lines.push(``);
204
+ }
205
+
206
+ if (filesChanged.length > 0) {
207
+ lines.push(`## Files Changed`, ``);
208
+ for (const f of filesChanged.slice(0, 20)) {
209
+ lines.push(`- \`${f}\``);
210
+ }
211
+ lines.push(``);
212
+ }
213
+
214
+ if (nextSteps.length > 0) {
215
+ lines.push(`## Next Session Should`, ``);
216
+ for (const step of nextSteps) {
217
+ lines.push(`- ${step}`);
218
+ }
219
+ lines.push(``);
220
+ }
221
+
222
+ return lines.join("\n");
223
+ }
224
+
225
+ // =============================================================================
226
+ // Content Extraction
227
+ // =============================================================================
228
+
229
+ function extractTopics(messages: TranscriptMessage[]): string[] {
230
+ const topics: string[] = [];
231
+ const seen = new Set<string>();
232
+
233
+ // Get themes from user messages
234
+ for (const msg of messages) {
235
+ if (msg.role !== "user") continue;
236
+ const first = msg.content.split("\n")[0]?.trim();
237
+ if (!first || first.length < 10 || first.length > 200) continue;
238
+ if (first.startsWith("/")) continue; // slash commands
239
+
240
+ const key = first.slice(0, 50).toLowerCase();
241
+ if (seen.has(key)) continue;
242
+ seen.add(key);
243
+ topics.push(first);
244
+ }
245
+
246
+ return topics.slice(0, 5);
247
+ }
248
+
249
+ function extractActions(messages: TranscriptMessage[]): string[] {
250
+ const actions: string[] = [];
251
+ const seen = new Set<string>();
252
+
253
+ const actionPatterns = [
254
+ /\b(?:created|wrote|added|implemented|built|set up|configured|installed|fixed|updated|modified|refactored|deleted|removed)\b/i,
255
+ ];
256
+
257
+ for (const msg of messages) {
258
+ if (msg.role !== "assistant") continue;
259
+
260
+ const sentences = msg.content.split(/(?<=[.!?])\s+/);
261
+ for (const sentence of sentences) {
262
+ if (sentence.length < 15 || sentence.length > 300) continue;
263
+ if (!actionPatterns.some(p => p.test(sentence))) continue;
264
+
265
+ const key = sentence.slice(0, 60).toLowerCase();
266
+ if (seen.has(key)) continue;
267
+ seen.add(key);
268
+ actions.push(sentence.trim());
269
+ }
270
+ }
271
+
272
+ return actions.slice(0, 10);
273
+ }
274
+
275
+ function extractNextSteps(messages: TranscriptMessage[]): string[] {
276
+ const nextSteps: string[] = [];
277
+ const seen = new Set<string>();
278
+
279
+ const nextPatterns = [
280
+ /\bnext\s+(?:step|task|we\s+(?:need|should|can)|up|thing)\b/i,
281
+ /\btodo\b/i,
282
+ /\bremaining\b/i,
283
+ /\blater\b.*\b(?:we|you)\s+(?:can|should|need)\b/i,
284
+ /\bstill\s+need\s+to\b/i,
285
+ /\bnot\s+yet\s+(?:done|implemented|completed)\b/i,
286
+ ];
287
+
288
+ // Scan last 30 messages (most relevant for next steps)
289
+ const tail = messages.slice(-30);
290
+ for (const msg of tail) {
291
+ if (msg.role !== "assistant") continue;
292
+
293
+ const sentences = msg.content.split(/(?<=[.!?])\s+/);
294
+ for (const sentence of sentences) {
295
+ if (sentence.length < 15 || sentence.length > 300) continue;
296
+ if (!nextPatterns.some(p => p.test(sentence))) continue;
297
+
298
+ const key = sentence.slice(0, 60).toLowerCase();
299
+ if (seen.has(key)) continue;
300
+ seen.add(key);
301
+ nextSteps.push(sentence.trim());
302
+ }
303
+ }
304
+
305
+ return nextSteps.slice(0, 5);
306
+ }
307
+
308
+ const MAX_FILES_EXTRACTED = 200;
309
+
310
+ function extractFilesChanged(messages: TranscriptMessage[]): string[] {
311
+ const files = new Set<string>();
312
+
313
+ const filePatterns = [
314
+ /(?:created|wrote|edited|modified|updated|deleted)\s+(?:file\s+)?[`"]?([^\s`"]+\.\w{1,10})[`"]?/gi,
315
+ /(?:Write|Edit|Read)\s+tool.*?[`"]([^\s`"]+\.\w{1,10})[`"]?/gi,
316
+ /^\s*(?:[-+]){3}\s+(a|b)\/(.+\.\w{1,10})/gm,
317
+ ];
318
+
319
+ for (const msg of messages) {
320
+ if (msg.role !== "assistant") continue;
321
+ if (files.size >= MAX_FILES_EXTRACTED) break;
322
+ for (const pattern of filePatterns) {
323
+ pattern.lastIndex = 0;
324
+ let match;
325
+ while ((match = pattern.exec(msg.content)) !== null) {
326
+ if (files.size >= MAX_FILES_EXTRACTED) break;
327
+ const file = match[2] || match[1];
328
+ if (file && !file.includes("*") && file.length < 200) {
329
+ files.add(file);
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ return [...files];
336
+ }
337
+
338
+ function extractSummaryLine(messages: TranscriptMessage[]): string {
339
+ // Get first user message as summary theme
340
+ const firstUser = messages.find(m => m.role === "user");
341
+ if (!firstUser) return "Unknown session";
342
+
343
+ const first = firstUser.content.split("\n")[0]?.trim() || "";
344
+ return first.length > 100 ? first.slice(0, 100) + "..." : first;
345
+ }