clawmem 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- package/src/watcher.ts +58 -0
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
|
+
});
|