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
package/src/clawmem.ts ADDED
@@ -0,0 +1,1849 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * ClawMem CLI - Hybrid agent memory (QMD search + SAME memory layer)
4
+ */
5
+
6
+ import { parseArgs } from "util";
7
+ import { existsSync, mkdirSync, readFileSync } from "fs";
8
+ import { resolve as pathResolve, basename } from "path";
9
+ import {
10
+ createStore,
11
+ enableProductionMode,
12
+ getDefaultDbPath,
13
+ canonicalDocId,
14
+ type Store,
15
+ type SearchResult,
16
+ DEFAULT_EMBED_MODEL,
17
+ DEFAULT_QUERY_MODEL,
18
+ DEFAULT_RERANK_MODEL,
19
+ DEFAULT_GLOB,
20
+ extractSnippet,
21
+ } from "./store.ts";
22
+ import {
23
+ getDefaultLlamaCpp,
24
+ setDefaultLlamaCpp,
25
+ disposeDefaultLlamaCpp,
26
+ formatDocForEmbedding,
27
+ formatQueryForEmbedding,
28
+ LlamaCpp,
29
+ type Queryable,
30
+ } from "./llm.ts";
31
+ import {
32
+ loadConfig,
33
+ addCollection as collectionsAdd,
34
+ removeCollection as collectionsRemove,
35
+ listCollections as collectionsList,
36
+ getCollection,
37
+ isValidCollectionName,
38
+ getConfigPath,
39
+ } from "./collections.ts";
40
+ import { formatSearchResults, type OutputFormat } from "./formatter.ts";
41
+ import { indexCollection, parseDocument } from "./indexer.ts";
42
+ import { detectBeadsProject } from "./beads.ts";
43
+ import { applyCompositeScoring, hasRecencyIntent, type EnrichedResult } from "./memory.ts";
44
+ import { enrichResults, reciprocalRankFusion, toRanked, type RankedResult } from "./search-utils.ts";
45
+ import { splitDocument } from "./splitter.ts";
46
+ import { getProfile, updateProfile, isProfileStale } from "./profile.ts";
47
+ import { regenerateAllDirectoryContexts } from "./directory-context.ts";
48
+ import { readHookInput, writeHookOutput, makeEmptyOutput, type HookOutput } from "./hooks.ts";
49
+ import { contextSurfacing } from "./hooks/context-surfacing.ts";
50
+ import { sessionBootstrap } from "./hooks/session-bootstrap.ts";
51
+ import { decisionExtractor } from "./hooks/decision-extractor.ts";
52
+ import { handoffGenerator } from "./hooks/handoff-generator.ts";
53
+ import { feedbackLoop } from "./hooks/feedback-loop.ts";
54
+ import { stalenessCheck } from "./hooks/staleness-check.ts";
55
+ import { precompactExtract } from "./hooks/precompact-extract.ts";
56
+ import { postcompactInject } from "./hooks/postcompact-inject.ts";
57
+ import { pretoolInject } from "./hooks/pretool-inject.ts";
58
+ import { curatorNudge } from "./hooks/curator-nudge.ts";
59
+
60
+ enableProductionMode();
61
+
62
+ // =============================================================================
63
+ // Store lifecycle
64
+ // =============================================================================
65
+
66
+ let store: Store | null = null;
67
+
68
+ function getStore(): Store {
69
+ if (!store) {
70
+ store = createStore(undefined, { busyTimeout: 5000 });
71
+ }
72
+ return store;
73
+ }
74
+
75
+ function closeStore(): void {
76
+ if (store) {
77
+ store.close();
78
+ store = null;
79
+ }
80
+ }
81
+
82
+ // =============================================================================
83
+ // Terminal colors
84
+ // =============================================================================
85
+
86
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
87
+ const c = {
88
+ reset: useColor ? "\x1b[0m" : "",
89
+ dim: useColor ? "\x1b[2m" : "",
90
+ bold: useColor ? "\x1b[1m" : "",
91
+ cyan: useColor ? "\x1b[36m" : "",
92
+ yellow: useColor ? "\x1b[33m" : "",
93
+ green: useColor ? "\x1b[32m" : "",
94
+ red: useColor ? "\x1b[31m" : "",
95
+ magenta: useColor ? "\x1b[35m" : "",
96
+ blue: useColor ? "\x1b[34m" : "",
97
+ };
98
+
99
+ // =============================================================================
100
+ // Helpers
101
+ // =============================================================================
102
+
103
+ function die(msg: string): never {
104
+ console.error(`${c.red}Error:${c.reset} ${msg}`);
105
+ process.exit(1);
106
+ }
107
+
108
+ // =============================================================================
109
+ // Commands
110
+ // =============================================================================
111
+
112
+ async function cmdInit() {
113
+ const cacheDir = getDefaultDbPath().replace(/\/[^/]+$/, "");
114
+ if (!existsSync(cacheDir)) {
115
+ mkdirSync(cacheDir, { recursive: true });
116
+ }
117
+
118
+ // Create store (initializes DB)
119
+ const s = getStore();
120
+ const configPath = getConfigPath();
121
+
122
+ console.log(`${c.green}ClawMem initialized${c.reset}`);
123
+ console.log(` Database: ${s.dbPath}`);
124
+ console.log(` Config: ${configPath}`);
125
+ console.log();
126
+ console.log("Next steps:");
127
+ console.log(` clawmem collection add ~/notes --name notes`);
128
+ console.log(` clawmem update`);
129
+ console.log(` clawmem embed`);
130
+ }
131
+
132
+ async function cmdCollectionAdd(args: string[]) {
133
+ const { values, positionals } = parseArgs({
134
+ args,
135
+ options: {
136
+ name: { type: "string" },
137
+ pattern: { type: "string", default: DEFAULT_GLOB },
138
+ },
139
+ allowPositionals: true,
140
+ });
141
+
142
+ const dirPath = positionals[0];
143
+ if (!dirPath) die("Usage: clawmem collection add <path> --name <name>");
144
+
145
+ const absPath = pathResolve(dirPath);
146
+ if (!existsSync(absPath)) die(`Directory not found: ${absPath}`);
147
+
148
+ const name = values.name || basename(absPath).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
149
+ if (!isValidCollectionName(name)) die(`Invalid collection name: ${name}`);
150
+
151
+ collectionsAdd(name, absPath, values.pattern);
152
+ console.log(`${c.green}Added collection '${name}'${c.reset} → ${absPath}`);
153
+ console.log(` Pattern: ${values.pattern}`);
154
+ console.log();
155
+ console.log(`Run ${c.cyan}clawmem update${c.reset} to index files`);
156
+ }
157
+
158
+ async function cmdCollectionList() {
159
+ const collections = collectionsList();
160
+ if (collections.length === 0) {
161
+ console.log("No collections configured.");
162
+ console.log(`Add one with: ${c.cyan}clawmem collection add <path> --name <name>${c.reset}`);
163
+ return;
164
+ }
165
+
166
+ for (const col of collections) {
167
+ const s = getStore();
168
+ const count = (s.db.prepare(
169
+ "SELECT COUNT(*) as c FROM documents WHERE collection = ? AND active = 1"
170
+ ).get(col.name) as { c: number }).c;
171
+
172
+ console.log(`${c.bold}${col.name}${c.reset}`);
173
+ console.log(` Path: ${col.path}`);
174
+ console.log(` Pattern: ${col.pattern}`);
175
+ console.log(` Files: ${count}`);
176
+ if (col.update) console.log(` Update: ${col.update}`);
177
+ console.log();
178
+ }
179
+ }
180
+
181
+ async function cmdCollectionRemove(args: string[]) {
182
+ const name = args[0];
183
+ if (!name) die("Usage: clawmem collection remove <name>");
184
+
185
+ if (collectionsRemove(name)) {
186
+ console.log(`${c.green}Removed collection '${name}'${c.reset}`);
187
+ } else {
188
+ die(`Collection '${name}' not found`);
189
+ }
190
+ }
191
+
192
+ async function cmdUpdate(args: string[]) {
193
+ const { values } = parseArgs({
194
+ args,
195
+ options: {
196
+ pull: { type: "boolean", default: false },
197
+ embed: { type: "boolean", default: false },
198
+ },
199
+ allowPositionals: false,
200
+ });
201
+
202
+ const collections = collectionsList();
203
+ if (collections.length === 0) die("No collections configured. Add one first.");
204
+
205
+ const s = getStore();
206
+
207
+ for (const col of collections) {
208
+ // Run pre-update command if configured
209
+ if (values.pull && col.update) {
210
+ console.log(`${c.dim}Running: ${col.update}${c.reset}`);
211
+ const result = Bun.spawnSync(["bash", "-c", col.update], { cwd: col.path });
212
+ if (result.exitCode !== 0) {
213
+ console.error(`${c.yellow}Warning: update command failed for ${col.name}${c.reset}`);
214
+ }
215
+ }
216
+
217
+ console.log(`${c.cyan}Indexing ${col.name}${c.reset} (${col.path})`);
218
+ const stats = await indexCollection(s, col.name, col.path, col.pattern);
219
+ console.log(` ${c.green}+${stats.added}${c.reset} added, ${c.yellow}~${stats.updated}${c.reset} updated, ${c.dim}=${stats.unchanged}${c.reset} unchanged, ${c.red}-${stats.removed}${c.reset} removed`);
220
+ }
221
+
222
+ // Auto-embed if --embed flag is set
223
+ if (values.embed) {
224
+ console.log();
225
+ await cmdEmbed([]);
226
+ } else {
227
+ console.log();
228
+ console.log(`Run ${c.cyan}clawmem embed${c.reset} to generate embeddings for new content`);
229
+ }
230
+
231
+ // Auto-rebuild profile if stale
232
+ if (isProfileStale(s)) {
233
+ updateProfile(s);
234
+ console.log(`${c.dim}Profile auto-rebuilt (stale)${c.reset}`);
235
+ }
236
+ }
237
+
238
+ async function cmdEmbed(args: string[]) {
239
+ const { values } = parseArgs({
240
+ args,
241
+ options: { force: { type: "boolean", short: "f", default: false } },
242
+ allowPositionals: false,
243
+ });
244
+
245
+ const s = getStore();
246
+
247
+ if (values.force) {
248
+ console.log(`${c.yellow}Force mode: clearing all embeddings${c.reset}`);
249
+ s.clearAllEmbeddings();
250
+ }
251
+
252
+ // Clean stale embeddings (orphaned hashes from updated/deleted documents)
253
+ const cleaned = s.cleanStaleEmbeddings();
254
+ if (cleaned > 0) {
255
+ console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
256
+ }
257
+
258
+ // Use fragment-based pipeline: split documents into semantic fragments and embed each
259
+ const hashes = s.getHashesNeedingFragments();
260
+ if (hashes.length === 0) {
261
+ console.log(`${c.green}All documents already embedded${c.reset}`);
262
+ return;
263
+ }
264
+
265
+ // Count total fragments first for ETA
266
+ let totalFragEstimate = 0;
267
+ const docFragCounts: number[] = [];
268
+ for (const { body, path } of hashes) {
269
+ let frontmatter: Record<string, any> | undefined;
270
+ try {
271
+ const parsed = parseDocument(body, path);
272
+ frontmatter = parsed.meta as any;
273
+ } catch { /* skip */ }
274
+ const frags = splitDocument(body, frontmatter);
275
+ docFragCounts.push(frags.length);
276
+ totalFragEstimate += frags.length;
277
+ }
278
+ console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
279
+
280
+ const embedUrl = process.env.CLAWMEM_EMBED_URL;
281
+ if (embedUrl) {
282
+ console.log(`Using remote GPU embedding: ${embedUrl}`);
283
+ } else {
284
+ // Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
285
+ setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
286
+ }
287
+ const llm = getDefaultLlamaCpp();
288
+ let embedded = 0;
289
+ let totalFragments = 0;
290
+ let failedFragments = 0;
291
+ const batchStart = Date.now();
292
+
293
+ // Cloud API: global batch pacing state (persists across documents)
294
+ // TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
295
+ const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
296
+ const CLOUD_BATCH_SIZE = 50;
297
+ const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
298
+ const CLOUD_TPM_SAFETY = 0.85;
299
+ const CHARS_PER_TOKEN = 4;
300
+ let lastBatchSentAt = 0;
301
+
302
+ for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
303
+ const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
304
+ const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
305
+ const canId = canonicalDocId(collection, path);
306
+
307
+ // Parse frontmatter for fragment splitting
308
+ let frontmatter: Record<string, any> | undefined;
309
+ try {
310
+ const parsed = parseDocument(body, path);
311
+ frontmatter = parsed.meta as any;
312
+ } catch {
313
+ // No frontmatter or parsing error — fine, skip it
314
+ }
315
+
316
+ const fragments = splitDocument(body, frontmatter);
317
+ const docStart = Date.now();
318
+ console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
319
+
320
+ if (isCloudEmbed) {
321
+ // Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
322
+ const allTexts: string[] = [];
323
+ for (const frag of fragments) {
324
+ const label = frag.label || title;
325
+ allTexts.push(formatDocForEmbedding(frag.content, label));
326
+ }
327
+
328
+ for (let batchStart = 0; batchStart < allTexts.length; batchStart += CLOUD_BATCH_SIZE) {
329
+ // Global TPM-aware delay: compute required wait based on last batch's token count,
330
+ // then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
331
+ // including first batch of each document (inter-document pacing).
332
+ if (lastBatchSentAt > 0) {
333
+ // Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
334
+ // Free: 100000 (default), Paid: 2000000, Premium: 50000000
335
+ const batchEnd0 = Math.min(batchStart + CLOUD_BATCH_SIZE, allTexts.length);
336
+ const estimatedTokens = allTexts.slice(batchStart, batchEnd0)
337
+ .reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
338
+ // Use current batch estimate (not previous batch actuals — previous batch may differ in size)
339
+ const batchTokens = estimatedTokens;
340
+ const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
341
+ const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
342
+ const elapsed = Date.now() - lastBatchSentAt;
343
+ const remainingMs = requiredGapMs - elapsed;
344
+ if (remainingMs > 0) {
345
+ const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
346
+ await new Promise(r => setTimeout(r, jittered));
347
+ }
348
+ }
349
+
350
+ const batchEnd = Math.min(batchStart + CLOUD_BATCH_SIZE, allTexts.length);
351
+ const batchTexts = allTexts.slice(batchStart, batchEnd);
352
+ lastBatchSentAt = Date.now();
353
+ const reqStart = Date.now();
354
+
355
+ try {
356
+ const results = await llm.embedBatch(batchTexts);
357
+ const reqMs = Date.now() - reqStart;
358
+ const tokensUsed = llm.lastBatchTokens;
359
+
360
+ for (let i = 0; i < results.length; i++) {
361
+ const seq = batchStart + i;
362
+ const frag = fragments[seq]!;
363
+ const result = results[i];
364
+ if (result) {
365
+ s.ensureVecTable(result.embedding.length);
366
+ s.insertEmbedding(
367
+ hash, seq, frag.startLine, new Float32Array(result.embedding),
368
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
369
+ );
370
+ totalFragments++;
371
+ } else {
372
+ failedFragments++;
373
+ }
374
+ }
375
+ console.error(` batch ${batchStart + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
376
+ } catch (err) {
377
+ failedFragments += batchTexts.length;
378
+ console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStart + 1}-${batchEnd}: ${err}${c.reset}`);
379
+ }
380
+ }
381
+ } else {
382
+ // Local mode: embed one at a time (no rate limit concern)
383
+ for (let seq = 0; seq < fragments.length; seq++) {
384
+ const frag = fragments[seq]!;
385
+ const label = frag.label || title;
386
+ const text = formatDocForEmbedding(frag.content, label);
387
+
388
+ try {
389
+ const fragStart = Date.now();
390
+ const result = await llm.embed(text);
391
+ const fragMs = Date.now() - fragStart;
392
+ if (result) {
393
+ s.ensureVecTable(result.embedding.length);
394
+ s.insertEmbedding(
395
+ hash, seq, frag.startLine, new Float32Array(result.embedding),
396
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
397
+ );
398
+ totalFragments++;
399
+ if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
400
+ console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
401
+ }
402
+ } else {
403
+ failedFragments++;
404
+ console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) → null result [${text.length} chars]`);
405
+ }
406
+ } catch (err) {
407
+ failedFragments++;
408
+ console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
409
+ }
410
+ }
411
+ }
412
+
413
+ embedded++;
414
+ const docMs = Date.now() - docStart;
415
+ const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
416
+ console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
417
+ }
418
+
419
+ const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
420
+ console.log();
421
+ console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
422
+
423
+ await disposeDefaultLlamaCpp();
424
+ }
425
+
426
+ async function cmdStatus() {
427
+ const s = getStore();
428
+ const status = s.getStatus();
429
+
430
+ console.log(`${c.bold}ClawMem Status${c.reset}`);
431
+ console.log(` Database: ${s.dbPath}`);
432
+ console.log(` Documents: ${status.totalDocuments}`);
433
+ console.log(` Unembedded: ${status.needsEmbedding}`);
434
+ console.log(` Vectors: ${status.hasVectorIndex ? "yes" : "no"}`);
435
+ console.log();
436
+
437
+ if (status.collections.length > 0) {
438
+ console.log(`${c.bold}Collections:${c.reset}`);
439
+ for (const col of status.collections) {
440
+ console.log(` ${col.name}: ${col.documents} docs (${col.path})`);
441
+ }
442
+ }
443
+
444
+ // SAME metadata stats
445
+ const types = s.db.prepare(`
446
+ SELECT content_type, COUNT(*) as cnt FROM documents WHERE active = 1 GROUP BY content_type ORDER BY cnt DESC
447
+ `).all() as { content_type: string; cnt: number }[];
448
+
449
+ if (types.length > 0) {
450
+ console.log();
451
+ console.log(`${c.bold}Content Types:${c.reset}`);
452
+ for (const t of types) {
453
+ console.log(` ${t.content_type}: ${t.cnt}`);
454
+ }
455
+ }
456
+
457
+ const sessions = s.db.prepare("SELECT COUNT(*) as cnt FROM session_log").get() as { cnt: number };
458
+ if (sessions.cnt > 0) {
459
+ console.log();
460
+ console.log(`${c.bold}Sessions:${c.reset} ${sessions.cnt} tracked`);
461
+ }
462
+ }
463
+
464
+ async function cmdSearch(args: string[]) {
465
+ const { values, positionals } = parseArgs({
466
+ args,
467
+ options: {
468
+ num: { type: "string", short: "n", default: "10" },
469
+ collection: { type: "string", short: "c" },
470
+ json: { type: "boolean", default: false },
471
+ "min-score": { type: "string", default: "0" },
472
+ },
473
+ allowPositionals: true,
474
+ });
475
+
476
+ const query = positionals.join(" ");
477
+ if (!query) die("Usage: clawmem search <query>");
478
+
479
+ const s = getStore();
480
+ const limit = parseInt(values.num!, 10);
481
+ const minScore = parseFloat(values["min-score"]!);
482
+
483
+ const results = s.searchFTS(query, limit * 2);
484
+ const enriched = enrichResults(s, results, query);
485
+ const scored = applyCompositeScoring(enriched, query)
486
+ .filter(r => r.compositeScore >= minScore)
487
+ .slice(0, limit);
488
+
489
+ if (values.json) {
490
+ console.log(JSON.stringify(scored.map(r => ({
491
+ file: r.displayPath,
492
+ title: r.title,
493
+ score: r.compositeScore,
494
+ searchScore: r.score,
495
+ recencyScore: r.recencyScore,
496
+ contentType: r.contentType,
497
+ })), null, 2));
498
+ } else {
499
+ printResults(scored, query);
500
+ }
501
+ }
502
+
503
+ async function cmdVsearch(args: string[]) {
504
+ const { values, positionals } = parseArgs({
505
+ args,
506
+ options: {
507
+ num: { type: "string", short: "n", default: "10" },
508
+ collection: { type: "string", short: "c" },
509
+ json: { type: "boolean", default: false },
510
+ "min-score": { type: "string", default: "0.3" },
511
+ },
512
+ allowPositionals: true,
513
+ });
514
+
515
+ const query = positionals.join(" ");
516
+ if (!query) die("Usage: clawmem vsearch <query>");
517
+
518
+ const s = getStore();
519
+ const limit = parseInt(values.num!, 10);
520
+ const minScore = parseFloat(values["min-score"]!);
521
+
522
+ const results = await s.searchVec(query, DEFAULT_EMBED_MODEL, limit * 2);
523
+ const enriched = enrichResults(s, results, query);
524
+ const scored = applyCompositeScoring(enriched, query)
525
+ .filter(r => r.compositeScore >= minScore)
526
+ .slice(0, limit);
527
+
528
+ if (values.json) {
529
+ console.log(JSON.stringify(scored.map(r => ({
530
+ file: r.displayPath,
531
+ title: r.title,
532
+ score: r.compositeScore,
533
+ searchScore: r.score,
534
+ recencyScore: r.recencyScore,
535
+ contentType: r.contentType,
536
+ })), null, 2));
537
+ } else {
538
+ printResults(scored, query);
539
+ }
540
+
541
+ await disposeDefaultLlamaCpp();
542
+ }
543
+
544
+ async function cmdQuery(args: string[]) {
545
+ const { values, positionals } = parseArgs({
546
+ args,
547
+ options: {
548
+ num: { type: "string", short: "n", default: "10" },
549
+ collection: { type: "string", short: "c" },
550
+ json: { type: "boolean", default: false },
551
+ "min-score": { type: "string", default: "0" },
552
+ },
553
+ allowPositionals: true,
554
+ });
555
+
556
+ const query = positionals.join(" ");
557
+ if (!query) die("Usage: clawmem query <query>");
558
+
559
+ const s = getStore();
560
+ const limit = parseInt(values.num!, 10);
561
+ const minScore = parseFloat(values["min-score"]!);
562
+
563
+ // Step 1: BM25 for strong signal check
564
+ const ftsResults = s.searchFTS(query, 20);
565
+ const topScore = ftsResults[0]?.score ?? 0;
566
+ const secondScore = ftsResults[1]?.score ?? 0;
567
+ const strongSignal = topScore >= 0.85 && (topScore - secondScore) >= 0.15;
568
+
569
+ // Step 2: Query expansion (skip if strong BM25 signal)
570
+ let expandedQueries: { type: string; text: string }[] = [];
571
+ if (!strongSignal) {
572
+ try {
573
+ const expanded = await s.expandQuery(query, DEFAULT_QUERY_MODEL);
574
+ expandedQueries = expanded.map(text => {
575
+ // Parse "type: text" format from expansion
576
+ const colonIdx = text.indexOf(": ");
577
+ if (colonIdx > 0 && colonIdx < 5) {
578
+ return { type: text.slice(0, colonIdx), text: text.slice(colonIdx + 2) };
579
+ }
580
+ return { type: "vec", text };
581
+ });
582
+ } catch {
583
+ // Fallback: no expansion
584
+ }
585
+ }
586
+
587
+ // Step 3: Parallel searches
588
+ const allRanked: { results: RankedResult[]; weight: number }[] = [];
589
+
590
+ // Original query BM25 + vec (weight 2x)
591
+ allRanked.push({ results: ftsResults.map(toRanked), weight: 2 });
592
+ const vecResults = await s.searchVec(query, DEFAULT_EMBED_MODEL, 20);
593
+ allRanked.push({ results: vecResults.map(toRanked), weight: 2 });
594
+
595
+ // Expanded queries (weight 1x)
596
+ for (const eq of expandedQueries) {
597
+ if (eq.type === "lex") {
598
+ const r = s.searchFTS(eq.text, 20);
599
+ allRanked.push({ results: r.map(toRanked), weight: 1 });
600
+ } else {
601
+ const r = await s.searchVec(eq.text, DEFAULT_EMBED_MODEL, 20);
602
+ allRanked.push({ results: r.map(toRanked), weight: 1 });
603
+ }
604
+ }
605
+
606
+ // Step 4: RRF fusion
607
+ const rrfResults = reciprocalRankFusion(
608
+ allRanked.map(a => a.results),
609
+ allRanked.map(a => a.weight),
610
+ 60
611
+ );
612
+
613
+ // Step 5: Take top 30 for reranking
614
+ const candidates = rrfResults.slice(0, 30);
615
+
616
+ // Step 6: Rerank
617
+ let reranked: { file: string; score: number }[] = [];
618
+ try {
619
+ const docs = candidates.map(r => ({ file: r.file, text: r.body.slice(0, 4000) }));
620
+ reranked = await s.rerank(query, docs, DEFAULT_RERANK_MODEL);
621
+ } catch {
622
+ reranked = candidates.map(r => ({ file: r.file, score: r.score }));
623
+ }
624
+
625
+ // Step 7: Position-aware blending
626
+ const rrfRankMap = new Map(candidates.map((r, i) => [r.file, i + 1]));
627
+ const blended = reranked.map(r => {
628
+ const rrfRank = rrfRankMap.get(r.file) || candidates.length;
629
+ let rrfWeight: number;
630
+ if (rrfRank <= 3) rrfWeight = 0.75;
631
+ else if (rrfRank <= 10) rrfWeight = 0.60;
632
+ else rrfWeight = 0.40;
633
+
634
+ const blendedScore = rrfWeight * (1 / rrfRank) + (1 - rrfWeight) * r.score;
635
+ return { file: r.file, score: blendedScore };
636
+ });
637
+ blended.sort((a, b) => b.score - a.score);
638
+
639
+ // Step 8: Map back to full results and apply composite scoring
640
+ const resultMap = new Map(
641
+ [...ftsResults, ...vecResults].map(r => [r.filepath, r])
642
+ );
643
+ const fullResults = blended
644
+ .map(b => resultMap.get(b.file))
645
+ .filter((r): r is SearchResult => r !== undefined)
646
+ .map(r => ({ ...r, score: blended.find(b => b.file === r.filepath)?.score ?? r.score }));
647
+
648
+ const enriched = enrichResults(s, fullResults, query);
649
+ const scored = applyCompositeScoring(enriched, query)
650
+ .filter(r => r.compositeScore >= minScore)
651
+ .slice(0, limit);
652
+
653
+ if (values.json) {
654
+ console.log(JSON.stringify(scored.map(r => ({
655
+ file: r.displayPath,
656
+ title: r.title,
657
+ score: r.compositeScore,
658
+ searchScore: r.score,
659
+ recencyScore: r.recencyScore,
660
+ contentType: r.contentType,
661
+ })), null, 2));
662
+ } else {
663
+ printResults(scored, query);
664
+ }
665
+
666
+ await disposeDefaultLlamaCpp();
667
+ }
668
+
669
+ function printResults(results: Array<{ displayPath: string; title: string; compositeScore: number; score: number; contentType: string; body?: string }>, query: string) {
670
+ if (results.length === 0) {
671
+ console.log(`${c.dim}No results found${c.reset}`);
672
+ return;
673
+ }
674
+
675
+ for (const r of results) {
676
+ const scoreBar = "█".repeat(Math.round(r.compositeScore * 10));
677
+ const scoreStr = r.compositeScore.toFixed(2);
678
+ const typeTag = r.contentType !== "note" ? ` ${c.magenta}[${r.contentType}]${c.reset}` : "";
679
+ console.log(`${c.cyan}${scoreStr}${c.reset} ${c.dim}${scoreBar}${c.reset} ${c.bold}${r.title}${c.reset}${typeTag}`);
680
+ console.log(` ${c.dim}${r.displayPath}${c.reset}`);
681
+
682
+ if (r.body) {
683
+ const snippet = extractSnippet(r.body, query, 200);
684
+ const lines = snippet.snippet.split("\n").slice(1, 4); // Skip header line
685
+ for (const line of lines) {
686
+ console.log(` ${c.dim}${line.trim()}${c.reset}`);
687
+ }
688
+ }
689
+ console.log();
690
+ }
691
+ }
692
+
693
+ // =============================================================================
694
+ // Hook dispatch
695
+ // =============================================================================
696
+
697
+ async function cmdHook(args: string[]) {
698
+ const hookName = args[0];
699
+ if (!hookName) die("Usage: clawmem hook <name>");
700
+
701
+ const input = await readHookInput();
702
+ const s = getStore();
703
+ let output: HookOutput;
704
+
705
+ try {
706
+ switch (hookName) {
707
+ case "context-surfacing":
708
+ output = await contextSurfacing(s, input);
709
+ break;
710
+ case "session-bootstrap":
711
+ output = await sessionBootstrap(s, input);
712
+ break;
713
+ case "decision-extractor":
714
+ output = await decisionExtractor(s, input);
715
+ break;
716
+ case "handoff-generator":
717
+ output = await handoffGenerator(s, input);
718
+ break;
719
+ case "feedback-loop":
720
+ output = await feedbackLoop(s, input);
721
+ break;
722
+ case "staleness-check":
723
+ output = await stalenessCheck(s, input);
724
+ break;
725
+ case "precompact-extract":
726
+ output = await precompactExtract(s, input);
727
+ break;
728
+ case "postcompact-inject":
729
+ output = await postcompactInject(s, input);
730
+ break;
731
+ case "pretool-inject":
732
+ output = await pretoolInject(s, input);
733
+ break;
734
+ case "curator-nudge":
735
+ output = await curatorNudge(s, input);
736
+ break;
737
+ default:
738
+ die(`Unknown hook: ${hookName}. Available: context-surfacing, session-bootstrap, decision-extractor, handoff-generator, feedback-loop, staleness-check, precompact-extract, postcompact-inject, pretool-inject, curator-nudge`);
739
+ output = makeEmptyOutput(); // unreachable, satisfies TS
740
+ }
741
+ } catch (err) {
742
+ // Hooks must never crash — silent fallback
743
+ console.error(`Hook ${hookName} error: ${err}`);
744
+ output = makeEmptyOutput(hookName);
745
+ }
746
+
747
+ writeHookOutput(output);
748
+ }
749
+
750
+ // =============================================================================
751
+ // IO6: Surface command (pre-prompt context injection for daemon mode)
752
+ // =============================================================================
753
+
754
+ async function readStdinRaw(): Promise<string> {
755
+ const chunks: Uint8Array[] = [];
756
+ for await (const chunk of Bun.stdin.stream()) {
757
+ chunks.push(chunk);
758
+ }
759
+ return Buffer.concat(chunks).toString("utf-8").trim();
760
+ }
761
+
762
+ async function cmdSurface(args: string[]) {
763
+ const isBootstrap = args.includes("--bootstrap");
764
+ const isContext = args.includes("--context");
765
+ const useStdin = args.includes("--stdin");
766
+
767
+ if (!isBootstrap && !isContext) {
768
+ die("Usage: clawmem surface --context --stdin OR clawmem surface --bootstrap --stdin");
769
+ }
770
+
771
+ const input = useStdin ? await readStdinRaw() : args.find(a => !a.startsWith("--")) || "";
772
+ if (!input) process.exit(0);
773
+
774
+ // Open store: writable for both (context-surfacing writes dedupe data)
775
+ const s = createStore(undefined, { busyTimeout: 500 });
776
+
777
+ try {
778
+ if (isBootstrap) {
779
+ // IO6b: session-bootstrap + staleness-check
780
+ const sessionId = input.trim() || `io6-${Date.now()}`;
781
+
782
+ const bootstrapResult = await sessionBootstrap(s, {
783
+ prompt: "",
784
+ hookEventName: "io6-bootstrap",
785
+ sessionId,
786
+ transcriptPath: undefined,
787
+ });
788
+
789
+ const stalenessResult = await stalenessCheck(s, {
790
+ prompt: "",
791
+ hookEventName: "io6-staleness",
792
+ sessionId,
793
+ transcriptPath: undefined,
794
+ });
795
+
796
+ // Output both if present (bootstrap first, staleness appended)
797
+ const output = [
798
+ bootstrapResult.hookSpecificOutput?.additionalContext,
799
+ stalenessResult.hookSpecificOutput?.additionalContext,
800
+ ]
801
+ .filter(Boolean)
802
+ .join("\n");
803
+
804
+ if (output) process.stdout.write(output);
805
+ } else {
806
+ // IO6a: context-surfacing
807
+ if (input.length < 20) process.exit(0);
808
+
809
+ const result = await contextSurfacing(s, {
810
+ prompt: input,
811
+ hookEventName: "io6-context",
812
+ sessionId: undefined,
813
+ transcriptPath: undefined,
814
+ });
815
+
816
+ const ctx = result.hookSpecificOutput?.additionalContext;
817
+ if (ctx) process.stdout.write(ctx);
818
+ }
819
+ } finally {
820
+ s.close();
821
+ }
822
+ process.exit(0);
823
+ }
824
+
825
+ async function cmdBudget(args: string[]) {
826
+ const { values } = parseArgs({
827
+ args,
828
+ options: { session: { type: "string" }, last: { type: "string", default: "5" } },
829
+ allowPositionals: false,
830
+ });
831
+
832
+ const s = getStore();
833
+
834
+ if (values.session) {
835
+ const usages = s.getUsageForSession(values.session);
836
+ if (usages.length === 0) {
837
+ console.log(`No usage records for session ${values.session}`);
838
+ return;
839
+ }
840
+ for (const u of usages) {
841
+ const paths = JSON.parse(u.injectedPaths) as string[];
842
+ console.log(`${c.dim}${u.timestamp}${c.reset} ${c.cyan}${u.hookName}${c.reset} ${u.estimatedTokens} tokens, ${paths.length} notes ${u.wasReferenced ? c.green + "referenced" + c.reset : c.dim + "not referenced" + c.reset}`);
843
+ }
844
+ } else {
845
+ const sessions = s.getRecentSessions(parseInt(values.last!, 10));
846
+ if (sessions.length === 0) {
847
+ console.log("No sessions tracked yet.");
848
+ return;
849
+ }
850
+ for (const sess of sessions) {
851
+ const usages = s.getUsageForSession(sess.sessionId);
852
+ const totalTokens = usages.reduce((sum, u) => sum + u.estimatedTokens, 0);
853
+ const refCount = usages.filter(u => u.wasReferenced).length;
854
+ console.log(`${c.bold}${sess.sessionId.slice(0, 8)}${c.reset} ${c.dim}${sess.startedAt}${c.reset} ${totalTokens} tokens, ${refCount}/${usages.length} referenced`);
855
+ if (sess.summary) console.log(` ${c.dim}${sess.summary.slice(0, 80)}${c.reset}`);
856
+ }
857
+ }
858
+ }
859
+
860
+ async function cmdLog(args: string[]) {
861
+ const { values } = parseArgs({
862
+ args,
863
+ options: { last: { type: "string", default: "10" } },
864
+ allowPositionals: false,
865
+ });
866
+
867
+ const s = getStore();
868
+ const sessions = s.getRecentSessions(parseInt(values.last!, 10));
869
+
870
+ if (sessions.length === 0) {
871
+ console.log("No sessions tracked.");
872
+ return;
873
+ }
874
+
875
+ for (const sess of sessions) {
876
+ const duration = sess.endedAt
877
+ ? `${Math.round((new Date(sess.endedAt).getTime() - new Date(sess.startedAt).getTime()) / 60000)}min`
878
+ : "active";
879
+ console.log(`${c.bold}${sess.sessionId.slice(0, 8)}${c.reset} ${c.dim}${sess.startedAt}${c.reset} (${duration})`);
880
+ if (sess.handoffPath) console.log(` Handoff: ${sess.handoffPath}`);
881
+ if (sess.summary) console.log(` ${sess.summary.slice(0, 100)}`);
882
+ if (sess.filesChanged.length > 0) console.log(` Files: ${sess.filesChanged.slice(0, 5).join(", ")}`);
883
+ console.log();
884
+ }
885
+ }
886
+
887
+ // =============================================================================
888
+ // MCP Server
889
+ // =============================================================================
890
+
891
+ async function cmdMcp() {
892
+ enableMcpStdioMode();
893
+ const { startMcpServer } = await import("./mcp.ts");
894
+ await startMcpServer();
895
+ }
896
+
897
+ async function cmdServe(args: string[]) {
898
+ const port = parseInt(args.find((_, i, a) => a[i - 1] === "--port") || "7438", 10);
899
+ const host = args.find((_, i, a) => a[i - 1] === "--host") || "127.0.0.1";
900
+ const s = getStore();
901
+ const { startServer } = await import("./server.ts");
902
+ const server = startServer(s, port, host);
903
+ console.log(`ClawMem HTTP server listening on http://${host}:${port}`);
904
+ console.log(`Token auth: ${process.env.CLAWMEM_API_TOKEN ? "enabled" : "disabled (set CLAWMEM_API_TOKEN)"}`);
905
+ console.log(`Press Ctrl+C to stop.`);
906
+ await new Promise(() => {});
907
+ }
908
+
909
+ // In MCP stdio mode, stdout is reserved exclusively for JSON-RPC messages.
910
+ // Any accidental console.log/info/debug/warn output will corrupt the protocol stream.
911
+ function enableMcpStdioMode(): void {
912
+ process.env.CLAWMEM_STDIO_MODE = "true";
913
+ if (!process.env.NO_COLOR) process.env.NO_COLOR = "1";
914
+
915
+ const err = console.error.bind(console);
916
+ // Bun's console properties are writable; still guard in case of future changes.
917
+ try { (console as any).log = err; } catch {}
918
+ try { (console as any).info = err; } catch {}
919
+ try { (console as any).debug = err; } catch {}
920
+ try { (console as any).warn = err; } catch {}
921
+ }
922
+
923
+ // =============================================================================
924
+ // Setup Commands
925
+ // =============================================================================
926
+
927
+ async function cmdSetup(args: string[]) {
928
+ const subCmd = args[0];
929
+ switch (subCmd) {
930
+ case "hooks": await cmdSetupHooks(args.slice(1)); break;
931
+ case "mcp": await cmdSetupMcp(args.slice(1)); break;
932
+ case "curator": await cmdSetupCurator(args.slice(1)); break;
933
+ case "openclaw": await cmdSetupOpenClaw(args.slice(1)); break;
934
+ default: die("Usage: clawmem setup <hooks|mcp|curator|openclaw> [--remove]");
935
+ }
936
+ }
937
+
938
+ async function cmdSetupHooks(args: string[]) {
939
+ const remove = args.includes("--remove");
940
+ const settingsPath = pathResolve(process.env.HOME || "~", ".claude", "settings.json");
941
+
942
+ // Find clawmem binary
943
+ const binPath = findClawmemBinary();
944
+
945
+ let settings: any = {};
946
+ if (existsSync(settingsPath)) {
947
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
948
+ }
949
+
950
+ if (!settings.hooks) settings.hooks = {};
951
+
952
+ if (remove) {
953
+ // Remove clawmem hooks
954
+ for (const event of ["UserPromptSubmit", "Stop", "SessionStart", "PreCompact"]) {
955
+ if (settings.hooks[event]) {
956
+ settings.hooks[event] = settings.hooks[event].filter((entry: any) =>
957
+ !entry.hooks?.some((h: any) => h.command?.includes("clawmem"))
958
+ );
959
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
960
+ }
961
+ }
962
+ console.log(`${c.green}Removed ClawMem hooks from ${settingsPath}${c.reset}`);
963
+ } else {
964
+ // Install clawmem hooks
965
+ // Production-validated hook set:
966
+ // - session-bootstrap/staleness-check omitted: context-surfacing on first prompt
967
+ // handles retrieval more precisely, and postcompact-inject covers post-compaction.
968
+ // session-bootstrap adds ~2000 tokens before the user types anything.
969
+ // - timeout wrappers prevent hooks from blocking the session on GPU timeouts.
970
+ const hookConfig: Record<string, string[]> = {
971
+ UserPromptSubmit: ["context-surfacing"],
972
+ SessionStart: ["postcompact-inject", "curator-nudge"],
973
+ PreCompact: ["precompact-extract"],
974
+ Stop: ["decision-extractor", "handoff-generator", "feedback-loop"],
975
+ };
976
+
977
+ // Timeout per event type (seconds)
978
+ const timeouts: Record<string, number> = {
979
+ UserPromptSubmit: 5,
980
+ SessionStart: 5,
981
+ PreCompact: 5,
982
+ Stop: 10,
983
+ };
984
+
985
+ for (const [event, hooks] of Object.entries(hookConfig)) {
986
+ if (!settings.hooks[event]) settings.hooks[event] = [];
987
+
988
+ // Remove existing clawmem entries first
989
+ settings.hooks[event] = settings.hooks[event].filter((entry: any) =>
990
+ !entry.hooks?.some((h: any) => h.command?.includes("clawmem"))
991
+ );
992
+
993
+ const timeout = timeouts[event] || 5;
994
+
995
+ // Add new entries with timeout wrappers
996
+ settings.hooks[event].push({
997
+ matcher: "",
998
+ hooks: hooks.map(name => ({
999
+ type: "command",
1000
+ command: `timeout ${timeout} ${binPath} hook ${name}`,
1001
+ })),
1002
+ });
1003
+ }
1004
+
1005
+ console.log(`${c.green}Installed ClawMem hooks to ${settingsPath}${c.reset}`);
1006
+ for (const [event, hooks] of Object.entries(hookConfig)) {
1007
+ console.log(` ${event}: ${hooks.join(", ")}`);
1008
+ }
1009
+ }
1010
+
1011
+ const { writeFileSync: wfs } = await import("fs");
1012
+ const dir = pathResolve(process.env.HOME || "~", ".claude");
1013
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1014
+ wfs(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1015
+ }
1016
+
1017
+ async function cmdSetupMcp(args: string[]) {
1018
+ const remove = args.includes("--remove");
1019
+ const claudeJsonPath = pathResolve(process.env.HOME || "~", ".claude.json");
1020
+
1021
+ let config: any = {};
1022
+ if (existsSync(claudeJsonPath)) {
1023
+ config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1024
+ }
1025
+
1026
+ if (!config.mcpServers) config.mcpServers = {};
1027
+
1028
+ if (remove) {
1029
+ delete config.mcpServers.clawmem;
1030
+ console.log(`${c.green}Removed ClawMem MCP from ${claudeJsonPath}${c.reset}`);
1031
+ } else {
1032
+ const binPath = findClawmemBinary();
1033
+ config.mcpServers.clawmem = {
1034
+ command: binPath,
1035
+ args: ["mcp"],
1036
+ };
1037
+ console.log(`${c.green}Registered ClawMem MCP in ${claudeJsonPath}${c.reset}`);
1038
+ console.log(` Command: ${binPath} mcp`);
1039
+ }
1040
+
1041
+ const { writeFileSync: wfs } = await import("fs");
1042
+ wfs(claudeJsonPath, JSON.stringify(config, null, 2) + "\n");
1043
+ }
1044
+
1045
+ async function cmdSetupCurator(args: string[]) {
1046
+ const remove = args.includes("--remove");
1047
+ const agentsDir = pathResolve(process.env.HOME || "~", ".claude", "agents");
1048
+ const targetPath = pathResolve(agentsDir, "clawmem-curator.md");
1049
+ const sourcePath = pathResolve(import.meta.dir, "..", "agents", "clawmem-curator.md");
1050
+
1051
+ if (remove) {
1052
+ if (existsSync(targetPath)) {
1053
+ const { unlinkSync } = await import("fs");
1054
+ unlinkSync(targetPath);
1055
+ console.log(`${c.green}Removed curator agent from ${targetPath}${c.reset}`);
1056
+ } else {
1057
+ console.log(`${c.dim}Curator agent not installed at ${targetPath}${c.reset}`);
1058
+ }
1059
+ return;
1060
+ }
1061
+
1062
+ if (!existsSync(sourcePath)) {
1063
+ die(`Curator agent definition not found at ${sourcePath}`);
1064
+ }
1065
+
1066
+ if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true });
1067
+
1068
+ const { copyFileSync } = await import("fs");
1069
+ copyFileSync(sourcePath, targetPath);
1070
+ console.log(`${c.green}Installed curator agent to ${targetPath}${c.reset}`);
1071
+ console.log(` Trigger: "curate memory", "run curator", or "memory maintenance"`);
1072
+ }
1073
+
1074
+ function cmdPath() {
1075
+ console.log(getDefaultDbPath());
1076
+ }
1077
+
1078
+ async function cmdSetupOpenClaw(args: string[]) {
1079
+ const remove = args.includes("--remove");
1080
+ const binPath = findClawmemBinary();
1081
+ const pluginDir = pathResolve(import.meta.dir, "openclaw");
1082
+
1083
+ if (remove) {
1084
+ console.log(`${c.green}To remove ClawMem from OpenClaw:${c.reset}`);
1085
+ console.log(` 1. Remove the symlink: rm ~/.openclaw/extensions/clawmem`);
1086
+ console.log(` 2. Remove from config: openclaw config set plugins.slots.contextEngine legacy`);
1087
+ return;
1088
+ }
1089
+
1090
+ if (!existsSync(pathResolve(pluginDir, "index.ts"))) {
1091
+ die(`OpenClaw plugin files not found at ${pluginDir}`);
1092
+ }
1093
+
1094
+ console.log(`${c.green}ClawMem OpenClaw Plugin Setup${c.reset}`);
1095
+ console.log();
1096
+ console.log(`Plugin source: ${pluginDir}`);
1097
+ console.log(`ClawMem binary: ${binPath}`);
1098
+ console.log();
1099
+ console.log(`${c.bold}Installation steps:${c.reset}`);
1100
+ console.log();
1101
+ console.log(` 1. Symlink the plugin into OpenClaw extensions:`);
1102
+ console.log(` ${c.cyan}ln -s ${pluginDir} ~/.openclaw/extensions/clawmem${c.reset}`);
1103
+ console.log();
1104
+ console.log(` 2. Copy the plugin manifest:`);
1105
+ console.log(` ${c.cyan}cp ${pluginDir}/plugin.json ~/.openclaw/extensions/clawmem/openclaw.plugin.json${c.reset}`);
1106
+ console.log();
1107
+ console.log(` 3. Set ClawMem as the active context engine:`);
1108
+ console.log(` ${c.cyan}openclaw config set plugins.slots.contextEngine clawmem${c.reset}`);
1109
+ console.log();
1110
+ console.log(` 4. Configure GPU endpoints (if not using defaults):`);
1111
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuEmbed http://YOUR_GPU:8088${c.reset}`);
1112
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuLlm http://YOUR_GPU:8089${c.reset}`);
1113
+ console.log(` ${c.cyan}openclaw config set plugins.entries.clawmem.config.gpuRerank http://YOUR_GPU:8090${c.reset}`);
1114
+ console.log();
1115
+ console.log(` 5. Start the REST API (for tool calls):`);
1116
+ console.log(` ${c.cyan}clawmem serve &${c.reset}`);
1117
+ console.log();
1118
+ console.log(`${c.dim}ClawMem will work alongside Claude Code hooks — both modes share the same vault.${c.reset}`);
1119
+ }
1120
+
1121
+ function findClawmemBinary(): string {
1122
+ // Check common locations
1123
+ const candidates = [
1124
+ pathResolve(import.meta.dir, "..", "bin", "clawmem"),
1125
+ pathResolve(process.env.HOME || "~", ".local", "bin", "clawmem"),
1126
+ "/usr/local/bin/clawmem",
1127
+ ];
1128
+ for (const p of candidates) {
1129
+ if (existsSync(p)) return p;
1130
+ }
1131
+ return "clawmem"; // Assume in PATH
1132
+ }
1133
+
1134
+ // =============================================================================
1135
+ // Watch (File Watcher Daemon)
1136
+ // =============================================================================
1137
+
1138
+ async function cmdWatch() {
1139
+ const { startWatcher } = await import("./watcher.ts");
1140
+ const collections = collectionsList()
1141
+ .sort((a, b) => b.path.length - a.path.length); // Most specific path first for prefix matching
1142
+
1143
+ if (collections.length === 0) {
1144
+ die("No collections configured. Add one first: clawmem collection add <path> --name <name>");
1145
+ }
1146
+
1147
+ const dirs = collections.map(col => col.path);
1148
+ const s = getStore();
1149
+
1150
+ console.log(`${c.bold}Watching ${dirs.length} collection(s) for changes...${c.reset}`);
1151
+ for (const col of collections) {
1152
+ console.log(` ${c.dim}${col.name}: ${col.path}${c.reset}`);
1153
+ }
1154
+ console.log(`${c.dim}Press Ctrl+C to stop.${c.reset}`);
1155
+
1156
+ const watcher = startWatcher(dirs, {
1157
+ debounceMs: 2000,
1158
+ onChanged: async (fullPath, event) => {
1159
+ // Find which collection this belongs to
1160
+ const col = collections.find(c => fullPath.startsWith(c.path));
1161
+ if (!col) return;
1162
+
1163
+ const relativePath = fullPath.slice(col.path.length + 1);
1164
+ console.log(`${c.dim}[${event}]${c.reset} ${col.name}/${relativePath}`);
1165
+
1166
+ // Beads: trigger sync on any change within .beads/ directory
1167
+ // Dolt backend writes to .beads/dolt/ — watch for any file change there
1168
+ if (fullPath.includes(".beads/")) {
1169
+ const projectDir = detectBeadsProject(fullPath.replace(/\/\.beads\/.*$/, ""));
1170
+ if (projectDir) {
1171
+ const result = await s.syncBeadsIssues(projectDir);
1172
+ console.log(` beads: +${result.created} ~${result.synced}`);
1173
+ }
1174
+ return;
1175
+ }
1176
+
1177
+ // Re-index just this collection
1178
+ const stats = await indexCollection(s, col.name, col.path, col.pattern);
1179
+ if (stats.added > 0 || stats.updated > 0 || stats.removed > 0) {
1180
+ console.log(` +${stats.added} ~${stats.updated} -${stats.removed}`);
1181
+ }
1182
+ },
1183
+ onError: (err) => {
1184
+ console.error(`${c.red}Watch error: ${err.message}${c.reset}`);
1185
+ },
1186
+ });
1187
+
1188
+ // Keep running until Ctrl+C
1189
+ process.on("SIGINT", () => {
1190
+ watcher.close();
1191
+ closeStore();
1192
+ process.exit(0);
1193
+ });
1194
+
1195
+ // Block forever
1196
+ await new Promise(() => {});
1197
+ }
1198
+
1199
+ // =============================================================================
1200
+ // Reindex
1201
+ // =============================================================================
1202
+
1203
+ async function cmdReindex(args: string[]) {
1204
+ const force = args.includes("--force") || args.includes("-f");
1205
+ const collections = collectionsList();
1206
+
1207
+ if (collections.length === 0) {
1208
+ die("No collections configured.");
1209
+ }
1210
+
1211
+ const s = getStore();
1212
+
1213
+ if (force) {
1214
+ // Delete all documents and re-scan
1215
+ s.db.exec("UPDATE documents SET active = 0");
1216
+ console.log(`${c.yellow}Force reindex: deactivated all documents${c.reset}`);
1217
+ }
1218
+
1219
+ for (const col of collections) {
1220
+ console.log(`Indexing ${c.bold}${col.name}${c.reset} (${col.path})...`);
1221
+ const stats = await indexCollection(s, col.name, col.path, col.pattern);
1222
+ console.log(` +${stats.added} added, ~${stats.updated} updated, =${stats.unchanged} unchanged, -${stats.removed} removed`);
1223
+ }
1224
+ }
1225
+
1226
+ // =============================================================================
1227
+ // Doctor (Health Check)
1228
+ // =============================================================================
1229
+
1230
+ async function cmdDoctor() {
1231
+ console.log(`${c.bold}ClawMem Doctor${c.reset}\n`);
1232
+ let issues = 0;
1233
+
1234
+ // 1. Database
1235
+ try {
1236
+ const s = getStore();
1237
+ const docCount = (s.db.prepare("SELECT COUNT(*) as n FROM documents WHERE active = 1").get() as any).n;
1238
+ console.log(`${c.green}✓${c.reset} Database: ${s.dbPath} (${docCount} documents)`);
1239
+ } catch (err) {
1240
+ console.log(`${c.red}✗${c.reset} Database: ${err}`);
1241
+ issues++;
1242
+ }
1243
+
1244
+ // 2. Collections
1245
+ try {
1246
+ const collections = collectionsList();
1247
+ if (collections.length === 0) {
1248
+ console.log(`${c.yellow}!${c.reset} No collections configured`);
1249
+ issues++;
1250
+ } else {
1251
+ for (const col of collections) {
1252
+ if (existsSync(col.path)) {
1253
+ console.log(`${c.green}✓${c.reset} Collection "${col.name}": ${col.path}`);
1254
+ } else {
1255
+ console.log(`${c.red}✗${c.reset} Collection "${col.name}": ${col.path} (directory not found)`);
1256
+ issues++;
1257
+ }
1258
+ }
1259
+ }
1260
+ } catch (err) {
1261
+ console.log(`${c.red}✗${c.reset} Collections config: ${err}`);
1262
+ issues++;
1263
+ }
1264
+
1265
+ // 3. Embeddings
1266
+ try {
1267
+ const s = getStore();
1268
+ const needsEmbed = s.getHashesNeedingEmbedding();
1269
+ const hasVectors = !!s.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'").get();
1270
+ if (hasVectors) {
1271
+ console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
1272
+ } else {
1273
+ console.log(`${c.yellow}!${c.reset} Vector index: not created yet (run 'clawmem embed')`);
1274
+ }
1275
+ } catch {
1276
+ console.log(`${c.yellow}!${c.reset} Vector index: could not check`);
1277
+ }
1278
+
1279
+ // 4. Content types
1280
+ try {
1281
+ const s = getStore();
1282
+ const types = s.db.prepare(
1283
+ "SELECT content_type, COUNT(*) as n FROM documents WHERE active = 1 GROUP BY content_type"
1284
+ ).all() as { content_type: string; n: number }[];
1285
+ const typeStr = types.map(t => `${t.content_type}:${t.n}`).join(", ");
1286
+ console.log(`${c.green}✓${c.reset} Content types: ${typeStr || "none"}`);
1287
+ } catch {
1288
+ // Skip
1289
+ }
1290
+
1291
+ // 5. Hooks installed
1292
+ try {
1293
+ const settingsPath = pathResolve(process.env.HOME || "~", ".claude", "settings.json");
1294
+ if (existsSync(settingsPath)) {
1295
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
1296
+ const hasHooks = Object.values(settings.hooks || {}).some((arr: any) =>
1297
+ Array.isArray(arr) && arr.some((entry: any) =>
1298
+ entry.hooks?.some((h: any) => h.command?.includes("clawmem"))
1299
+ )
1300
+ );
1301
+ if (hasHooks) {
1302
+ console.log(`${c.green}✓${c.reset} Claude Code hooks: installed`);
1303
+ } else {
1304
+ console.log(`${c.yellow}!${c.reset} Claude Code hooks: not installed (run 'clawmem setup hooks')`);
1305
+ }
1306
+ } else {
1307
+ console.log(`${c.yellow}!${c.reset} Claude Code hooks: settings.json not found`);
1308
+ }
1309
+ } catch {
1310
+ console.log(`${c.yellow}!${c.reset} Claude Code hooks: could not check`);
1311
+ }
1312
+
1313
+ // 6. MCP registered
1314
+ try {
1315
+ const claudeJsonPath = pathResolve(process.env.HOME || "~", ".claude.json");
1316
+ if (existsSync(claudeJsonPath)) {
1317
+ const config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1318
+ if (config.mcpServers?.clawmem) {
1319
+ console.log(`${c.green}✓${c.reset} MCP server: registered in ~/.claude.json`);
1320
+ } else {
1321
+ console.log(`${c.yellow}!${c.reset} MCP server: not registered (run 'clawmem setup mcp')`);
1322
+ }
1323
+ }
1324
+ } catch {
1325
+ console.log(`${c.yellow}!${c.reset} MCP server: could not check`);
1326
+ }
1327
+
1328
+ // 7. Sessions
1329
+ try {
1330
+ const s = getStore();
1331
+ const sessions = s.getRecentSessions(1);
1332
+ if (sessions.length > 0) {
1333
+ console.log(`${c.green}✓${c.reset} Sessions: last session ${sessions[0]!.startedAt}`);
1334
+ } else {
1335
+ console.log(`${c.dim}-${c.reset} Sessions: none tracked yet`);
1336
+ }
1337
+ } catch {
1338
+ // Skip
1339
+ }
1340
+
1341
+ console.log();
1342
+ if (issues > 0) {
1343
+ console.log(`${c.yellow}${issues} issue(s) found.${c.reset}`);
1344
+ } else {
1345
+ console.log(`${c.green}All checks passed.${c.reset}`);
1346
+ }
1347
+ }
1348
+
1349
+ // =============================================================================
1350
+ // Bootstrap
1351
+ // =============================================================================
1352
+
1353
+ async function cmdBootstrap(args: string[]) {
1354
+ const { values, positionals } = parseArgs({
1355
+ args,
1356
+ options: {
1357
+ name: { type: "string" },
1358
+ "skip-embed": { type: "boolean", default: false },
1359
+ },
1360
+ allowPositionals: true,
1361
+ });
1362
+
1363
+ const vaultPath = positionals[0];
1364
+ if (!vaultPath) die("Usage: clawmem bootstrap <vault-path> [--name <name>] [--skip-embed]");
1365
+
1366
+ const absPath = pathResolve(vaultPath);
1367
+ if (!existsSync(absPath)) die(`Directory not found: ${absPath}`);
1368
+
1369
+ const name = values.name || basename(absPath).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1370
+
1371
+ // 1. Init (skip if already initialized)
1372
+ const dbPath = getDefaultDbPath();
1373
+ if (!existsSync(dbPath)) {
1374
+ console.log(`${c.cyan}Step 1: Initializing ClawMem${c.reset}`);
1375
+ await cmdInit();
1376
+ } else {
1377
+ console.log(`${c.dim}Step 1: Already initialized${c.reset}`);
1378
+ }
1379
+
1380
+ // 2. Collection add (skip if already exists)
1381
+ const existing = collectionsList().find(col => col.path === absPath);
1382
+ if (!existing) {
1383
+ console.log(`${c.cyan}Step 2: Adding collection '${name}'${c.reset}`);
1384
+ collectionsAdd(name, absPath, DEFAULT_GLOB);
1385
+ console.log(` ${c.green}Added${c.reset} ${absPath}`);
1386
+ } else {
1387
+ console.log(`${c.dim}Step 2: Collection already exists (${existing.name})${c.reset}`);
1388
+ }
1389
+
1390
+ // 3. Update
1391
+ console.log(`${c.cyan}Step 3: Indexing files${c.reset}`);
1392
+ await cmdUpdate([]);
1393
+
1394
+ // 4. Embed (unless --skip-embed)
1395
+ if (!values["skip-embed"]) {
1396
+ console.log(`${c.cyan}Step 4: Embedding documents${c.reset}`);
1397
+ await cmdEmbed([]);
1398
+ } else {
1399
+ console.log(`${c.dim}Step 4: Skipping embeddings (--skip-embed)${c.reset}`);
1400
+ }
1401
+
1402
+ // 5. Setup hooks
1403
+ console.log(`${c.cyan}Step 5: Installing hooks${c.reset}`);
1404
+ await cmdSetupHooks([]);
1405
+
1406
+ // 6. Setup MCP
1407
+ console.log(`${c.cyan}Step 6: Registering MCP${c.reset}`);
1408
+ await cmdSetupMcp([]);
1409
+
1410
+ console.log();
1411
+ console.log(`${c.green}ClawMem bootstrapped for ${absPath}${c.reset}`);
1412
+ }
1413
+
1414
+ // =============================================================================
1415
+ // Install Service
1416
+ // =============================================================================
1417
+
1418
+ async function cmdInstallService(args: string[]) {
1419
+ const remove = args.includes("--remove");
1420
+ const enable = args.includes("--enable");
1421
+
1422
+ const { join: pathJoin } = await import("path");
1423
+ const os = await import("os");
1424
+ const { execSync } = await import("child_process");
1425
+
1426
+ const servicePath = pathJoin(os.homedir(), ".config", "systemd", "user", "clawmem-watcher.service");
1427
+
1428
+ if (remove) {
1429
+ try { execSync("systemctl --user stop clawmem-watcher.service 2>/dev/null"); } catch { /* may not be running */ }
1430
+ try { execSync("systemctl --user disable clawmem-watcher.service 2>/dev/null"); } catch { /* may not be enabled */ }
1431
+ if (existsSync(servicePath)) {
1432
+ const { unlinkSync } = await import("fs");
1433
+ unlinkSync(servicePath);
1434
+ }
1435
+ execSync("systemctl --user daemon-reload");
1436
+ console.log(`${c.green}Removed clawmem-watcher service${c.reset}`);
1437
+ return;
1438
+ }
1439
+
1440
+ const binPath = findClawmemBinary();
1441
+ const unit = `[Unit]
1442
+ Description=ClawMem File Watcher
1443
+ After=default.target
1444
+
1445
+ [Service]
1446
+ Type=simple
1447
+ ExecStart=${process.argv[0]} ${pathResolve(import.meta.dir, "clawmem.ts")} watch
1448
+ Restart=on-failure
1449
+ RestartSec=5
1450
+ Environment=HOME=${os.homedir()}
1451
+
1452
+ [Install]
1453
+ WantedBy=default.target
1454
+ `;
1455
+
1456
+ const serviceDir = pathJoin(os.homedir(), ".config", "systemd", "user");
1457
+ if (!existsSync(serviceDir)) mkdirSync(serviceDir, { recursive: true });
1458
+
1459
+ const { writeFileSync: wfs } = await import("fs");
1460
+ wfs(servicePath, unit);
1461
+ execSync("systemctl --user daemon-reload");
1462
+
1463
+ console.log(`${c.green}Installed clawmem-watcher service${c.reset}`);
1464
+ console.log(` ${servicePath}`);
1465
+
1466
+ if (enable) {
1467
+ execSync("systemctl --user enable --now clawmem-watcher.service");
1468
+ console.log(` ${c.green}Enabled and started${c.reset}`);
1469
+ } else {
1470
+ console.log(` Run: ${c.cyan}systemctl --user enable --now clawmem-watcher.service${c.reset}`);
1471
+ }
1472
+ }
1473
+
1474
+ // =============================================================================
1475
+ // Directory Context
1476
+ // =============================================================================
1477
+
1478
+ async function cmdUpdateContext() {
1479
+ const s = getStore();
1480
+ const count = regenerateAllDirectoryContexts(s);
1481
+ console.log(`${c.green}Updated CLAUDE.md in ${count} directories${c.reset}`);
1482
+ }
1483
+
1484
+ // =============================================================================
1485
+ // Profile
1486
+ // =============================================================================
1487
+
1488
+ async function cmdProfile(args: string[]) {
1489
+ const s = getStore();
1490
+
1491
+ if (args[0] === "rebuild") {
1492
+ updateProfile(s);
1493
+ console.log(`${c.green}Profile rebuilt${c.reset}`);
1494
+ return;
1495
+ }
1496
+
1497
+ const profile = getProfile(s);
1498
+ if (!profile) {
1499
+ console.log("No profile found. Run: clawmem profile rebuild");
1500
+ return;
1501
+ }
1502
+
1503
+ console.log(`${c.bold}User Profile${c.reset}`);
1504
+ if (profile.static.length > 0) {
1505
+ console.log(`\n${c.cyan}Known Context:${c.reset}`);
1506
+ for (const fact of profile.static) {
1507
+ console.log(` - ${fact}`);
1508
+ }
1509
+ }
1510
+ if (profile.dynamic.length > 0) {
1511
+ console.log(`\n${c.cyan}Current Focus:${c.reset}`);
1512
+ for (const item of profile.dynamic) {
1513
+ console.log(` - ${item}`);
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ // =============================================================================
1519
+ // Main dispatch
1520
+ // =============================================================================
1521
+
1522
+ async function main() {
1523
+ const args = process.argv.slice(2);
1524
+ const command = args[0];
1525
+ const subArgs = args.slice(1);
1526
+
1527
+ try {
1528
+ switch (command) {
1529
+ case "init":
1530
+ await cmdInit();
1531
+ break;
1532
+ case "collection": {
1533
+ const subCmd = subArgs[0];
1534
+ const subSubArgs = subArgs.slice(1);
1535
+ switch (subCmd) {
1536
+ case "add": await cmdCollectionAdd(subSubArgs); break;
1537
+ case "list": await cmdCollectionList(); break;
1538
+ case "remove": await cmdCollectionRemove(subSubArgs); break;
1539
+ default: die("Usage: clawmem collection <add|list|remove>");
1540
+ }
1541
+ break;
1542
+ }
1543
+ case "update":
1544
+ await cmdUpdate(subArgs);
1545
+ break;
1546
+ case "embed":
1547
+ await cmdEmbed(subArgs);
1548
+ break;
1549
+ case "status":
1550
+ await cmdStatus();
1551
+ break;
1552
+ case "search":
1553
+ await cmdSearch(subArgs);
1554
+ break;
1555
+ case "vsearch":
1556
+ await cmdVsearch(subArgs);
1557
+ break;
1558
+ case "query":
1559
+ await cmdQuery(subArgs);
1560
+ break;
1561
+ case "hook":
1562
+ await cmdHook(subArgs);
1563
+ break;
1564
+ case "budget":
1565
+ await cmdBudget(subArgs);
1566
+ break;
1567
+ case "log":
1568
+ await cmdLog(subArgs);
1569
+ break;
1570
+ case "mcp":
1571
+ await cmdMcp();
1572
+ break;
1573
+ case "serve":
1574
+ await cmdServe(subArgs);
1575
+ break;
1576
+ case "setup":
1577
+ await cmdSetup(subArgs);
1578
+ break;
1579
+ case "watch":
1580
+ await cmdWatch();
1581
+ break;
1582
+ case "reindex":
1583
+ await cmdReindex(subArgs);
1584
+ break;
1585
+ case "doctor":
1586
+ await cmdDoctor();
1587
+ break;
1588
+ case "bootstrap":
1589
+ await cmdBootstrap(subArgs);
1590
+ break;
1591
+ case "install-service":
1592
+ await cmdInstallService(subArgs);
1593
+ break;
1594
+ case "profile":
1595
+ await cmdProfile(subArgs);
1596
+ break;
1597
+ case "update-context":
1598
+ await cmdUpdateContext();
1599
+ break;
1600
+ case "surface":
1601
+ await cmdSurface(subArgs);
1602
+ break;
1603
+ case "reflect":
1604
+ await cmdReflect(subArgs);
1605
+ break;
1606
+ case "path":
1607
+ cmdPath();
1608
+ break;
1609
+ case "consolidate":
1610
+ await cmdConsolidate(subArgs);
1611
+ break;
1612
+ case "help":
1613
+ case "--help":
1614
+ case "-h":
1615
+ case undefined:
1616
+ printHelp();
1617
+ break;
1618
+ default:
1619
+ die(`Unknown command: ${command}. Run 'clawmem help' for usage.`);
1620
+ }
1621
+ } finally {
1622
+ closeStore();
1623
+ }
1624
+ }
1625
+
1626
+ // =============================================================================
1627
+ // Cross-Session Reflection
1628
+ // =============================================================================
1629
+
1630
+ async function cmdReflect(args: string[]) {
1631
+ const s = getStore();
1632
+ const days = parseInt(args[0] || "14");
1633
+ const cutoff = new Date();
1634
+ cutoff.setDate(cutoff.getDate() - days);
1635
+
1636
+ const recentDocs = s.getDocumentsByType("decision", 50)
1637
+ .filter(d => d.modifiedAt && d.modifiedAt >= cutoff.toISOString());
1638
+
1639
+ if (recentDocs.length === 0) {
1640
+ console.log(`No decisions found in the last ${days} days.`);
1641
+ return;
1642
+ }
1643
+
1644
+ console.log(`${c.bold}Reflection Report${c.reset} (last ${days} days, ${recentDocs.length} decisions)\n`);
1645
+
1646
+ // Noun-phrase clustering: find recurring 2-3 word phrases across decisions
1647
+ const phrases = new Map<string, number>();
1648
+ const stopWords = new Set(["the", "that", "this", "with", "from", "have", "will", "been", "were", "they", "their", "what", "when", "which", "about", "into", "more", "some", "than", "them", "then", "very", "also", "just", "should", "would", "could", "does", "make", "like", "using", "used"]);
1649
+
1650
+ for (const d of recentDocs) {
1651
+ const doc = s.findDocument(d.path);
1652
+ if ("error" in doc) continue;
1653
+ const body = s.getDocumentBody(doc) || "";
1654
+ const words = body.toLowerCase()
1655
+ .replace(/[^a-z0-9\s-]/g, " ")
1656
+ .split(/\s+/)
1657
+ .filter(w => w.length > 3 && !stopWords.has(w));
1658
+
1659
+ // Ordered bigrams (preserve phrase direction)
1660
+ for (let i = 0; i < words.length - 1; i++) {
1661
+ const pair = `${words[i]!} ${words[i + 1]!}`;
1662
+ phrases.set(pair, (phrases.get(pair) || 0) + 1);
1663
+ }
1664
+ // Trigrams for better phrase capture
1665
+ for (let i = 0; i < words.length - 2; i++) {
1666
+ const triple = `${words[i]!} ${words[i + 1]!} ${words[i + 2]!}`;
1667
+ phrases.set(triple, (phrases.get(triple) || 0) + 1);
1668
+ }
1669
+ }
1670
+
1671
+ // Report patterns appearing 3+ times (prefer longer phrases)
1672
+ const patterns = [...phrases.entries()]
1673
+ .filter(([, count]) => count >= 3)
1674
+ .sort((a, b) => {
1675
+ const lenDiff = b[0].split(" ").length - a[0].split(" ").length;
1676
+ return b[1] - a[1] || lenDiff;
1677
+ })
1678
+ .slice(0, 20);
1679
+
1680
+ if (patterns.length > 0) {
1681
+ console.log(`${c.bold}Recurring Themes:${c.reset}`);
1682
+ for (const [pair, count] of patterns) {
1683
+ console.log(` ${c.green}[${count}x]${c.reset} ${pair}`);
1684
+ }
1685
+ } else {
1686
+ console.log("No recurring patterns found (threshold: 3+ occurrences).");
1687
+ }
1688
+
1689
+ // Also report antipatterns
1690
+ const antiDocs = s.getDocumentsByType("antipattern", 10)
1691
+ .filter(d => d.modifiedAt && d.modifiedAt >= cutoff.toISOString());
1692
+
1693
+ if (antiDocs.length > 0) {
1694
+ console.log(`\n${c.bold}Recent Antipatterns (${antiDocs.length}):${c.reset}`);
1695
+ for (const d of antiDocs) {
1696
+ console.log(` ${c.red}●${c.reset} ${d.title} (${d.modifiedAt?.slice(0, 10)})`);
1697
+ }
1698
+ }
1699
+
1700
+ // Co-activation clusters
1701
+ const coActs = s.db.prepare(`
1702
+ SELECT doc_a, doc_b, count FROM co_activations
1703
+ WHERE count >= 3
1704
+ ORDER BY count DESC
1705
+ LIMIT 10
1706
+ `).all() as { doc_a: string; doc_b: string; count: number }[];
1707
+
1708
+ if (coActs.length > 0) {
1709
+ console.log(`\n${c.bold}Strong Co-Activations (accessed together 3+ times):${c.reset}`);
1710
+ for (const ca of coActs) {
1711
+ console.log(` ${c.cyan}[${ca.count}x]${c.reset} ${ca.doc_a} ↔ ${ca.doc_b}`);
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ // =============================================================================
1717
+ // Memory Consolidation (E12)
1718
+ // =============================================================================
1719
+
1720
+ async function cmdConsolidate(args: string[]) {
1721
+ const s = getStore();
1722
+ const dryRun = args.includes("--dry-run");
1723
+ const maxDocs = parseInt(args.find(a => /^\d+$/.test(a)) || "50");
1724
+
1725
+ // Find low-confidence documents that might be duplicates
1726
+ const candidates = s.db.prepare(`
1727
+ SELECT id, collection, path, title, hash, confidence, modified_at
1728
+ FROM documents
1729
+ WHERE active = 1 AND confidence < 0.4
1730
+ ORDER BY confidence ASC
1731
+ LIMIT ?
1732
+ `).all(maxDocs) as { id: number; collection: string; path: string; title: string; hash: string; confidence: number; modified_at: string }[];
1733
+
1734
+ if (candidates.length === 0) {
1735
+ console.log("No low-confidence documents to consolidate.");
1736
+ return;
1737
+ }
1738
+
1739
+ console.log(`${c.bold}Consolidation Analysis${c.reset} (${candidates.length} candidates, confidence < 0.4)${dryRun ? " [DRY RUN]" : ""}\n`);
1740
+
1741
+ let mergeCount = 0;
1742
+
1743
+ for (const candidate of candidates) {
1744
+ // BM25 search with title as query to find similar docs
1745
+ const similar = s.searchFTS(candidate.title, 5);
1746
+ const candidateBody = s.getDocumentBody({ filepath: `clawmem://${candidate.collection}/${candidate.path}` } as any) || "";
1747
+
1748
+ const matches = similar.filter(r => {
1749
+ if (r.filepath === `clawmem://${candidate.collection}/${candidate.path}`) return false;
1750
+ if (r.score < 0.7) return false;
1751
+
1752
+ // Require same collection
1753
+ const rCollection = r.collectionName;
1754
+ if (rCollection !== candidate.collection) return false;
1755
+
1756
+ // Require body similarity (Jaccard on word sets)
1757
+ const matchBody = r.body || "";
1758
+ if (matchBody.length === 0 || candidateBody.length === 0) return false;
1759
+ const wordsA = new Set(candidateBody.toLowerCase().split(/\s+/).filter(w => w.length > 3));
1760
+ const wordsB = new Set(matchBody.toLowerCase().split(/\s+/).filter(w => w.length > 3));
1761
+ if (wordsA.size === 0 || wordsB.size === 0) return false;
1762
+ let intersection = 0;
1763
+ for (const w of wordsA) { if (wordsB.has(w)) intersection++; }
1764
+ const jaccard = intersection / (wordsA.size + wordsB.size - intersection);
1765
+ if (jaccard < 0.4) return false;
1766
+
1767
+ return true;
1768
+ });
1769
+
1770
+ if (matches.length === 0) continue;
1771
+
1772
+ const bestMatch = matches[0]!;
1773
+ console.log(` ${c.yellow}Duplicate:${c.reset} ${candidate.collection}/${candidate.path} (conf: ${candidate.confidence.toFixed(2)})`);
1774
+ console.log(` ${c.green}Keep:${c.reset} ${bestMatch.displayPath} (score: ${bestMatch.score.toFixed(3)})`);
1775
+
1776
+ if (!dryRun) {
1777
+ s.archiveDocuments([candidate.id]);
1778
+ mergeCount++;
1779
+ }
1780
+ console.log();
1781
+ }
1782
+
1783
+ console.log(`${dryRun ? "Would consolidate" : "Consolidated"}: ${mergeCount} document(s)`);
1784
+ }
1785
+
1786
+ function printHelp() {
1787
+ console.log(`
1788
+ ${c.bold}ClawMem${c.reset} - Hybrid Agent Memory
1789
+
1790
+ ${c.bold}Setup:${c.reset}
1791
+ clawmem init Initialize ClawMem
1792
+ clawmem bootstrap <path> [--name N] One-command setup (init+add+update+embed+hooks+mcp)
1793
+ clawmem collection add <path> --name <name>
1794
+ clawmem collection list
1795
+ clawmem collection remove <name>
1796
+ clawmem setup hooks [--remove] Install/remove Claude Code hooks
1797
+ clawmem setup mcp [--remove] Register/remove MCP in ~/.claude.json
1798
+ clawmem setup curator [--remove] Install/remove curator agent to ~/.claude/agents/
1799
+ clawmem setup openclaw [--remove] Show OpenClaw plugin installation steps
1800
+ clawmem install-service [--enable] Install systemd watcher service
1801
+
1802
+ ${c.bold}Indexing:${c.reset}
1803
+ clawmem update [--pull] [--embed] Re-scan collections (--embed auto-embeds)
1804
+ clawmem embed [-f] Generate fragment embeddings
1805
+ clawmem reindex [--force] Full re-index
1806
+ clawmem watch File watcher daemon
1807
+ clawmem status Show index status
1808
+
1809
+ ${c.bold}Search:${c.reset}
1810
+ clawmem search <query> [-n N] BM25 keyword search
1811
+ clawmem vsearch <query> [-n N] Vector similarity
1812
+ clawmem query <query> [-n N] Hybrid + rerank (best)
1813
+
1814
+ ${c.bold}Memory:${c.reset}
1815
+ clawmem budget [--session ID] Token utilization
1816
+ clawmem log [--last N] Session history
1817
+ clawmem profile Show user profile
1818
+ clawmem profile rebuild Force profile rebuild
1819
+
1820
+ ${c.bold}Hooks:${c.reset}
1821
+ clawmem hook <name> Run hook (stdin JSON)
1822
+ clawmem surface --context --stdin IO6a: pre-prompt context injection
1823
+ clawmem surface --bootstrap --stdin IO6b: per-session bootstrap injection
1824
+
1825
+ ${c.bold}Analysis:${c.reset}
1826
+ clawmem reflect [N] Cross-session reflection (last N days, default 14)
1827
+ clawmem consolidate [--dry-run] [N] Find and archive duplicate low-confidence docs
1828
+
1829
+ ${c.bold}Integration:${c.reset}
1830
+ clawmem mcp Start stdio MCP server
1831
+ clawmem serve [--port 7438] [--host 127.0.0.1] Start HTTP REST API server
1832
+ clawmem update-context Regenerate all directory CLAUDE.md files
1833
+ clawmem doctor Full health check
1834
+ clawmem path Print database path
1835
+
1836
+ ${c.bold}Options:${c.reset}
1837
+ -n, --num <N> Number of results
1838
+ -c, --collection Filter by collection
1839
+ --json JSON output
1840
+ --min-score <N> Minimum score threshold
1841
+ -f, --force Force re-embed/reindex all
1842
+ --pull Run update commands before indexing
1843
+ `);
1844
+ }
1845
+
1846
+ main().catch(err => {
1847
+ console.error(err);
1848
+ process.exit(1);
1849
+ });