fossel 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +91 -17
  2. package/dist/cli.js +739 -174
  3. package/dist/index.js +426 -73
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -164,6 +164,25 @@ var init_migrate = __esm({
164
164
  tx(rows);
165
165
  }
166
166
  }
167
+ },
168
+ {
169
+ name: "007_add_memory_embeddings",
170
+ apply: (db) => {
171
+ db.exec(`
172
+ CREATE TABLE IF NOT EXISTS memory_embeddings (
173
+ memory_rowid INTEGER PRIMARY KEY,
174
+ dim INTEGER NOT NULL,
175
+ version INTEGER NOT NULL,
176
+ vector BLOB NOT NULL,
177
+ updated_at INTEGER NOT NULL
178
+ );
179
+
180
+ CREATE TRIGGER IF NOT EXISTS memories_embeddings_ad
181
+ AFTER DELETE ON memories BEGIN
182
+ DELETE FROM memory_embeddings WHERE memory_rowid = old.rowid;
183
+ END;
184
+ `);
185
+ }
167
186
  }
168
187
  ];
169
188
  }
@@ -215,6 +234,96 @@ var init_client = __esm({
215
234
  }
216
235
  });
217
236
 
237
+ // src/lib/dedupe.ts
238
+ function normalizeText(text) {
239
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
240
+ }
241
+ function tokenize(text) {
242
+ return normalizeText(text).split(" ").filter((token) => token.length >= 2);
243
+ }
244
+ function trigrams(text) {
245
+ const padded = ` ${text} `;
246
+ const grams = /* @__PURE__ */ new Set();
247
+ for (let i = 0; i < padded.length - 2; i += 1) {
248
+ grams.add(padded.slice(i, i + 3));
249
+ }
250
+ return grams;
251
+ }
252
+ function jaccard(a, b) {
253
+ if (a.size === 0 && b.size === 0) {
254
+ return 1;
255
+ }
256
+ let intersection = 0;
257
+ for (const value of a) {
258
+ if (b.has(value)) {
259
+ intersection += 1;
260
+ }
261
+ }
262
+ const union = a.size + b.size - intersection;
263
+ return union === 0 ? 0 : intersection / union;
264
+ }
265
+ function similarity(a, b) {
266
+ const normalizedA = normalizeText(a);
267
+ const normalizedB = normalizeText(b);
268
+ if (!normalizedA && !normalizedB) {
269
+ return 1;
270
+ }
271
+ if (!normalizedA || !normalizedB) {
272
+ return 0;
273
+ }
274
+ if (normalizedA === normalizedB) {
275
+ return 1;
276
+ }
277
+ const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
278
+ const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
279
+ return wordScore * 0.55 + triScore * 0.45;
280
+ }
281
+ function findDuplicate(db, repo, note, options = {}) {
282
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
283
+ const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
284
+ const normalized = normalizeText(note);
285
+ if (!normalized) {
286
+ return null;
287
+ }
288
+ const exact = db.prepare(
289
+ `
290
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
291
+ FROM memories
292
+ WHERE repo = ? AND note_normalized = ?
293
+ ORDER BY updated_at DESC
294
+ LIMIT 1
295
+ `
296
+ ).get(repo, normalized);
297
+ if (exact) {
298
+ return { memory: exact, similarity: 1 };
299
+ }
300
+ const candidates = db.prepare(
301
+ `
302
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
303
+ FROM memories
304
+ WHERE repo = ?
305
+ ORDER BY updated_at DESC
306
+ LIMIT ?
307
+ `
308
+ ).all(repo, limit);
309
+ let best = null;
310
+ for (const candidate of candidates) {
311
+ const score = similarity(note, candidate.note);
312
+ if (score >= threshold && (!best || score > best.similarity)) {
313
+ best = { memory: candidate, similarity: score };
314
+ }
315
+ }
316
+ return best;
317
+ }
318
+ var DEFAULT_THRESHOLD, DEFAULT_CANDIDATE_LIMIT;
319
+ var init_dedupe = __esm({
320
+ "src/lib/dedupe.ts"() {
321
+ "use strict";
322
+ DEFAULT_THRESHOLD = 0.82;
323
+ DEFAULT_CANDIDATE_LIMIT = 200;
324
+ }
325
+ });
326
+
218
327
  // src/lib/repo.ts
219
328
  import { spawnSync } from "child_process";
220
329
  import { basename } from "path";
@@ -341,7 +450,7 @@ function resolveRepoArg(input, cwd, db) {
341
450
  }
342
451
  function mergeRepoKeys(db, from, to) {
343
452
  if (from === to) {
344
- return { movedAliases: 0, movedMemories: 0 };
453
+ return { movedAliases: 0, movedMemories: 0, rewrittenNotes: 0 };
345
454
  }
346
455
  const tx = db.transaction(() => {
347
456
  const aliasResult = db.prepare("UPDATE repo_aliases SET canonical = ? WHERE canonical = ?").run(to, from);
@@ -358,15 +467,79 @@ function mergeRepoKeys(db, from, to) {
358
467
  movedMemories += result.changes;
359
468
  }
360
469
  movedMemories += updateMemories.run(to, from).changes;
470
+ const rewrittenNotes = rewriteStaleRepoMentions(db, from, to);
361
471
  upsertAlias(db, from, to);
362
472
  upsertAlias(db, to, to);
363
473
  return {
364
474
  movedAliases: aliasResult.changes,
365
- movedMemories
475
+ movedMemories,
476
+ rewrittenNotes
366
477
  };
367
478
  });
368
479
  return tx();
369
480
  }
481
+ function tokenBoundaryReplace(text, from, to) {
482
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
483
+ const pattern = new RegExp(`(^|[^\\w/-])(${escaped})(?=$|[^\\w/-])`, "g");
484
+ return text.replace(pattern, (_match, prefix) => `${prefix}${to}`);
485
+ }
486
+ function rewriteStaleRepoMentions(db, from, to) {
487
+ const candidates = db.prepare(
488
+ `
489
+ SELECT rowid AS row_id, note, metadata_json
490
+ FROM memories
491
+ WHERE note LIKE ?
492
+ `
493
+ ).all(`%${from}%`);
494
+ if (candidates.length === 0) {
495
+ return 0;
496
+ }
497
+ const update = db.prepare(
498
+ `
499
+ UPDATE memories
500
+ SET note = ?, note_normalized = ?, metadata_json = ?, updated_at = ?
501
+ WHERE rowid = ?
502
+ `
503
+ );
504
+ const now = Math.floor(Date.now() / 1e3);
505
+ let rewritten = 0;
506
+ for (const row of candidates) {
507
+ const next = tokenBoundaryReplace(row.note, from, to);
508
+ if (next === row.note) {
509
+ continue;
510
+ }
511
+ const normalized = next.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
512
+ let metadata;
513
+ try {
514
+ const parsed = JSON.parse(row.metadata_json);
515
+ metadata = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
516
+ } catch {
517
+ metadata = {};
518
+ }
519
+ metadata.changelog = metadata.changelog ?? [];
520
+ metadata.changelog.push({
521
+ at: now,
522
+ action: "alias_rewrite",
523
+ previous_note: row.note,
524
+ rewrote_alias: from
525
+ });
526
+ update.run(next, normalized, JSON.stringify(metadata), now, row.row_id);
527
+ rewritten += 1;
528
+ }
529
+ return rewritten;
530
+ }
531
+ function findMemoriesMentioningAlias(db, alias, canonical) {
532
+ const rows = db.prepare(
533
+ `
534
+ SELECT rowid AS row_id, repo, note
535
+ FROM memories
536
+ WHERE repo = ? AND note LIKE ?
537
+ `
538
+ ).all(canonical, `%${alias}%`);
539
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
540
+ const pattern = new RegExp(`(^|[^\\w/-])${escaped}(?=$|[^\\w/-])`);
541
+ return rows.filter((row) => pattern.test(row.note));
542
+ }
370
543
  var REMOTE_PATTERNS;
371
544
  var init_repo = __esm({
372
545
  "src/lib/repo.ts"() {
@@ -384,6 +557,170 @@ var init_repo = __esm({
384
557
  }
385
558
  });
386
559
 
560
+ // src/lib/embeddings.ts
561
+ function embeddingsEnabled() {
562
+ const value = process.env.FOSSEL_EMBEDDINGS?.trim().toLowerCase();
563
+ return value === "1" || value === "true" || value === "on" || value === "yes";
564
+ }
565
+ function fnv1a(str) {
566
+ let hash = 2166136261;
567
+ for (let i = 0; i < str.length; i += 1) {
568
+ hash ^= str.charCodeAt(i);
569
+ hash = Math.imul(hash, 16777619);
570
+ }
571
+ return hash >>> 0;
572
+ }
573
+ function tokenize2(text) {
574
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim().split(" ").filter((token) => token.length >= 2);
575
+ }
576
+ function embedText(text) {
577
+ const vector = new Float32Array(EMBEDDING_DIM);
578
+ const tokens = tokenize2(text);
579
+ if (tokens.length === 0) {
580
+ return vector;
581
+ }
582
+ const addFeature = (feature, weight) => {
583
+ const h = fnv1a(feature);
584
+ const index = h % EMBEDDING_DIM;
585
+ const sign = (fnv1a(`#${feature}`) & 1) === 0 ? 1 : -1;
586
+ vector[index] += sign * weight;
587
+ };
588
+ for (let i = 0; i < tokens.length; i += 1) {
589
+ addFeature(tokens[i], 1);
590
+ if (i + 1 < tokens.length) {
591
+ addFeature(`${tokens[i]} ${tokens[i + 1]}`, 0.6);
592
+ }
593
+ }
594
+ let norm = 0;
595
+ for (let i = 0; i < EMBEDDING_DIM; i += 1) {
596
+ norm += vector[i] * vector[i];
597
+ }
598
+ norm = Math.sqrt(norm);
599
+ if (norm > 0) {
600
+ for (let i = 0; i < EMBEDDING_DIM; i += 1) {
601
+ vector[i] /= norm;
602
+ }
603
+ }
604
+ return vector;
605
+ }
606
+ function cosineSimilarity(a, b) {
607
+ if (a.length !== b.length) {
608
+ return 0;
609
+ }
610
+ let dot = 0;
611
+ for (let i = 0; i < a.length; i += 1) {
612
+ dot += a[i] * b[i];
613
+ }
614
+ return dot;
615
+ }
616
+ function vectorToBuffer(vector) {
617
+ return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
618
+ }
619
+ function bufferToVector(buffer) {
620
+ const copy = Buffer.from(buffer);
621
+ return new Float32Array(
622
+ copy.buffer,
623
+ copy.byteOffset,
624
+ Math.floor(copy.byteLength / 4)
625
+ );
626
+ }
627
+ var EMBEDDING_DIM, EMBEDDING_VERSION;
628
+ var init_embeddings = __esm({
629
+ "src/lib/embeddings.ts"() {
630
+ "use strict";
631
+ EMBEDDING_DIM = 256;
632
+ EMBEDDING_VERSION = 1;
633
+ }
634
+ });
635
+
636
+ // src/lib/vector-index.ts
637
+ function indexMemoryEmbedding(db, rowId, note) {
638
+ if (!embeddingsEnabled()) {
639
+ return;
640
+ }
641
+ const vector = embedText(note);
642
+ db.prepare(
643
+ `
644
+ INSERT INTO memory_embeddings (memory_rowid, dim, version, vector, updated_at)
645
+ VALUES (?, ?, ?, ?, ?)
646
+ ON CONFLICT(memory_rowid) DO UPDATE SET
647
+ dim = excluded.dim,
648
+ version = excluded.version,
649
+ vector = excluded.vector,
650
+ updated_at = excluded.updated_at
651
+ `
652
+ ).run(
653
+ rowId,
654
+ EMBEDDING_DIM,
655
+ EMBEDDING_VERSION,
656
+ vectorToBuffer(vector),
657
+ Math.floor(Date.now() / 1e3)
658
+ );
659
+ }
660
+ function backfillRepoEmbeddings(db, repo) {
661
+ if (!embeddingsEnabled()) {
662
+ return 0;
663
+ }
664
+ const rows = db.prepare(
665
+ `
666
+ SELECT m.rowid AS row_id, m.note
667
+ FROM memories AS m
668
+ LEFT JOIN memory_embeddings AS e ON e.memory_rowid = m.rowid
669
+ WHERE m.repo = ?
670
+ AND (e.memory_rowid IS NULL OR e.version != ? OR e.dim != ?)
671
+ `
672
+ ).all(repo, EMBEDDING_VERSION, EMBEDDING_DIM);
673
+ if (rows.length === 0) {
674
+ return 0;
675
+ }
676
+ const tx = db.transaction((batch) => {
677
+ for (const row of batch) {
678
+ indexMemoryEmbedding(db, row.row_id, row.note);
679
+ }
680
+ });
681
+ tx(rows);
682
+ return rows.length;
683
+ }
684
+ function vectorSearch(db, repo, query, limit) {
685
+ if (!embeddingsEnabled()) {
686
+ return [];
687
+ }
688
+ backfillRepoEmbeddings(db, repo);
689
+ const queryVector = embedText(query);
690
+ let queryNorm = 0;
691
+ for (let i = 0; i < queryVector.length; i += 1) {
692
+ queryNorm += queryVector[i] * queryVector[i];
693
+ }
694
+ if (queryNorm === 0) {
695
+ return [];
696
+ }
697
+ const rows = db.prepare(
698
+ `
699
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
700
+ m.created_at, m.updated_at, m.pinned, e.vector AS vector
701
+ FROM memory_embeddings AS e
702
+ JOIN memories AS m ON m.rowid = e.memory_rowid
703
+ WHERE m.repo = ? AND e.dim = ? AND e.version = ?
704
+ `
705
+ ).all(repo, EMBEDDING_DIM, EMBEDDING_VERSION);
706
+ const scored = [];
707
+ for (const row of rows) {
708
+ const { vector, ...memory } = row;
709
+ const score = cosineSimilarity(queryVector, bufferToVector(vector));
710
+ if (score > 0) {
711
+ scored.push({ ...memory, score });
712
+ }
713
+ }
714
+ scored.sort((a, b) => b.score - a.score);
715
+ return scored.slice(0, limit);
716
+ }
717
+ var init_vector_index = __esm({
718
+ "src/lib/vector-index.ts"() {
719
+ "use strict";
720
+ init_embeddings();
721
+ }
722
+ });
723
+
387
724
  // src/lib/context.ts
388
725
  function parseTags(raw) {
389
726
  try {
@@ -393,6 +730,9 @@ function parseTags(raw) {
393
730
  return [];
394
731
  }
395
732
  }
733
+ function normalizeNoteForReadDedupe(text) {
734
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
735
+ }
396
736
  function buildFtsQuery(query) {
397
737
  const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
398
738
  if (terms.length === 0) {
@@ -403,11 +743,19 @@ function buildFtsQuery(query) {
403
743
  function fetchRepoContext(db, repo, limit, query) {
404
744
  const rows = [];
405
745
  const seen = /* @__PURE__ */ new Set();
746
+ const seenNormalized = /* @__PURE__ */ new Set();
406
747
  const push = (memory, source, rank) => {
407
748
  if (seen.has(memory.row_id)) {
408
749
  return;
409
750
  }
751
+ const normalized = normalizeNoteForReadDedupe(memory.note);
752
+ if (normalized && seenNormalized.has(normalized)) {
753
+ return;
754
+ }
410
755
  seen.add(memory.row_id);
756
+ if (normalized) {
757
+ seenNormalized.add(normalized);
758
+ }
411
759
  rows.push({ ...memory, source, rank });
412
760
  };
413
761
  const pinned = db.prepare(
@@ -422,7 +770,10 @@ function fetchRepoContext(db, repo, limit, query) {
422
770
  for (const row of pinned) {
423
771
  push(row, "pinned");
424
772
  }
425
- if (rows.length < limit) {
773
+ const pushRecent = () => {
774
+ if (rows.length >= limit) {
775
+ return;
776
+ }
426
777
  const recent = db.prepare(
427
778
  `
428
779
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -431,13 +782,20 @@ function fetchRepoContext(db, repo, limit, query) {
431
782
  ORDER BY updated_at DESC
432
783
  LIMIT ?
433
784
  `
434
- ).all(repo, limit - rows.length);
785
+ ).all(repo, limit);
435
786
  for (const row of recent) {
436
787
  push(row, "recent");
788
+ if (rows.length >= limit) {
789
+ break;
790
+ }
437
791
  }
792
+ };
793
+ if (!query) {
794
+ pushRecent();
438
795
  }
439
796
  if (query && rows.length < limit) {
440
797
  const ftsQuery = buildFtsQuery(query);
798
+ const ftsRows = [];
441
799
  if (ftsQuery) {
442
800
  try {
443
801
  const matches = db.prepare(
@@ -451,15 +809,49 @@ function fetchRepoContext(db, repo, limit, query) {
451
809
  LIMIT ?
452
810
  `
453
811
  ).all(ftsQuery, repo, limit);
454
- for (const row of matches) {
455
- push(row, "search", row.rank);
456
- if (rows.length >= limit) {
457
- break;
812
+ ftsRows.push(...matches);
813
+ } catch {
814
+ }
815
+ }
816
+ const vectorRows = embeddingsEnabled() ? vectorSearch(db, repo, query, limit) : [];
817
+ if (vectorRows.length > 0) {
818
+ const RRF_K = 60;
819
+ const fused = /* @__PURE__ */ new Map();
820
+ const accumulate = (list, rankOf) => {
821
+ list.forEach((memory, index) => {
822
+ const contribution = 1 / (RRF_K + index + 1);
823
+ const prior = fused.get(memory.row_id);
824
+ if (prior) {
825
+ prior.score += contribution;
826
+ } else {
827
+ fused.set(memory.row_id, {
828
+ memory,
829
+ score: contribution,
830
+ rank: rankOf?.(memory, index)
831
+ });
458
832
  }
833
+ });
834
+ };
835
+ accumulate(ftsRows, (m) => m.rank);
836
+ accumulate(vectorRows);
837
+ const ordered = Array.from(fused.values()).sort(
838
+ (a, b) => b.score - a.score
839
+ );
840
+ for (const { memory, rank } of ordered) {
841
+ push(memory, "search", rank);
842
+ if (rows.length >= limit) {
843
+ break;
844
+ }
845
+ }
846
+ } else {
847
+ for (const row of ftsRows) {
848
+ push(row, "search", row.rank);
849
+ if (rows.length >= limit) {
850
+ break;
459
851
  }
460
- } catch {
461
852
  }
462
853
  }
854
+ pushRecent();
463
855
  }
464
856
  return rows.slice(0, limit);
465
857
  }
@@ -522,6 +914,8 @@ var init_context = __esm({
522
914
  "src/lib/context.ts"() {
523
915
  "use strict";
524
916
  init_client();
917
+ init_embeddings();
918
+ init_vector_index();
525
919
  SECTION_TITLES = {
526
920
  convention: "Conventions",
527
921
  bug_fix: "Bug Fixes",
@@ -533,93 +927,17 @@ var init_context = __esm({
533
927
  }
534
928
  });
535
929
 
536
- // src/lib/dedupe.ts
537
- function normalizeText(text) {
538
- return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
539
- }
540
- function tokenize(text) {
541
- return normalizeText(text).split(" ").filter((token) => token.length >= 2);
542
- }
543
- function trigrams(text) {
544
- const padded = ` ${text} `;
545
- const grams = /* @__PURE__ */ new Set();
546
- for (let i = 0; i < padded.length - 2; i += 1) {
547
- grams.add(padded.slice(i, i + 3));
548
- }
549
- return grams;
550
- }
551
- function jaccard(a, b) {
552
- if (a.size === 0 && b.size === 0) {
553
- return 1;
554
- }
555
- let intersection = 0;
556
- for (const value of a) {
557
- if (b.has(value)) {
558
- intersection += 1;
559
- }
560
- }
561
- const union = a.size + b.size - intersection;
562
- return union === 0 ? 0 : intersection / union;
563
- }
564
- function similarity(a, b) {
565
- const normalizedA = normalizeText(a);
566
- const normalizedB = normalizeText(b);
567
- if (!normalizedA && !normalizedB) {
568
- return 1;
569
- }
570
- if (!normalizedA || !normalizedB) {
571
- return 0;
572
- }
573
- if (normalizedA === normalizedB) {
574
- return 1;
575
- }
576
- const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
577
- const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
578
- return wordScore * 0.55 + triScore * 0.45;
579
- }
580
- function findDuplicate(db, repo, note, options = {}) {
581
- const threshold = options.threshold ?? DEFAULT_THRESHOLD;
582
- const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
583
- const normalized = normalizeText(note);
584
- if (!normalized) {
585
- return null;
586
- }
587
- const exact = db.prepare(
588
- `
589
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
590
- FROM memories
591
- WHERE repo = ? AND note_normalized = ?
592
- ORDER BY updated_at DESC
593
- LIMIT 1
594
- `
595
- ).get(repo, normalized);
596
- if (exact) {
597
- return { memory: exact, similarity: 1 };
598
- }
599
- const candidates = db.prepare(
600
- `
601
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
602
- FROM memories
603
- WHERE repo = ?
604
- ORDER BY updated_at DESC
605
- LIMIT ?
606
- `
607
- ).all(repo, limit);
608
- let best = null;
609
- for (const candidate of candidates) {
610
- const score = similarity(note, candidate.note);
611
- if (score >= threshold && (!best || score > best.similarity)) {
612
- best = { memory: candidate, similarity: score };
613
- }
930
+ // src/lib/workspace.ts
931
+ function getWorkspaceRoot() {
932
+ const fromEnv = process.env.FOSSEL_WORKSPACE?.trim();
933
+ if (fromEnv) {
934
+ return fromEnv;
614
935
  }
615
- return best;
936
+ return process.cwd();
616
937
  }
617
- var DEFAULT_THRESHOLD, DEFAULT_CANDIDATE_LIMIT;
618
- var init_dedupe = __esm({
619
- "src/lib/dedupe.ts"() {
938
+ var init_workspace = __esm({
939
+ "src/lib/workspace.ts"() {
620
940
  "use strict";
621
- DEFAULT_THRESHOLD = 0.82;
622
- DEFAULT_CANDIDATE_LIMIT = 200;
623
941
  }
624
942
  });
625
943
 
@@ -666,7 +984,7 @@ function registerDedupeRepoTool(server) {
666
984
  async ({ repo, threshold, apply }) => {
667
985
  try {
668
986
  const db = getDb();
669
- const resolved = resolveRepoArg(repo, process.cwd(), db);
987
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
670
988
  const rows = db.prepare(
671
989
  `
672
990
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
@@ -798,6 +1116,7 @@ var init_dedupe_repo = __esm({
798
1116
  init_client();
799
1117
  init_dedupe();
800
1118
  init_repo();
1119
+ init_workspace();
801
1120
  dedupeRepoInputSchema = {
802
1121
  repo: z.string().trim().min(1).optional(),
803
1122
  threshold: z.number().min(0.5).max(1).default(0.85),
@@ -806,20 +1125,55 @@ var init_dedupe_repo = __esm({
806
1125
  }
807
1126
  });
808
1127
 
1128
+ // src/lib/memory.ts
1129
+ function findMemoryByAnyId(db, input) {
1130
+ const numeric = typeof input === "number" ? input : Number(input);
1131
+ const isNumericId = Number.isInteger(numeric) && numeric > 0 && String(numeric) === String(input).trim();
1132
+ if (isNumericId) {
1133
+ const row = db.prepare(
1134
+ `
1135
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
1136
+ FROM memories
1137
+ WHERE rowid = ?
1138
+ `
1139
+ ).get(numeric);
1140
+ if (row) {
1141
+ return row;
1142
+ }
1143
+ }
1144
+ const stringInput = String(input).trim();
1145
+ if (stringInput.length === 0) {
1146
+ return null;
1147
+ }
1148
+ const stringRow = db.prepare(
1149
+ `
1150
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
1151
+ FROM memories
1152
+ WHERE id = ?
1153
+ `
1154
+ ).get(stringInput);
1155
+ return stringRow ?? null;
1156
+ }
1157
+ var init_memory = __esm({
1158
+ "src/lib/memory.ts"() {
1159
+ "use strict";
1160
+ }
1161
+ });
1162
+
809
1163
  // src/tools/delete.ts
810
1164
  import { z as z2 } from "zod";
811
1165
  function registerDeleteMemoryTool(server) {
812
1166
  server.registerTool(
813
1167
  "delete_memory",
814
1168
  {
815
- description: "Delete a memory from storage by id.",
1169
+ description: "Delete a memory from storage by id. Accepts either the numeric row id or the legacy string id.",
816
1170
  inputSchema: deleteMemoryInputSchema
817
1171
  },
818
1172
  async ({ id }) => {
819
1173
  try {
820
1174
  const db = getDb();
821
- const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(id);
822
- if (!row) {
1175
+ const memory = findMemoryByAnyId(db, id);
1176
+ if (!memory) {
823
1177
  return {
824
1178
  isError: true,
825
1179
  content: [
@@ -830,15 +1184,15 @@ function registerDeleteMemoryTool(server) {
830
1184
  ]
831
1185
  };
832
1186
  }
833
- const deleteTx = db.transaction((memoryId) => {
834
- db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
1187
+ const deleteTx = db.transaction((rowId) => {
1188
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(rowId);
835
1189
  });
836
- deleteTx(id);
1190
+ deleteTx(memory.row_id);
837
1191
  return {
838
1192
  content: [
839
1193
  {
840
1194
  type: "text",
841
- text: `Deleted memory ${id}.`
1195
+ text: `Deleted memory ${memory.row_id} (legacy: ${memory.id}).`
842
1196
  }
843
1197
  ]
844
1198
  };
@@ -862,8 +1216,12 @@ var init_delete = __esm({
862
1216
  "src/tools/delete.ts"() {
863
1217
  "use strict";
864
1218
  init_client();
1219
+ init_memory();
865
1220
  deleteMemoryInputSchema = {
866
- id: z2.string().trim().min(1, "id is required")
1221
+ // Accept either the numeric row_id or the legacy nanoid string. Tools used
1222
+ // to disagree about which form to take; this unifies them so callers can
1223
+ // paste whichever id they have in front of them.
1224
+ id: z2.union([z2.number().int().positive(), z2.string().trim().min(1)])
867
1225
  };
868
1226
  }
869
1227
  });
@@ -880,7 +1238,7 @@ function registerGetContextTool(server) {
880
1238
  async ({ repo, query, limit, format }) => {
881
1239
  try {
882
1240
  const db = getDb();
883
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1241
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
884
1242
  const rows = fetchRepoContext(db, resolved.canonical, limit, query);
885
1243
  const text = formatContext(rows, {
886
1244
  repo: resolved.canonical,
@@ -917,6 +1275,7 @@ var init_get_context = __esm({
917
1275
  init_client();
918
1276
  init_context();
919
1277
  init_repo();
1278
+ init_workspace();
920
1279
  getContextInputSchema = {
921
1280
  repo: z3.string().trim().min(1).optional(),
922
1281
  query: z3.string().trim().min(1).optional(),
@@ -949,7 +1308,7 @@ function registerGetRepoContextTool(server) {
949
1308
  async ({ repo, limit }) => {
950
1309
  try {
951
1310
  const db = getDb();
952
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1311
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
953
1312
  const rows = db.prepare(
954
1313
  `
955
1314
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -1020,6 +1379,7 @@ var init_get_repo = __esm({
1020
1379
  "use strict";
1021
1380
  init_client();
1022
1381
  init_repo();
1382
+ init_workspace();
1023
1383
  getRepoContextInputSchema = {
1024
1384
  repo: z4.string().trim().min(1).optional(),
1025
1385
  limit: z4.number().int().positive().max(100).default(10)
@@ -1029,7 +1389,7 @@ var init_get_repo = __esm({
1029
1389
 
1030
1390
  // src/tools/pin.ts
1031
1391
  import { z as z5 } from "zod";
1032
- function setPinnedState(memoryId, pinned) {
1392
+ function setPinnedState(rowId, pinned) {
1033
1393
  const db = getDb();
1034
1394
  const now = Math.floor(Date.now() / 1e3);
1035
1395
  const updateResult = db.prepare(
@@ -1038,7 +1398,7 @@ function setPinnedState(memoryId, pinned) {
1038
1398
  SET pinned = ?, updated_at = ?
1039
1399
  WHERE rowid = ?
1040
1400
  `
1041
- ).run(pinned, now, memoryId);
1401
+ ).run(pinned, now, rowId);
1042
1402
  if (updateResult.changes === 0) {
1043
1403
  return null;
1044
1404
  }
@@ -1048,7 +1408,7 @@ function setPinnedState(memoryId, pinned) {
1048
1408
  FROM memories
1049
1409
  WHERE rowid = ?
1050
1410
  `
1051
- ).get(memoryId);
1411
+ ).get(rowId);
1052
1412
  }
1053
1413
  function registerPinMemoryTool(server) {
1054
1414
  server.registerTool(
@@ -1059,7 +1419,20 @@ function registerPinMemoryTool(server) {
1059
1419
  },
1060
1420
  async ({ id }) => {
1061
1421
  try {
1062
- const memory = setPinnedState(id, 1);
1422
+ const db = getDb();
1423
+ const target = findMemoryByAnyId(db, id);
1424
+ if (!target) {
1425
+ return {
1426
+ isError: true,
1427
+ content: [
1428
+ {
1429
+ type: "text",
1430
+ text: `Memory ${id} not found.`
1431
+ }
1432
+ ]
1433
+ };
1434
+ }
1435
+ const memory = setPinnedState(target.row_id, 1);
1063
1436
  if (!memory) {
1064
1437
  return {
1065
1438
  isError: true,
@@ -1103,7 +1476,20 @@ function registerUnpinMemoryTool(server) {
1103
1476
  },
1104
1477
  async ({ id }) => {
1105
1478
  try {
1106
- const memory = setPinnedState(id, 0);
1479
+ const db = getDb();
1480
+ const target = findMemoryByAnyId(db, id);
1481
+ if (!target) {
1482
+ return {
1483
+ isError: true,
1484
+ content: [
1485
+ {
1486
+ type: "text",
1487
+ text: `Memory ${id} not found.`
1488
+ }
1489
+ ]
1490
+ };
1491
+ }
1492
+ const memory = setPinnedState(target.row_id, 0);
1107
1493
  if (!memory) {
1108
1494
  return {
1109
1495
  isError: true,
@@ -1143,8 +1529,10 @@ var init_pin = __esm({
1143
1529
  "src/tools/pin.ts"() {
1144
1530
  "use strict";
1145
1531
  init_client();
1532
+ init_memory();
1146
1533
  pinInputSchema = {
1147
- id: z5.number().int().positive()
1534
+ // Accept numeric row_id or legacy string id for parity with the other tools.
1535
+ id: z5.union([z5.number().int().positive(), z5.string().trim().min(1)])
1148
1536
  };
1149
1537
  }
1150
1538
  });
@@ -1528,7 +1916,7 @@ function registerRememberTool(server) {
1528
1916
  async ({ note, repo, type, tags }) => {
1529
1917
  try {
1530
1918
  const db = getDb();
1531
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1919
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1532
1920
  const inferred = inferMemoryFromNote(note);
1533
1921
  const finalType = type ?? inferred.type;
1534
1922
  const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
@@ -1566,6 +1954,7 @@ function registerRememberTool(server) {
1566
1954
  now,
1567
1955
  existing.row_id
1568
1956
  );
1957
+ indexMemoryEmbedding(db, existing.row_id, longerNote);
1569
1958
  return {
1570
1959
  content: [
1571
1960
  {
@@ -1606,6 +1995,9 @@ function registerRememberTool(server) {
1606
1995
  normalizeText(note)
1607
1996
  );
1608
1997
  const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
1998
+ if (inserted) {
1999
+ indexMemoryEmbedding(db, inserted.row_id, note);
2000
+ }
1609
2001
  return {
1610
2002
  content: [
1611
2003
  {
@@ -1637,6 +2029,8 @@ var init_remember = __esm({
1637
2029
  init_dedupe();
1638
2030
  init_inference();
1639
2031
  init_repo();
2032
+ init_vector_index();
2033
+ init_workspace();
1640
2034
  rememberInputSchema = {
1641
2035
  note: z6.string().trim().min(1, "note is required"),
1642
2036
  repo: z6.string().trim().min(1).optional(),
@@ -1658,7 +2052,7 @@ function registerResolveRepoTool(server) {
1658
2052
  async ({ cwd }) => {
1659
2053
  try {
1660
2054
  const db = getDb();
1661
- const target = cwd?.trim() || process.cwd();
2055
+ const target = cwd?.trim() || getWorkspaceRoot();
1662
2056
  const resolved = resolveRepo(target, db);
1663
2057
  const payload = {
1664
2058
  canonical: resolved.canonical,
@@ -1696,6 +2090,7 @@ var init_resolve_repo = __esm({
1696
2090
  "use strict";
1697
2091
  init_client();
1698
2092
  init_repo();
2093
+ init_workspace();
1699
2094
  resolveRepoInputSchema = {
1700
2095
  cwd: z7.string().trim().min(1).optional()
1701
2096
  };
@@ -1704,12 +2099,20 @@ var init_resolve_repo = __esm({
1704
2099
 
1705
2100
  // src/tools/search.ts
1706
2101
  import { z as z8 } from "zod";
1707
- function normalizeFtsQuery(query) {
1708
- const terms = query.trim().split(/\s+/).map((term) => term.replaceAll('"', '""')).filter(Boolean);
1709
- if (terms.length === 0) {
1710
- throw new Error("query must contain searchable text");
2102
+ function tokenizeQuery(query) {
2103
+ return query.toLowerCase().replace(/["()]/g, " ").split(/[\s/_\-.,;:!?]+/).map((token) => token.replace(/[^a-z0-9*]/g, "")).filter((token) => token.length >= 2);
2104
+ }
2105
+ function buildFtsQuery2(tokens) {
2106
+ if (tokens.length === 0) {
2107
+ return null;
1711
2108
  }
1712
- return terms.map((term) => `"${term}"`).join(" AND ");
2109
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
2110
+ }
2111
+ function buildFtsQueryOr(tokens) {
2112
+ if (tokens.length === 0) {
2113
+ return null;
2114
+ }
2115
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" OR ");
1713
2116
  }
1714
2117
  function parseTags4(raw) {
1715
2118
  try {
@@ -1719,37 +2122,73 @@ function parseTags4(raw) {
1719
2122
  return [];
1720
2123
  }
1721
2124
  }
2125
+ function runFts(ftsQuery, resolvedRepo, limit) {
2126
+ const db = getDb();
2127
+ try {
2128
+ if (resolvedRepo) {
2129
+ return db.prepare(
2130
+ `
2131
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
2132
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
2133
+ FROM memories_fts
2134
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
2135
+ WHERE memories_fts MATCH ? AND m.repo = ?
2136
+ ORDER BY rank
2137
+ LIMIT ?
2138
+ `
2139
+ ).all(ftsQuery, resolvedRepo, limit);
2140
+ }
2141
+ return db.prepare(
2142
+ `
2143
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
2144
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
2145
+ FROM memories_fts
2146
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
2147
+ WHERE memories_fts MATCH ?
2148
+ ORDER BY rank
2149
+ LIMIT ?
2150
+ `
2151
+ ).all(ftsQuery, limit);
2152
+ } catch {
2153
+ return [];
2154
+ }
2155
+ }
1722
2156
  function registerSearchMemoryTool(server) {
1723
2157
  server.registerTool(
1724
2158
  "search_memory",
1725
2159
  {
1726
- description: "Search memories using full-text search with optional repository filtering.",
2160
+ description: "Search memories using full-text search with optional repository filtering. Falls back to recent + pinned context when the query has no exact matches.",
1727
2161
  inputSchema: searchMemoryInputSchema
1728
2162
  },
1729
2163
  async ({ query, repo, limit }) => {
1730
2164
  try {
1731
2165
  const db = getDb();
1732
- const ftsQuery = normalizeFtsQuery(query);
1733
- const resolvedRepo = repo ? resolveRepoArg(repo, process.cwd(), db).canonical : void 0;
1734
- const rows = resolvedRepo ? db.prepare(
1735
- `
1736
- SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags, m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1737
- FROM memories_fts
1738
- JOIN memories AS m ON m.rowid = memories_fts.rowid
1739
- WHERE memories_fts MATCH ? AND m.repo = ?
1740
- ORDER BY rank
1741
- LIMIT ?
1742
- `
1743
- ).all(ftsQuery, resolvedRepo, limit) : db.prepare(
1744
- `
1745
- SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags, m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1746
- FROM memories_fts
1747
- JOIN memories AS m ON m.rowid = memories_fts.rowid
1748
- WHERE memories_fts MATCH ?
1749
- ORDER BY rank
1750
- LIMIT ?
1751
- `
1752
- ).all(ftsQuery, limit);
2166
+ const tokens = tokenizeQuery(query);
2167
+ const resolvedRepo = repo ? resolveRepoArg(repo, getWorkspaceRoot(), db).canonical : void 0;
2168
+ const andQuery = buildFtsQuery2(tokens);
2169
+ let rows = [];
2170
+ if (andQuery) {
2171
+ rows = runFts(andQuery, resolvedRepo, limit);
2172
+ }
2173
+ if (rows.length === 0 && tokens.length > 1) {
2174
+ const orQuery = buildFtsQueryOr(tokens);
2175
+ if (orQuery) {
2176
+ rows = runFts(orQuery, resolvedRepo, limit);
2177
+ }
2178
+ }
2179
+ if (rows.length === 0 && resolvedRepo && embeddingsEnabled()) {
2180
+ const semantic = vectorSearch(db, resolvedRepo, query, limit);
2181
+ rows = semantic.map(({ score, ...row }) => ({
2182
+ ...row,
2183
+ rank: score
2184
+ }));
2185
+ }
2186
+ let usedFallback = false;
2187
+ if (rows.length === 0 && resolvedRepo) {
2188
+ const fallback = fetchRepoContext(db, resolvedRepo, limit);
2189
+ rows = fallback.map((row) => ({ ...row, rank: 0 }));
2190
+ usedFallback = fallback.length > 0;
2191
+ }
1753
2192
  if (rows.length === 0) {
1754
2193
  return {
1755
2194
  content: [
@@ -1767,11 +2206,12 @@ function registerSearchMemoryTool(server) {
1767
2206
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
1768
2207
  ${pinPrefix}${row.note}${tagsText}`;
1769
2208
  }).join("\n\n");
2209
+ const header = usedFallback ? `No exact match for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}; showing recent + pinned context:` : `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:`;
1770
2210
  return {
1771
2211
  content: [
1772
2212
  {
1773
2213
  type: "text",
1774
- text: `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:
2214
+ text: `${header}
1775
2215
 
1776
2216
  ${formatted}`
1777
2217
  }
@@ -1797,7 +2237,11 @@ var init_search = __esm({
1797
2237
  "src/tools/search.ts"() {
1798
2238
  "use strict";
1799
2239
  init_client();
2240
+ init_context();
2241
+ init_embeddings();
1800
2242
  init_repo();
2243
+ init_vector_index();
2244
+ init_workspace();
1801
2245
  searchMemoryInputSchema = {
1802
2246
  query: z8.string().trim().min(1, "query is required"),
1803
2247
  repo: z8.string().trim().min(1).optional(),
@@ -1819,7 +2263,7 @@ function registerStoreContextTool(server) {
1819
2263
  async ({ repo, type, note, tags }) => {
1820
2264
  try {
1821
2265
  const db = getDb();
1822
- const resolved = resolveRepoArg(repo, process.cwd(), db);
2266
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1823
2267
  const now = Math.floor(Date.now() / 1e3);
1824
2268
  const id = nanoid2();
1825
2269
  const normalizedTags = Array.from(
@@ -1847,6 +2291,9 @@ function registerStoreContextTool(server) {
1847
2291
  WHERE id = ?
1848
2292
  `
1849
2293
  ).get(id);
2294
+ if (stored) {
2295
+ indexMemoryEmbedding(db, stored.row_id, note);
2296
+ }
1850
2297
  return {
1851
2298
  content: [
1852
2299
  {
@@ -1877,6 +2324,8 @@ var init_store = __esm({
1877
2324
  init_client();
1878
2325
  init_dedupe();
1879
2326
  init_repo();
2327
+ init_vector_index();
2328
+ init_workspace();
1880
2329
  storeContextInputSchema = {
1881
2330
  repo: z9.string().trim().min(1).optional(),
1882
2331
  type: z9.enum(MEMORY_TYPES),
@@ -1898,7 +2347,7 @@ function registerSummarizeRepoContextTool(server) {
1898
2347
  async ({ repo }) => {
1899
2348
  try {
1900
2349
  const db = getDb();
1901
- const resolved = resolveRepoArg(repo, process.cwd(), db);
2350
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1902
2351
  const rows = db.prepare(
1903
2352
  `
1904
2353
  SELECT rowid AS row_id, type, note, pinned
@@ -1962,6 +2411,7 @@ var init_summarize = __esm({
1962
2411
  "use strict";
1963
2412
  init_client();
1964
2413
  init_repo();
2414
+ init_workspace();
1965
2415
  summarizeRepoContextInputSchema = {
1966
2416
  repo: z10.string().trim().min(1).optional()
1967
2417
  };
@@ -2006,7 +2456,7 @@ function registerUpdateMemoryTool(server) {
2006
2456
  server.registerTool(
2007
2457
  "update_memory",
2008
2458
  {
2009
- description: "Update an existing memory by numeric id with partial fields.",
2459
+ description: "Update an existing memory by id (numeric or legacy string) with partial fields.",
2010
2460
  inputSchema: updateMemoryInputSchema
2011
2461
  },
2012
2462
  async ({ id, content, memory_type }) => {
@@ -2023,14 +2473,8 @@ function registerUpdateMemoryTool(server) {
2023
2473
  };
2024
2474
  }
2025
2475
  const db = getDb();
2026
- const existing = db.prepare(
2027
- `
2028
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
2029
- FROM memories
2030
- WHERE rowid = ?
2031
- `
2032
- ).get(id);
2033
- if (!existing) {
2476
+ const target = findMemoryByAnyId(db, id);
2477
+ if (!target) {
2034
2478
  return {
2035
2479
  isError: true,
2036
2480
  content: [
@@ -2041,6 +2485,13 @@ function registerUpdateMemoryTool(server) {
2041
2485
  ]
2042
2486
  };
2043
2487
  }
2488
+ const existing = db.prepare(
2489
+ `
2490
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
2491
+ FROM memories
2492
+ WHERE rowid = ?
2493
+ `
2494
+ ).get(target.row_id);
2044
2495
  const now = Math.floor(Date.now() / 1e3);
2045
2496
  const nextType = memory_type ?? existing.type;
2046
2497
  const nextNote = content ?? existing.note;
@@ -2052,7 +2503,8 @@ function registerUpdateMemoryTool(server) {
2052
2503
  SET type = ?, note = ?, note_normalized = ?, updated_at = ?
2053
2504
  WHERE rowid = ?
2054
2505
  `
2055
- ).run(nextType, nextNote, nextNormalized, now, id);
2506
+ ).run(nextType, nextNote, nextNormalized, now, existing.row_id);
2507
+ indexMemoryEmbedding(db, existing.row_id, nextNote);
2056
2508
  } else {
2057
2509
  db.prepare(
2058
2510
  `
@@ -2060,7 +2512,7 @@ function registerUpdateMemoryTool(server) {
2060
2512
  SET type = ?, note = ?, updated_at = ?
2061
2513
  WHERE rowid = ?
2062
2514
  `
2063
- ).run(nextType, nextNote, now, id);
2515
+ ).run(nextType, nextNote, now, existing.row_id);
2064
2516
  }
2065
2517
  const updated = db.prepare(
2066
2518
  `
@@ -2068,7 +2520,7 @@ function registerUpdateMemoryTool(server) {
2068
2520
  FROM memories
2069
2521
  WHERE rowid = ?
2070
2522
  `
2071
- ).get(id);
2523
+ ).get(existing.row_id);
2072
2524
  if (!updated) {
2073
2525
  return {
2074
2526
  isError: true,
@@ -2109,8 +2561,12 @@ var init_update = __esm({
2109
2561
  "use strict";
2110
2562
  init_client();
2111
2563
  init_dedupe();
2564
+ init_memory();
2565
+ init_vector_index();
2112
2566
  updateMemoryInputSchema = {
2113
- id: z11.number().int().positive(),
2567
+ // Accept numeric row_id or legacy string id so callers can paste whichever
2568
+ // form they have.
2569
+ id: z11.union([z11.number().int().positive(), z11.string().trim().min(1)]),
2114
2570
  content: z11.string().trim().min(1).optional(),
2115
2571
  memory_type: z11.enum(MEMORY_TYPES).optional()
2116
2572
  };
@@ -2143,7 +2599,7 @@ function registerStartupContextResource(server) {
2143
2599
  async (uri) => {
2144
2600
  try {
2145
2601
  const db = getDb();
2146
- const resolved = resolveRepo(process.cwd(), db);
2602
+ const resolved = resolveRepo(getWorkspaceRoot(), db);
2147
2603
  const rows = fetchRepoContext(db, resolved.canonical, 5);
2148
2604
  const text = formatContext(rows, {
2149
2605
  repo: resolved.canonical,
@@ -2180,7 +2636,7 @@ async function startServer() {
2180
2636
  initDb(dbPath);
2181
2637
  const server = new McpServer({
2182
2638
  name: "fossel",
2183
- version: "1.1.0"
2639
+ version: "1.2.0"
2184
2640
  });
2185
2641
  registerRememberTool(server);
2186
2642
  registerGetContextTool(server);
@@ -2205,6 +2661,7 @@ var init_index = __esm({
2205
2661
  init_client();
2206
2662
  init_context();
2207
2663
  init_repo();
2664
+ init_workspace();
2208
2665
  init_dedupe_repo();
2209
2666
  init_delete();
2210
2667
  init_get_context();
@@ -2230,13 +2687,14 @@ var init_index = __esm({
2230
2687
 
2231
2688
  // src/cli.ts
2232
2689
  init_client();
2690
+ init_dedupe();
2233
2691
  init_repo();
2234
2692
  import { homedir as homedir2 } from "os";
2235
2693
  import { join as join2 } from "path";
2236
2694
  import { statSync } from "fs";
2237
2695
  import { nanoid as nanoid3 } from "nanoid";
2238
2696
  var DEFAULT_DB_PATH = join2(homedir2(), ".fossel", "memory.db");
2239
- var INIT_MEMORY_TEXT = "Fossel is active for this repo. Use store_context to save context.";
2697
+ var INIT_MEMORY_TEXT = "Fossel is active for this repo. Say 'remember this' or call get_context to retrieve repo memories.";
2240
2698
  function resolveDbPath2() {
2241
2699
  return process.env.FOSSEL_DB_PATH?.trim() || DEFAULT_DB_PATH;
2242
2700
  }
@@ -2260,7 +2718,7 @@ function ensureSampleMemoryIfEmpty(repo) {
2260
2718
  "[]",
2261
2719
  now,
2262
2720
  now,
2263
- INIT_MEMORY_TEXT.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim()
2721
+ normalizeText(INIT_MEMORY_TEXT)
2264
2722
  );
2265
2723
  return true;
2266
2724
  }
@@ -2269,7 +2727,12 @@ var MCP_CONFIG_SNIPPET = JSON.stringify(
2269
2727
  mcpServers: {
2270
2728
  fossel: {
2271
2729
  command: "npx",
2272
- args: ["-y", "fossel"]
2730
+ args: ["-y", "fossel"],
2731
+ // FOSSEL_WORKSPACE pins the workspace root so the server detects the
2732
+ // right repo even when the IDE launches MCP servers from another cwd.
2733
+ env: {
2734
+ FOSSEL_WORKSPACE: "${workspaceFolder}"
2735
+ }
2273
2736
  }
2274
2737
  }
2275
2738
  },
@@ -2293,7 +2756,44 @@ function findMergeCandidates(canonical) {
2293
2756
  return otherTail === tail || otherTail === canonical || row.repo === tail;
2294
2757
  });
2295
2758
  }
2296
- function runInit() {
2759
+ function autoDedupeExact(repo) {
2760
+ const db = getDb();
2761
+ const groups = db.prepare(
2762
+ `
2763
+ SELECT note_normalized, type, COUNT(*) AS count
2764
+ FROM memories
2765
+ WHERE repo = ? AND note_normalized != ''
2766
+ GROUP BY note_normalized, type
2767
+ HAVING COUNT(*) > 1
2768
+ `
2769
+ ).all(repo);
2770
+ if (groups.length === 0) {
2771
+ return 0;
2772
+ }
2773
+ let removed = 0;
2774
+ const tx = db.transaction(() => {
2775
+ for (const group of groups) {
2776
+ const rows = db.prepare(
2777
+ `
2778
+ SELECT rowid AS row_id, pinned, updated_at
2779
+ FROM memories
2780
+ WHERE repo = ? AND note_normalized = ? AND type = ?
2781
+ ORDER BY pinned DESC, updated_at DESC, rowid DESC
2782
+ `
2783
+ ).all(repo, group.note_normalized, group.type);
2784
+ const [keep, ...rest] = rows;
2785
+ if (!keep) continue;
2786
+ const drop = db.prepare("DELETE FROM memories WHERE rowid = ?");
2787
+ for (const row of rest) {
2788
+ drop.run(row.row_id);
2789
+ removed += 1;
2790
+ }
2791
+ }
2792
+ });
2793
+ tx();
2794
+ return removed;
2795
+ }
2796
+ function runInit(options) {
2297
2797
  const dbPath = resolveDbPath2();
2298
2798
  initDb(dbPath);
2299
2799
  const db = getDb();
@@ -2301,12 +2801,18 @@ function runInit() {
2301
2801
  const candidates = findMergeCandidates(resolved.canonical);
2302
2802
  let mergedAliases = 0;
2303
2803
  let mergedMemories = 0;
2804
+ let rewrittenNotes = 0;
2304
2805
  for (const candidate of candidates) {
2305
2806
  const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
2306
2807
  mergedAliases += result.movedAliases;
2307
2808
  mergedMemories += result.movedMemories;
2809
+ rewrittenNotes += result.rewrittenNotes;
2308
2810
  }
2309
2811
  const sampleAdded = ensureSampleMemoryIfEmpty(resolved.canonical);
2812
+ let autoMerged = 0;
2813
+ if (options.autoDedupe) {
2814
+ autoMerged = autoDedupeExact(resolved.canonical);
2815
+ }
2310
2816
  const countRow = db.prepare("SELECT COUNT(*) AS count FROM memories WHERE repo = ?").get(resolved.canonical);
2311
2817
  console.log("Fossel \u2014 local-first MCP memory for your repos.\n");
2312
2818
  console.log(`Canonical repo key: ${resolved.canonical}`);
@@ -2318,12 +2824,16 @@ function runInit() {
2318
2824
  console.log(` aliases: ${resolved.aliases.join(", ")}`);
2319
2825
  }
2320
2826
  console.log("");
2321
- if (mergedAliases > 0 || mergedMemories > 0) {
2827
+ if (mergedAliases > 0 || mergedMemories > 0 || rewrittenNotes > 0) {
2322
2828
  console.log(
2323
- `Merged ${mergedMemories} memory row(s) and ${mergedAliases} alias row(s) into ${resolved.canonical}.`
2829
+ `Merged ${mergedMemories} memory row(s), ${mergedAliases} alias row(s), and rewrote ${rewrittenNotes} stale mention(s) into ${resolved.canonical}.`
2324
2830
  );
2325
2831
  console.log("");
2326
2832
  }
2833
+ if (autoMerged > 0) {
2834
+ console.log(`Auto-deduped ${autoMerged} exact duplicate row(s).`);
2835
+ console.log("");
2836
+ }
2327
2837
  console.log("MCP config (Cursor: ~/.cursor/mcp.json, Claude Desktop: settings):");
2328
2838
  console.log(MCP_CONFIG_SNIPPET);
2329
2839
  console.log("");
@@ -2339,9 +2849,11 @@ function runInit() {
2339
2849
  console.log(" resolve_repo \u2014 show which repo key Fossel will use");
2340
2850
  console.log(" store_context \u2014 explicit save (advanced)");
2341
2851
  console.log(" dedupe_repo \u2014 merge near-duplicate memories");
2852
+ console.log("");
2853
+ console.log("Set FOSSEL_WORKSPACE in your MCP config to your project root if Fossel detects the wrong repo.");
2342
2854
  closeDb();
2343
2855
  }
2344
- function runDoctor() {
2856
+ function gatherDoctorReport() {
2345
2857
  const dbPath = resolveDbPath2();
2346
2858
  const lines = [];
2347
2859
  let ok = true;
@@ -2366,6 +2878,22 @@ function runDoctor() {
2366
2878
  } else {
2367
2879
  lines.push("No sibling repo keys.");
2368
2880
  }
2881
+ const staleMentions = [];
2882
+ for (const alias of resolved.aliases) {
2883
+ if (alias === resolved.canonical) continue;
2884
+ const found2 = findMemoriesMentioningAlias(db, alias, resolved.canonical);
2885
+ for (const row of found2) {
2886
+ staleMentions.push({ alias, row_id: row.row_id, note: row.note });
2887
+ }
2888
+ }
2889
+ if (staleMentions.length > 0) {
2890
+ ok = false;
2891
+ lines.push(
2892
+ `\u26A0 ${staleMentions.length} memory note(s) still mention a deprecated repo key. Run \`fossel doctor --fix\` to rewrite them.`
2893
+ );
2894
+ } else {
2895
+ lines.push("No memory notes reference deprecated repo keys.");
2896
+ }
2369
2897
  const duplicateRows = db.prepare(
2370
2898
  `
2371
2899
  SELECT note_normalized, COUNT(*) AS count
@@ -2379,7 +2907,7 @@ function runDoctor() {
2379
2907
  ok = false;
2380
2908
  const total = duplicateRows.reduce((sum, row) => sum + row.count - 1, 0);
2381
2909
  lines.push(
2382
- `\u26A0 ${duplicateRows.length} duplicate clusters covering ${total} extra row(s). Run \`dedupe_repo\` with apply=true.`
2910
+ `\u26A0 ${duplicateRows.length} duplicate clusters covering ${total} extra row(s). Run \`fossel doctor --fix\` (or \`dedupe_repo\` with apply=true) to merge.`
2383
2911
  );
2384
2912
  } else {
2385
2913
  lines.push("No exact-duplicate memory clusters.");
@@ -2393,7 +2921,13 @@ function runDoctor() {
2393
2921
  "Claude",
2394
2922
  "claude_desktop_config.json"
2395
2923
  ),
2396
- join2(homedir2(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
2924
+ join2(
2925
+ homedir2(),
2926
+ "Library",
2927
+ "Application Support",
2928
+ "Claude",
2929
+ "claude_desktop_config.json"
2930
+ )
2397
2931
  ];
2398
2932
  const found = mcpConfigCandidates.filter((path) => {
2399
2933
  try {
@@ -2411,13 +2945,41 @@ function runDoctor() {
2411
2945
  }
2412
2946
  const totalRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
2413
2947
  lines.push(`Total memories across all repos: ${totalRow.count}`);
2414
- closeDb();
2415
- console.log(lines.join("\n"));
2948
+ return { ok, lines, duplicateClusters: duplicateRows.length, staleMentions };
2949
+ }
2950
+ function runDoctor(options) {
2951
+ const report = gatherDoctorReport();
2952
+ console.log(report.lines.join("\n"));
2416
2953
  console.log("");
2417
- console.log(ok ? "Status: OK" : "Status: needs attention (see \u26A0 lines above)");
2418
- if (!ok) {
2419
- process.exitCode = 1;
2954
+ if (!options.fix) {
2955
+ console.log(report.ok ? "Status: OK" : "Status: needs attention (see \u26A0 lines above)");
2956
+ if (!report.ok) {
2957
+ process.exitCode = 1;
2958
+ }
2959
+ closeDb();
2960
+ return;
2420
2961
  }
2962
+ const db = getDb();
2963
+ const resolved = resolveRepo(process.cwd(), db);
2964
+ const candidates = findMergeCandidates(resolved.canonical);
2965
+ let movedMemories = 0;
2966
+ let rewrittenNotes = 0;
2967
+ for (const candidate of candidates) {
2968
+ const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
2969
+ movedMemories += result.movedMemories;
2970
+ rewrittenNotes += result.rewrittenNotes;
2971
+ }
2972
+ const removed = autoDedupeExact(resolved.canonical);
2973
+ console.log("Applied fixes:");
2974
+ console.log(` merged repo memory rows: ${movedMemories}`);
2975
+ console.log(` rewrote stale mentions: ${rewrittenNotes}`);
2976
+ console.log(` removed exact duplicates: ${removed}`);
2977
+ console.log("");
2978
+ console.log("Re-run `fossel doctor` to verify.");
2979
+ closeDb();
2980
+ }
2981
+ function parseFlag(args, name) {
2982
+ return args.includes(`--${name}`);
2421
2983
  }
2422
2984
  async function main() {
2423
2985
  const command = process.argv[2];
@@ -2427,15 +2989,18 @@ async function main() {
2427
2989
  return;
2428
2990
  }
2429
2991
  if (command === "init") {
2430
- runInit();
2992
+ const args = process.argv.slice(3);
2993
+ const autoDedupe = !parseFlag(args, "no-dedupe");
2994
+ runInit({ autoDedupe });
2431
2995
  return;
2432
2996
  }
2433
2997
  if (command === "doctor") {
2434
- runDoctor();
2998
+ const args = process.argv.slice(3);
2999
+ runDoctor({ fix: parseFlag(args, "fix") });
2435
3000
  return;
2436
3001
  }
2437
3002
  console.error(`Unknown command: ${command}`);
2438
- console.error("Usage: fossel [init | doctor]");
3003
+ console.error("Usage: fossel [init [--no-dedupe] | doctor [--fix]]");
2439
3004
  process.exit(1);
2440
3005
  }
2441
3006
  main().catch((error) => {