clawmem 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/mcp.ts +179 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "On-device context engine and memory for AI agents. Claude Code and OpenClaw. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/mcp.ts CHANGED
@@ -817,6 +817,144 @@ This is the recommended entry point for ALL memory queries.`,
817
817
  }
818
818
  );
819
819
 
820
+ // ---------------------------------------------------------------------------
821
+ // Lifecycle search helpers — resilient candidate finding for pin/snooze/forget
822
+ // ---------------------------------------------------------------------------
823
+
824
+ type LifecycleCandidate = {
825
+ displayPath: string;
826
+ title: string;
827
+ score: number;
828
+ source: "path" | "fts" | "title" | "vec";
829
+ };
830
+
831
+ const STOPWORDS = new Set([
832
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
833
+ "in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
834
+ "and", "or", "not", "no", "but", "if", "then", "so", "do", "did",
835
+ "has", "have", "had", "it", "its", "this", "that", "my", "our",
836
+ ]);
837
+
838
+ /**
839
+ * Cascading search for lifecycle mutations: path match → BM25 → title overlap → vector.
840
+ * Returns ranked candidates. Never returns wrong results silently.
841
+ */
842
+ async function findMemoryCandidates(
843
+ store: Store,
844
+ query: string,
845
+ limit: number = 5
846
+ ): Promise<LifecycleCandidate[]> {
847
+ // 1. Exact path match (handles queries like "stack/research/foo.md")
848
+ if (query.includes("/") || query.endsWith(".md")) {
849
+ const normalized = query.replace(/^\//, "");
850
+ const pathHits = store.db.prepare(`
851
+ SELECT collection || '/' || path as displayPath, title
852
+ FROM documents WHERE active = 1 AND invalidated_at IS NULL
853
+ AND (path LIKE ? OR collection || '/' || path LIKE ?)
854
+ LIMIT ?
855
+ `).all(`%${normalized}%`, `%${normalized}%`, limit) as { displayPath: string; title: string }[];
856
+ if (pathHits.length > 0) {
857
+ return pathHits.map((h, i) => ({ ...h, score: 1.0 - i * 0.05, source: "path" as const }));
858
+ }
859
+ }
860
+
861
+ // 2. BM25 full-text search (fast, exact terms)
862
+ const ftsResults = store.searchFTS(query, limit);
863
+ if (ftsResults.length > 0) {
864
+ return ftsResults.map(r => ({
865
+ displayPath: r.displayPath,
866
+ title: r.title,
867
+ score: r.score,
868
+ source: "fts" as const,
869
+ }));
870
+ }
871
+
872
+ // 3. Title-token overlap (catches BM25 failures from too many AND'd terms)
873
+ const tokens = query.toLowerCase().split(/\s+/)
874
+ .filter(w => w.length >= 2 && !STOPWORDS.has(w))
875
+ .map(w => w.replace(/[^a-z0-9]/g, ""))
876
+ .filter(w => w.length >= 2);
877
+
878
+ if (tokens.length > 0) {
879
+ const minMatch = Math.max(2, Math.ceil(tokens.length / 2));
880
+ const titleHits = store.db.prepare(`
881
+ SELECT displayPath, title, match_count FROM (
882
+ SELECT collection || '/' || path as displayPath, title, modified_at,
883
+ ${tokens.map(() => `(CASE WHEN LOWER(title) LIKE ? THEN 1 ELSE 0 END)`).join(" + ")} as match_count
884
+ FROM documents
885
+ WHERE active = 1 AND invalidated_at IS NULL
886
+ ) WHERE match_count >= ?
887
+ ORDER BY match_count DESC, modified_at DESC
888
+ LIMIT ?
889
+ `).all(...tokens.map(t => `%${t}%`), minMatch, limit) as { displayPath: string; title: string; match_count: number }[];
890
+
891
+ if (titleHits.length > 0) {
892
+ return titleHits.map(h => ({
893
+ displayPath: h.displayPath,
894
+ title: h.title,
895
+ score: h.match_count / tokens.length,
896
+ source: "title" as const,
897
+ }));
898
+ }
899
+ }
900
+
901
+ // 4. Vector search fallback (semantic similarity)
902
+ try {
903
+ const llm = getDefaultLlamaCpp();
904
+ if (llm) {
905
+ const vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit);
906
+ if (vecResults.length > 0) {
907
+ return vecResults.map(r => ({
908
+ displayPath: r.displayPath,
909
+ title: r.title,
910
+ score: r.score,
911
+ source: "vec" as const,
912
+ }));
913
+ }
914
+ }
915
+ } catch {
916
+ // Vector search unavailable — degrade gracefully
917
+ }
918
+
919
+ return [];
920
+ }
921
+
922
+ /**
923
+ * Select a single target from candidates, or return an ambiguity message.
924
+ * Stricter confidence requirement for destructive ops (forget).
925
+ */
926
+ function selectLifecycleTarget(
927
+ candidates: LifecycleCandidate[],
928
+ query: string,
929
+ destructive: boolean = false
930
+ ): { target: LifecycleCandidate } | { ambiguous: string } | { notFound: string } {
931
+ if (candidates.length === 0) {
932
+ return { notFound: `No matching memory found for "${query}"` };
933
+ }
934
+
935
+ const top = candidates[0]!;
936
+
937
+ // Clear winner: high score OR significant gap to #2
938
+ const gap = candidates.length > 1 ? top.score - candidates[1]!.score : 1.0;
939
+ const confident = top.score >= 0.7 || gap >= 0.2;
940
+
941
+ // For destructive ops (forget), require higher confidence
942
+ if (destructive && !confident) {
943
+ const list = candidates.slice(0, 3).map((c, i) =>
944
+ `${i + 1}. ${c.displayPath} — "${c.title}" (${c.source}, score: ${c.score.toFixed(2)})`
945
+ ).join("\n");
946
+ return { ambiguous: `Multiple possible matches. Please be more specific or use a path:\n${list}` };
947
+ }
948
+
949
+ // For non-destructive ops (pin/snooze), accept top hit if any candidates exist
950
+ if (!confident && candidates.length > 1) {
951
+ // Low confidence but not destructive — take top hit but warn
952
+ return { target: top };
953
+ }
954
+
955
+ return { target: top };
956
+ }
957
+
820
958
  // ---------------------------------------------------------------------------
821
959
  // Tool: memory_forget
822
960
  // ---------------------------------------------------------------------------
@@ -833,28 +971,32 @@ This is the recommended entry point for ALL memory queries.`,
833
971
  },
834
972
  },
835
973
  async ({ query, confirm, vault }) => {
836
- const store = getStore(vault);
837
- const results = store.searchFTS(query, 5);
838
- if (results.length === 0) {
839
- return { content: [{ type: "text", text: `No matching memory found for "${query}"` }] };
974
+ const s = getStore(vault);
975
+ const candidates = await findMemoryCandidates(s, query, 5);
976
+ const selection = selectLifecycleTarget(candidates, query, true); // destructive = true
977
+
978
+ if ("notFound" in selection) {
979
+ return { content: [{ type: "text", text: selection.notFound }] };
980
+ }
981
+ if ("ambiguous" in selection) {
982
+ return { content: [{ type: "text", text: selection.ambiguous }] };
840
983
  }
841
984
 
842
- const best = results[0]!;
985
+ const best = selection.target;
843
986
  const parts = best.displayPath.split("/");
844
987
  const collection = parts[0]!;
845
988
  const path = parts.slice(1).join("/");
846
989
 
847
990
  if (!confirm) {
848
991
  return {
849
- content: [{ type: "text", text: `Would forget: ${best.displayPath} — "${best.title}" (score ${Math.round(best.score * 100)}%)` }],
992
+ content: [{ type: "text", text: `Would forget: ${best.displayPath} — "${best.title}" (${best.source}, score ${Math.round(best.score * 100)}%)` }],
850
993
  structuredContent: { path: best.displayPath, title: best.title, score: best.score, action: "preview" },
851
994
  };
852
995
  }
853
996
 
854
- store.deactivateDocument(collection, path);
997
+ s.deactivateDocument(collection, path);
855
998
 
856
- // Log the deletion as audit trail
857
- store.insertUsage({
999
+ s.insertUsage({
858
1000
  sessionId: "mcp-forget",
859
1001
  timestamp: new Date().toISOString(),
860
1002
  hookName: "memory_forget",
@@ -1976,21 +2118,27 @@ This is the recommended entry point for ALL memory queries.`,
1976
2118
  },
1977
2119
  },
1978
2120
  async ({ query, unpin, vault }) => {
1979
- const store = getStore(vault);
1980
- const results = store.searchFTS(query, 3);
1981
- if (results.length === 0) {
1982
- return { content: [{ type: "text", text: "No matching memory found." }], isError: true };
2121
+ const s = getStore(vault);
2122
+ const candidates = await findMemoryCandidates(s, query);
2123
+ const selection = selectLifecycleTarget(candidates, query);
2124
+
2125
+ if ("notFound" in selection) {
2126
+ return { content: [{ type: "text", text: selection.notFound }], isError: true };
2127
+ }
2128
+ if ("ambiguous" in selection) {
2129
+ return { content: [{ type: "text", text: selection.ambiguous }], isError: true };
1983
2130
  }
1984
- const r = results[0]!;
2131
+
2132
+ const r = selection.target;
1985
2133
  const parts = r.displayPath.split("/");
1986
2134
  const collection = parts[0]!;
1987
2135
  const path = parts.slice(1).join("/");
1988
- const doc = store.findActiveDocument(collection, path);
2136
+ const doc = s.findActiveDocument(collection, path);
1989
2137
  if (!doc) {
1990
2138
  return { content: [{ type: "text", text: "Document not found." }], isError: true };
1991
2139
  }
1992
- store.pinDocument(collection, path, !unpin);
1993
- store.insertUsage({
2140
+ s.pinDocument(collection, path, !unpin);
2141
+ s.insertUsage({
1994
2142
  sessionId: "mcp-pin",
1995
2143
  timestamp: new Date().toISOString(),
1996
2144
  hookName: "memory_pin",
@@ -2019,21 +2167,27 @@ This is the recommended entry point for ALL memory queries.`,
2019
2167
  },
2020
2168
  },
2021
2169
  async ({ query, until, vault }) => {
2022
- const store = getStore(vault);
2023
- const results = store.searchFTS(query, 3);
2024
- if (results.length === 0) {
2025
- return { content: [{ type: "text", text: "No matching memory found." }], isError: true };
2170
+ const s = getStore(vault);
2171
+ const candidates = await findMemoryCandidates(s, query);
2172
+ const selection = selectLifecycleTarget(candidates, query);
2173
+
2174
+ if ("notFound" in selection) {
2175
+ return { content: [{ type: "text", text: selection.notFound }], isError: true };
2026
2176
  }
2027
- const r = results[0]!;
2177
+ if ("ambiguous" in selection) {
2178
+ return { content: [{ type: "text", text: selection.ambiguous }], isError: true };
2179
+ }
2180
+
2181
+ const r = selection.target;
2028
2182
  const parts = r.displayPath.split("/");
2029
2183
  const collection = parts[0]!;
2030
2184
  const path = parts.slice(1).join("/");
2031
- const doc = store.findActiveDocument(collection, path);
2185
+ const doc = s.findActiveDocument(collection, path);
2032
2186
  if (!doc) {
2033
2187
  return { content: [{ type: "text", text: "Document not found." }], isError: true };
2034
2188
  }
2035
- store.snoozeDocument(collection, path, until || null);
2036
- store.insertUsage({
2189
+ s.snoozeDocument(collection, path, until || null);
2190
+ s.insertUsage({
2037
2191
  sessionId: "mcp-snooze",
2038
2192
  timestamp: new Date().toISOString(),
2039
2193
  hookName: "memory_snooze",