fossel 1.1.0 → 1.1.1

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 +17 -4
  2. package/dist/cli.js +483 -167
  3. package/dist/index.js +187 -64
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -212,6 +212,9 @@ function parseTags(raw) {
212
212
  return [];
213
213
  }
214
214
  }
215
+ function normalizeNoteForReadDedupe(text) {
216
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
217
+ }
215
218
  function buildFtsQuery(query) {
216
219
  const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
217
220
  if (terms.length === 0) {
@@ -222,11 +225,19 @@ function buildFtsQuery(query) {
222
225
  function fetchRepoContext(db, repo, limit, query) {
223
226
  const rows = [];
224
227
  const seen = /* @__PURE__ */ new Set();
228
+ const seenNormalized = /* @__PURE__ */ new Set();
225
229
  const push = (memory, source, rank) => {
226
230
  if (seen.has(memory.row_id)) {
227
231
  return;
228
232
  }
233
+ const normalized = normalizeNoteForReadDedupe(memory.note);
234
+ if (normalized && seenNormalized.has(normalized)) {
235
+ return;
236
+ }
229
237
  seen.add(memory.row_id);
238
+ if (normalized) {
239
+ seenNormalized.add(normalized);
240
+ }
230
241
  rows.push({ ...memory, source, rank });
231
242
  };
232
243
  const pinned = db.prepare(
@@ -472,6 +483,15 @@ function resolveRepoArg(input, cwd, db) {
472
483
  };
473
484
  }
474
485
 
486
+ // src/lib/workspace.ts
487
+ function getWorkspaceRoot() {
488
+ const fromEnv = process.env.FOSSEL_WORKSPACE?.trim();
489
+ if (fromEnv) {
490
+ return fromEnv;
491
+ }
492
+ return process.cwd();
493
+ }
494
+
475
495
  // src/tools/dedupe-repo.ts
476
496
  import { z } from "zod";
477
497
 
@@ -606,7 +626,7 @@ function registerDedupeRepoTool(server) {
606
626
  async ({ repo, threshold, apply }) => {
607
627
  try {
608
628
  const db = getDb();
609
- const resolved = resolveRepoArg(repo, process.cwd(), db);
629
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
610
630
  const rows = db.prepare(
611
631
  `
612
632
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
@@ -734,21 +754,56 @@ Re-run with apply=true to merge.`
734
754
 
735
755
  // src/tools/delete.ts
736
756
  import { z as z2 } from "zod";
757
+
758
+ // src/lib/memory.ts
759
+ function findMemoryByAnyId(db, input) {
760
+ const numeric = typeof input === "number" ? input : Number(input);
761
+ const isNumericId = Number.isInteger(numeric) && numeric > 0 && String(numeric) === String(input).trim();
762
+ if (isNumericId) {
763
+ const row = db.prepare(
764
+ `
765
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
766
+ FROM memories
767
+ WHERE rowid = ?
768
+ `
769
+ ).get(numeric);
770
+ if (row) {
771
+ return row;
772
+ }
773
+ }
774
+ const stringInput = String(input).trim();
775
+ if (stringInput.length === 0) {
776
+ return null;
777
+ }
778
+ const stringRow = db.prepare(
779
+ `
780
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
781
+ FROM memories
782
+ WHERE id = ?
783
+ `
784
+ ).get(stringInput);
785
+ return stringRow ?? null;
786
+ }
787
+
788
+ // src/tools/delete.ts
737
789
  var deleteMemoryInputSchema = {
738
- id: z2.string().trim().min(1, "id is required")
790
+ // Accept either the numeric row_id or the legacy nanoid string. Tools used
791
+ // to disagree about which form to take; this unifies them so callers can
792
+ // paste whichever id they have in front of them.
793
+ id: z2.union([z2.number().int().positive(), z2.string().trim().min(1)])
739
794
  };
740
795
  function registerDeleteMemoryTool(server) {
741
796
  server.registerTool(
742
797
  "delete_memory",
743
798
  {
744
- description: "Delete a memory from storage by id.",
799
+ description: "Delete a memory from storage by id. Accepts either the numeric row id or the legacy string id.",
745
800
  inputSchema: deleteMemoryInputSchema
746
801
  },
747
802
  async ({ id }) => {
748
803
  try {
749
804
  const db = getDb();
750
- const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(id);
751
- if (!row) {
805
+ const memory = findMemoryByAnyId(db, id);
806
+ if (!memory) {
752
807
  return {
753
808
  isError: true,
754
809
  content: [
@@ -759,15 +814,15 @@ function registerDeleteMemoryTool(server) {
759
814
  ]
760
815
  };
761
816
  }
762
- const deleteTx = db.transaction((memoryId) => {
763
- db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
817
+ const deleteTx = db.transaction((rowId) => {
818
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(rowId);
764
819
  });
765
- deleteTx(id);
820
+ deleteTx(memory.row_id);
766
821
  return {
767
822
  content: [
768
823
  {
769
824
  type: "text",
770
- text: `Deleted memory ${id}.`
825
+ text: `Deleted memory ${memory.row_id} (legacy: ${memory.id}).`
771
826
  }
772
827
  ]
773
828
  };
@@ -805,7 +860,7 @@ function registerGetContextTool(server) {
805
860
  async ({ repo, query, limit, format }) => {
806
861
  try {
807
862
  const db = getDb();
808
- const resolved = resolveRepoArg(repo, process.cwd(), db);
863
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
809
864
  const rows = fetchRepoContext(db, resolved.canonical, limit, query);
810
865
  const text = formatContext(rows, {
811
866
  repo: resolved.canonical,
@@ -863,7 +918,7 @@ function registerGetRepoContextTool(server) {
863
918
  async ({ repo, limit }) => {
864
919
  try {
865
920
  const db = getDb();
866
- const resolved = resolveRepoArg(repo, process.cwd(), db);
921
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
867
922
  const rows = db.prepare(
868
923
  `
869
924
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -932,9 +987,10 @@ ${sections.join("\n\n")}`
932
987
  // src/tools/pin.ts
933
988
  import { z as z5 } from "zod";
934
989
  var pinInputSchema = {
935
- id: z5.number().int().positive()
990
+ // Accept numeric row_id or legacy string id for parity with the other tools.
991
+ id: z5.union([z5.number().int().positive(), z5.string().trim().min(1)])
936
992
  };
937
- function setPinnedState(memoryId, pinned) {
993
+ function setPinnedState(rowId, pinned) {
938
994
  const db = getDb();
939
995
  const now = Math.floor(Date.now() / 1e3);
940
996
  const updateResult = db.prepare(
@@ -943,7 +999,7 @@ function setPinnedState(memoryId, pinned) {
943
999
  SET pinned = ?, updated_at = ?
944
1000
  WHERE rowid = ?
945
1001
  `
946
- ).run(pinned, now, memoryId);
1002
+ ).run(pinned, now, rowId);
947
1003
  if (updateResult.changes === 0) {
948
1004
  return null;
949
1005
  }
@@ -953,7 +1009,7 @@ function setPinnedState(memoryId, pinned) {
953
1009
  FROM memories
954
1010
  WHERE rowid = ?
955
1011
  `
956
- ).get(memoryId);
1012
+ ).get(rowId);
957
1013
  }
958
1014
  function registerPinMemoryTool(server) {
959
1015
  server.registerTool(
@@ -964,7 +1020,20 @@ function registerPinMemoryTool(server) {
964
1020
  },
965
1021
  async ({ id }) => {
966
1022
  try {
967
- const memory = setPinnedState(id, 1);
1023
+ const db = getDb();
1024
+ const target = findMemoryByAnyId(db, id);
1025
+ if (!target) {
1026
+ return {
1027
+ isError: true,
1028
+ content: [
1029
+ {
1030
+ type: "text",
1031
+ text: `Memory ${id} not found.`
1032
+ }
1033
+ ]
1034
+ };
1035
+ }
1036
+ const memory = setPinnedState(target.row_id, 1);
968
1037
  if (!memory) {
969
1038
  return {
970
1039
  isError: true,
@@ -1008,7 +1077,20 @@ function registerUnpinMemoryTool(server) {
1008
1077
  },
1009
1078
  async ({ id }) => {
1010
1079
  try {
1011
- const memory = setPinnedState(id, 0);
1080
+ const db = getDb();
1081
+ const target = findMemoryByAnyId(db, id);
1082
+ if (!target) {
1083
+ return {
1084
+ isError: true,
1085
+ content: [
1086
+ {
1087
+ type: "text",
1088
+ text: `Memory ${id} not found.`
1089
+ }
1090
+ ]
1091
+ };
1092
+ }
1093
+ const memory = setPinnedState(target.row_id, 0);
1012
1094
  if (!memory) {
1013
1095
  return {
1014
1096
  isError: true,
@@ -1425,7 +1507,7 @@ function registerRememberTool(server) {
1425
1507
  async ({ note, repo, type, tags }) => {
1426
1508
  try {
1427
1509
  const db = getDb();
1428
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1510
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1429
1511
  const inferred = inferMemoryFromNote(note);
1430
1512
  const finalType = type ?? inferred.type;
1431
1513
  const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
@@ -1542,7 +1624,7 @@ function registerResolveRepoTool(server) {
1542
1624
  async ({ cwd }) => {
1543
1625
  try {
1544
1626
  const db = getDb();
1545
- const target = cwd?.trim() || process.cwd();
1627
+ const target = cwd?.trim() || getWorkspaceRoot();
1546
1628
  const resolved = resolveRepo(target, db);
1547
1629
  const payload = {
1548
1630
  canonical: resolved.canonical,
@@ -1582,12 +1664,20 @@ var searchMemoryInputSchema = {
1582
1664
  repo: z8.string().trim().min(1).optional(),
1583
1665
  limit: z8.number().int().positive().max(50).default(5)
1584
1666
  };
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");
1667
+ function tokenizeQuery(query) {
1668
+ return query.toLowerCase().replace(/["()]/g, " ").split(/[\s/_\-.,;:!?]+/).map((token) => token.replace(/[^a-z0-9*]/g, "")).filter((token) => token.length >= 2);
1669
+ }
1670
+ function buildFtsQuery2(tokens) {
1671
+ if (tokens.length === 0) {
1672
+ return null;
1589
1673
  }
1590
- return terms.map((term) => `"${term}"`).join(" AND ");
1674
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
1675
+ }
1676
+ function buildFtsQueryOr(tokens) {
1677
+ if (tokens.length === 0) {
1678
+ return null;
1679
+ }
1680
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" OR ");
1591
1681
  }
1592
1682
  function parseTags4(raw) {
1593
1683
  try {
@@ -1597,37 +1687,66 @@ function parseTags4(raw) {
1597
1687
  return [];
1598
1688
  }
1599
1689
  }
1690
+ function runFts(ftsQuery, resolvedRepo, limit) {
1691
+ const db = getDb();
1692
+ try {
1693
+ if (resolvedRepo) {
1694
+ return db.prepare(
1695
+ `
1696
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1697
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1698
+ FROM memories_fts
1699
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1700
+ WHERE memories_fts MATCH ? AND m.repo = ?
1701
+ ORDER BY rank
1702
+ LIMIT ?
1703
+ `
1704
+ ).all(ftsQuery, resolvedRepo, limit);
1705
+ }
1706
+ return db.prepare(
1707
+ `
1708
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1709
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1710
+ FROM memories_fts
1711
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1712
+ WHERE memories_fts MATCH ?
1713
+ ORDER BY rank
1714
+ LIMIT ?
1715
+ `
1716
+ ).all(ftsQuery, limit);
1717
+ } catch {
1718
+ return [];
1719
+ }
1720
+ }
1600
1721
  function registerSearchMemoryTool(server) {
1601
1722
  server.registerTool(
1602
1723
  "search_memory",
1603
1724
  {
1604
- description: "Search memories using full-text search with optional repository filtering.",
1725
+ 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
1726
  inputSchema: searchMemoryInputSchema
1606
1727
  },
1607
1728
  async ({ query, repo, limit }) => {
1608
1729
  try {
1609
1730
  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);
1731
+ const tokens = tokenizeQuery(query);
1732
+ const resolvedRepo = repo ? resolveRepoArg(repo, getWorkspaceRoot(), db).canonical : void 0;
1733
+ const andQuery = buildFtsQuery2(tokens);
1734
+ let rows = [];
1735
+ if (andQuery) {
1736
+ rows = runFts(andQuery, resolvedRepo, limit);
1737
+ }
1738
+ if (rows.length === 0 && tokens.length > 1) {
1739
+ const orQuery = buildFtsQueryOr(tokens);
1740
+ if (orQuery) {
1741
+ rows = runFts(orQuery, resolvedRepo, limit);
1742
+ }
1743
+ }
1744
+ let usedFallback = false;
1745
+ if (rows.length === 0 && resolvedRepo) {
1746
+ const fallback = fetchRepoContext(db, resolvedRepo, limit);
1747
+ rows = fallback.map((row) => ({ ...row, rank: 0 }));
1748
+ usedFallback = fallback.length > 0;
1749
+ }
1631
1750
  if (rows.length === 0) {
1632
1751
  return {
1633
1752
  content: [
@@ -1645,11 +1764,12 @@ function registerSearchMemoryTool(server) {
1645
1764
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
1646
1765
  ${pinPrefix}${row.note}${tagsText}`;
1647
1766
  }).join("\n\n");
1767
+ const header = usedFallback ? `No exact match for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}; showing recent + pinned context:` : `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:`;
1648
1768
  return {
1649
1769
  content: [
1650
1770
  {
1651
1771
  type: "text",
1652
- text: `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:
1772
+ text: `${header}
1653
1773
 
1654
1774
  ${formatted}`
1655
1775
  }
@@ -1690,7 +1810,7 @@ function registerStoreContextTool(server) {
1690
1810
  async ({ repo, type, note, tags }) => {
1691
1811
  try {
1692
1812
  const db = getDb();
1693
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1813
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1694
1814
  const now = Math.floor(Date.now() / 1e3);
1695
1815
  const id = nanoid2();
1696
1816
  const normalizedTags = Array.from(
@@ -1765,7 +1885,7 @@ function registerSummarizeRepoContextTool(server) {
1765
1885
  async ({ repo }) => {
1766
1886
  try {
1767
1887
  const db = getDb();
1768
- const resolved = resolveRepoArg(repo, process.cwd(), db);
1888
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1769
1889
  const rows = db.prepare(
1770
1890
  `
1771
1891
  SELECT rowid AS row_id, type, note, pinned
@@ -1827,7 +1947,9 @@ ${entries.join("\n")}`);
1827
1947
  // src/tools/update.ts
1828
1948
  import { z as z11 } from "zod";
1829
1949
  var updateMemoryInputSchema = {
1830
- id: z11.number().int().positive(),
1950
+ // Accept numeric row_id or legacy string id so callers can paste whichever
1951
+ // form they have.
1952
+ id: z11.union([z11.number().int().positive(), z11.string().trim().min(1)]),
1831
1953
  content: z11.string().trim().min(1).optional(),
1832
1954
  memory_type: z11.enum(MEMORY_TYPES).optional()
1833
1955
  };
@@ -1859,7 +1981,7 @@ function registerUpdateMemoryTool(server) {
1859
1981
  server.registerTool(
1860
1982
  "update_memory",
1861
1983
  {
1862
- description: "Update an existing memory by numeric id with partial fields.",
1984
+ description: "Update an existing memory by id (numeric or legacy string) with partial fields.",
1863
1985
  inputSchema: updateMemoryInputSchema
1864
1986
  },
1865
1987
  async ({ id, content, memory_type }) => {
@@ -1876,14 +1998,8 @@ function registerUpdateMemoryTool(server) {
1876
1998
  };
1877
1999
  }
1878
2000
  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) {
2001
+ const target = findMemoryByAnyId(db, id);
2002
+ if (!target) {
1887
2003
  return {
1888
2004
  isError: true,
1889
2005
  content: [
@@ -1894,6 +2010,13 @@ function registerUpdateMemoryTool(server) {
1894
2010
  ]
1895
2011
  };
1896
2012
  }
2013
+ const existing = db.prepare(
2014
+ `
2015
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
2016
+ FROM memories
2017
+ WHERE rowid = ?
2018
+ `
2019
+ ).get(target.row_id);
1897
2020
  const now = Math.floor(Date.now() / 1e3);
1898
2021
  const nextType = memory_type ?? existing.type;
1899
2022
  const nextNote = content ?? existing.note;
@@ -1905,7 +2028,7 @@ function registerUpdateMemoryTool(server) {
1905
2028
  SET type = ?, note = ?, note_normalized = ?, updated_at = ?
1906
2029
  WHERE rowid = ?
1907
2030
  `
1908
- ).run(nextType, nextNote, nextNormalized, now, id);
2031
+ ).run(nextType, nextNote, nextNormalized, now, existing.row_id);
1909
2032
  } else {
1910
2033
  db.prepare(
1911
2034
  `
@@ -1913,7 +2036,7 @@ function registerUpdateMemoryTool(server) {
1913
2036
  SET type = ?, note = ?, updated_at = ?
1914
2037
  WHERE rowid = ?
1915
2038
  `
1916
- ).run(nextType, nextNote, now, id);
2039
+ ).run(nextType, nextNote, now, existing.row_id);
1917
2040
  }
1918
2041
  const updated = db.prepare(
1919
2042
  `
@@ -1921,7 +2044,7 @@ function registerUpdateMemoryTool(server) {
1921
2044
  FROM memories
1922
2045
  WHERE rowid = ?
1923
2046
  `
1924
- ).get(id);
2047
+ ).get(existing.row_id);
1925
2048
  if (!updated) {
1926
2049
  return {
1927
2050
  isError: true,
@@ -1973,7 +2096,7 @@ function registerStartupContextResource(server) {
1973
2096
  async (uri) => {
1974
2097
  try {
1975
2098
  const db = getDb();
1976
- const resolved = resolveRepo(process.cwd(), db);
2099
+ const resolved = resolveRepo(getWorkspaceRoot(), db);
1977
2100
  const rows = fetchRepoContext(db, resolved.canonical, 5);
1978
2101
  const text = formatContext(rows, {
1979
2102
  repo: resolved.canonical,
@@ -2010,7 +2133,7 @@ async function startServer() {
2010
2133
  initDb(dbPath);
2011
2134
  const server = new McpServer({
2012
2135
  name: "fossel",
2013
- version: "1.1.0"
2136
+ version: "1.1.1"
2014
2137
  });
2015
2138
  registerRememberTool(server);
2016
2139
  registerGetContextTool(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fossel",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Local-first MCP memory for Cursor & Claude: repo context in SQLite, FTS5 search, pins, PR summaries. No cloud.",
5
5
  "keywords": [
6
6
  "mcp",