@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 +115 -41
- package/claude/skill/memo/SKILL.md +88 -48
- package/dist/cli.js +73 -4
- package/dist/memo-mcp-server.js +72 -3
- package/package.json +1 -1
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 (
|
|
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
|
-
persistent, persona-aware memory on the user's machine. Everything lives
|
|
10
|
-
`~/.hmanlab/` (one root SQLite DB + a `personas/` directory of YAML
|
|
11
|
-
No cloud, no account, no telemetry.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
45
|
-
├── root.db #
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
├──
|
|
50
|
-
└──
|
|
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.
|
|
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.
|
|
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}`);
|
package/dist/memo-mcp-server.js
CHANGED
|
@@ -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.
|
|
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());
|