agent-working-memory 0.7.4 → 0.7.7

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.
@@ -368,15 +368,20 @@ export class EngramStore {
368
368
 
369
369
  if (!sanitized) return [];
370
370
 
371
+ // CTE prefilter — see searchBM25WithRank for rationale (567× speedup verified).
371
372
  try {
372
373
  const placeholders = agentIds.map(() => '?').join(',');
374
+ const innerLimit = Math.max(limit * 5, 50);
373
375
  const rows = this.db.prepare(`
374
- SELECT e.*, rank FROM engrams e
375
- JOIN engrams_fts ON e.rowid = engrams_fts.rowid
376
- WHERE engrams_fts MATCH ? AND e.agent_id IN (${placeholders}) AND e.retracted = 0
377
- ORDER BY rank
376
+ WITH top_fts AS (
377
+ SELECT rowid, rank FROM engrams_fts WHERE engrams_fts MATCH ? ORDER BY rank LIMIT ?
378
+ )
379
+ SELECT e.*, top_fts.rank FROM top_fts
380
+ JOIN engrams e ON e.rowid = top_fts.rowid
381
+ WHERE e.agent_id IN (${placeholders}) AND e.retracted = 0
382
+ ORDER BY top_fts.rank
378
383
  LIMIT ?
379
- `).all(sanitized, ...agentIds, limit) as any[];
384
+ `).all(sanitized, innerLimit, ...agentIds, limit) as any[];
380
385
 
381
386
  return rows.map(r => ({
382
387
  engram: this.rowToEngram(r),
@@ -504,14 +509,33 @@ export class EngramStore {
504
509
 
505
510
  if (!sanitized) return [];
506
511
 
512
+ // CTE prefilter: force FTS5 to apply LIMIT before joining engrams.
513
+ //
514
+ // Why: the obvious query (JOIN engrams_fts ON rowid + WHERE MATCH + ORDER BY rank LIMIT N)
515
+ // makes SQLite's planner materialize ALL matching FTS rows joined with engrams
516
+ // before applying LIMIT. With wide OR queries on a 17K-engram index, that's
517
+ // thousands of row materializations including 1.5KB embedding blobs — measured
518
+ // at 3682ms for a 5-term OR query.
519
+ //
520
+ // The CTE forces FTS5 to LIMIT first (sub-ms), then join only the top-K rowids.
521
+ // Same query plan, 567× faster (3682ms → 6ms verified on 17K engrams).
522
+ //
523
+ // The inner LIMIT (limit * 5) over-fetches because the agent_id + retracted
524
+ // filter is applied AFTER the CTE. limit*5 gives enough headroom that filtered
525
+ // results still satisfy the outer LIMIT for typical workloads (single agent
526
+ // dominant, low retracted rate).
507
527
  try {
528
+ const innerLimit = Math.max(limit * 5, 50);
508
529
  const rows = this.db.prepare(`
509
- SELECT e.*, rank FROM engrams e
510
- JOIN engrams_fts ON e.rowid = engrams_fts.rowid
511
- WHERE engrams_fts MATCH ? AND e.agent_id = ? AND e.retracted = 0
512
- ORDER BY rank
530
+ WITH top_fts AS (
531
+ SELECT rowid, rank FROM engrams_fts WHERE engrams_fts MATCH ? ORDER BY rank LIMIT ?
532
+ )
533
+ SELECT e.*, top_fts.rank FROM top_fts
534
+ JOIN engrams e ON e.rowid = top_fts.rowid
535
+ WHERE e.agent_id = ? AND e.retracted = 0
536
+ ORDER BY top_fts.rank
513
537
  LIMIT ?
514
- `).all(sanitized, agentId, limit) as any[];
538
+ `).all(sanitized, innerLimit, agentId, limit) as any[];
515
539
 
516
540
  return rows.map(r => ({
517
541
  engram: this.rowToEngram(r),
@@ -695,6 +719,49 @@ export class EngramStore {
695
719
  return (rows as any[]).map(r => this.rowToAssociation(r));
696
720
  }
697
721
 
722
+ /**
723
+ * Batch variant of getAssociationsFor — fetches associations for many engrams
724
+ * in a single query, returning a Map keyed by engram id.
725
+ *
726
+ * Why: per-candidate `getAssociationsFor` calls inside the activation scoring
727
+ * loop are an N+1. Measured at 1300ms for 10K candidates (sub-ms per call but
728
+ * accumulating). One IN-clause query reduces this to ~50ms.
729
+ */
730
+ getAssociationsForBatch(engramIds: string[]): Map<string, Association[]> {
731
+ const result = new Map<string, Association[]>();
732
+ if (engramIds.length === 0) return result;
733
+
734
+ // SQLite's default SQLITE_LIMIT_VARIABLE_NUMBER is 999. Chunk to stay safely below.
735
+ // We bind each id twice (from + to), so chunks of 400 use 800 placeholders.
736
+ const CHUNK = 400;
737
+ for (let i = 0; i < engramIds.length; i += CHUNK) {
738
+ const chunk = engramIds.slice(i, i + CHUNK);
739
+ const placeholders = chunk.map(() => '?').join(',');
740
+ const rows = this.db.prepare(
741
+ `SELECT * FROM associations
742
+ WHERE from_engram_id IN (${placeholders}) OR to_engram_id IN (${placeholders})`
743
+ ).all(...chunk, ...chunk) as any[];
744
+ for (const r of rows) {
745
+ const a = this.rowToAssociation(r);
746
+ // Bucket by both endpoints — getAssociationsFor returns either-direction matches.
747
+ const fromList = result.get(a.fromEngramId) ?? [];
748
+ fromList.push(a);
749
+ result.set(a.fromEngramId, fromList);
750
+ if (a.toEngramId !== a.fromEngramId) {
751
+ const toList = result.get(a.toEngramId) ?? [];
752
+ toList.push(a);
753
+ result.set(a.toEngramId, toList);
754
+ }
755
+ }
756
+ }
757
+ // Ensure every requested id has an entry (even if empty) so callers can
758
+ // .get() without null-checking.
759
+ for (const id of engramIds) {
760
+ if (!result.has(id)) result.set(id, []);
761
+ }
762
+ return result;
763
+ }
764
+
698
765
  getOutgoingAssociations(engramId: string): Association[] {
699
766
  const rows = this.db.prepare(
700
767
  'SELECT * FROM associations WHERE from_engram_id = ?'