clawmem 0.2.4 → 0.2.6

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.
package/AGENTS.md CHANGED
@@ -636,6 +636,9 @@ Symptom: "UserPromptSubmit hook error" on context-surfacing hook (intermittent)
636
636
  → Default hook timeout is 8s (since v0.1.1). If you have an older install, re-run
637
637
  `clawmem setup hooks`. If persistent, restart the watcher: `systemctl --user restart
638
638
  clawmem-watcher.service`. Healthy memory is under 100MB — if 400MB+, restart clears it.
639
+ → v0.2.4 fix: hook's SQLite busy_timeout was 500ms — too tight. During A-MEM enrichment
640
+ or heavy indexing, watcher write locks exceed 500ms, causing SQLITE_BUSY. Raised to
641
+ 5000ms (matches MCP server). Still completes within the 8s outer timeout.
639
642
 
640
643
  Symptom: WSL hangs or becomes unresponsive during long sessions / watcher has 100K+ FDs
641
644
  → Pre-v0.2.3: fs.watch(recursive: true) registered inotify watches on EVERY subdirectory,
package/CLAUDE.md CHANGED
@@ -636,6 +636,9 @@ Symptom: "UserPromptSubmit hook error" on context-surfacing hook (intermittent)
636
636
  → Default hook timeout is 8s (since v0.1.1). If you have an older install, re-run
637
637
  `clawmem setup hooks`. If persistent, restart the watcher: `systemctl --user restart
638
638
  clawmem-watcher.service`. Healthy memory is under 100MB — if 400MB+, restart clears it.
639
+ → v0.2.4 fix: hook's SQLite busy_timeout was 500ms — too tight. During A-MEM enrichment
640
+ or heavy indexing, watcher write locks exceed 500ms, causing SQLITE_BUSY. Raised to
641
+ 5000ms (matches MCP server). Still completes within the 8s outer timeout.
639
642
 
640
643
  Symptom: WSL hangs or becomes unresponsive during long sessions / watcher has 100K+ FDs
641
644
  → Pre-v0.2.3: fs.watch(recursive: true) registered inotify watches on EVERY subdirectory,
package/SKILL.md CHANGED
@@ -642,6 +642,9 @@ Symptom: "UserPromptSubmit hook error" on context-surfacing hook (intermittent)
642
642
  -> Default hook timeout is 8s (since v0.1.1). If you have an older install, re-run
643
643
  `clawmem setup hooks`. If persistent, restart the watcher: `systemctl --user restart
644
644
  clawmem-watcher.service`. Healthy memory is under 100MB — if 400MB+, restart clears it.
645
+ -> v0.2.4 fix: hook's SQLite busy_timeout was 500ms — too tight. During A-MEM enrichment
646
+ or heavy indexing, watcher write locks exceed 500ms, causing SQLITE_BUSY. Raised to
647
+ 5000ms (matches MCP server). Still completes within the 8s outer timeout.
645
648
 
646
649
  Symptom: WSL hangs or becomes unresponsive during long sessions / watcher has 100K+ FDs
647
650
  -> Pre-v0.2.3: fs.watch(recursive: true) registered inotify watches on EVERY subdirectory,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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/clawmem.ts CHANGED
@@ -772,7 +772,7 @@ async function cmdSurface(args: string[]) {
772
772
  if (!input) process.exit(0);
773
773
 
774
774
  // Open store: writable for both (context-surfacing writes dedupe data)
775
- const s = createStore(undefined, { busyTimeout: 500 });
775
+ const s = createStore(undefined, { busyTimeout: 5000 });
776
776
 
777
777
  try {
778
778
  if (isBootstrap) {
package/src/mcp.ts CHANGED
@@ -817,6 +817,143 @@ 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 collection || '/' || path as displayPath, title,
882
+ ${tokens.map((_, i) => `(CASE WHEN LOWER(title) LIKE ? THEN 1 ELSE 0 END)`).join(" + ")} as match_count
883
+ FROM documents
884
+ WHERE active = 1 AND invalidated_at IS NULL
885
+ HAVING match_count >= ?
886
+ ORDER BY match_count DESC, modified_at DESC
887
+ LIMIT ?
888
+ `).all(...tokens.map(t => `%${t}%`), minMatch, limit) as { displayPath: string; title: string; match_count: number }[];
889
+
890
+ if (titleHits.length > 0) {
891
+ return titleHits.map(h => ({
892
+ displayPath: h.displayPath,
893
+ title: h.title,
894
+ score: h.match_count / tokens.length,
895
+ source: "title" as const,
896
+ }));
897
+ }
898
+ }
899
+
900
+ // 4. Vector search fallback (semantic similarity)
901
+ try {
902
+ const llm = getDefaultLlamaCpp();
903
+ if (llm) {
904
+ const vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit);
905
+ if (vecResults.length > 0) {
906
+ return vecResults.map(r => ({
907
+ displayPath: r.displayPath,
908
+ title: r.title,
909
+ score: r.score,
910
+ source: "vec" as const,
911
+ }));
912
+ }
913
+ }
914
+ } catch {
915
+ // Vector search unavailable — degrade gracefully
916
+ }
917
+
918
+ return [];
919
+ }
920
+
921
+ /**
922
+ * Select a single target from candidates, or return an ambiguity message.
923
+ * Stricter confidence requirement for destructive ops (forget).
924
+ */
925
+ function selectLifecycleTarget(
926
+ candidates: LifecycleCandidate[],
927
+ query: string,
928
+ destructive: boolean = false
929
+ ): { target: LifecycleCandidate } | { ambiguous: string } | { notFound: string } {
930
+ if (candidates.length === 0) {
931
+ return { notFound: `No matching memory found for "${query}"` };
932
+ }
933
+
934
+ const top = candidates[0]!;
935
+
936
+ // Clear winner: high score OR significant gap to #2
937
+ const gap = candidates.length > 1 ? top.score - candidates[1]!.score : 1.0;
938
+ const confident = top.score >= 0.7 || gap >= 0.2;
939
+
940
+ // For destructive ops (forget), require higher confidence
941
+ if (destructive && !confident) {
942
+ const list = candidates.slice(0, 3).map((c, i) =>
943
+ `${i + 1}. ${c.displayPath} — "${c.title}" (${c.source}, score: ${c.score.toFixed(2)})`
944
+ ).join("\n");
945
+ return { ambiguous: `Multiple possible matches. Please be more specific or use a path:\n${list}` };
946
+ }
947
+
948
+ // For non-destructive ops (pin/snooze), accept top hit if any candidates exist
949
+ if (!confident && candidates.length > 1) {
950
+ // Low confidence but not destructive — take top hit but warn
951
+ return { target: top };
952
+ }
953
+
954
+ return { target: top };
955
+ }
956
+
820
957
  // ---------------------------------------------------------------------------
821
958
  // Tool: memory_forget
822
959
  // ---------------------------------------------------------------------------
@@ -833,28 +970,32 @@ This is the recommended entry point for ALL memory queries.`,
833
970
  },
834
971
  },
835
972
  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}"` }] };
973
+ const s = getStore(vault);
974
+ const candidates = await findMemoryCandidates(s, query, 5);
975
+ const selection = selectLifecycleTarget(candidates, query, true); // destructive = true
976
+
977
+ if ("notFound" in selection) {
978
+ return { content: [{ type: "text", text: selection.notFound }] };
979
+ }
980
+ if ("ambiguous" in selection) {
981
+ return { content: [{ type: "text", text: selection.ambiguous }] };
840
982
  }
841
983
 
842
- const best = results[0]!;
984
+ const best = selection.target;
843
985
  const parts = best.displayPath.split("/");
844
986
  const collection = parts[0]!;
845
987
  const path = parts.slice(1).join("/");
846
988
 
847
989
  if (!confirm) {
848
990
  return {
849
- content: [{ type: "text", text: `Would forget: ${best.displayPath} — "${best.title}" (score ${Math.round(best.score * 100)}%)` }],
991
+ content: [{ type: "text", text: `Would forget: ${best.displayPath} — "${best.title}" (${best.source}, score ${Math.round(best.score * 100)}%)` }],
850
992
  structuredContent: { path: best.displayPath, title: best.title, score: best.score, action: "preview" },
851
993
  };
852
994
  }
853
995
 
854
- store.deactivateDocument(collection, path);
996
+ s.deactivateDocument(collection, path);
855
997
 
856
- // Log the deletion as audit trail
857
- store.insertUsage({
998
+ s.insertUsage({
858
999
  sessionId: "mcp-forget",
859
1000
  timestamp: new Date().toISOString(),
860
1001
  hookName: "memory_forget",
@@ -1976,21 +2117,27 @@ This is the recommended entry point for ALL memory queries.`,
1976
2117
  },
1977
2118
  },
1978
2119
  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 };
2120
+ const s = getStore(vault);
2121
+ const candidates = await findMemoryCandidates(s, query);
2122
+ const selection = selectLifecycleTarget(candidates, query);
2123
+
2124
+ if ("notFound" in selection) {
2125
+ return { content: [{ type: "text", text: selection.notFound }], isError: true };
2126
+ }
2127
+ if ("ambiguous" in selection) {
2128
+ return { content: [{ type: "text", text: selection.ambiguous }], isError: true };
1983
2129
  }
1984
- const r = results[0]!;
2130
+
2131
+ const r = selection.target;
1985
2132
  const parts = r.displayPath.split("/");
1986
2133
  const collection = parts[0]!;
1987
2134
  const path = parts.slice(1).join("/");
1988
- const doc = store.findActiveDocument(collection, path);
2135
+ const doc = s.findActiveDocument(collection, path);
1989
2136
  if (!doc) {
1990
2137
  return { content: [{ type: "text", text: "Document not found." }], isError: true };
1991
2138
  }
1992
- store.pinDocument(collection, path, !unpin);
1993
- store.insertUsage({
2139
+ s.pinDocument(collection, path, !unpin);
2140
+ s.insertUsage({
1994
2141
  sessionId: "mcp-pin",
1995
2142
  timestamp: new Date().toISOString(),
1996
2143
  hookName: "memory_pin",
@@ -2019,21 +2166,27 @@ This is the recommended entry point for ALL memory queries.`,
2019
2166
  },
2020
2167
  },
2021
2168
  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 };
2169
+ const s = getStore(vault);
2170
+ const candidates = await findMemoryCandidates(s, query);
2171
+ const selection = selectLifecycleTarget(candidates, query);
2172
+
2173
+ if ("notFound" in selection) {
2174
+ return { content: [{ type: "text", text: selection.notFound }], isError: true };
2026
2175
  }
2027
- const r = results[0]!;
2176
+ if ("ambiguous" in selection) {
2177
+ return { content: [{ type: "text", text: selection.ambiguous }], isError: true };
2178
+ }
2179
+
2180
+ const r = selection.target;
2028
2181
  const parts = r.displayPath.split("/");
2029
2182
  const collection = parts[0]!;
2030
2183
  const path = parts.slice(1).join("/");
2031
- const doc = store.findActiveDocument(collection, path);
2184
+ const doc = s.findActiveDocument(collection, path);
2032
2185
  if (!doc) {
2033
2186
  return { content: [{ type: "text", text: "Document not found." }], isError: true };
2034
2187
  }
2035
- store.snoozeDocument(collection, path, until || null);
2036
- store.insertUsage({
2188
+ s.snoozeDocument(collection, path, until || null);
2189
+ s.insertUsage({
2037
2190
  sessionId: "mcp-snooze",
2038
2191
  timestamp: new Date().toISOString(),
2039
2192
  hookName: "memory_snooze",