clawmem 0.10.7 → 0.11.2
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 +2 -2
- package/CLAUDE.md +2 -2
- package/package.json +2 -2
- package/src/clawmem.ts +352 -188
- package/src/hooks/context-surfacing.ts +8 -2
- package/src/llm.ts +107 -36
- package/src/mcp.ts +32 -12
- package/src/store.ts +373 -131
- package/src/worker-lease.ts +34 -0
- package/src/hermes/__pycache__/__init__.cpython-312.pyc +0 -0
package/src/clawmem.ts
CHANGED
|
@@ -13,11 +13,16 @@ import {
|
|
|
13
13
|
canonicalDocId,
|
|
14
14
|
type Store,
|
|
15
15
|
type SearchResult,
|
|
16
|
+
type ExpandedQuery,
|
|
16
17
|
DEFAULT_EMBED_MODEL,
|
|
17
18
|
DEFAULT_QUERY_MODEL,
|
|
18
19
|
DEFAULT_RERANK_MODEL,
|
|
19
20
|
DEFAULT_GLOB,
|
|
20
21
|
extractSnippet,
|
|
22
|
+
FatalVectorError,
|
|
23
|
+
VecDimensionMismatchError,
|
|
24
|
+
VecModelMismatchError,
|
|
25
|
+
EmbedLeaseLostError,
|
|
21
26
|
} from "./store.ts";
|
|
22
27
|
import {
|
|
23
28
|
getDefaultLlamaCpp,
|
|
@@ -28,6 +33,11 @@ import {
|
|
|
28
33
|
LlamaCpp,
|
|
29
34
|
type Queryable,
|
|
30
35
|
} from "./llm.ts";
|
|
36
|
+
import {
|
|
37
|
+
acquireWorkerLease,
|
|
38
|
+
releaseWorkerLease,
|
|
39
|
+
renewWorkerLease,
|
|
40
|
+
} from "./worker-lease.ts";
|
|
31
41
|
import {
|
|
32
42
|
loadConfig,
|
|
33
43
|
addCollection as collectionsAdd,
|
|
@@ -385,200 +395,331 @@ async function cmdEmbed(args: string[]) {
|
|
|
385
395
|
|
|
386
396
|
const s = getStore();
|
|
387
397
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
398
|
+
// Embedding lease: serialize embed commands (manual / embed timer / update --embed)
|
|
399
|
+
// so two embeds cannot run at once. It is RENEWABLE (token-fenced heartbeat), not a
|
|
400
|
+
// fixed-TTL lease — a full-vault rebuild outlasts any fixed TTL and would be reclaimed
|
|
401
|
+
// mid-run. Without serialization, two embeds using different same-dimension models can
|
|
402
|
+
// silently build a heterogeneous vector space (dimension checks can't catch that).
|
|
403
|
+
// See EMBED-LEASE-RENEWAL-DESIGN.md / INCIDENT-2026-06-22.
|
|
404
|
+
const LEASE_NAME = "embedding";
|
|
405
|
+
const LEASE_TTL_MS = 60_000;
|
|
406
|
+
const lease = acquireWorkerLease(s, LEASE_NAME, LEASE_TTL_MS);
|
|
407
|
+
if (!lease.acquired || !lease.token) {
|
|
408
|
+
console.log(`${c.yellow}Another embed is already in progress (lease held); skipping.${c.reset}`);
|
|
409
|
+
return;
|
|
391
410
|
}
|
|
411
|
+
const leaseToken = lease.token;
|
|
412
|
+
// Passed into every vector mutation (clear, stale-clean, table-create, insert) so
|
|
413
|
+
// each verifies ownership before mutating — a process that lost the lease mid-await
|
|
414
|
+
// cannot wipe/recreate/write the vector store under the new holder.
|
|
415
|
+
const leaseGuard = { workerName: LEASE_NAME, token: leaseToken };
|
|
416
|
+
let leaseLost = false;
|
|
417
|
+
const heartbeat = setInterval(() => {
|
|
418
|
+
if (!renewWorkerLease(s, LEASE_NAME, leaseToken, LEASE_TTL_MS)) leaseLost = true;
|
|
419
|
+
}, Math.floor(LEASE_TTL_MS / 2));
|
|
392
420
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
421
|
+
try {
|
|
422
|
+
const embedUrl = process.env.CLAWMEM_EMBED_URL;
|
|
423
|
+
if (embedUrl) {
|
|
424
|
+
console.log(`Using remote GPU embedding: ${embedUrl}`);
|
|
425
|
+
} else {
|
|
426
|
+
// Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
|
|
427
|
+
setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
|
|
428
|
+
}
|
|
429
|
+
const llm = getDefaultLlamaCpp();
|
|
398
430
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
431
|
+
// Probe the live model's output dimension (+ model name). Returns null on ANY
|
|
432
|
+
// failure so a down/flaky endpoint can NEVER trigger a destructive clear.
|
|
433
|
+
const probeEmbed = async (): Promise<{ dim: number; model: string } | null> => {
|
|
434
|
+
try {
|
|
435
|
+
const r = await llm.embed("clawmem dimension probe");
|
|
436
|
+
return r && r.embedding && r.embedding.length > 0
|
|
437
|
+
? { dim: r.embedding.length, model: r.model ?? "" }
|
|
438
|
+
: null;
|
|
439
|
+
} catch { return null; }
|
|
440
|
+
};
|
|
405
441
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
|
|
420
|
-
|
|
421
|
-
const embedUrl = process.env.CLAWMEM_EMBED_URL;
|
|
422
|
-
if (embedUrl) {
|
|
423
|
-
console.log(`Using remote GPU embedding: ${embedUrl}`);
|
|
424
|
-
} else {
|
|
425
|
-
// Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
|
|
426
|
-
setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
|
|
427
|
-
}
|
|
428
|
-
const llm = getDefaultLlamaCpp();
|
|
429
|
-
let embedded = 0;
|
|
430
|
-
let totalFragments = 0;
|
|
431
|
-
let failedFragments = 0;
|
|
432
|
-
const batchStart = Date.now();
|
|
433
|
-
|
|
434
|
-
// Cloud API: global batch pacing state (persists across documents)
|
|
435
|
-
// TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
|
|
436
|
-
const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
|
|
437
|
-
const CLOUD_BATCH_SIZE = 50;
|
|
438
|
-
const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
|
|
439
|
-
const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
|
|
440
|
-
const CHARS_PER_TOKEN = 4;
|
|
441
|
-
let lastBatchSentAt = 0; // global timestamp of last batch send
|
|
442
|
-
|
|
443
|
-
for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
|
|
444
|
-
const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
|
|
445
|
-
const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
|
|
446
|
-
const canId = canonicalDocId(collection, path);
|
|
447
|
-
|
|
448
|
-
// Parse frontmatter for fragment splitting
|
|
449
|
-
let frontmatter: Record<string, any> | undefined;
|
|
450
|
-
try {
|
|
451
|
-
const parsed = parseDocument(body, path);
|
|
452
|
-
frontmatter = parsed.meta as any;
|
|
453
|
-
} catch {
|
|
454
|
-
// No frontmatter or parsing error — fine, skip it
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const fragments = splitDocument(body, frontmatter);
|
|
458
|
-
const docStart = Date.now();
|
|
459
|
-
const prevTotalFragments = totalFragments;
|
|
460
|
-
const prevFailedFragments = failedFragments;
|
|
461
|
-
let seq0Succeeded = false;
|
|
462
|
-
console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
|
|
463
|
-
|
|
464
|
-
if (isCloudEmbed) {
|
|
465
|
-
// Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
|
|
466
|
-
const allTexts: string[] = [];
|
|
467
|
-
for (const frag of fragments) {
|
|
468
|
-
const label = frag.label || title;
|
|
469
|
-
allTexts.push(formatDocForEmbedding(frag.content, label));
|
|
442
|
+
// Bind the whole run to one (dim, model); every fragment is validated before it is
|
|
443
|
+
// stored. On a fresh vault these are set from the first successful fragment.
|
|
444
|
+
let expectedDim: number | null = null;
|
|
445
|
+
let expectedModel: string | null = null;
|
|
446
|
+
|
|
447
|
+
if (values.force) {
|
|
448
|
+
// Probe FIRST — validate the endpoint before destroying anything (so a force
|
|
449
|
+
// re-embed against a dead endpoint cannot clear the vault and then fail).
|
|
450
|
+
const probe = await probeEmbed();
|
|
451
|
+
if (!probe) {
|
|
452
|
+
console.error(`${c.red}Force re-embed aborted: could not reach the embedding endpoint. Nothing was cleared.${c.reset}`);
|
|
453
|
+
return;
|
|
470
454
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
|
|
486
|
-
const elapsed = Date.now() - lastBatchSentAt;
|
|
487
|
-
const remainingMs = requiredGapMs - elapsed;
|
|
488
|
-
if (remainingMs > 0) {
|
|
489
|
-
const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
|
|
490
|
-
await new Promise(r => setTimeout(r, jittered));
|
|
491
|
-
}
|
|
455
|
+
console.log(`${c.yellow}Force mode: clearing all embeddings (rebuilding at dim ${probe.dim})${c.reset}`);
|
|
456
|
+
expectedDim = probe.dim;
|
|
457
|
+
expectedModel = probe.model || null;
|
|
458
|
+
s.clearAllEmbeddings(leaseGuard);
|
|
459
|
+
} else {
|
|
460
|
+
// Implicit run: NON-DESTRUCTIVE drift check (dimension AND model). Catches
|
|
461
|
+
// drift even when the worklist is empty (query embeddings would already be
|
|
462
|
+
// incompatible with the stored table). Never clears — aborts with instructions.
|
|
463
|
+
const existingDim = s.getVecTableDim(); // throws VecSchemaError on malformed DDL → caught below
|
|
464
|
+
if (existingDim !== null) {
|
|
465
|
+
const probe = await probeEmbed();
|
|
466
|
+
if (probe && probe.dim !== existingDim) {
|
|
467
|
+
console.error(`${c.red}Embedding dimension changed (${existingDim} → ${probe.dim}). Run 'clawmem embed --force' to clear and rebuild the full vault.${c.reset}`);
|
|
468
|
+
return;
|
|
492
469
|
}
|
|
470
|
+
// Same dimension but a DIFFERENT model still mixes the vector space (cosine
|
|
471
|
+
// across two models is meaningless) and the dim check cannot see it. Compare
|
|
472
|
+
// the probe's model against what the vault was built with; abort on mismatch.
|
|
473
|
+
const existingModels = s.getVecModels();
|
|
474
|
+
if (existingModels.length > 1) {
|
|
475
|
+
console.error(`${c.red}Vault already contains mixed embedding models: ${existingModels.join(", ")}. Run 'clawmem embed --force' to rebuild with a single model.${c.reset}`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (probe && probe.model && existingModels.length === 1 && existingModels[0] !== probe.model) {
|
|
479
|
+
console.error(`${c.red}Embedding model changed (${existingModels[0]} → ${probe.model}) at the same dimension. Mixing models in one vector space breaks similarity. Run 'clawmem embed --force' to rebuild with the current model.${c.reset}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
expectedDim = existingDim;
|
|
483
|
+
if (probe && probe.model) expectedModel = probe.model;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Clean stale embeddings (orphaned hashes from updated/deleted documents)
|
|
488
|
+
const cleaned = s.cleanStaleEmbeddings(leaseGuard);
|
|
489
|
+
if (cleaned > 0) {
|
|
490
|
+
console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Use fragment-based pipeline: split documents into semantic fragments and embed each
|
|
494
|
+
const hashes = s.getHashesNeedingFragments();
|
|
495
|
+
if (hashes.length === 0) {
|
|
496
|
+
console.log(`${c.green}All documents already embedded${c.reset}`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Count total fragments first for ETA
|
|
501
|
+
let totalFragEstimate = 0;
|
|
502
|
+
const docFragCounts: number[] = [];
|
|
503
|
+
for (const { body, path } of hashes) {
|
|
504
|
+
let frontmatter: Record<string, any> | undefined;
|
|
505
|
+
try {
|
|
506
|
+
const parsed = parseDocument(body, path);
|
|
507
|
+
frontmatter = parsed.meta as any;
|
|
508
|
+
} catch { /* skip */ }
|
|
509
|
+
const frags = splitDocument(body, frontmatter);
|
|
510
|
+
docFragCounts.push(frags.length);
|
|
511
|
+
totalFragEstimate += frags.length;
|
|
512
|
+
}
|
|
513
|
+
console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
|
|
514
|
+
|
|
515
|
+
let embedded = 0;
|
|
516
|
+
let totalFragments = 0;
|
|
517
|
+
let failedFragments = 0;
|
|
518
|
+
const batchStart = Date.now();
|
|
519
|
+
|
|
520
|
+
// Cloud API: global batch pacing state (persists across documents)
|
|
521
|
+
// TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
|
|
522
|
+
const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
|
|
523
|
+
const CLOUD_BATCH_SIZE = 50;
|
|
524
|
+
const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
|
|
525
|
+
const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
|
|
526
|
+
const CHARS_PER_TOKEN = 4;
|
|
527
|
+
let lastBatchSentAt = 0; // global timestamp of last batch send
|
|
528
|
+
|
|
529
|
+
// Bind the run to one (dim, model) and validate every embedding before it is stored.
|
|
530
|
+
// The first successful fragment sets the binding on a fresh vault; any later drift —
|
|
531
|
+
// a dimension change OR a same-dimension model swap from a flapping endpoint — throws
|
|
532
|
+
// a fatal error that aborts the whole run (caught below). This is what dimension
|
|
533
|
+
// checks alone cannot do: catch a different 2560-d model.
|
|
534
|
+
const bindAndValidate = (result: { embedding: number[] | Float32Array; model?: string }) => {
|
|
535
|
+
const dim = result.embedding.length;
|
|
536
|
+
if (expectedDim === null) {
|
|
537
|
+
expectedDim = dim;
|
|
538
|
+
if (!expectedModel && result.model) expectedModel = result.model;
|
|
539
|
+
} else if (dim !== expectedDim) {
|
|
540
|
+
throw new VecDimensionMismatchError(expectedDim, dim);
|
|
541
|
+
}
|
|
542
|
+
if (expectedModel && result.model && result.model !== expectedModel) {
|
|
543
|
+
throw new VecModelMismatchError(expectedModel, result.model);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
493
546
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const reqStart = Date.now();
|
|
547
|
+
for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
|
|
548
|
+
// Abort cleanly if the heartbeat reported the lease was reclaimed.
|
|
549
|
+
if (leaseLost) throw new EmbedLeaseLostError();
|
|
498
550
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const tokensUsed = llm.lastBatchTokens;
|
|
551
|
+
const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
|
|
552
|
+
const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
|
|
553
|
+
const canId = canonicalDocId(collection, path);
|
|
503
554
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
555
|
+
// Parse frontmatter for fragment splitting
|
|
556
|
+
let frontmatter: Record<string, any> | undefined;
|
|
557
|
+
try {
|
|
558
|
+
const parsed = parseDocument(body, path);
|
|
559
|
+
frontmatter = parsed.meta as any;
|
|
560
|
+
} catch {
|
|
561
|
+
// No frontmatter or parsing error — fine, skip it
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const fragments = splitDocument(body, frontmatter);
|
|
565
|
+
const docStart = Date.now();
|
|
566
|
+
const prevFailedFragments = failedFragments;
|
|
567
|
+
let seq0Succeeded = false;
|
|
568
|
+
|
|
569
|
+
// Mark the doc 'pending' and increment embed_attempts ONCE before its first
|
|
570
|
+
// fragment, so a crash mid-document leaves it retryable (re-selected by
|
|
571
|
+
// getHashesNeedingFragments). Completion setters below are state-only.
|
|
572
|
+
s.markEmbedStart(hash);
|
|
573
|
+
console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
|
|
574
|
+
|
|
575
|
+
if (isCloudEmbed) {
|
|
576
|
+
// Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
|
|
577
|
+
const allTexts: string[] = [];
|
|
578
|
+
for (const frag of fragments) {
|
|
579
|
+
const label = frag.label || title;
|
|
580
|
+
allTexts.push(formatDocForEmbedding(frag.content, label));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
for (let batchStartIdx = 0; batchStartIdx < allTexts.length; batchStartIdx += CLOUD_BATCH_SIZE) {
|
|
584
|
+
// Abort before each batch if the lease was reclaimed — a large document
|
|
585
|
+
// must not keep writing after another process took the lease (HIGH-3).
|
|
586
|
+
if (leaseLost) throw new EmbedLeaseLostError();
|
|
587
|
+
// Global TPM-aware delay: compute required wait based on last batch's token count,
|
|
588
|
+
// then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
|
|
589
|
+
// including first batch of each document (inter-document pacing).
|
|
590
|
+
if (lastBatchSentAt > 0) {
|
|
591
|
+
// Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
|
|
592
|
+
// Free: 100000 (default), Paid: 2000000, Premium: 50000000
|
|
593
|
+
const batchEnd0 = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
|
|
594
|
+
const estimatedTokens = allTexts.slice(batchStartIdx, batchEnd0)
|
|
595
|
+
.reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
|
|
596
|
+
// Use current batch estimate (not previous batch actuals — previous batch may differ in size)
|
|
597
|
+
const batchTokens = estimatedTokens;
|
|
598
|
+
const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
|
|
599
|
+
const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
|
|
600
|
+
const elapsed = Date.now() - lastBatchSentAt;
|
|
601
|
+
const remainingMs = requiredGapMs - elapsed;
|
|
602
|
+
if (remainingMs > 0) {
|
|
603
|
+
const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
|
|
604
|
+
await new Promise(r => setTimeout(r, jittered));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const batchEnd = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
|
|
609
|
+
const batchTexts = allTexts.slice(batchStartIdx, batchEnd);
|
|
610
|
+
lastBatchSentAt = Date.now();
|
|
611
|
+
const reqStart = Date.now();
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
const results = await llm.embedBatch(batchTexts);
|
|
615
|
+
const reqMs = Date.now() - reqStart;
|
|
616
|
+
const tokensUsed = llm.lastBatchTokens;
|
|
617
|
+
|
|
618
|
+
for (let i = 0; i < results.length; i++) {
|
|
619
|
+
const seq = batchStartIdx + i;
|
|
620
|
+
const frag = fragments[seq]!;
|
|
621
|
+
const result = results[i];
|
|
622
|
+
if (result) {
|
|
623
|
+
bindAndValidate(result);
|
|
624
|
+
s.ensureVecTable(result.embedding.length, leaseGuard);
|
|
625
|
+
s.insertEmbedding(
|
|
626
|
+
hash, seq, frag.startLine, new Float32Array(result.embedding),
|
|
627
|
+
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
|
|
628
|
+
leaseGuard
|
|
629
|
+
);
|
|
630
|
+
totalFragments++;
|
|
631
|
+
if (seq === 0) seq0Succeeded = true;
|
|
632
|
+
} else {
|
|
633
|
+
failedFragments++;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
console.error(` batch ${batchStartIdx + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch → abort the whole run
|
|
639
|
+
failedFragments += batchTexts.length;
|
|
640
|
+
console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStartIdx + 1}-${batchEnd}: ${err}${c.reset}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} else {
|
|
644
|
+
// Local mode: embed one at a time (no rate limit concern)
|
|
645
|
+
for (let seq = 0; seq < fragments.length; seq++) {
|
|
646
|
+
// Abort before each fragment if the lease was reclaimed — bounds any
|
|
647
|
+
// post-loss writing to at most one fragment of a large doc (HIGH-3).
|
|
648
|
+
if (leaseLost) throw new EmbedLeaseLostError();
|
|
649
|
+
const frag = fragments[seq]!;
|
|
650
|
+
const label = frag.label || title;
|
|
651
|
+
const text = formatDocForEmbedding(frag.content, label);
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
const fragStart = Date.now();
|
|
655
|
+
const result = await llm.embed(text);
|
|
656
|
+
const fragMs = Date.now() - fragStart;
|
|
508
657
|
if (result) {
|
|
509
|
-
|
|
658
|
+
bindAndValidate(result);
|
|
659
|
+
s.ensureVecTable(result.embedding.length, leaseGuard);
|
|
510
660
|
s.insertEmbedding(
|
|
511
661
|
hash, seq, frag.startLine, new Float32Array(result.embedding),
|
|
512
|
-
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
|
|
662
|
+
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
|
|
663
|
+
leaseGuard
|
|
513
664
|
);
|
|
514
665
|
totalFragments++;
|
|
515
666
|
if (seq === 0) seq0Succeeded = true;
|
|
667
|
+
if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
|
|
668
|
+
console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
|
|
669
|
+
}
|
|
516
670
|
} else {
|
|
517
671
|
failedFragments++;
|
|
672
|
+
console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) → null result [${text.length} chars]`);
|
|
518
673
|
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
} catch (err) {
|
|
522
|
-
failedFragments += batchTexts.length;
|
|
523
|
-
console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStart + 1}-${batchEnd}: ${err}${c.reset}`);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
} else {
|
|
527
|
-
// Local mode: embed one at a time (no rate limit concern)
|
|
528
|
-
for (let seq = 0; seq < fragments.length; seq++) {
|
|
529
|
-
const frag = fragments[seq]!;
|
|
530
|
-
const label = frag.label || title;
|
|
531
|
-
const text = formatDocForEmbedding(frag.content, label);
|
|
532
|
-
|
|
533
|
-
try {
|
|
534
|
-
const fragStart = Date.now();
|
|
535
|
-
const result = await llm.embed(text);
|
|
536
|
-
const fragMs = Date.now() - fragStart;
|
|
537
|
-
if (result) {
|
|
538
|
-
s.ensureVecTable(result.embedding.length);
|
|
539
|
-
s.insertEmbedding(
|
|
540
|
-
hash, seq, frag.startLine, new Float32Array(result.embedding),
|
|
541
|
-
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
|
|
542
|
-
);
|
|
543
|
-
totalFragments++;
|
|
544
|
-
if (seq === 0) seq0Succeeded = true;
|
|
545
|
-
if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
|
|
546
|
-
console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
|
|
547
|
-
}
|
|
548
|
-
} else {
|
|
674
|
+
} catch (err) {
|
|
675
|
+
if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch → abort the whole run
|
|
549
676
|
failedFragments++;
|
|
550
|
-
console.error(
|
|
677
|
+
console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
|
|
551
678
|
}
|
|
552
|
-
} catch (err) {
|
|
553
|
-
failedFragments++;
|
|
554
|
-
console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
|
|
555
679
|
}
|
|
556
680
|
}
|
|
681
|
+
|
|
682
|
+
// Embed-state completion: mark synced ONLY when the WHOLE document succeeded (no
|
|
683
|
+
// failed fragments) — a partial embed must not be silently permanent. Any failure
|
|
684
|
+
// → 'failed' (state-only; attempts already incremented at markEmbedStart) so the
|
|
685
|
+
// worklist retries it, bounded by embed_attempts < 3.
|
|
686
|
+
const docFragsFail = failedFragments - prevFailedFragments;
|
|
687
|
+
if (seq0Succeeded && docFragsFail === 0) {
|
|
688
|
+
s.markEmbedSynced(hash);
|
|
689
|
+
} else if (!seq0Succeeded) {
|
|
690
|
+
s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
|
|
691
|
+
} else {
|
|
692
|
+
s.markEmbedFailed(hash, `${docFragsFail} fragment(s) failed`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
embedded++;
|
|
696
|
+
const docMs = Date.now() - docStart;
|
|
697
|
+
const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
|
|
698
|
+
console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
|
|
557
699
|
}
|
|
558
700
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
701
|
+
const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
|
|
702
|
+
console.log();
|
|
703
|
+
console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
// Fatal aborts must NOT exit 0 — otherwise the embed timer / `update --embed`
|
|
706
|
+
// cannot tell the run was incomplete. Set a nonzero exit code (cleanup still
|
|
707
|
+
// runs in finally). Non-fatal errors propagate unchanged.
|
|
708
|
+
if (err instanceof EmbedLeaseLostError) {
|
|
709
|
+
// Checked before FatalVectorError because EmbedLeaseLostError extends it.
|
|
710
|
+
console.error(`${c.red}Embed aborted: lost the embedding lease (another embed process took over). Re-run 'clawmem embed'.${c.reset}`);
|
|
711
|
+
process.exitCode = 1;
|
|
712
|
+
} else if (err instanceof FatalVectorError) {
|
|
713
|
+
console.error(`${c.red}Embed aborted: ${(err as Error).message}${c.reset}`);
|
|
714
|
+
process.exitCode = 1;
|
|
566
715
|
} else {
|
|
567
|
-
|
|
568
|
-
s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
|
|
716
|
+
throw err;
|
|
569
717
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
|
|
718
|
+
} finally {
|
|
719
|
+
clearInterval(heartbeat);
|
|
720
|
+
releaseWorkerLease(s, LEASE_NAME, leaseToken);
|
|
721
|
+
await disposeDefaultLlamaCpp();
|
|
575
722
|
}
|
|
576
|
-
|
|
577
|
-
const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
|
|
578
|
-
console.log();
|
|
579
|
-
console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
|
|
580
|
-
|
|
581
|
-
await disposeDefaultLlamaCpp();
|
|
582
723
|
}
|
|
583
724
|
|
|
584
725
|
async function cmdStatus() {
|
|
@@ -799,19 +940,13 @@ async function cmdQuery(args: string[]) {
|
|
|
799
940
|
const secondScore = ftsResults[1]?.score ?? 0;
|
|
800
941
|
const strongSignal = topScore >= 0.85 && (topScore - secondScore) >= 0.15;
|
|
801
942
|
|
|
802
|
-
// Step 2: Query expansion (skip if strong BM25 signal)
|
|
803
|
-
|
|
943
|
+
// Step 2: Query expansion (skip if strong BM25 signal). expandQuery now returns
|
|
944
|
+
// typed ExpandedQuery[] (lex/vec/hyde) — no more brittle string re-parsing, and
|
|
945
|
+
// the original query is no longer echoed back as a phantom "vec" expansion.
|
|
946
|
+
let expandedQueries: ExpandedQuery[] = [];
|
|
804
947
|
if (!strongSignal) {
|
|
805
948
|
try {
|
|
806
|
-
|
|
807
|
-
expandedQueries = expanded.map(text => {
|
|
808
|
-
// Parse "type: text" format from expansion
|
|
809
|
-
const colonIdx = text.indexOf(": ");
|
|
810
|
-
if (colonIdx > 0 && colonIdx < 5) {
|
|
811
|
-
return { type: text.slice(0, colonIdx), text: text.slice(colonIdx + 2) };
|
|
812
|
-
}
|
|
813
|
-
return { type: "vec", text };
|
|
814
|
-
});
|
|
949
|
+
expandedQueries = await s.expandQuery(query, DEFAULT_QUERY_MODEL);
|
|
815
950
|
} catch {
|
|
816
951
|
// Fallback: no expansion
|
|
817
952
|
}
|
|
@@ -819,20 +954,27 @@ async function cmdQuery(args: string[]) {
|
|
|
819
954
|
|
|
820
955
|
// Step 3: Parallel searches
|
|
821
956
|
const allRanked: { results: RankedResult[]; weight: number }[] = [];
|
|
957
|
+
// Retain the raw SearchResult from every leg (original + typed expansions) so a
|
|
958
|
+
// candidate found ONLY via an expansion leg survives Step 8's resultMap lookup.
|
|
959
|
+
const candidateResults: SearchResult[] = [];
|
|
822
960
|
|
|
823
961
|
// Original query BM25 + vec (weight 2x)
|
|
824
962
|
allRanked.push({ results: ftsResults.map(toRanked), weight: 2 });
|
|
963
|
+
candidateResults.push(...ftsResults);
|
|
825
964
|
const vecResults = await s.searchVec(query, DEFAULT_EMBED_MODEL, 20);
|
|
826
965
|
allRanked.push({ results: vecResults.map(toRanked), weight: 2 });
|
|
966
|
+
candidateResults.push(...vecResults);
|
|
827
967
|
|
|
828
|
-
// Expanded queries (weight 1x)
|
|
968
|
+
// Expanded queries (weight 1x): lex → FTS, vec/hyde → vector
|
|
829
969
|
for (const eq of expandedQueries) {
|
|
830
970
|
if (eq.type === "lex") {
|
|
831
|
-
const r = s.searchFTS(eq.
|
|
971
|
+
const r = s.searchFTS(eq.query, 20);
|
|
832
972
|
allRanked.push({ results: r.map(toRanked), weight: 1 });
|
|
973
|
+
candidateResults.push(...r);
|
|
833
974
|
} else {
|
|
834
|
-
const r = await s.searchVec(eq.
|
|
975
|
+
const r = await s.searchVec(eq.query, DEFAULT_EMBED_MODEL, 20);
|
|
835
976
|
allRanked.push({ results: r.map(toRanked), weight: 1 });
|
|
977
|
+
candidateResults.push(...r);
|
|
836
978
|
}
|
|
837
979
|
}
|
|
838
980
|
|
|
@@ -869,9 +1011,10 @@ async function cmdQuery(args: string[]) {
|
|
|
869
1011
|
});
|
|
870
1012
|
blended.sort((a, b) => b.score - a.score);
|
|
871
1013
|
|
|
872
|
-
// Step 8: Map back to full results and apply composite scoring
|
|
1014
|
+
// Step 8: Map back to full results and apply composite scoring. Build the map from
|
|
1015
|
+
// ALL legs (incl. typed expansions) so expansion-only candidates aren't dropped.
|
|
873
1016
|
const resultMap = new Map(
|
|
874
|
-
|
|
1017
|
+
candidateResults.map(r => [r.filepath, r])
|
|
875
1018
|
);
|
|
876
1019
|
const fullResults = blended
|
|
877
1020
|
.map(b => resultMap.get(b.file))
|
|
@@ -1891,13 +2034,34 @@ async function cmdDoctor() {
|
|
|
1891
2034
|
const s = getStore();
|
|
1892
2035
|
const needsEmbed = s.getHashesNeedingEmbedding();
|
|
1893
2036
|
const hasVectors = !!s.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'").get();
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2037
|
+
// ALWAYS run the consistency check — the WORST desync (content_vectors rows but
|
|
2038
|
+
// vectors_vec entirely absent) lives in the no-table case, so it must not be
|
|
2039
|
+
// skipped. set-diff, NOT counts (one missing + one orphan cancel in a count check).
|
|
2040
|
+
// Pending docs are reported separately — they are in neither table, not a desync.
|
|
2041
|
+
const vc = s.getVectorConsistency();
|
|
2042
|
+
if (!hasVectors && vc.cvCount === 0) {
|
|
1897
2043
|
console.log(`${c.yellow}!${c.reset} Vector index: not created yet (run 'clawmem embed')`);
|
|
2044
|
+
} else if (!hasVectors) {
|
|
2045
|
+
console.log(`${c.red}✗${c.reset} Vector index: vectors_vec is MISSING but ${vc.cvCount} content_vectors row(s) exist — full desync. Run 'clawmem embed --force' to rebuild.`);
|
|
2046
|
+
issues++;
|
|
2047
|
+
} else {
|
|
2048
|
+
console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
|
|
2049
|
+
if (vc.cvMissingVv > 0 || vc.vvOrphan > 0) {
|
|
2050
|
+
console.log(`${c.red}✗${c.reset} Vector consistency: ${vc.cvMissingVv} metadata row(s) missing a vector, ${vc.vvOrphan} orphan vector(s) (content_vectors=${vc.cvCount}, vectors_vec=${vc.vvCount}). Run 'clawmem embed --force' to rebuild.`);
|
|
2051
|
+
issues++;
|
|
2052
|
+
} else {
|
|
2053
|
+
console.log(`${c.green}✓${c.reset} Vector consistency: ${vc.vvCount} vectors match ${vc.cvCount} metadata rows (${vc.pending} pending)`);
|
|
2054
|
+
}
|
|
1898
2055
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
2056
|
+
// Mixed embedding models = a heterogeneous vector space (cosine across different
|
|
2057
|
+
// models is meaningless) even when keys/dimensions are consistent. Flag it.
|
|
2058
|
+
const vecModels = s.getVecModels();
|
|
2059
|
+
if (vecModels.length > 1) {
|
|
2060
|
+
console.log(`${c.red}✗${c.reset} Embedding models: vault has MIXED models (${vecModels.join(", ")}) — heterogeneous vector space. Run 'clawmem embed --force' to rebuild with one model.`);
|
|
2061
|
+
issues++;
|
|
2062
|
+
}
|
|
2063
|
+
} catch (err) {
|
|
2064
|
+
console.log(`${c.yellow}!${c.reset} Vector index: could not check (${(err as Error).message})`);
|
|
1901
2065
|
}
|
|
1902
2066
|
|
|
1903
2067
|
// 4. Content types
|
|
@@ -264,8 +264,14 @@ export async function contextSurfacing(
|
|
|
264
264
|
const seen = new Set(results.map(r => r.filepath));
|
|
265
265
|
for (const eq of expanded.slice(0, 3)) {
|
|
266
266
|
if (Date.now() - startTime > 6000) break; // hard stop at 6s
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
// Typed routing: lex → FTS; vec/hyde → vector (deep profile + time budget only).
|
|
268
|
+
let hits: SearchResult[] = [];
|
|
269
|
+
if (eq.type === 'lex') {
|
|
270
|
+
hits = store.searchFTS(eq.query, 5);
|
|
271
|
+
} else if (profile.useVector) {
|
|
272
|
+
try { hits = await store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 5); } catch { /* vector leg non-fatal */ }
|
|
273
|
+
}
|
|
274
|
+
for (const r of hits) {
|
|
269
275
|
if (!seen.has(r.filepath)) {
|
|
270
276
|
seen.add(r.filepath);
|
|
271
277
|
results.push(r);
|