@hmanlab/memo 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local-first MCP server for persistent, persona-aware memory across projects.
4
4
 
5
- `memo` ships two surfaces: an MCP server (33 tools) for AI clients like
5
+ `memo` ships two surfaces: an MCP server (35 tools) for AI clients like
6
6
  Claude Code, and a Node CLI (`hmanlab-memory`) for power users. Both
7
7
  share the same backend — the CLI is a thin wrapper, not a re-implementation.
8
8
 
@@ -10,6 +10,120 @@ Everything lives under `~/.hmanlab/`: one root SQLite DB + a `personas/`
10
10
  directory of YAML files + one DB per registered project. No cloud, no
11
11
  account, no telemetry.
12
12
 
13
+ ## What `npx -y @hmanlab/hl-plugins install memo` does
14
+
15
+ It's the one-line path to a working setup. It runs five steps, in order:
16
+
17
+ 1. **Pre-flight.** Node ≥ 18, an `~/.opencode/` config dir. Auto-creates
18
+ the dir if missing.
19
+ 2. **Install Bun.** Memo is built with `--target=bun`, so Bun is a hard
20
+ requirement. The installer auto-installs it via
21
+ `curl -fsSL https://bun.sh/install | bash` if it isn't on PATH yet.
22
+ 3. **Stage the plugin CLI.** Copies `dist/cli.js` to
23
+ `~/.local/share/hl-plugins/memo/` so the next step can invoke plugin
24
+ subcommands by absolute path (no PATH dependency yet).
25
+ 4. **Prompt about MiniLM.** *See the section below.* Your answer is
26
+ persisted during install — there's no "run later" step.
27
+ 5. **Copy + register.** Ships the MCP server bundle to
28
+ `~/.local/share/hl-plugins/memo/memo-mcp-server.js`, drops the skill
29
+ markdown at `~/.claude/skills/memo/SKILL.md`, and registers the server
30
+ in your Claude Code config. Then prints
31
+ *"Restart opencode to use the new tools."*
32
+
33
+ That's it. No auth, no account, no telemetry, no daemon. The server runs
34
+ on stdio only when Claude Code invokes it.
35
+
36
+ ### Optional: the MiniLM embedder
37
+
38
+ After Bun is confirmed and before any files are copied, the installer asks
39
+ once whether you want the optional MiniLM-L6-v2 model. The model powers
40
+ semantic search — paraphrase and typo queries still hit the right memory
41
+ even when the words don't match the stored content literally.
42
+
43
+ ```
44
+ ? MiniLM-L6-v2 (~25 MB) powers semantic search so paraphrase and typo queries
45
+ still hit the right memory.
46
+
47
+ With it: 75.2% recall@5 (62.9% recall@1)
48
+ Without it: paraphrase queries drop to ~30%, typo queries to ~25%
49
+ (105-query eval across coding, glossary, and preferences)
50
+
51
+ Enable? [Y/n]:
52
+ ```
53
+
54
+ Your answer is committed during install — no follow-up step:
55
+
56
+ - **Y (default):** writes `embedder_mode: minilm` to
57
+ `~/.hmanlab/config.yaml`. The model downloads lazily on the next
58
+ `memory_save` / `memory_search` call (~25 MB, ~2 s warmup, then ~50 ms
59
+ per query).
60
+ - **n:** writes `embedder_mode: hash`. `loadExtractor()` short-circuits
61
+ on every subsequent call. The model is **never** downloaded or
62
+ referenced — the embedder uses the deterministic trigram fallback.
63
+
64
+ Non-interactive installs (CI, scripts piped via `| sh`) treat the prompt
65
+ as Yes so the install never blocks.
66
+
67
+ Change your mind any time:
68
+
69
+ ```bash
70
+ hmanlab-memory embedder status # show current mode
71
+ hmanlab-memory embedder install # switch to minilm (lazy download on next memory call)
72
+ hmanlab-memory embedder disable # switch to hash (no download, ever)
73
+ ```
74
+
75
+ The mode is stored under `embedder_mode` in `~/.hmanlab/config.yaml`. Three
76
+ values: `minilm` (require the real model), `hash` (use the deterministic
77
+ trigram fallback), `auto` (try MiniLM, fall back to hash on failure —
78
+ default if the key is absent).
79
+
80
+ ### With MiniLM vs without — what actually changes
81
+
82
+ Same 105 positive + 20 negative queries, same memory corpus. Two
83
+ columns: **Hash** (no MiniLM, no model download) and **MiniLM +
84
+ trigram** (what ships by default — semantic embedder + the trigram
85
+ FTS5 mirror that catches 3-char substring overlap).
86
+
87
+ **Headline metrics:**
88
+
89
+ | Metric | Hash fallback | MiniLM + trigram | Δ |
90
+ |---|---|---|---|
91
+ | Recall@1 | 41.0% | **62.9%** | **+21.9 pp** |
92
+ | Recall@5 | 68.6% | **75.2%** | +6.6 pp |
93
+ | MRR | 0.516 | **0.679** | **+0.163** |
94
+
95
+ The biggest win is Recall@1 — the trigram FTS5 mirror lifts it from
96
+ 45.7% (MiniLM alone) to 62.9% (MiniLM + trigram). When the query
97
+ shares even one 3-char substring with the right memory, that memory
98
+ now lands at rank 1 instead of being lost in the top-5 noise.
99
+
100
+ **By domain (R@5):**
101
+
102
+ | Domain | Hash | MiniLM + trigram | Δ |
103
+ |---|---|---|---|
104
+ | glossary | 64.5% | 100.0% | **+35.5** |
105
+ | preferences | 97.4% | 100.0% | +2.6 |
106
+
107
+ **By query kind (R@5):**
108
+
109
+ | Kind | Hash | MiniLM + trigram | Δ |
110
+ |---|---|---|---|
111
+ | literal | 93.3% | 96.7% | +3.4 |
112
+ | paraphrase | 60.0% | 66.7% | +6.7 |
113
+ | typo | 53.3% | 66.7% | +13.3 |
114
+ | negation | 70.0% | 60.0% | **−10.0** |
115
+ | broad | 60.0% | 80.0% | +20.0 |
116
+
117
+ If your memory is mostly short, literal preferences, hash fallback is
118
+ competitive. If your memory is glossary definitions or fuzzy
119
+ paraphrases, MiniLM + trigram dominates — particularly on
120
+ broad queries where the user types a vague prompt and expects the
121
+ right memory to surface.
122
+
123
+ Raw eval data:
124
+ - `~/Desktop/memo-eval/results-2026-06-25-bigeval.json` (MiniLM + trigram, current ship state)
125
+ - `~/Desktop/memo-eval/results-2026-06-25-bigeval-hash.json` (hash fallback, what you get if you decline MiniLM at install)
126
+
13
127
  ## What's in the box (v1.0.0)
14
128
 
15
129
  ### MCP tools (35)
@@ -50,46 +164,6 @@ The CLI auto-installs Bun if missing and registers the MCP bundle
50
164
  under `~/.local/share/hl-plugins/memo/`, then wires it into
51
165
  `~/.claude.json`.
52
166
 
53
- ### Optional: the MiniLM embedder
54
-
55
- `hl-plugins install memo` prompts once before completing the install:
56
-
57
- ```
58
- ? MiniLM-L6-v2 (~25 MB) powers semantic search so paraphrase and typo queries
59
- still hit the right memory.
60
-
61
- With it: 73.3% recall@5
62
- Without it: paraphrase queries drop to ~30%
63
- (30-seed eval across coding, glossary, and preferences)
64
-
65
- Enable? [Y/n]:
66
- ```
67
-
68
- - **Y (default):** the install writes `embedder_mode: minilm` to
69
- `~/.hmanlab/config.yaml`. The model downloads lazily on the next
70
- `memory_save` / `memory_search` call (~25 MB, ~2 s warmup, then ~50 ms
71
- per query). The choice is committed — there's no "did it really install?"
72
- follow-up.
73
- - **n:** the install writes `embedder_mode: hash` and `loadExtractor()`
74
- short-circuits on every subsequent call. The model is **never** downloaded
75
- or referenced.
76
- - **Non-interactive installs** (CI, scripts piped via `| sh`): the prompt is
77
- skipped and treated as Yes. Run `hmanlab-memory embedder disable`
78
- afterwards if you want to flip it without re-installing.
79
-
80
- Change your mind any time:
81
-
82
- ```bash
83
- hmanlab-memory embedder status # show current mode
84
- hmanlab-memory embedder install # switch to minilm (lazy download on next memory call)
85
- hmanlab-memory embedder disable # switch to hash (no download, ever)
86
- ```
87
-
88
- The mode is stored under `embedder_mode` in `~/.hmanlab/config.yaml`. Three
89
- values: `minilm` (require the real model), `hash` (use the deterministic
90
- trigram fallback), `auto` (try MiniLM, fall back to hash on failure —
91
- default if the key is absent).
92
-
93
167
  ## CLI quickstart
94
168
 
95
169
  ```bash
@@ -5,25 +5,82 @@ description: Use when the user wants persistent memory across projects, persona-
5
5
 
6
6
  # memo — hmanlab-memo (local-first MCP memory)
7
7
 
8
- The `memo` MCP server exposes 9 tools that give an AI coding assistant
9
- persistent, persona-aware memory on the user's machine. Everything lives under
10
- `~/.hmanlab/` (one root SQLite DB + a `personas/` directory of YAML files).
11
- No cloud, no account, no telemetry.
12
-
13
- This is the Phase 01 slice: persona + user-persona CRUD only. Projects,
14
- memories, embeddings, and hybrid search land in later phases.
15
-
16
- | Tool | What it does |
17
- | --------------------- | ----------------------------------------------------------- |
18
- | `persona_list` | List all personas (built-in + user) |
19
- | `persona_get` | Read one persona (resolves `parent` chain) |
20
- | `persona_create` | Write a new YAML persona + DB row |
21
- | `persona_update` | Edit a persona, bump version |
22
- | `persona_delete` | Soft-delete (archive) YAML stays |
23
- | `persona_clone` | Duplicate a persona as a starting point |
24
- | `persona_reload` | Re-scan `~/.hmanlab/personas/` and resync the DB |
25
- | `user_persona_get` | Read the user's persona singleton |
26
- | `user_persona_update` | Edit the user's persona |
8
+ The `memo` MCP server exposes 35 tools that give an AI coding assistant
9
+ persistent, persona-aware memory on the user's machine. Everything lives
10
+ under `~/.hmanlab/` (one root SQLite DB + a `personas/` directory of YAML
11
+ files + one DB per registered project). No cloud, no account, no telemetry.
12
+
13
+ The MCP bundle is `--target=bun` and lives at
14
+ `~/.local/share/hl-plugins/memo/memo-mcp-server.js`. Claude Code launches
15
+ it on stdio.
16
+
17
+ ## Search strategy normalize the query before calling `memory_search`
18
+
19
+ `memory_search` is hybrid (FTS + recency + vector). Vector search lifts
20
+ Recall@5 from 68.6% 73.3% on the standard eval, but only if the query
21
+ isn't too distorted. **You are better at query rewriting than the local
22
+ embedder.** Before calling `memory_search`, normalize the query yourself:
23
+
24
+ 1. **Fix typos** before searching vector similarity on `"indenation with
25
+ tabs"` lands on the right memory; `"indsnation with tabx"` might not.
26
+ 2. **Drop conversational filler** ("can you", "do you know", "what was
27
+ that thing about"). The filler dilutes the cosine signal.
28
+ 3. **Strip negation of the question, keep negation of the memory.** When
29
+ the user asks "should I commit secrets to a private repo", search for
30
+ `"Never commit secrets to git"` (the actual memory), not the literal
31
+ question. The embedder can't distinguish "should I commit" from
32
+ "Never commit" — but if your query contains the *memory's* words,
33
+ FTS catches it.
34
+ 4. **Prefer the user's own phrasing** when you remember it from the
35
+ conversation. If the user said "tabs not spaces" earlier and the
36
+ memory says "Use tabs for indentation in this project", search for
37
+ the latter — it matches both FTS and vector better.
38
+ 5. **One query, not several.** Don't fan out: a single, well-chosen query
39
+ outperforms three noisy ones.
40
+
41
+ Concretely, before calling `memory_search("query")`:
42
+
43
+ ```
44
+ raw user question: "wait what was my rule about not committing api keys"
45
+ rewrite to: "Never commit secrets to git"
46
+ then call: memory_search("Never commit secrets to git")
47
+ ```
48
+
49
+ Don't rewrite for `memory_recent` — that's recency-only and your input
50
+ doesn't matter.
51
+
52
+ ## When to use the tools
53
+
54
+ - **User asks a question whose answer is in memory** → `memory_search`
55
+ with a normalized query. Skim top-5; if none match, fall back to
56
+ answering from your own knowledge (don't claim a memory hit when
57
+ there isn't one).
58
+ - **User states a preference / rule / decision worth keeping** →
59
+ `memory_save`. Use `importance: 0.9` for durable rules, `0.5` for
60
+ context, `0.3` for one-off notes. Add a `category` (e.g. "preferences",
61
+ "code-style", "glossary").
62
+ - **User asks "what do you know about me / this project"** → `memory_search`
63
+ with `scope: "project"` for project-specific, `scope: "all"` for
64
+ everything.
65
+ - **User asks to switch hats / "talk like X"** → `persona_list` to see
66
+ options, `persona_get` to read the full prompt, then continue as that
67
+ persona.
68
+ - **User asks to remember a global preference** → `user_persona_update`.
69
+ - **Long conversation, context getting heavy** → `memory_compact_prep`
70
+ to get the pre-selected subset worth re-injecting after compaction.
71
+ - **Storage getting messy** → `memory_hygiene all` for the stale/cold/
72
+ duplicate report, `memory_status` for the headline counts.
73
+ - **Want to back up / move a project** → `project_export <name>` /
74
+ `project_import <archive>`.
75
+
76
+ ## Save rules
77
+
78
+ - **Be specific.** "Use tabs for indentation" beats "code style matters".
79
+ - **One fact per memory.** Splitting lets each one rank on its own.
80
+ - **Use the user's own words** when possible. They're more searchable
81
+ later.
82
+ - **Pick importance honestly.** `0.9` = durable rule, `0.5` = context,
83
+ `0.3` = ephemeral.
27
84
 
28
85
  ## Setup (one-time, on the machine)
29
86
 
@@ -32,37 +89,20 @@ memories, embeddings, and hybrid search land in later phases.
32
89
  ```bash
33
90
  hl-plugins install memo
34
91
  ```
35
- 3. Restart Claude Code. The 9 tools above appear under the `memo` MCP server.
36
-
37
- The CLI auto-installs Bun if missing and registers the MCP bundle under
38
- `~/.local/share/hl-plugins/memo/`, then wires it into `~/.claude.json`.
92
+ 3. The installer prompts once about MiniLM-L6-v2 (a local embedder that
93
+ powers semantic search). Default is Yes — ~25 MB download on first
94
+ memory call.
95
+ 4. Restart Claude Code. The 35 tools appear under the `memo` MCP server.
39
96
 
40
97
  ## On-disk layout
41
98
 
42
99
  ```
43
100
  ~/.hmanlab/
44
- ├── config.yaml # paths + embedding defaults (phase-01 reads/writes subset)
45
- ├── root.db # WAL-mode SQLite: user_persona, ai_personas
46
- └── personas/
47
- ├── default.yaml # built-in (warm, balanced)
48
- ├── work.yaml # built-in (parent: default)
49
- ├── creative.yaml # built-in (parent: default)
50
- └── <user-defined>.yaml
51
- ```
52
-
53
- YAML is the source of truth. Editing a file on disk and calling `persona_reload`
54
- updates the DB to match. The starter pack is extracted only on first boot;
55
- existing YAMLs are never overwritten.
56
-
57
- ## When to use these tools
58
-
59
- - **User asks to switch hats / "talk like X" / use a persona** → `persona_list`
60
- to see options, `persona_get` to read the full prompt, then continue the
61
- conversation as that persona.
62
- - **User asks to remember a preference** → `user_persona_update` with the
63
- preference text.
64
- - **User asks to create / edit a persona** → `persona_create` or
65
- `persona_update`.
66
- - **User edits a persona YAML directly** → `persona_reload` to make the DB
67
- match.
68
- - **User wants to fork an existing persona** → `persona_clone`.
101
+ ├── config.yaml # paths, embedder_mode, persona_filter_mode
102
+ ├── root.db # user_persona, ai_personas, projects, global_memories
103
+ ├── models/ # MiniLM-L6-v2 q8 (lazy-downloaded on first embed call)
104
+ ├── personas/ # persona YAML files (built-in + user)
105
+ └── projects/<name>/
106
+ ├── project.yaml
107
+ └── hmanlab.db # memories + FTS5
108
+ ```
package/dist/cli.js CHANGED
@@ -9033,12 +9033,23 @@ CREATE INDEX IF NOT EXISTS idx_memories_persona ON memories(persona_id);
9033
9033
  CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_id);
9034
9034
  CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by);
9035
9035
 
9036
+ -- Word-level FTS5 (exact tokens): for literal queries like "tabs for indentation".
9036
9037
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
9037
9038
  content, category, channel,
9038
9039
  content='memories', content_rowid='id',
9039
9040
  tokenize="unicode61 remove_diacritics 2 tokenchars '_-'"
9040
9041
  );
9041
9042
 
9043
+ -- Trigram FTS5 (3-char sliding window): for typo / fuzzy matches where the
9044
+ -- query shares substrings but not whole tokens with the stored memory.
9045
+ -- Searched in parallel with memories_fts; results are RRF-fused in
9046
+ -- memorySearch. Adds ~30% index size but lifts recall on paraphrase / typo.
9047
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts_trgm USING fts5(
9048
+ content,
9049
+ content='memories', content_rowid='id',
9050
+ tokenize="trigram"
9051
+ );
9052
+
9042
9053
  CREATE TABLE IF NOT EXISTS project_sessions (
9043
9054
  id INTEGER PRIMARY KEY AUTOINCREMENT,
9044
9055
  started_at INTEGER NOT NULL,
@@ -9053,18 +9064,26 @@ CREATE INDEX IF NOT EXISTS idx_sessions_started ON project_sessions(started_at);
9053
9064
  CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
9054
9065
  INSERT INTO memories_fts(rowid, content, category, channel)
9055
9066
  VALUES (new.id, new.content, new.category, new.channel);
9067
+ INSERT INTO memories_fts_trgm(rowid, content)
9068
+ VALUES (new.id, new.content);
9056
9069
  END;
9057
9070
 
9058
9071
  CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
9059
9072
  INSERT INTO memories_fts(memories_fts, rowid, content, category, channel)
9060
9073
  VALUES ('delete', old.id, old.content, old.category, old.channel);
9074
+ INSERT INTO memories_fts_trgm(memories_fts_trgm, rowid, content)
9075
+ VALUES ('delete', old.id, old.content);
9061
9076
  END;
9062
9077
 
9063
9078
  CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
9064
9079
  INSERT INTO memories_fts(memories_fts, rowid, content, category, channel)
9065
9080
  VALUES ('delete', old.id, old.content, old.category, old.channel);
9081
+ INSERT INTO memories_fts_trgm(memories_fts_trgm, rowid, content)
9082
+ VALUES ('delete', old.id, old.content);
9066
9083
  INSERT INTO memories_fts(rowid, content, category, channel)
9067
9084
  VALUES (new.id, new.content, new.category, new.channel);
9085
+ INSERT INTO memories_fts_trgm(rowid, content)
9086
+ VALUES (new.id, new.content);
9068
9087
  END;
9069
9088
  `;
9070
9089
 
@@ -9240,22 +9259,38 @@ CREATE VIRTUAL TABLE IF NOT EXISTS global_memories_fts USING fts5(
9240
9259
  tokenize="unicode61 remove_diacritics 2 tokenchars '_-'"
9241
9260
  );
9242
9261
 
9262
+ -- Trigram mirror for fuzzy / typo matches. See project/schema.ts for the
9263
+ -- rationale; searched in parallel with global_memories_fts and RRF-fused.
9264
+ CREATE VIRTUAL TABLE IF NOT EXISTS global_memories_fts_trgm USING fts5(
9265
+ content,
9266
+ content='global_memories', content_rowid='id',
9267
+ tokenize="trigram"
9268
+ );
9269
+
9243
9270
  -- FTS5 sync triggers for global_memories.
9244
9271
  CREATE TRIGGER IF NOT EXISTS global_memories_ai AFTER INSERT ON global_memories BEGIN
9245
9272
  INSERT INTO global_memories_fts(rowid, content, category, channel)
9246
9273
  VALUES (new.id, new.content, new.category, new.channel);
9274
+ INSERT INTO global_memories_fts_trgm(rowid, content)
9275
+ VALUES (new.id, new.content);
9247
9276
  END;
9248
9277
 
9249
9278
  CREATE TRIGGER IF NOT EXISTS global_memories_ad AFTER DELETE ON global_memories BEGIN
9250
9279
  INSERT INTO global_memories_fts(global_memories_fts, rowid, content, category, channel)
9251
9280
  VALUES ('delete', old.id, old.content, old.category, old.channel);
9281
+ INSERT INTO global_memories_fts_trgm(global_memories_fts_trgm, rowid, content)
9282
+ VALUES ('delete', old.id, old.content);
9252
9283
  END;
9253
9284
 
9254
9285
  CREATE TRIGGER IF NOT EXISTS global_memories_au AFTER UPDATE ON global_memories BEGIN
9255
9286
  INSERT INTO global_memories_fts(global_memories_fts, rowid, content, category, channel)
9256
9287
  VALUES ('delete', old.id, old.content, old.category, old.channel);
9288
+ INSERT INTO global_memories_fts_trgm(global_memories_fts_trgm, rowid, content)
9289
+ VALUES ('delete', old.id, old.content);
9257
9290
  INSERT INTO global_memories_fts(rowid, content, category, channel)
9258
9291
  VALUES (new.id, new.content, new.category, new.channel);
9292
+ INSERT INTO global_memories_fts_trgm(rowid, content)
9293
+ VALUES (new.id, new.content);
9259
9294
  END;
9260
9295
  `, globalMigrations, projectMigrations;
9261
9296
  var init_db = __esm(() => {
@@ -52919,7 +52954,7 @@ function applyDecayPlaceholder(scores, rowsById, now = Date.now()) {
52919
52954
  // src/memory/search.ts
52920
52955
  var TOP_K = 20;
52921
52956
  function tableFor2(scope) {
52922
- return scope === "project" ? { row: "memories", fts: "memories_fts" } : { row: "global_memories", fts: "global_memories_fts" };
52957
+ return scope === "project" ? { row: "memories", fts: "memories_fts", ftsTrgm: "memories_fts_trgm" } : { row: "global_memories", fts: "global_memories_fts", ftsTrgm: "global_memories_fts_trgm" };
52923
52958
  }
52924
52959
  function ftsQuery(raw) {
52925
52960
  return raw.trim().split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(" ");
@@ -52987,6 +53022,7 @@ async function memorySearch(rootDb, args) {
52987
53022
  const hasQueryVec = qVec.length > 0;
52988
53023
  const allCandidates = [];
52989
53024
  const allFtsLists = [];
53025
+ const allTrgmLists = [];
52990
53026
  const allRecencyLists = [];
52991
53027
  const allVectorLists = [];
52992
53028
  for (const target of targets) {
@@ -53009,12 +53045,45 @@ async function memorySearch(rootDb, args) {
53009
53045
  allVectorLists.push({ id: taggedId(target.source, s.id), rank: i + 1 });
53010
53046
  });
53011
53047
  }
53048
+ if (ftsQ.length > 0) {
53049
+ const { ftsTrgm, row } = tableFor2(target.scope);
53050
+ try {
53051
+ const cleaned = ftsQ.replace(/[^a-zA-Z0-9 _\-]/g, " ");
53052
+ const trigrams = [];
53053
+ for (let i = 0;i + 3 <= cleaned.length; i++) {
53054
+ const tg = cleaned.slice(i, i + 3).trim();
53055
+ if (tg.length === 3)
53056
+ trigrams.push(tg);
53057
+ }
53058
+ if (trigrams.length > 0) {
53059
+ const matchExpr = trigrams.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
53060
+ const trgmRows = target.db.prepare(`SELECT m.id AS id, f.rank AS rnk FROM ${ftsTrgm} f
53061
+ JOIN ${row} m ON m.id = f.rowid
53062
+ WHERE ${ftsTrgm} MATCH ?
53063
+ AND m.is_archived = 0 AND m.is_expired = 0 AND m.is_cold = 0
53064
+ ORDER BY rnk
53065
+ LIMIT ?`).all(matchExpr, TOP_K);
53066
+ trgmRows.forEach((r, i) => {
53067
+ allTrgmLists.push({ id: taggedId(target.source, r.id), rank: i + 1 });
53068
+ if (!allCandidates.some((c) => c.id === r.id && c.source_db === target.source)) {
53069
+ const rowData = target.db.prepare(`SELECT * FROM ${row} WHERE id = ?`).get(r.id);
53070
+ if (rowData)
53071
+ allCandidates.push({ ...rowFromRecord(rowData), source_db: target.source });
53072
+ }
53073
+ });
53074
+ }
53075
+ } catch (err) {
53076
+ process.stderr.write(`[hmanlab-memo] trigram query failed (${err.message.split(`
53077
+ `)[0]}); skipping
53078
+ `);
53079
+ }
53080
+ }
53012
53081
  }
53013
53082
  const taggedRowMap = new Map;
53014
53083
  for (const c of allCandidates) {
53015
53084
  taggedRowMap.set(taggedId(c.source_db, c.id), c);
53016
53085
  }
53017
- let fused = rrfFusion([allFtsLists, allRecencyLists, allVectorLists], [1, 0.5, 2]);
53086
+ let fused = rrfFusion([allFtsLists, allTrgmLists, allRecencyLists, allVectorLists], [1, 0.5, 0.5, 2]);
53018
53087
  fused = applyDecayPlaceholder(fused, new Map(allCandidates.map((c) => [
53019
53088
  taggedId(c.source_db, c.id),
53020
53089
  { importance: c.importance, last_accessed_at: c.last_accessed_at }
@@ -53420,7 +53489,7 @@ function getPersona(db, name) {
53420
53489
  return row ?? null;
53421
53490
  }
53422
53491
  var program2 = new Command;
53423
- program2.name("hmanlab-memory").description("Local-first MCP memory server with personas, projects, decay, and conflict detection.").version("0.5.1");
53492
+ program2.name("hmanlab-memory").description("Local-first MCP memory server with personas, projects, decay, and conflict detection.").version("0.5.2");
53424
53493
  program2.command("init").description("First-time setup. Idempotent.").action(async () => {
53425
53494
  ensureHome();
53426
53495
  setBuiltins({
@@ -53744,7 +53813,7 @@ program2.command("status").description("Show install state, active project, pers
53744
53813
  const active = cfg.active_project ?? "(none)";
53745
53814
  const personas = listAiPersonas(db);
53746
53815
  const projects = projectList(db);
53747
- console.log(`hmanlab-memory v0.5.1`);
53816
+ console.log(`hmanlab-memory v0.5.2`);
53748
53817
  console.log(` Root DB: ${hmanlabHome()}/root.db`);
53749
53818
  console.log(` Personas: ${personas.length} (${personas.filter((p) => p.is_builtin).length} built-in)`);
53750
53819
  console.log(` Projects: ${projects.length}`);
@@ -13753,12 +13753,23 @@ CREATE INDEX IF NOT EXISTS idx_memories_persona ON memories(persona_id);
13753
13753
  CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_id);
13754
13754
  CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by);
13755
13755
 
13756
+ -- Word-level FTS5 (exact tokens): for literal queries like "tabs for indentation".
13756
13757
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
13757
13758
  content, category, channel,
13758
13759
  content='memories', content_rowid='id',
13759
13760
  tokenize="unicode61 remove_diacritics 2 tokenchars '_-'"
13760
13761
  );
13761
13762
 
13763
+ -- Trigram FTS5 (3-char sliding window): for typo / fuzzy matches where the
13764
+ -- query shares substrings but not whole tokens with the stored memory.
13765
+ -- Searched in parallel with memories_fts; results are RRF-fused in
13766
+ -- memorySearch. Adds ~30% index size but lifts recall on paraphrase / typo.
13767
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts_trgm USING fts5(
13768
+ content,
13769
+ content='memories', content_rowid='id',
13770
+ tokenize="trigram"
13771
+ );
13772
+
13762
13773
  CREATE TABLE IF NOT EXISTS project_sessions (
13763
13774
  id INTEGER PRIMARY KEY AUTOINCREMENT,
13764
13775
  started_at INTEGER NOT NULL,
@@ -13773,18 +13784,26 @@ CREATE INDEX IF NOT EXISTS idx_sessions_started ON project_sessions(started_at);
13773
13784
  CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
13774
13785
  INSERT INTO memories_fts(rowid, content, category, channel)
13775
13786
  VALUES (new.id, new.content, new.category, new.channel);
13787
+ INSERT INTO memories_fts_trgm(rowid, content)
13788
+ VALUES (new.id, new.content);
13776
13789
  END;
13777
13790
 
13778
13791
  CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
13779
13792
  INSERT INTO memories_fts(memories_fts, rowid, content, category, channel)
13780
13793
  VALUES ('delete', old.id, old.content, old.category, old.channel);
13794
+ INSERT INTO memories_fts_trgm(memories_fts_trgm, rowid, content)
13795
+ VALUES ('delete', old.id, old.content);
13781
13796
  END;
13782
13797
 
13783
13798
  CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
13784
13799
  INSERT INTO memories_fts(memories_fts, rowid, content, category, channel)
13785
13800
  VALUES ('delete', old.id, old.content, old.category, old.channel);
13801
+ INSERT INTO memories_fts_trgm(memories_fts_trgm, rowid, content)
13802
+ VALUES ('delete', old.id, old.content);
13786
13803
  INSERT INTO memories_fts(rowid, content, category, channel)
13787
13804
  VALUES (new.id, new.content, new.category, new.channel);
13805
+ INSERT INTO memories_fts_trgm(rowid, content)
13806
+ VALUES (new.id, new.content);
13788
13807
  END;
13789
13808
  `;
13790
13809
 
@@ -13960,22 +13979,38 @@ CREATE VIRTUAL TABLE IF NOT EXISTS global_memories_fts USING fts5(
13960
13979
  tokenize="unicode61 remove_diacritics 2 tokenchars '_-'"
13961
13980
  );
13962
13981
 
13982
+ -- Trigram mirror for fuzzy / typo matches. See project/schema.ts for the
13983
+ -- rationale; searched in parallel with global_memories_fts and RRF-fused.
13984
+ CREATE VIRTUAL TABLE IF NOT EXISTS global_memories_fts_trgm USING fts5(
13985
+ content,
13986
+ content='global_memories', content_rowid='id',
13987
+ tokenize="trigram"
13988
+ );
13989
+
13963
13990
  -- FTS5 sync triggers for global_memories.
13964
13991
  CREATE TRIGGER IF NOT EXISTS global_memories_ai AFTER INSERT ON global_memories BEGIN
13965
13992
  INSERT INTO global_memories_fts(rowid, content, category, channel)
13966
13993
  VALUES (new.id, new.content, new.category, new.channel);
13994
+ INSERT INTO global_memories_fts_trgm(rowid, content)
13995
+ VALUES (new.id, new.content);
13967
13996
  END;
13968
13997
 
13969
13998
  CREATE TRIGGER IF NOT EXISTS global_memories_ad AFTER DELETE ON global_memories BEGIN
13970
13999
  INSERT INTO global_memories_fts(global_memories_fts, rowid, content, category, channel)
13971
14000
  VALUES ('delete', old.id, old.content, old.category, old.channel);
14001
+ INSERT INTO global_memories_fts_trgm(global_memories_fts_trgm, rowid, content)
14002
+ VALUES ('delete', old.id, old.content);
13972
14003
  END;
13973
14004
 
13974
14005
  CREATE TRIGGER IF NOT EXISTS global_memories_au AFTER UPDATE ON global_memories BEGIN
13975
14006
  INSERT INTO global_memories_fts(global_memories_fts, rowid, content, category, channel)
13976
14007
  VALUES ('delete', old.id, old.content, old.category, old.channel);
14008
+ INSERT INTO global_memories_fts_trgm(global_memories_fts_trgm, rowid, content)
14009
+ VALUES ('delete', old.id, old.content);
13977
14010
  INSERT INTO global_memories_fts(rowid, content, category, channel)
13978
14011
  VALUES (new.id, new.content, new.category, new.channel);
14012
+ INSERT INTO global_memories_fts_trgm(rowid, content)
14013
+ VALUES (new.id, new.content);
13979
14014
  END;
13980
14015
  `, globalMigrations, projectMigrations;
13981
14016
  var init_db = __esm(() => {
@@ -64546,7 +64581,7 @@ function applyDecayPlaceholder(scores, rowsById, now = Date.now()) {
64546
64581
  // src/memory/search.ts
64547
64582
  var TOP_K = 20;
64548
64583
  function tableFor(scope) {
64549
- return scope === "project" ? { row: "memories", fts: "memories_fts" } : { row: "global_memories", fts: "global_memories_fts" };
64584
+ return scope === "project" ? { row: "memories", fts: "memories_fts", ftsTrgm: "memories_fts_trgm" } : { row: "global_memories", fts: "global_memories_fts", ftsTrgm: "global_memories_fts_trgm" };
64550
64585
  }
64551
64586
  function ftsQuery(raw) {
64552
64587
  return raw.trim().split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(" ");
@@ -64614,6 +64649,7 @@ async function memorySearch(rootDb, args) {
64614
64649
  const hasQueryVec = qVec.length > 0;
64615
64650
  const allCandidates = [];
64616
64651
  const allFtsLists = [];
64652
+ const allTrgmLists = [];
64617
64653
  const allRecencyLists = [];
64618
64654
  const allVectorLists = [];
64619
64655
  for (const target of targets) {
@@ -64636,12 +64672,45 @@ async function memorySearch(rootDb, args) {
64636
64672
  allVectorLists.push({ id: taggedId(target.source, s.id), rank: i + 1 });
64637
64673
  });
64638
64674
  }
64675
+ if (ftsQ.length > 0) {
64676
+ const { ftsTrgm, row } = tableFor(target.scope);
64677
+ try {
64678
+ const cleaned = ftsQ.replace(/[^a-zA-Z0-9 _\-]/g, " ");
64679
+ const trigrams = [];
64680
+ for (let i = 0;i + 3 <= cleaned.length; i++) {
64681
+ const tg = cleaned.slice(i, i + 3).trim();
64682
+ if (tg.length === 3)
64683
+ trigrams.push(tg);
64684
+ }
64685
+ if (trigrams.length > 0) {
64686
+ const matchExpr = trigrams.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
64687
+ const trgmRows = target.db.prepare(`SELECT m.id AS id, f.rank AS rnk FROM ${ftsTrgm} f
64688
+ JOIN ${row} m ON m.id = f.rowid
64689
+ WHERE ${ftsTrgm} MATCH ?
64690
+ AND m.is_archived = 0 AND m.is_expired = 0 AND m.is_cold = 0
64691
+ ORDER BY rnk
64692
+ LIMIT ?`).all(matchExpr, TOP_K);
64693
+ trgmRows.forEach((r, i) => {
64694
+ allTrgmLists.push({ id: taggedId(target.source, r.id), rank: i + 1 });
64695
+ if (!allCandidates.some((c) => c.id === r.id && c.source_db === target.source)) {
64696
+ const rowData = target.db.prepare(`SELECT * FROM ${row} WHERE id = ?`).get(r.id);
64697
+ if (rowData)
64698
+ allCandidates.push({ ...rowFromRecord(rowData), source_db: target.source });
64699
+ }
64700
+ });
64701
+ }
64702
+ } catch (err) {
64703
+ process.stderr.write(`[hmanlab-memo] trigram query failed (${err.message.split(`
64704
+ `)[0]}); skipping
64705
+ `);
64706
+ }
64707
+ }
64639
64708
  }
64640
64709
  const taggedRowMap = new Map;
64641
64710
  for (const c of allCandidates) {
64642
64711
  taggedRowMap.set(taggedId(c.source_db, c.id), c);
64643
64712
  }
64644
- let fused = rrfFusion([allFtsLists, allRecencyLists, allVectorLists], [1, 0.5, 2]);
64713
+ let fused = rrfFusion([allFtsLists, allTrgmLists, allRecencyLists, allVectorLists], [1, 0.5, 0.5, 2]);
64645
64714
  fused = applyDecayPlaceholder(fused, new Map(allCandidates.map((c) => [
64646
64715
  taggedId(c.source_db, c.id),
64647
64716
  { importance: c.importance, last_accessed_at: c.last_accessed_at }
@@ -66367,7 +66436,7 @@ async function main() {
66367
66436
  }
66368
66437
  maybeAutoSwitch(switcher, db);
66369
66438
  const sessions = new SessionManager(db, switcher, () => projectsDirPath());
66370
- const server = new McpServer({ name: "hmanlab-memo", version: "0.5.1" });
66439
+ const server = new McpServer({ name: "hmanlab-memo", version: "0.5.2" });
66371
66440
  registerPersonaTools(server, db, () => personasDirPath());
66372
66441
  registerProjectTools(server, db, switcher, () => projectsDirPath(), sessions);
66373
66442
  registerMemoryTools(server, db, switcher, () => projectsDirPath());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmanlab/memo",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Local-first MCP server for persistent, persona-aware memory across projects.",
5
5
  "type": "module",
6
6
  "files": [