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/index.js CHANGED
@@ -138,6 +138,25 @@ var migrations = [
138
138
  tx(rows);
139
139
  }
140
140
  }
141
+ },
142
+ {
143
+ name: "007_add_memory_embeddings",
144
+ apply: (db) => {
145
+ db.exec(`
146
+ CREATE TABLE IF NOT EXISTS memory_embeddings (
147
+ memory_rowid INTEGER PRIMARY KEY,
148
+ dim INTEGER NOT NULL,
149
+ version INTEGER NOT NULL,
150
+ vector BLOB NOT NULL,
151
+ updated_at INTEGER NOT NULL
152
+ );
153
+
154
+ CREATE TRIGGER IF NOT EXISTS memories_embeddings_ad
155
+ AFTER DELETE ON memories BEGIN
156
+ DELETE FROM memory_embeddings WHERE memory_rowid = old.rowid;
157
+ END;
158
+ `);
159
+ }
141
160
  }
142
161
  ];
143
162
  function runMigrations(db) {
@@ -195,6 +214,158 @@ function getDb() {
195
214
  return dbInstance;
196
215
  }
197
216
 
217
+ // src/lib/embeddings.ts
218
+ var EMBEDDING_DIM = 256;
219
+ var EMBEDDING_VERSION = 1;
220
+ function embeddingsEnabled() {
221
+ const value = process.env.FOSSEL_EMBEDDINGS?.trim().toLowerCase();
222
+ return value === "1" || value === "true" || value === "on" || value === "yes";
223
+ }
224
+ function fnv1a(str) {
225
+ let hash = 2166136261;
226
+ for (let i = 0; i < str.length; i += 1) {
227
+ hash ^= str.charCodeAt(i);
228
+ hash = Math.imul(hash, 16777619);
229
+ }
230
+ return hash >>> 0;
231
+ }
232
+ function tokenize(text) {
233
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim().split(" ").filter((token) => token.length >= 2);
234
+ }
235
+ function embedText(text) {
236
+ const vector = new Float32Array(EMBEDDING_DIM);
237
+ const tokens = tokenize(text);
238
+ if (tokens.length === 0) {
239
+ return vector;
240
+ }
241
+ const addFeature = (feature, weight) => {
242
+ const h = fnv1a(feature);
243
+ const index = h % EMBEDDING_DIM;
244
+ const sign = (fnv1a(`#${feature}`) & 1) === 0 ? 1 : -1;
245
+ vector[index] += sign * weight;
246
+ };
247
+ for (let i = 0; i < tokens.length; i += 1) {
248
+ addFeature(tokens[i], 1);
249
+ if (i + 1 < tokens.length) {
250
+ addFeature(`${tokens[i]} ${tokens[i + 1]}`, 0.6);
251
+ }
252
+ }
253
+ let norm = 0;
254
+ for (let i = 0; i < EMBEDDING_DIM; i += 1) {
255
+ norm += vector[i] * vector[i];
256
+ }
257
+ norm = Math.sqrt(norm);
258
+ if (norm > 0) {
259
+ for (let i = 0; i < EMBEDDING_DIM; i += 1) {
260
+ vector[i] /= norm;
261
+ }
262
+ }
263
+ return vector;
264
+ }
265
+ function cosineSimilarity(a, b) {
266
+ if (a.length !== b.length) {
267
+ return 0;
268
+ }
269
+ let dot = 0;
270
+ for (let i = 0; i < a.length; i += 1) {
271
+ dot += a[i] * b[i];
272
+ }
273
+ return dot;
274
+ }
275
+ function vectorToBuffer(vector) {
276
+ return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
277
+ }
278
+ function bufferToVector(buffer) {
279
+ const copy = Buffer.from(buffer);
280
+ return new Float32Array(
281
+ copy.buffer,
282
+ copy.byteOffset,
283
+ Math.floor(copy.byteLength / 4)
284
+ );
285
+ }
286
+
287
+ // src/lib/vector-index.ts
288
+ function indexMemoryEmbedding(db, rowId, note) {
289
+ if (!embeddingsEnabled()) {
290
+ return;
291
+ }
292
+ const vector = embedText(note);
293
+ db.prepare(
294
+ `
295
+ INSERT INTO memory_embeddings (memory_rowid, dim, version, vector, updated_at)
296
+ VALUES (?, ?, ?, ?, ?)
297
+ ON CONFLICT(memory_rowid) DO UPDATE SET
298
+ dim = excluded.dim,
299
+ version = excluded.version,
300
+ vector = excluded.vector,
301
+ updated_at = excluded.updated_at
302
+ `
303
+ ).run(
304
+ rowId,
305
+ EMBEDDING_DIM,
306
+ EMBEDDING_VERSION,
307
+ vectorToBuffer(vector),
308
+ Math.floor(Date.now() / 1e3)
309
+ );
310
+ }
311
+ function backfillRepoEmbeddings(db, repo) {
312
+ if (!embeddingsEnabled()) {
313
+ return 0;
314
+ }
315
+ const rows = db.prepare(
316
+ `
317
+ SELECT m.rowid AS row_id, m.note
318
+ FROM memories AS m
319
+ LEFT JOIN memory_embeddings AS e ON e.memory_rowid = m.rowid
320
+ WHERE m.repo = ?
321
+ AND (e.memory_rowid IS NULL OR e.version != ? OR e.dim != ?)
322
+ `
323
+ ).all(repo, EMBEDDING_VERSION, EMBEDDING_DIM);
324
+ if (rows.length === 0) {
325
+ return 0;
326
+ }
327
+ const tx = db.transaction((batch) => {
328
+ for (const row of batch) {
329
+ indexMemoryEmbedding(db, row.row_id, row.note);
330
+ }
331
+ });
332
+ tx(rows);
333
+ return rows.length;
334
+ }
335
+ function vectorSearch(db, repo, query, limit) {
336
+ if (!embeddingsEnabled()) {
337
+ return [];
338
+ }
339
+ backfillRepoEmbeddings(db, repo);
340
+ const queryVector = embedText(query);
341
+ let queryNorm = 0;
342
+ for (let i = 0; i < queryVector.length; i += 1) {
343
+ queryNorm += queryVector[i] * queryVector[i];
344
+ }
345
+ if (queryNorm === 0) {
346
+ return [];
347
+ }
348
+ const rows = db.prepare(
349
+ `
350
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
351
+ m.created_at, m.updated_at, m.pinned, e.vector AS vector
352
+ FROM memory_embeddings AS e
353
+ JOIN memories AS m ON m.rowid = e.memory_rowid
354
+ WHERE m.repo = ? AND e.dim = ? AND e.version = ?
355
+ `
356
+ ).all(repo, EMBEDDING_DIM, EMBEDDING_VERSION);
357
+ const scored = [];
358
+ for (const row of rows) {
359
+ const { vector, ...memory } = row;
360
+ const score = cosineSimilarity(queryVector, bufferToVector(vector));
361
+ if (score > 0) {
362
+ scored.push({ ...memory, score });
363
+ }
364
+ }
365
+ scored.sort((a, b) => b.score - a.score);
366
+ return scored.slice(0, limit);
367
+ }
368
+
198
369
  // src/lib/context.ts
199
370
  var SECTION_TITLES = {
200
371
  convention: "Conventions",
@@ -212,6 +383,9 @@ function parseTags(raw) {
212
383
  return [];
213
384
  }
214
385
  }
386
+ function normalizeNoteForReadDedupe(text) {
387
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
388
+ }
215
389
  function buildFtsQuery(query) {
216
390
  const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
217
391
  if (terms.length === 0) {
@@ -222,11 +396,19 @@ function buildFtsQuery(query) {
222
396
  function fetchRepoContext(db, repo, limit, query) {
223
397
  const rows = [];
224
398
  const seen = /* @__PURE__ */ new Set();
399
+ const seenNormalized = /* @__PURE__ */ new Set();
225
400
  const push = (memory, source, rank) => {
226
401
  if (seen.has(memory.row_id)) {
227
402
  return;
228
403
  }
404
+ const normalized = normalizeNoteForReadDedupe(memory.note);
405
+ if (normalized && seenNormalized.has(normalized)) {
406
+ return;
407
+ }
229
408
  seen.add(memory.row_id);
409
+ if (normalized) {
410
+ seenNormalized.add(normalized);
411
+ }
230
412
  rows.push({ ...memory, source, rank });
231
413
  };
232
414
  const pinned = db.prepare(
@@ -241,7 +423,10 @@ function fetchRepoContext(db, repo, limit, query) {
241
423
  for (const row of pinned) {
242
424
  push(row, "pinned");
243
425
  }
244
- if (rows.length < limit) {
426
+ const pushRecent = () => {
427
+ if (rows.length >= limit) {
428
+ return;
429
+ }
245
430
  const recent = db.prepare(
246
431
  `
247
432
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -250,13 +435,20 @@ function fetchRepoContext(db, repo, limit, query) {
250
435
  ORDER BY updated_at DESC
251
436
  LIMIT ?
252
437
  `
253
- ).all(repo, limit - rows.length);
438
+ ).all(repo, limit);
254
439
  for (const row of recent) {
255
440
  push(row, "recent");
441
+ if (rows.length >= limit) {
442
+ break;
443
+ }
256
444
  }
445
+ };
446
+ if (!query) {
447
+ pushRecent();
257
448
  }
258
449
  if (query && rows.length < limit) {
259
450
  const ftsQuery = buildFtsQuery(query);
451
+ const ftsRows = [];
260
452
  if (ftsQuery) {
261
453
  try {
262
454
  const matches = db.prepare(
@@ -270,15 +462,49 @@ function fetchRepoContext(db, repo, limit, query) {
270
462
  LIMIT ?
271
463
  `
272
464
  ).all(ftsQuery, repo, limit);
273
- for (const row of matches) {
274
- push(row, "search", row.rank);
275
- if (rows.length >= limit) {
276
- break;
465
+ ftsRows.push(...matches);
466
+ } catch {
467
+ }
468
+ }
469
+ const vectorRows = embeddingsEnabled() ? vectorSearch(db, repo, query, limit) : [];
470
+ if (vectorRows.length > 0) {
471
+ const RRF_K = 60;
472
+ const fused = /* @__PURE__ */ new Map();
473
+ const accumulate = (list, rankOf) => {
474
+ list.forEach((memory, index) => {
475
+ const contribution = 1 / (RRF_K + index + 1);
476
+ const prior = fused.get(memory.row_id);
477
+ if (prior) {
478
+ prior.score += contribution;
479
+ } else {
480
+ fused.set(memory.row_id, {
481
+ memory,
482
+ score: contribution,
483
+ rank: rankOf?.(memory, index)
484
+ });
277
485
  }
486
+ });
487
+ };
488
+ accumulate(ftsRows, (m) => m.rank);
489
+ accumulate(vectorRows);
490
+ const ordered = Array.from(fused.values()).sort(
491
+ (a, b) => b.score - a.score
492
+ );
493
+ for (const { memory, rank } of ordered) {
494
+ push(memory, "search", rank);
495
+ if (rows.length >= limit) {
496
+ break;
497
+ }
498
+ }
499
+ } else {
500
+ for (const row of ftsRows) {
501
+ push(row, "search", row.rank);
502
+ if (rows.length >= limit) {
503
+ break;
278
504
  }
279
- } catch {
280
505
  }
281
506
  }
507
+ pushRecent();
282
508
  }
283
509
  return rows.slice(0, limit);
284
510
  }
@@ -472,6 +698,15 @@ function resolveRepoArg(input, cwd, db) {
472
698
  };
473
699
  }
474
700
 
701
+ // src/lib/workspace.ts
702
+ function getWorkspaceRoot() {
703
+ const fromEnv = process.env.FOSSEL_WORKSPACE?.trim();
704
+ if (fromEnv) {
705
+ return fromEnv;
706
+ }
707
+ return process.cwd();
708
+ }
709
+
475
710
  // src/tools/dedupe-repo.ts
476
711
  import { z } from "zod";
477
712
 
@@ -481,7 +716,7 @@ var DEFAULT_CANDIDATE_LIMIT = 200;
481
716
  function normalizeText(text) {
482
717
  return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
483
718
  }
484
- function tokenize(text) {
719
+ function tokenize2(text) {
485
720
  return normalizeText(text).split(" ").filter((token) => token.length >= 2);
486
721
  }
487
722
  function trigrams(text) {
@@ -517,7 +752,7 @@ function similarity(a, b) {
517
752
  if (normalizedA === normalizedB) {
518
753
  return 1;
519
754
  }
520
- const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
755
+ const wordScore = jaccard(new Set(tokenize2(normalizedA)), new Set(tokenize2(normalizedB)));
521
756
  const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
522
757
  return wordScore * 0.55 + triScore * 0.45;
523
758
  }
@@ -606,7 +841,7 @@ function registerDedupeRepoTool(server) {
606
841
  async ({ repo, threshold, apply }) => {
607
842
  try {
608
843
  const db = getDb();
609
- const resolved = resolveRepoArg(repo, process.cwd(), db);
844
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
610
845
  const rows = db.prepare(
611
846
  `
612
847
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
@@ -734,21 +969,56 @@ Re-run with apply=true to merge.`
734
969
 
735
970
  // src/tools/delete.ts
736
971
  import { z as z2 } from "zod";
972
+
973
+ // src/lib/memory.ts
974
+ function findMemoryByAnyId(db, input) {
975
+ const numeric = typeof input === "number" ? input : Number(input);
976
+ const isNumericId = Number.isInteger(numeric) && numeric > 0 && String(numeric) === String(input).trim();
977
+ if (isNumericId) {
978
+ const row = db.prepare(
979
+ `
980
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
981
+ FROM memories
982
+ WHERE rowid = ?
983
+ `
984
+ ).get(numeric);
985
+ if (row) {
986
+ return row;
987
+ }
988
+ }
989
+ const stringInput = String(input).trim();
990
+ if (stringInput.length === 0) {
991
+ return null;
992
+ }
993
+ const stringRow = db.prepare(
994
+ `
995
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
996
+ FROM memories
997
+ WHERE id = ?
998
+ `
999
+ ).get(stringInput);
1000
+ return stringRow ?? null;
1001
+ }
1002
+
1003
+ // src/tools/delete.ts
737
1004
  var deleteMemoryInputSchema = {
738
- id: z2.string().trim().min(1, "id is required")
1005
+ // Accept either the numeric row_id or the legacy nanoid string. Tools used
1006
+ // to disagree about which form to take; this unifies them so callers can
1007
+ // paste whichever id they have in front of them.
1008
+ id: z2.union([z2.number().int().positive(), z2.string().trim().min(1)])
739
1009
  };
740
1010
  function registerDeleteMemoryTool(server) {
741
1011
  server.registerTool(
742
1012
  "delete_memory",
743
1013
  {
744
- description: "Delete a memory from storage by id.",
1014
+ description: "Delete a memory from storage by id. Accepts either the numeric row id or the legacy string id.",
745
1015
  inputSchema: deleteMemoryInputSchema
746
1016
  },
747
1017
  async ({ id }) => {
748
1018
  try {
749
1019
  const db = getDb();
750
- const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(id);
751
- if (!row) {
1020
+ const memory = findMemoryByAnyId(db, id);
1021
+ if (!memory) {
752
1022
  return {
753
1023
  isError: true,
754
1024
  content: [
@@ -759,15 +1029,15 @@ function registerDeleteMemoryTool(server) {
759
1029
  ]
760
1030
  };
761
1031
  }
762
- const deleteTx = db.transaction((memoryId) => {
763
- db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
1032
+ const deleteTx = db.transaction((rowId) => {
1033
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(rowId);
764
1034
  });
765
- deleteTx(id);
1035
+ deleteTx(memory.row_id);
766
1036
  return {
767
1037
  content: [
768
1038
  {
769
1039
  type: "text",
770
- text: `Deleted memory ${id}.`
1040
+ text: `Deleted memory ${memory.row_id} (legacy: ${memory.id}).`
771
1041
  }
772
1042
  ]
773
1043
  };
@@ -805,7 +1075,7 @@ function registerGetContextTool(server) {
805
1075
  async ({ repo, query, limit, format }) => {
806
1076
  try {
807
1077
  const db = getDb();
808
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1078
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
809
1079
  const rows = fetchRepoContext(db, resolved.canonical, limit, query);
810
1080
  const text = formatContext(rows, {
811
1081
  repo: resolved.canonical,
@@ -863,7 +1133,7 @@ function registerGetRepoContextTool(server) {
863
1133
  async ({ repo, limit }) => {
864
1134
  try {
865
1135
  const db = getDb();
866
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1136
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
867
1137
  const rows = db.prepare(
868
1138
  `
869
1139
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -932,9 +1202,10 @@ ${sections.join("\n\n")}`
932
1202
  // src/tools/pin.ts
933
1203
  import { z as z5 } from "zod";
934
1204
  var pinInputSchema = {
935
- id: z5.number().int().positive()
1205
+ // Accept numeric row_id or legacy string id for parity with the other tools.
1206
+ id: z5.union([z5.number().int().positive(), z5.string().trim().min(1)])
936
1207
  };
937
- function setPinnedState(memoryId, pinned) {
1208
+ function setPinnedState(rowId, pinned) {
938
1209
  const db = getDb();
939
1210
  const now = Math.floor(Date.now() / 1e3);
940
1211
  const updateResult = db.prepare(
@@ -943,7 +1214,7 @@ function setPinnedState(memoryId, pinned) {
943
1214
  SET pinned = ?, updated_at = ?
944
1215
  WHERE rowid = ?
945
1216
  `
946
- ).run(pinned, now, memoryId);
1217
+ ).run(pinned, now, rowId);
947
1218
  if (updateResult.changes === 0) {
948
1219
  return null;
949
1220
  }
@@ -953,7 +1224,7 @@ function setPinnedState(memoryId, pinned) {
953
1224
  FROM memories
954
1225
  WHERE rowid = ?
955
1226
  `
956
- ).get(memoryId);
1227
+ ).get(rowId);
957
1228
  }
958
1229
  function registerPinMemoryTool(server) {
959
1230
  server.registerTool(
@@ -964,7 +1235,20 @@ function registerPinMemoryTool(server) {
964
1235
  },
965
1236
  async ({ id }) => {
966
1237
  try {
967
- const memory = setPinnedState(id, 1);
1238
+ const db = getDb();
1239
+ const target = findMemoryByAnyId(db, id);
1240
+ if (!target) {
1241
+ return {
1242
+ isError: true,
1243
+ content: [
1244
+ {
1245
+ type: "text",
1246
+ text: `Memory ${id} not found.`
1247
+ }
1248
+ ]
1249
+ };
1250
+ }
1251
+ const memory = setPinnedState(target.row_id, 1);
968
1252
  if (!memory) {
969
1253
  return {
970
1254
  isError: true,
@@ -1008,7 +1292,20 @@ function registerUnpinMemoryTool(server) {
1008
1292
  },
1009
1293
  async ({ id }) => {
1010
1294
  try {
1011
- const memory = setPinnedState(id, 0);
1295
+ const db = getDb();
1296
+ const target = findMemoryByAnyId(db, id);
1297
+ if (!target) {
1298
+ return {
1299
+ isError: true,
1300
+ content: [
1301
+ {
1302
+ type: "text",
1303
+ text: `Memory ${id} not found.`
1304
+ }
1305
+ ]
1306
+ };
1307
+ }
1308
+ const memory = setPinnedState(target.row_id, 0);
1012
1309
  if (!memory) {
1013
1310
  return {
1014
1311
  isError: true,
@@ -1425,7 +1722,7 @@ function registerRememberTool(server) {
1425
1722
  async ({ note, repo, type, tags }) => {
1426
1723
  try {
1427
1724
  const db = getDb();
1428
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1725
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1429
1726
  const inferred = inferMemoryFromNote(note);
1430
1727
  const finalType = type ?? inferred.type;
1431
1728
  const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
@@ -1463,6 +1760,7 @@ function registerRememberTool(server) {
1463
1760
  now,
1464
1761
  existing.row_id
1465
1762
  );
1763
+ indexMemoryEmbedding(db, existing.row_id, longerNote);
1466
1764
  return {
1467
1765
  content: [
1468
1766
  {
@@ -1503,6 +1801,9 @@ function registerRememberTool(server) {
1503
1801
  normalizeText(note)
1504
1802
  );
1505
1803
  const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
1804
+ if (inserted) {
1805
+ indexMemoryEmbedding(db, inserted.row_id, note);
1806
+ }
1506
1807
  return {
1507
1808
  content: [
1508
1809
  {
@@ -1542,7 +1843,7 @@ function registerResolveRepoTool(server) {
1542
1843
  async ({ cwd }) => {
1543
1844
  try {
1544
1845
  const db = getDb();
1545
- const target = cwd?.trim() || process.cwd();
1846
+ const target = cwd?.trim() || getWorkspaceRoot();
1546
1847
  const resolved = resolveRepo(target, db);
1547
1848
  const payload = {
1548
1849
  canonical: resolved.canonical,
@@ -1582,12 +1883,20 @@ var searchMemoryInputSchema = {
1582
1883
  repo: z8.string().trim().min(1).optional(),
1583
1884
  limit: z8.number().int().positive().max(50).default(5)
1584
1885
  };
1585
- function normalizeFtsQuery(query) {
1586
- const terms = query.trim().split(/\s+/).map((term) => term.replaceAll('"', '""')).filter(Boolean);
1587
- if (terms.length === 0) {
1588
- throw new Error("query must contain searchable text");
1886
+ function tokenizeQuery(query) {
1887
+ return query.toLowerCase().replace(/["()]/g, " ").split(/[\s/_\-.,;:!?]+/).map((token) => token.replace(/[^a-z0-9*]/g, "")).filter((token) => token.length >= 2);
1888
+ }
1889
+ function buildFtsQuery2(tokens) {
1890
+ if (tokens.length === 0) {
1891
+ return null;
1589
1892
  }
1590
- return terms.map((term) => `"${term}"`).join(" AND ");
1893
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
1894
+ }
1895
+ function buildFtsQueryOr(tokens) {
1896
+ if (tokens.length === 0) {
1897
+ return null;
1898
+ }
1899
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" OR ");
1591
1900
  }
1592
1901
  function parseTags4(raw) {
1593
1902
  try {
@@ -1597,37 +1906,73 @@ function parseTags4(raw) {
1597
1906
  return [];
1598
1907
  }
1599
1908
  }
1909
+ function runFts(ftsQuery, resolvedRepo, limit) {
1910
+ const db = getDb();
1911
+ try {
1912
+ if (resolvedRepo) {
1913
+ return db.prepare(
1914
+ `
1915
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1916
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1917
+ FROM memories_fts
1918
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1919
+ WHERE memories_fts MATCH ? AND m.repo = ?
1920
+ ORDER BY rank
1921
+ LIMIT ?
1922
+ `
1923
+ ).all(ftsQuery, resolvedRepo, limit);
1924
+ }
1925
+ return db.prepare(
1926
+ `
1927
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1928
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1929
+ FROM memories_fts
1930
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1931
+ WHERE memories_fts MATCH ?
1932
+ ORDER BY rank
1933
+ LIMIT ?
1934
+ `
1935
+ ).all(ftsQuery, limit);
1936
+ } catch {
1937
+ return [];
1938
+ }
1939
+ }
1600
1940
  function registerSearchMemoryTool(server) {
1601
1941
  server.registerTool(
1602
1942
  "search_memory",
1603
1943
  {
1604
- description: "Search memories using full-text search with optional repository filtering.",
1944
+ description: "Search memories using full-text search with optional repository filtering. Falls back to recent + pinned context when the query has no exact matches.",
1605
1945
  inputSchema: searchMemoryInputSchema
1606
1946
  },
1607
1947
  async ({ query, repo, limit }) => {
1608
1948
  try {
1609
1949
  const db = getDb();
1610
- const ftsQuery = normalizeFtsQuery(query);
1611
- const resolvedRepo = repo ? resolveRepoArg(repo, process.cwd(), db).canonical : void 0;
1612
- const rows = resolvedRepo ? db.prepare(
1613
- `
1614
- 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
1615
- FROM memories_fts
1616
- JOIN memories AS m ON m.rowid = memories_fts.rowid
1617
- WHERE memories_fts MATCH ? AND m.repo = ?
1618
- ORDER BY rank
1619
- LIMIT ?
1620
- `
1621
- ).all(ftsQuery, resolvedRepo, limit) : db.prepare(
1622
- `
1623
- 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
1624
- FROM memories_fts
1625
- JOIN memories AS m ON m.rowid = memories_fts.rowid
1626
- WHERE memories_fts MATCH ?
1627
- ORDER BY rank
1628
- LIMIT ?
1629
- `
1630
- ).all(ftsQuery, limit);
1950
+ const tokens = tokenizeQuery(query);
1951
+ const resolvedRepo = repo ? resolveRepoArg(repo, getWorkspaceRoot(), db).canonical : void 0;
1952
+ const andQuery = buildFtsQuery2(tokens);
1953
+ let rows = [];
1954
+ if (andQuery) {
1955
+ rows = runFts(andQuery, resolvedRepo, limit);
1956
+ }
1957
+ if (rows.length === 0 && tokens.length > 1) {
1958
+ const orQuery = buildFtsQueryOr(tokens);
1959
+ if (orQuery) {
1960
+ rows = runFts(orQuery, resolvedRepo, limit);
1961
+ }
1962
+ }
1963
+ if (rows.length === 0 && resolvedRepo && embeddingsEnabled()) {
1964
+ const semantic = vectorSearch(db, resolvedRepo, query, limit);
1965
+ rows = semantic.map(({ score, ...row }) => ({
1966
+ ...row,
1967
+ rank: score
1968
+ }));
1969
+ }
1970
+ let usedFallback = false;
1971
+ if (rows.length === 0 && resolvedRepo) {
1972
+ const fallback = fetchRepoContext(db, resolvedRepo, limit);
1973
+ rows = fallback.map((row) => ({ ...row, rank: 0 }));
1974
+ usedFallback = fallback.length > 0;
1975
+ }
1631
1976
  if (rows.length === 0) {
1632
1977
  return {
1633
1978
  content: [
@@ -1645,11 +1990,12 @@ function registerSearchMemoryTool(server) {
1645
1990
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
1646
1991
  ${pinPrefix}${row.note}${tagsText}`;
1647
1992
  }).join("\n\n");
1993
+ const header = usedFallback ? `No exact match for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}; showing recent + pinned context:` : `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:`;
1648
1994
  return {
1649
1995
  content: [
1650
1996
  {
1651
1997
  type: "text",
1652
- text: `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:
1998
+ text: `${header}
1653
1999
 
1654
2000
  ${formatted}`
1655
2001
  }
@@ -1690,7 +2036,7 @@ function registerStoreContextTool(server) {
1690
2036
  async ({ repo, type, note, tags }) => {
1691
2037
  try {
1692
2038
  const db = getDb();
1693
- const resolved = resolveRepoArg(repo, process.cwd(), db);
2039
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1694
2040
  const now = Math.floor(Date.now() / 1e3);
1695
2041
  const id = nanoid2();
1696
2042
  const normalizedTags = Array.from(
@@ -1718,6 +2064,9 @@ function registerStoreContextTool(server) {
1718
2064
  WHERE id = ?
1719
2065
  `
1720
2066
  ).get(id);
2067
+ if (stored) {
2068
+ indexMemoryEmbedding(db, stored.row_id, note);
2069
+ }
1721
2070
  return {
1722
2071
  content: [
1723
2072
  {
@@ -1765,7 +2114,7 @@ function registerSummarizeRepoContextTool(server) {
1765
2114
  async ({ repo }) => {
1766
2115
  try {
1767
2116
  const db = getDb();
1768
- const resolved = resolveRepoArg(repo, process.cwd(), db);
2117
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1769
2118
  const rows = db.prepare(
1770
2119
  `
1771
2120
  SELECT rowid AS row_id, type, note, pinned
@@ -1827,7 +2176,9 @@ ${entries.join("\n")}`);
1827
2176
  // src/tools/update.ts
1828
2177
  import { z as z11 } from "zod";
1829
2178
  var updateMemoryInputSchema = {
1830
- id: z11.number().int().positive(),
2179
+ // Accept numeric row_id or legacy string id so callers can paste whichever
2180
+ // form they have.
2181
+ id: z11.union([z11.number().int().positive(), z11.string().trim().min(1)]),
1831
2182
  content: z11.string().trim().min(1).optional(),
1832
2183
  memory_type: z11.enum(MEMORY_TYPES).optional()
1833
2184
  };
@@ -1859,7 +2210,7 @@ function registerUpdateMemoryTool(server) {
1859
2210
  server.registerTool(
1860
2211
  "update_memory",
1861
2212
  {
1862
- description: "Update an existing memory by numeric id with partial fields.",
2213
+ description: "Update an existing memory by id (numeric or legacy string) with partial fields.",
1863
2214
  inputSchema: updateMemoryInputSchema
1864
2215
  },
1865
2216
  async ({ id, content, memory_type }) => {
@@ -1876,14 +2227,8 @@ function registerUpdateMemoryTool(server) {
1876
2227
  };
1877
2228
  }
1878
2229
  const db = getDb();
1879
- const existing = db.prepare(
1880
- `
1881
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
1882
- FROM memories
1883
- WHERE rowid = ?
1884
- `
1885
- ).get(id);
1886
- if (!existing) {
2230
+ const target = findMemoryByAnyId(db, id);
2231
+ if (!target) {
1887
2232
  return {
1888
2233
  isError: true,
1889
2234
  content: [
@@ -1894,6 +2239,13 @@ function registerUpdateMemoryTool(server) {
1894
2239
  ]
1895
2240
  };
1896
2241
  }
2242
+ const existing = db.prepare(
2243
+ `
2244
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
2245
+ FROM memories
2246
+ WHERE rowid = ?
2247
+ `
2248
+ ).get(target.row_id);
1897
2249
  const now = Math.floor(Date.now() / 1e3);
1898
2250
  const nextType = memory_type ?? existing.type;
1899
2251
  const nextNote = content ?? existing.note;
@@ -1905,7 +2257,8 @@ function registerUpdateMemoryTool(server) {
1905
2257
  SET type = ?, note = ?, note_normalized = ?, updated_at = ?
1906
2258
  WHERE rowid = ?
1907
2259
  `
1908
- ).run(nextType, nextNote, nextNormalized, now, id);
2260
+ ).run(nextType, nextNote, nextNormalized, now, existing.row_id);
2261
+ indexMemoryEmbedding(db, existing.row_id, nextNote);
1909
2262
  } else {
1910
2263
  db.prepare(
1911
2264
  `
@@ -1913,7 +2266,7 @@ function registerUpdateMemoryTool(server) {
1913
2266
  SET type = ?, note = ?, updated_at = ?
1914
2267
  WHERE rowid = ?
1915
2268
  `
1916
- ).run(nextType, nextNote, now, id);
2269
+ ).run(nextType, nextNote, now, existing.row_id);
1917
2270
  }
1918
2271
  const updated = db.prepare(
1919
2272
  `
@@ -1921,7 +2274,7 @@ function registerUpdateMemoryTool(server) {
1921
2274
  FROM memories
1922
2275
  WHERE rowid = ?
1923
2276
  `
1924
- ).get(id);
2277
+ ).get(existing.row_id);
1925
2278
  if (!updated) {
1926
2279
  return {
1927
2280
  isError: true,
@@ -1973,7 +2326,7 @@ function registerStartupContextResource(server) {
1973
2326
  async (uri) => {
1974
2327
  try {
1975
2328
  const db = getDb();
1976
- const resolved = resolveRepo(process.cwd(), db);
2329
+ const resolved = resolveRepo(getWorkspaceRoot(), db);
1977
2330
  const rows = fetchRepoContext(db, resolved.canonical, 5);
1978
2331
  const text = formatContext(rows, {
1979
2332
  repo: resolved.canonical,
@@ -2010,7 +2363,7 @@ async function startServer() {
2010
2363
  initDb(dbPath);
2011
2364
  const server = new McpServer({
2012
2365
  name: "fossel",
2013
- version: "1.1.0"
2366
+ version: "1.2.0"
2014
2367
  });
2015
2368
  registerRememberTool(server);
2016
2369
  registerGetContextTool(server);