@letta-ai/memfs-search 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Letta, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MOD.md ADDED
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: "@letta-ai/memfs-search"
3
+ description: "Agent-callable MemFS memory search with built-in keyword search and optional QMD semantic/hybrid search."
4
+ ---
5
+
6
+ # MemFS search mod semantics
7
+
8
+ ## When to use
9
+
10
+ Use `memfs_search` before creating new memory, when the user asks what the agent already knows about something, or when exact remembered context may be stored in markdown memory files.
11
+
12
+ ## Tool
13
+
14
+ This package registers one tool:
15
+
16
+ - `memfs_search` - searches this agent's MemFS memory files.
17
+
18
+ ## Actions
19
+
20
+ - `search` (default) - search memory files.
21
+ - `status` - inspect memory path and QMD availability.
22
+
23
+ ## Search modes
24
+
25
+ - `keyword` - built-in markdown keyword search, no dependencies.
26
+ - `semantic` - QMD vector search, requires `qmd` setup.
27
+ - `hybrid` - QMD lexical + vector structured search, requires `qmd` setup.
28
+
29
+ If semantic or hybrid search fails because QMD is unavailable, retry with `mode: "keyword"`.
30
+
31
+ ## Important behavior
32
+
33
+ - The tool reads local markdown memory files from the current agent's MemFS projection.
34
+ - The tool first checks `MEMORY_DIR`, then common Letta local memory paths for `ctx.agent.id`.
35
+ - Keyword search skips large files over 1 MB and searches `.md` files only.
36
+ - QMD modes use collection name `memory` and `--no-rerank` to avoid surprise reranker/model downloads.
37
+ - The tool is read-only and marked `parallelSafe: true`.
38
+
39
+ ## Adaptation notes for agents
40
+
41
+ - Use `action: "status"` when memory path or QMD availability is unclear.
42
+ - Prefer `files_only: true` when you only need paths.
43
+ - Use `full: true` sparingly because it can return larger memory excerpts.
44
+ - Do not use this tool as a substitute for updating durable memory when a new stable preference or project fact should be recorded.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # MemFS Search
2
+
3
+ A Letta Code mod package that adds an agent-callable `memfs_search` tool for searching the current agent's MemFS memory files.
4
+
5
+ The tool includes built-in keyword search over markdown memory files and optional QMD-backed semantic/hybrid search when `qmd` is installed and indexed.
6
+
7
+ Original source: <https://tangled.org/cameron.stream/memfs-search>
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ letta install npm:@letta-ai/memfs-search
13
+ ```
14
+
15
+ Run `/reload` in active sessions after installing.
16
+
17
+ ## Tool
18
+
19
+ - `memfs_search` searches this agent's local MemFS projection.
20
+
21
+ Actions:
22
+
23
+ - `search` (default) searches memory files.
24
+ - `status` reports memory path and QMD availability.
25
+
26
+ Search modes:
27
+
28
+ - `keyword` - built in, no dependencies.
29
+ - `semantic` - uses `qmd` structured vector search.
30
+ - `hybrid` - uses `qmd` structured lexical + vector search.
31
+
32
+ Example tool call:
33
+
34
+ ```json
35
+ {
36
+ "action": "search",
37
+ "query": "commit footer preferences",
38
+ "mode": "keyword",
39
+ "limit": 5
40
+ }
41
+ ```
42
+
43
+ Status check:
44
+
45
+ ```json
46
+ { "action": "status" }
47
+ ```
48
+
49
+ ## QMD setup
50
+
51
+ `semantic` and `hybrid` modes require `qmd` to be installed and indexed. Install `@tobilu/qmd`, create a collection named `memory` over `$MEMORY_DIR`, and embed the memory markdown files.
52
+
53
+ If QMD is unavailable, use `mode: "keyword"`.
54
+
55
+ ## Memory path detection
56
+
57
+ The mod first uses `MEMORY_DIR` when present. If not present in the mod runtime, it falls back to common local Letta memory paths using the current agent id:
58
+
59
+ - `~/.letta/lc-local-backend/memfs/<agent-id>/memory`
60
+ - `~/.letta/agents/<agent-id>/memory`
61
+
62
+ ## Safety
63
+
64
+ This is trusted local code. It reads markdown memory files from the current agent's local MemFS projection and returns matching snippets to the model.
65
+
66
+ If a mod breaks startup or command handling, recover with:
67
+
68
+ ```bash
69
+ letta --no-mods
70
+ # or
71
+ LETTA_DISABLE_MODS=1 letta
72
+ ```
73
+
74
+ See [`MOD.md`](./MOD.md) for the agent-facing behavioral contract.
package/mods/index.mjs ADDED
@@ -0,0 +1,305 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const DEFAULT_LIMIT = 8;
9
+ const MAX_FILE_BYTES = 1_000_000;
10
+ const COLLECTION_NAME = "memory";
11
+ const QMD_TIMEOUT_MS = 60_000;
12
+
13
+ function candidateMemoryDirs(ctx) {
14
+ const candidates = [];
15
+ if (process.env.MEMORY_DIR) candidates.push(process.env.MEMORY_DIR);
16
+ const agentId = ctx?.agent?.id || process.env.AGENT_ID || "";
17
+ const home = process.env.HOME || "";
18
+ if (home && agentId) {
19
+ candidates.push(path.join(home, ".letta", "lc-local-backend", "memfs", agentId, "memory"));
20
+ candidates.push(path.join(home, ".letta", "agents", agentId, "memory"));
21
+ }
22
+ return candidates;
23
+ }
24
+
25
+ function memoryDir(ctx) {
26
+ for (const candidate of candidateMemoryDirs(ctx)) {
27
+ if (candidate && existsSync(candidate)) return candidate;
28
+ }
29
+ return candidateMemoryDirs(ctx)[0] || "";
30
+ }
31
+
32
+ function clampLimit(value) {
33
+ const n = Number.parseInt(String(value ?? DEFAULT_LIMIT), 10);
34
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
35
+ return Math.min(n, 50);
36
+ }
37
+
38
+ function qmdEnv(extraPath = "") {
39
+ const env = { ...process.env };
40
+ delete env.BUN_INSTALL;
41
+ if (extraPath) env.PATH = `${extraPath}:${env.PATH || ""}`;
42
+ return env;
43
+ }
44
+
45
+ async function commandExists(command) {
46
+ try {
47
+ await execFileAsync("sh", ["-lc", `command -v ${command}`], { env: qmdEnv(), timeout: 5_000 });
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ async function qmdBinDir() {
55
+ const { stdout } = await execFileAsync("sh", ["-lc", "command -v qmd"], { env: qmdEnv(), timeout: 5_000 });
56
+ return path.dirname(stdout.trim());
57
+ }
58
+
59
+ async function runQmd(args, cwd) {
60
+ // QMD can break when Bun's sqlite runtime is selected; unset BUN_INSTALL.
61
+ // Prefer the Node binary next to qmd so native sqlite modules match the qmd install.
62
+ const binDir = await qmdBinDir();
63
+ const { stdout, stderr } = await execFileAsync("qmd", args, {
64
+ cwd,
65
+ env: qmdEnv(binDir),
66
+ timeout: QMD_TIMEOUT_MS,
67
+ maxBuffer: 2 * 1024 * 1024,
68
+ });
69
+ return [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
70
+ }
71
+
72
+ async function qmdFilesArgs() {
73
+ try {
74
+ const { stdout } = await execFileAsync("qmd", ["query", "--help"], { env: qmdEnv(await qmdBinDir()), timeout: 5_000 });
75
+ return stdout.includes("--format") ? ["--format", "files"] : ["--files"];
76
+ } catch {
77
+ return ["--files"];
78
+ }
79
+ }
80
+
81
+ async function walkMarkdown(root, out = []) {
82
+ let entries;
83
+ try {
84
+ entries = await readdir(root, { withFileTypes: true });
85
+ } catch {
86
+ return out;
87
+ }
88
+ for (const entry of entries) {
89
+ const full = path.join(root, entry.name);
90
+ if (entry.isDirectory()) {
91
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
92
+ await walkMarkdown(full, out);
93
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
94
+ out.push(full);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function terms(query) {
101
+ return query.toLowerCase().split(/\s+/).map((s) => s.replace(/^\W+|\W+$/g, "")).filter(Boolean);
102
+ }
103
+
104
+ function makeSnippet(text, queryTerms, phrase = "") {
105
+ const lower = text.toLowerCase();
106
+ let index = phrase ? lower.indexOf(phrase) : -1;
107
+ if (index < 0) {
108
+ for (const term of queryTerms) {
109
+ index = lower.indexOf(term);
110
+ if (index >= 0) break;
111
+ }
112
+ }
113
+ if (index < 0) index = 0;
114
+ const start = Math.max(0, index - 180);
115
+ const end = Math.min(text.length, index + 420);
116
+ return text.slice(start, end).replace(/\s+/g, " ").trim();
117
+ }
118
+
119
+ function countOccurrences(haystack, needle) {
120
+ if (!needle) return 0;
121
+ let count = 0;
122
+ let pos = 0;
123
+ while (true) {
124
+ const next = haystack.indexOf(needle, pos);
125
+ if (next < 0) return count;
126
+ count += 1;
127
+ pos = next + Math.max(needle.length, 1);
128
+ }
129
+ }
130
+
131
+ function scoreText(text, rel, query, queryTerms) {
132
+ const lower = text.toLowerCase();
133
+ const relLower = rel.toLowerCase();
134
+ const phrase = query.toLowerCase().trim();
135
+ const uniqueTerms = [...new Set(queryTerms)];
136
+ const matchedTerms = uniqueTerms.filter((term) => lower.includes(term) || relLower.includes(term));
137
+
138
+ let score = 0;
139
+ // Exact phrase beats scattered term spam.
140
+ score += countOccurrences(lower, phrase) * 100;
141
+ score += countOccurrences(relLower, phrase) * 150;
142
+
143
+ // Prefer files that cover more of the query.
144
+ score += matchedTerms.length * 25;
145
+ if (matchedTerms.length === uniqueTerms.length) score += 75;
146
+
147
+ for (const term of uniqueTerms) {
148
+ const bodyCount = countOccurrences(lower, term);
149
+ const pathCount = countOccurrences(relLower, term);
150
+ // Cap repeated body matches so long noisy files do not dominate.
151
+ score += Math.min(bodyCount, 8) * 3;
152
+ score += pathCount * 20;
153
+ }
154
+
155
+ // Prefer core memory and short focused files when scores are close.
156
+ if (rel.startsWith("system/")) score += 20;
157
+ if (text.length < 10_000) score += 8;
158
+ if (text.length < 3_000) score += 8;
159
+
160
+ return { score, matchedTerms: matchedTerms.length, phrase };
161
+ }
162
+
163
+ async function keywordSearch(root, query, limit, filesOnly, full) {
164
+ const queryTerms = terms(query);
165
+ if (queryTerms.length === 0) return "No query terms.";
166
+
167
+ const files = await walkMarkdown(root);
168
+ const hits = [];
169
+ for (const file of files) {
170
+ let st;
171
+ try {
172
+ st = await stat(file);
173
+ } catch {
174
+ continue;
175
+ }
176
+ if (st.size > MAX_FILE_BYTES) continue;
177
+ let text;
178
+ try {
179
+ text = await readFile(file, "utf8");
180
+ } catch {
181
+ continue;
182
+ }
183
+ const rel = path.relative(root, file);
184
+ const scored = scoreText(text, rel, query, queryTerms);
185
+ if (scored.score > 0 && scored.matchedTerms > 0) {
186
+ hits.push({ file, rel, score: scored.score, text, phrase: scored.phrase });
187
+ }
188
+ }
189
+
190
+ hits.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
191
+ const top = hits.slice(0, limit);
192
+ if (top.length === 0) return "No matches.";
193
+ if (filesOnly) return top.map((h) => h.rel).join("\n");
194
+
195
+ return top.map((h, i) => {
196
+ const body = full ? h.text.trim().slice(0, 6000) : makeSnippet(h.text, queryTerms, h.phrase);
197
+ return [`## ${i + 1}. ${h.rel}`, `score: ${h.score}`, "", body].join("\n");
198
+ }).join("\n\n---\n\n");
199
+ }
200
+
201
+ async function search(args, ctx) {
202
+ const root = memoryDir(ctx);
203
+ if (!root || !existsSync(root)) {
204
+ return { status: "error", content: "MEMORY_DIR is not set or does not exist; memfs_search needs the agent memory filesystem path." };
205
+ }
206
+
207
+ const query = String(args.query || "").trim();
208
+ if (!query) return { status: "error", content: "query is required." };
209
+
210
+ const mode = String(args.mode || "keyword");
211
+ const limit = clampLimit(args.limit);
212
+ const filesOnly = Boolean(args.files_only);
213
+ const full = Boolean(args.full);
214
+
215
+ if (mode === "keyword") {
216
+ return await keywordSearch(root, query, limit, filesOnly, full);
217
+ }
218
+
219
+ if (!(await commandExists("qmd"))) {
220
+ return {
221
+ status: "error",
222
+ content: `Semantic/hybrid search requires QMD. Falling back is not automatic for mode=${mode}. Try mode=keyword, or install QMD with: npm install -g @tobilu/qmd`,
223
+ };
224
+ }
225
+
226
+ // QMD 2.5.x `vsearch` and plain `query` can trigger query-expansion or reranker
227
+ // model downloads on first use. Use structured query syntax plus --no-rerank to
228
+ // stay on the already-installed embedding/BM25 paths and avoid surprise 1GB+ pulls.
229
+ const qmdQuery = mode === "semantic" ? `vec: ${query}` : `lex: ${query}
230
+ vec: ${query}`;
231
+ const qmdArgs = ["query", qmdQuery, "-c", COLLECTION_NAME, "-n", String(limit), "--no-rerank"];
232
+ if (filesOnly) qmdArgs.push(...(await qmdFilesArgs()));
233
+ if (full) qmdArgs.push("--full");
234
+ try {
235
+ return await runQmd(qmdArgs, root);
236
+ } catch (error) {
237
+ return {
238
+ status: "error",
239
+ content: `QMD structured ${mode} search failed: ${error instanceof Error ? error.message : String(error)}
240
+ Try mode=keyword, or run the memfs-search skill setup/reindex flow.`,
241
+ };
242
+ }
243
+
244
+ }
245
+
246
+ async function status(ctx) {
247
+ const root = memoryDir(ctx);
248
+ const lines = [];
249
+ lines.push(`MEMORY_DIR: ${root || "not set"}`);
250
+ lines.push(`exists: ${root && existsSync(root) ? "yes" : "no"}`);
251
+ lines.push(`qmd: ${(await commandExists("qmd")) ? "available" : "not found"}`);
252
+ if (root && existsSync(root)) {
253
+ const count = (await walkMarkdown(root)).length;
254
+ lines.push(`markdown_files: ${count}`);
255
+ }
256
+ return lines.join("\n");
257
+ }
258
+
259
+ export default function activate(letta) {
260
+ if (!letta.capabilities.tools) return;
261
+
262
+ return letta.tools.register({
263
+ name: "memfs_search",
264
+ description: "Search this agent's MemFS memory files. Use before creating memory, when the user asks what you know about something, or when exact remembered context may be stored in memory. Supports built-in keyword search and optional QMD semantic/hybrid search when qmd is installed.",
265
+ parameters: {
266
+ type: "object",
267
+ properties: {
268
+ query: {
269
+ type: "string",
270
+ description: "Search query. Required unless action=status.",
271
+ },
272
+ mode: {
273
+ type: "string",
274
+ enum: ["keyword", "semantic", "hybrid"],
275
+ description: "Search mode. keyword is built in. semantic/hybrid use QMD structured queries with --no-rerank to avoid expansion/reranker model downloads.",
276
+ },
277
+ limit: {
278
+ type: "number",
279
+ description: "Maximum results, default 8, max 50.",
280
+ },
281
+ files_only: {
282
+ type: "boolean",
283
+ description: "Return only matching memory file paths. QMD modes auto-detect --files vs --format files.",
284
+ },
285
+ full: {
286
+ type: "boolean",
287
+ description: "Return fuller file contents/snippets instead of compact snippets.",
288
+ },
289
+ action: {
290
+ type: "string",
291
+ enum: ["search", "status"],
292
+ description: "Use status to check MEMORY_DIR/QMD availability. Defaults to search.",
293
+ },
294
+ },
295
+ additionalProperties: false,
296
+ },
297
+ requiresApproval: false,
298
+ parallelSafe: true,
299
+ async run(ctx) {
300
+ const action = String(ctx.args.action || "search");
301
+ if (action === "status") return await status(ctx);
302
+ return await search(ctx.args, ctx);
303
+ },
304
+ });
305
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@letta-ai/memfs-search",
3
+ "version": "0.1.0",
4
+ "description": "Letta Code mod package that adds an agent-callable MemFS memory search tool.",
5
+ "author": "just-cameron",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "keywords": [
9
+ "letta-package",
10
+ "letta-mod",
11
+ "letta-code",
12
+ "memfs",
13
+ "memory-search",
14
+ "qmd"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/letta-ai/mods.git",
19
+ "directory": "packages/memfs-search"
20
+ },
21
+ "files": [
22
+ "README.md",
23
+ "MOD.md",
24
+ "LICENSE",
25
+ "mods"
26
+ ],
27
+ "letta": {
28
+ "manifestVersion": 1,
29
+ "mods": [
30
+ "./mods/index.mjs"
31
+ ],
32
+ "capabilities": [
33
+ "tools"
34
+ ],
35
+ "engines": {
36
+ "lettaCodeCli": ">=0.27.14"
37
+ }
38
+ }
39
+ }