linksee-memory 0.2.0 → 0.4.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/README.md +39 -13
- package/dist/bin/import-sessions.js +10 -4
- package/dist/db/migrate.js +98 -0
- package/dist/db/schema.sql +6 -4
- package/dist/lib/normalize.d.ts +25 -0
- package/dist/lib/normalize.js +43 -0
- package/dist/lib/session-extractor.js +164 -38
- package/dist/mcp/elicitation.d.ts +24 -0
- package/dist/mcp/elicitation.js +58 -0
- package/dist/mcp/prompts.d.ts +21 -0
- package/dist/mcp/prompts.js +201 -0
- package/dist/mcp/resources.d.ts +18 -0
- package/dist/mcp/resources.js +162 -0
- package/dist/mcp/roots.d.ts +10 -0
- package/dist/mcp/roots.js +64 -0
- package/dist/mcp/sampling.d.ts +16 -0
- package/dist/mcp/sampling.js +78 -0
- package/dist/mcp/server.js +273 -70
- package/dist/skill/SKILL.md +274 -17
- package/package.json +84 -71
package/README.md
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
# linksee-memory
|
|
2
2
|
|
|
3
|
-
> Local-first agent memory MCP. A cross-agent brain for Claude Code, Cursor, and
|
|
3
|
+
> Local-first agent memory MCP. A cross-agent brain for **Claude Code, Cursor, OpenAI Codex, and Gemini CLI** — with a token-saving file diff cache that nobody else does. One SQLite file, all your LLMs read the same memory.
|
|
4
4
|
>
|
|
5
|
-
> **v0.
|
|
5
|
+
> **v0.3.0** ships the **Five Blocks**: Tools + Resources + Prompts + Sampling + Roots, plus the newer **Elicitation** primitive. Most public MCP servers expose only Tools; v0.3.0 moves linksee-memory into the differentiated tier. Backward compatible — all 8 v0.2.x tools keep their signatures. See [CHANGELOG.md](./CHANGELOG.md).
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/linksee-memory)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
[](https://registry.modelcontextprotocol.io/)
|
|
10
10
|
[](https://glama.ai/mcp/servers/michielinksee/linksee-memory)
|
|
11
11
|
|
|
12
|
-
🌐 **Landing page**: [linksee-site.vercel.app](https://linksee-site.vercel.app) (includes non-developer onboarding for Claude Desktop / Cursor / Claude Code)
|
|
12
|
+
🌐 **Landing page**: [linksee-site.vercel.app](https://linksee-site.vercel.app) (includes non-developer onboarding for Claude Desktop / Cursor / Claude Code / OpenAI Codex / Gemini CLI)
|
|
13
|
+
|
|
14
|
+
## 📣 As featured on
|
|
15
|
+
|
|
16
|
+
- **Zenn**: [あなたの Claude Code、 実は前回のセッションを完全に忘れている](https://zenn.dev/kanseilink/articles/linksee-memory-claude-code-recall-20260508) — 73 ♡ on Zenn, **165+ users on Hatena Bookmark**, picked up by tech blogs + YouTube shorts (May 2026)
|
|
17
|
+
- **Zenn**: [あなたの MCP server、 実は Tools しか使ってない (5 blocks 全実装 / v0.3.0)](https://zenn.dev/kanseilink/articles/linksee-memory-mcp-five-blocks-20260507) — the 1% of MCP servers that implement all 5 blocks
|
|
18
|
+
- **Zenn**: [あなたの Claude memory、 実は Claude にしか残らない (5 LLM 横断する方法)](https://zenn.dev/kanseilink/articles/linksee-memory-claude-cross-llm-20260511) — cross-LLM memory pattern (May 12, 2026)
|
|
19
|
+
- **Zenn**: [Glama listing で 3 週間止まった話 (5 つの罠と解決策)](https://zenn.dev/michielinksee/articles/linksee-memory-mcp-publish-glama-traps-20260506) — npm + Glama deployment retrospective
|
|
20
|
+
|
|
21
|
+
> 「Cordex/Cursor/Code/Gemini 全部につなげられるから、 横断的にできてる MCP ってところがこれのすごいところ」
|
|
22
|
+
> — [Hatena Bookmark, May 2026](https://b.hatena.ne.jp/entry/s/zenn.dev/kanseilink/articles/linksee-memory-claude-code-recall-20260508) (165+ users)
|
|
13
23
|
|
|
14
24
|
---
|
|
15
25
|
|
|
@@ -88,7 +98,7 @@ It is a Model Context Protocol (MCP) server that gives any AI agent four superpo
|
|
|
88
98
|
## Three pillars
|
|
89
99
|
|
|
90
100
|
1. **Token savings** via `read_smart` — sha256 + AST/heading/indent chunking. Re-reads return only diffs. **Measured 86% saved on a typical TS file edit, 99% saved on unchanged re-reads.**
|
|
91
|
-
2. **Cross-agent portability** — single SQLite file at `~/.linksee-memory/memory.db`. Same brain for Claude Code, Cursor, ChatGPT
|
|
101
|
+
2. **Cross-agent portability** — single SQLite file at `~/.linksee-memory/memory.db`. Same brain for Claude Code, Cursor, OpenAI Codex, Gemini CLI. (ChatGPT app needs Remote MCP — on roadmap for v0.4.)
|
|
92
102
|
3. **WHY-first structured memory** — six explicit layers (`goal` / `context` / `emotion` / `implementation` / `caveat` / `learning`). Solves "flat fact memory is useless without goals".
|
|
93
103
|
|
|
94
104
|
## Install
|
|
@@ -147,18 +157,29 @@ Add to `~/.claude/settings.json` to record every Claude Code session to your loc
|
|
|
147
157
|
|
|
148
158
|
Each turn end takes ~100 ms. Failures are silent (Claude Code never blocks). Logs at `~/.linksee-memory/hook.log`.
|
|
149
159
|
|
|
160
|
+
## v0.3.0 — Five Blocks at a glance
|
|
161
|
+
|
|
162
|
+
| MCP Block | Surface |
|
|
163
|
+
|---|---|
|
|
164
|
+
| **Tools** | 8 tools (unchanged signatures since v0.2). |
|
|
165
|
+
| **Resources** | 4 static URIs (`memory://stats`, `memory://hot`, `memory://recent`, `memory://caveats`) + 3 templates (`memory://entity/{name}`, `memory://layer/{layer}`, `memory://memory/{id}`). Browseable via `@-mention` in clients that support it. |
|
|
166
|
+
| **Prompts** | 5 reusable templates: `summarize-session`, `extract-caveats`, `weekly-consolidation`, `recall-and-write`, `entity-handoff`. |
|
|
167
|
+
| **Sampling** *(client opt-in)* | `consolidate{use_llm:true}` asks the client LLM to rewrite consolidated cluster summaries into prose. Falls back to the heuristic when the client declines. |
|
|
168
|
+
| **Roots** *(client opt-in)* | `recall_file{scope_to_roots:true}` filters path matches to files inside any client-provided working root. |
|
|
169
|
+
| **Elicitation** *(client opt-in, newer primitive)* | `forget{interactive:true, memory_id:N}` asks the user to confirm via the client UI before deleting. |
|
|
170
|
+
|
|
150
171
|
## Tools
|
|
151
172
|
|
|
152
173
|
| Tool | Purpose |
|
|
153
174
|
|---|---|
|
|
154
175
|
| `remember` | Store memory in 1 of 6 layers for an entity. Rejects pasted assistant output / CI logs unless `force=true`. Set `importance=1.0` to pin (survives auto-forget). |
|
|
155
176
|
| `recall` | FTS5 + heat × momentum × importance composite ranking with `match_reasons` explaining WHY each row matched. Supports pagination (`offset`/`has_more`), `band` filter, layer aliases (`decisions`/`warnings`/`how`/...), and `mark_accessed=false` for passive previews. |
|
|
156
|
-
| `recall_file` | Complete edit history of a file across all sessions, with per-edit user-intent context. |
|
|
177
|
+
| `recall_file` | Complete edit history of a file across all sessions, with per-edit user-intent context. **v0.3.0** `scope_to_roots` flag filters by client roots. |
|
|
157
178
|
| `update_memory` | **v0.1.0** Atomic edit of an existing memory. Preserves `memory_id` (session_file_edits links stay intact). Prefer over forget+remember. |
|
|
158
179
|
| `list_entities` | **v0.1.0** List what the memory knows about — cheapest "what do I know?" primitive. Filter by `kind`/`min_memories`; returns layer breakdown per entity. |
|
|
159
180
|
| `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits. |
|
|
160
|
-
| `forget` | Explicit delete OR auto-sweep based on `forgettingRisk`. Pinned (`importance>=1.0`) and caveat-layer memories are always preserved. |
|
|
161
|
-
| `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary. Supports `dry_run` preview. |
|
|
181
|
+
| `forget` | Explicit delete OR auto-sweep based on `forgettingRisk`. Pinned (`importance>=1.0`) and caveat-layer memories are always preserved. **v0.3.0** `interactive` flag asks the user via Elicitation before deleting a specific memory_id. |
|
|
182
|
+
| `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary. Supports `dry_run` preview. **v0.3.0** `use_llm` flag asks the client LLM (Sampling) to rewrite cluster summaries into prose. |
|
|
162
183
|
|
|
163
184
|
### CLI utilities
|
|
164
185
|
|
|
@@ -215,9 +236,12 @@ The conversation↔file linkage is the key. Every file edit captured by the Stop
|
|
|
215
236
|
- ✅ Core 6 MCP tools (`remember` / `recall` / `recall_file` / `forget` / `consolidate` / `read_smart`)
|
|
216
237
|
- ✅ Stop-hook auto-capture for Claude Code
|
|
217
238
|
- ✅ JP/EN trigram FTS5
|
|
239
|
+
- ✅ Five Blocks (v0.3.0): Tools + Resources + Prompts + Sampling + Roots + Elicitation
|
|
240
|
+
- ✅ Cursor + OpenAI Codex + Gemini CLI adapters (stdio MCP, same `npx -y linksee-memory`)
|
|
218
241
|
- 🚧 `PreToolUse` hook to auto-intercept `Read` (zero-config token savings)
|
|
219
|
-
-
|
|
220
|
-
- 🔮 Vector search via `sqlite-vec`
|
|
242
|
+
- 🔮 ChatGPT app (web/mobile) support via `linksee-memory-remote` (Remote MCP over HTTPS, v0.4)
|
|
243
|
+
- 🔮 Vector search via `sqlite-vec` (already in deps, embedding backend pending)
|
|
244
|
+
- 🔮 Cross-device cloud sync (Pro tier, summer 2026)
|
|
221
245
|
- 🔮 Optional anonymized telemetry → MCP-quality intelligence layer
|
|
222
246
|
|
|
223
247
|
## Comparison with Claude Code auto-memory
|
|
@@ -365,8 +389,8 @@ Three axes:
|
|
|
365
389
|
<details>
|
|
366
390
|
<summary><strong>Why not just use Claude's built-in auto-memory?</strong></summary>
|
|
367
391
|
|
|
368
|
-
Claude Code's auto-memory is Claude-only (doesn't help if you switch to Cursor or
|
|
369
|
-
- Works across Claude Code, Cursor,
|
|
392
|
+
Claude Code's auto-memory is Claude-only (doesn't help if you switch to Cursor, OpenAI Codex, or Gemini CLI) and stores flat markdown with no structure. linksee-memory is the same local-first principle but:
|
|
393
|
+
- Works across Claude Code, Cursor, OpenAI Codex, Gemini CLI (shared SQLite)
|
|
370
394
|
- Structured 6-layer format makes recall explainable
|
|
371
395
|
- Provides explicit forget/consolidate primitives rather than the agent guessing
|
|
372
396
|
</details>
|
|
@@ -404,8 +428,10 @@ In practice a solo developer hits ~100MB after 6 months of heavy use. A year-old
|
|
|
404
428
|
Yes — any MCP-compatible client works:
|
|
405
429
|
- **Claude Code**: `claude mcp add -s user linksee -- npx -y linksee-memory`
|
|
406
430
|
- **Claude Desktop**: add to `claude_desktop_config.json` (see [onboarding on the LP](https://linksee-site.vercel.app))
|
|
407
|
-
- **Cursor**: add to MCP settings in Cursor
|
|
408
|
-
- **
|
|
431
|
+
- **Cursor**: add to MCP settings in Cursor → Settings → Features → Model Context Protocol
|
|
432
|
+
- **OpenAI Codex**: `codex mcp add linksee -- npx -y linksee-memory` (or `~/.codex/config.toml` with `[mcp_servers.linksee]` block)
|
|
433
|
+
- **Gemini CLI**: add to `~/.gemini/settings.json` mcpServers section
|
|
434
|
+
- **ChatGPT (web/mobile app)**: stdio MCP not supported by the consumer app — requires Remote MCP server over HTTPS. `linksee-memory-remote` planned for v0.4.
|
|
409
435
|
- **Custom agent**: the MCP stdio protocol is documented at modelcontextprotocol.io
|
|
410
436
|
</details>
|
|
411
437
|
|
|
@@ -13,6 +13,7 @@ import { homedir } from 'node:os';
|
|
|
13
13
|
import { openDb, runMigrations } from '../db/migrate.js';
|
|
14
14
|
import { parseSessionFile, detectProjectName } from '../lib/session-parser.js';
|
|
15
15
|
import { extractSession } from '../lib/session-extractor.js';
|
|
16
|
+
import { normalizeEntityName } from '../lib/normalize.js';
|
|
16
17
|
const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
|
|
17
18
|
function usage() {
|
|
18
19
|
console.log(`Usage:
|
|
@@ -90,12 +91,15 @@ async function main() {
|
|
|
90
91
|
// different sessions/cwds collapses to one entity)
|
|
91
92
|
let projectEntityId;
|
|
92
93
|
const canonicalKey = `project:${projectName.toLowerCase()}`;
|
|
93
|
-
const
|
|
94
|
+
const normalized = normalizeEntityName(projectName);
|
|
95
|
+
const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND normalized_name = ?) OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', normalized, 'project', projectName);
|
|
94
96
|
if (existing) {
|
|
95
97
|
projectEntityId = existing.id;
|
|
98
|
+
// Backfill normalized_name if missing
|
|
99
|
+
db.prepare('UPDATE entities SET normalized_name = ? WHERE id = ? AND normalized_name IS NULL').run(normalized, projectEntityId);
|
|
96
100
|
}
|
|
97
101
|
else {
|
|
98
|
-
const ins = db.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)').run('project', projectName, canonicalKey);
|
|
102
|
+
const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
|
|
99
103
|
projectEntityId = Number(ins.lastInsertRowid);
|
|
100
104
|
}
|
|
101
105
|
const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source) VALUES (?, ?, ?, ?, ?)');
|
|
@@ -207,12 +211,14 @@ async function main() {
|
|
|
207
211
|
// Resolve entity per-SESSION (not per-project-dir) since each session may
|
|
208
212
|
// belong to a different project based on its file ops.
|
|
209
213
|
const canonicalKey = `project:${projectName.toLowerCase()}`;
|
|
210
|
-
const
|
|
214
|
+
const normalized = normalizeEntityName(projectName);
|
|
215
|
+
const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND normalized_name = ?) OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', normalized, 'project', projectName);
|
|
211
216
|
if (existing) {
|
|
212
217
|
projectEntityId = existing.id;
|
|
218
|
+
db.prepare('UPDATE entities SET normalized_name = ? WHERE id = ? AND normalized_name IS NULL').run(normalized, projectEntityId);
|
|
213
219
|
}
|
|
214
220
|
else {
|
|
215
|
-
const ins = db.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)').run('project', projectName, canonicalKey);
|
|
221
|
+
const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
|
|
216
222
|
projectEntityId = Number(ins.lastInsertRowid);
|
|
217
223
|
}
|
|
218
224
|
// Idempotent: wipe any prior data for THIS session before re-inserting (Phase B)
|
package/dist/db/migrate.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { normalizeEntityName } from '../lib/normalize.js';
|
|
7
8
|
const DEFAULT_DB_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
|
|
8
9
|
const DB_PATH = join(DEFAULT_DB_DIR, 'memory.db');
|
|
9
10
|
export function getDbPath() {
|
|
@@ -39,6 +40,14 @@ export function runMigrations(db) {
|
|
|
39
40
|
DROP TABLE IF EXISTS memories_fts;
|
|
40
41
|
`);
|
|
41
42
|
}
|
|
43
|
+
// v4 → v5: add normalized_name column BEFORE schema.sql runs,
|
|
44
|
+
// so the CREATE INDEX IF NOT EXISTS on (kind, normalized_name) succeeds.
|
|
45
|
+
if (currentVersion > 0 && currentVersion < 5) {
|
|
46
|
+
const cols = db.prepare("PRAGMA table_info(entities)").all();
|
|
47
|
+
if (!cols.some(c => c.name === 'normalized_name')) {
|
|
48
|
+
db.exec('ALTER TABLE entities ADD COLUMN normalized_name TEXT');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
42
51
|
db.exec(sql);
|
|
43
52
|
if (currentVersion > 0 && currentVersion < 4) {
|
|
44
53
|
db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
|
|
@@ -57,6 +66,95 @@ export function runMigrations(db) {
|
|
|
57
66
|
if (hasMemories?.name) {
|
|
58
67
|
db.prepare(`UPDATE memories SET protected = 1 WHERE importance >= 0.9 AND protected = 0`).run();
|
|
59
68
|
}
|
|
69
|
+
// v4 → v5: entity name normalization for dedup prevention.
|
|
70
|
+
// Adds normalized_name column + backfills from existing entity names.
|
|
71
|
+
if (currentVersion > 0 && currentVersion < 5) {
|
|
72
|
+
migrateV5EntityNormalization(db);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* v5 migration: add normalized_name column and backfill + merge duplicates.
|
|
77
|
+
*/
|
|
78
|
+
function migrateV5EntityNormalization(db) {
|
|
79
|
+
// Column + index already added before db.exec(sql) above.
|
|
80
|
+
// Now backfill normalized_name for all entities.
|
|
81
|
+
const entities = db.prepare('SELECT id, name FROM entities').all();
|
|
82
|
+
const updateStmt = db.prepare('UPDATE entities SET normalized_name = ? WHERE id = ?');
|
|
83
|
+
db.transaction(() => {
|
|
84
|
+
for (const e of entities) {
|
|
85
|
+
updateStmt.run(normalizeEntityName(e.name), e.id);
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
// 4. Auto-merge duplicate entities (same kind + normalized_name)
|
|
89
|
+
const dupes = db.prepare(`
|
|
90
|
+
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
91
|
+
FROM entities
|
|
92
|
+
WHERE normalized_name IS NOT NULL
|
|
93
|
+
GROUP BY kind, normalized_name
|
|
94
|
+
HAVING COUNT(*) > 1
|
|
95
|
+
`).all();
|
|
96
|
+
if (dupes.length > 0) {
|
|
97
|
+
console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
|
|
98
|
+
db.transaction(() => {
|
|
99
|
+
for (const dupe of dupes) {
|
|
100
|
+
const ids = dupe.ids.split(',').map(Number);
|
|
101
|
+
mergeEntityCluster(db, ids);
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Merge a cluster of duplicate entity IDs into one canonical entity.
|
|
108
|
+
* Picks the entity with the most memories (ties broken by canonical_key presence).
|
|
109
|
+
* Reassigns all memories, events, edges, consolidations to the kept entity.
|
|
110
|
+
*/
|
|
111
|
+
function mergeEntityCluster(db, ids) {
|
|
112
|
+
if (ids.length < 2)
|
|
113
|
+
return;
|
|
114
|
+
// Score each entity: prefer most memories, then has canonical_key, then lowest id
|
|
115
|
+
const rows = db.prepare(`
|
|
116
|
+
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
117
|
+
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
118
|
+
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
119
|
+
GROUP BY e.id
|
|
120
|
+
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
121
|
+
`).all(...ids);
|
|
122
|
+
const keep = rows[0];
|
|
123
|
+
const mergeIds = rows.slice(1).map(r => r.id);
|
|
124
|
+
console.log(` merge: keeping "${keep.name}" (id=${keep.id}, ${keep.mem_count} memories), absorbing ids=[${mergeIds.join(',')}]`);
|
|
125
|
+
// Inherit canonical_key if the kept entity lacks one
|
|
126
|
+
// Clear donor's key first to avoid UNIQUE constraint violation
|
|
127
|
+
if (!keep.canonical_key) {
|
|
128
|
+
const donor = rows.find(r => r.id !== keep.id && r.canonical_key);
|
|
129
|
+
if (donor) {
|
|
130
|
+
db.prepare('UPDATE entities SET canonical_key = NULL WHERE id = ?').run(donor.id);
|
|
131
|
+
db.prepare('UPDATE entities SET canonical_key = ? WHERE id = ?').run(donor.canonical_key, keep.id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const mid of mergeIds) {
|
|
135
|
+
// Reassign memories
|
|
136
|
+
db.prepare('UPDATE memories SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
137
|
+
// Reassign events
|
|
138
|
+
db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
139
|
+
// Reassign edges (both directions)
|
|
140
|
+
// Handle UNIQUE constraint: delete duplicates first
|
|
141
|
+
db.prepare(`
|
|
142
|
+
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
143
|
+
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
144
|
+
)
|
|
145
|
+
`).run(mid, keep.id);
|
|
146
|
+
db.prepare('UPDATE edges SET from_id = ? WHERE from_id = ?').run(keep.id, mid);
|
|
147
|
+
db.prepare(`
|
|
148
|
+
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
149
|
+
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
150
|
+
)
|
|
151
|
+
`).run(mid, keep.id);
|
|
152
|
+
db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
|
|
153
|
+
// Reassign consolidations
|
|
154
|
+
db.prepare('UPDATE consolidations SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
155
|
+
// Delete the duplicate entity
|
|
156
|
+
db.prepare('DELETE FROM entities WHERE id = ?').run(mid);
|
|
157
|
+
}
|
|
60
158
|
}
|
|
61
159
|
// CLI entrypoint
|
|
62
160
|
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('migrate.ts') || process.argv[1]?.endsWith('migrate.js')) {
|
package/dist/db/schema.sql
CHANGED
|
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS entities (
|
|
|
10
10
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
11
|
kind TEXT NOT NULL CHECK (kind IN ('person', 'company', 'project', 'concept', 'file', 'other')),
|
|
12
12
|
name TEXT NOT NULL,
|
|
13
|
+
normalized_name TEXT, -- lowercased, separator-normalized for dedup matching
|
|
13
14
|
canonical_key TEXT UNIQUE,
|
|
14
15
|
attributes TEXT,
|
|
15
16
|
momentum_score REAL NOT NULL DEFAULT 0.0, -- 0-10 cached; refreshed on event insert
|
|
@@ -18,8 +19,9 @@ CREATE TABLE IF NOT EXISTS entities (
|
|
|
18
19
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
19
20
|
);
|
|
20
21
|
|
|
21
|
-
CREATE INDEX IF NOT EXISTS idx_entities_kind
|
|
22
|
-
CREATE INDEX IF NOT EXISTS idx_entities_key
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(kind);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_entities_key ON entities(canonical_key);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_entities_normalized ON entities(kind, normalized_name);
|
|
23
25
|
|
|
24
26
|
-- ============================================================
|
|
25
27
|
-- Layer 3: Meanings — 6-layer structured memory per entity
|
|
@@ -194,6 +196,6 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
194
196
|
value TEXT NOT NULL
|
|
195
197
|
);
|
|
196
198
|
|
|
197
|
-
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '
|
|
199
|
+
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '5');
|
|
198
200
|
INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
|
|
199
|
-
UPDATE meta SET value = '
|
|
201
|
+
UPDATE meta SET value = '5' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity name normalization for deduplication.
|
|
3
|
+
*
|
|
4
|
+
* Problem: agents call the same project by different names:
|
|
5
|
+
* "CockpitMCP" / "Cockpit MCP" / "cockpit-mcp" / "Cockpit_MCP"
|
|
6
|
+
* "Linksee Memory" / "LinkseeMemory" / "linksee-memory"
|
|
7
|
+
* "Sake_Navi" / "Sake Navi"
|
|
8
|
+
*
|
|
9
|
+
* This module provides a single normalizeEntityName() that collapses
|
|
10
|
+
* all these variants to one canonical form for matching.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Normalize an entity name for dedup matching.
|
|
14
|
+
*
|
|
15
|
+
* Rules applied (in order):
|
|
16
|
+
* 1. CamelCase split: "CockpitMCP" → "Cockpit MCP"
|
|
17
|
+
* 2. Separator → space: "linksee-memory" → "linksee memory"
|
|
18
|
+
* 3. Lowercase: "Linksee Memory" → "linksee memory"
|
|
19
|
+
* 4. Collapse whitespace
|
|
20
|
+
* 5. Trim
|
|
21
|
+
*
|
|
22
|
+
* The result is used for equality matching (WHERE normalized_name = ?),
|
|
23
|
+
* NOT stored as the display name — the original name is preserved.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeEntityName(name: string): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity name normalization for deduplication.
|
|
3
|
+
*
|
|
4
|
+
* Problem: agents call the same project by different names:
|
|
5
|
+
* "CockpitMCP" / "Cockpit MCP" / "cockpit-mcp" / "Cockpit_MCP"
|
|
6
|
+
* "Linksee Memory" / "LinkseeMemory" / "linksee-memory"
|
|
7
|
+
* "Sake_Navi" / "Sake Navi"
|
|
8
|
+
*
|
|
9
|
+
* This module provides a single normalizeEntityName() that collapses
|
|
10
|
+
* all these variants to one canonical form for matching.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Normalize an entity name for dedup matching.
|
|
14
|
+
*
|
|
15
|
+
* Rules applied (in order):
|
|
16
|
+
* 1. CamelCase split: "CockpitMCP" → "Cockpit MCP"
|
|
17
|
+
* 2. Separator → space: "linksee-memory" → "linksee memory"
|
|
18
|
+
* 3. Lowercase: "Linksee Memory" → "linksee memory"
|
|
19
|
+
* 4. Collapse whitespace
|
|
20
|
+
* 5. Trim
|
|
21
|
+
*
|
|
22
|
+
* The result is used for equality matching (WHERE normalized_name = ?),
|
|
23
|
+
* NOT stored as the display name — the original name is preserved.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeEntityName(name) {
|
|
26
|
+
return name
|
|
27
|
+
// CamelCase boundaries: insert space between lowercase→uppercase
|
|
28
|
+
// "CockpitMCP" → "Cockpit MCP"
|
|
29
|
+
// "LinkseeMemory" → "Linksee Memory"
|
|
30
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
31
|
+
// Uppercase run followed by uppercase+lowercase:
|
|
32
|
+
// "MCPServer" → "MCP Server"
|
|
33
|
+
// "KanseiLINK" → "Kansei LINK"
|
|
34
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
35
|
+
// All separators → space
|
|
36
|
+
.replace(/[-_]/g, ' ')
|
|
37
|
+
// Lowercase everything
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
// Collapse multiple spaces
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.trim();
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=normalize.js.map
|