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.
- package/package.json +1 -1
- package/src/mcp.ts +179 -25
package/package.json
CHANGED
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
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
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 =
|
|
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
|
-
|
|
997
|
+
s.deactivateDocument(collection, path);
|
|
855
998
|
|
|
856
|
-
|
|
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
|
|
1980
|
-
const
|
|
1981
|
-
|
|
1982
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1993
|
-
|
|
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
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2036
|
-
|
|
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",
|