linksee-memory 0.0.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -6
- package/dist/bin/stats.d.ts +2 -0
- package/dist/bin/stats.js +192 -0
- package/dist/db/migrate.js +13 -6
- package/dist/mcp/server.js +411 -68
- package/dist/skill/SKILL.md +47 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# linksee-memory
|
|
2
2
|
|
|
3
3
|
> Local-first agent memory MCP. A cross-agent brain for Claude Code, Cursor, and ChatGPT Desktop — with a token-saving file diff cache that nobody else does.
|
|
4
|
+
>
|
|
5
|
+
> **v0.1.0** adds `update_memory`, `list_entities`, `match_reasons` on recall, pagination, pin-via-importance, layer aliases, consolidate dry-run, `linksee-memory-stats` CLI, and a momentum-refresh fix. See [CHANGELOG](#changelog).
|
|
4
6
|
|
|
5
7
|
[](https://www.npmjs.com/package/linksee-memory)
|
|
6
8
|
[](./LICENSE)
|
|
@@ -85,12 +87,24 @@ Each turn end takes ~100 ms. Failures are silent (Claude Code never blocks). Log
|
|
|
85
87
|
|
|
86
88
|
| Tool | Purpose |
|
|
87
89
|
|---|---|
|
|
88
|
-
| `remember` | Store memory in 1 of 6 layers for an entity |
|
|
89
|
-
| `recall` | FTS5 + heat
|
|
90
|
-
| `recall_file` |
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
90
|
+
| `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). |
|
|
91
|
+
| `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. |
|
|
92
|
+
| `recall_file` | Complete edit history of a file across all sessions, with per-edit user-intent context. |
|
|
93
|
+
| `update_memory` | **v0.1.0** Atomic edit of an existing memory. Preserves `memory_id` (session_file_edits links stay intact). Prefer over forget+remember. |
|
|
94
|
+
| `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. |
|
|
95
|
+
| `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits. |
|
|
96
|
+
| `forget` | Explicit delete OR auto-sweep based on `forgettingRisk`. Pinned (`importance>=1.0`) and caveat-layer memories are always preserved. |
|
|
97
|
+
| `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary. Supports `dry_run` preview. |
|
|
98
|
+
|
|
99
|
+
### CLI utilities
|
|
100
|
+
|
|
101
|
+
| Command | Purpose |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `npx linksee-memory` | MCP server (stdio) |
|
|
104
|
+
| `npx linksee-memory-sync` | Claude Code Stop-hook entry point |
|
|
105
|
+
| `npx linksee-memory-import` | Batch-import Claude Code session JSONL history |
|
|
106
|
+
| `npx linksee-memory-install-skill` | Install the Claude Code Skill that teaches the agent when to call recall/remember/read_smart |
|
|
107
|
+
| `npx linksee-memory-stats` | **v0.1.0** Summary of the local DB (entity count / layer breakdown / top entities / top edited files). Add `--json` for machine-readable output. |
|
|
94
108
|
|
|
95
109
|
## The 6 memory layers
|
|
96
110
|
|
|
@@ -280,6 +294,49 @@ Caveat and active-goal layers are always preserved. Consider scheduling a weekly
|
|
|
280
294
|
- **Security concerns**: see [SECURITY.md](./SECURITY.md) if present, or file a private advisory on GitHub
|
|
281
295
|
- **Company**: Synapse Arrows PTE. LTD. (Singapore)
|
|
282
296
|
|
|
297
|
+
## Changelog
|
|
298
|
+
|
|
299
|
+
### v0.1.0 — Major UX update (2026-04-18)
|
|
300
|
+
|
|
301
|
+
Based on one week of dogfooding, here's what changed:
|
|
302
|
+
|
|
303
|
+
**New tools**
|
|
304
|
+
- `update_memory` — atomic edit with preserved `memory_id`. Solves the "forget+remember breaks session_file_edits links" bug.
|
|
305
|
+
- `list_entities` — fast "what do I know about?" primitive for session init. Supports `kind`/`min_memories` filters and returns layer breakdown.
|
|
306
|
+
- `npx linksee-memory-stats` — local DB summary CLI.
|
|
307
|
+
|
|
308
|
+
**`recall` enhancements**
|
|
309
|
+
- `match_reasons` array on each memory: e.g. `["content_match_fts", "heat:hot", "pinned"]`.
|
|
310
|
+
- `score_breakdown` with per-dimension scores (relevance / heat / momentum / importance).
|
|
311
|
+
- Pagination via `offset` / `has_more` / `stopped_by`.
|
|
312
|
+
- `limit` parameter (hard cap, complements `max_tokens` budget).
|
|
313
|
+
- `band` filter to request only hot/warm/cold/frozen memories.
|
|
314
|
+
- `mark_accessed=false` for preview queries that shouldn't bump heat.
|
|
315
|
+
- **Layer aliases**: `decisions` → `learning`, `warnings` → `caveat`, `how` → `implementation`, etc.
|
|
316
|
+
- **Fix**: opportunistic refresh of stale entity momentum scores. Entities recalled >1 h after last remember() no longer return stale momentum.
|
|
317
|
+
|
|
318
|
+
**`remember` enhancements**
|
|
319
|
+
- Quality check: rejects pasted assistant output / CI logs / stack traces unless `force=true`.
|
|
320
|
+
- `importance=1.0` now implicitly pins the memory (survives auto-forget).
|
|
321
|
+
- Layer aliases accepted.
|
|
322
|
+
|
|
323
|
+
**`forget` changes**
|
|
324
|
+
- Pinned memories (importance=1.0) now preserved alongside caveat-layer memories.
|
|
325
|
+
- Clear error response when attempting to delete a protected or missing memory.
|
|
326
|
+
- dry-run now includes `sample_ids_to_drop`.
|
|
327
|
+
|
|
328
|
+
**`consolidate` changes**
|
|
329
|
+
- `dry_run: true` preview mode — reports cluster count + candidates without writing.
|
|
330
|
+
|
|
331
|
+
**Infra**
|
|
332
|
+
- Fixed fresh-DB migration bug (was querying `meta` table before it existed).
|
|
333
|
+
- Bumped to Node 20+ for structured language feature usage.
|
|
334
|
+
|
|
335
|
+
All changes are **backward compatible** — existing integrations continue to work. Server.ts version banner now reports `v0.1.0`.
|
|
336
|
+
|
|
337
|
+
### Older versions
|
|
338
|
+
See [GitHub Releases](https://github.com/michielinksee/linksee-memory/releases).
|
|
339
|
+
|
|
283
340
|
## License
|
|
284
341
|
|
|
285
342
|
MIT — Synapse Arrows PTE. LTD.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory-stats — summary of the local memory DB.
|
|
3
|
+
// Usage:
|
|
4
|
+
// npx linksee-memory-stats
|
|
5
|
+
// npx linksee-memory-stats --json
|
|
6
|
+
// npx linksee-memory-stats --per-entity 10
|
|
7
|
+
//
|
|
8
|
+
// Safe to run anytime (read-only).
|
|
9
|
+
import { statSync } from 'node:fs';
|
|
10
|
+
import { openDb, getDbPath } from '../db/migrate.js';
|
|
11
|
+
function parseArgs() {
|
|
12
|
+
const argv = process.argv.slice(2);
|
|
13
|
+
const a = { json: false, perEntity: 5, help: false };
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const v = argv[i];
|
|
16
|
+
if (v === '--json')
|
|
17
|
+
a.json = true;
|
|
18
|
+
else if (v === '--per-entity')
|
|
19
|
+
a.perEntity = Math.max(0, Number(argv[++i] || 5));
|
|
20
|
+
else if (v === '-h' || v === '--help')
|
|
21
|
+
a.help = true;
|
|
22
|
+
}
|
|
23
|
+
return a;
|
|
24
|
+
}
|
|
25
|
+
function humanBytes(n) {
|
|
26
|
+
if (n < 1024)
|
|
27
|
+
return `${n} B`;
|
|
28
|
+
if (n < 1024 * 1024)
|
|
29
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
30
|
+
if (n < 1024 * 1024 * 1024)
|
|
31
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
32
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
33
|
+
}
|
|
34
|
+
function humanDate(unix) {
|
|
35
|
+
if (!unix)
|
|
36
|
+
return '-';
|
|
37
|
+
return new Date(unix * 1000).toISOString().slice(0, 16).replace('T', ' ');
|
|
38
|
+
}
|
|
39
|
+
function humanAge(unix) {
|
|
40
|
+
if (!unix)
|
|
41
|
+
return '-';
|
|
42
|
+
const diff = Math.floor(Date.now() / 1000) - unix;
|
|
43
|
+
if (diff < 60)
|
|
44
|
+
return 'just now';
|
|
45
|
+
if (diff < 3600)
|
|
46
|
+
return `${Math.floor(diff / 60)}m ago`;
|
|
47
|
+
if (diff < 86400)
|
|
48
|
+
return `${Math.floor(diff / 3600)}h ago`;
|
|
49
|
+
if (diff < 86400 * 30)
|
|
50
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
51
|
+
return `${Math.floor(diff / 86400 / 30)}mo ago`;
|
|
52
|
+
}
|
|
53
|
+
function main() {
|
|
54
|
+
const args = parseArgs();
|
|
55
|
+
if (args.help) {
|
|
56
|
+
console.log(`linksee-memory-stats — summary of the local memory DB
|
|
57
|
+
|
|
58
|
+
--json Output machine-readable JSON
|
|
59
|
+
--per-entity N Show top N entities (default 5, 0 to skip)
|
|
60
|
+
-h, --help This message
|
|
61
|
+
`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const dbPath = getDbPath();
|
|
65
|
+
let sizeBytes = 0;
|
|
66
|
+
try {
|
|
67
|
+
sizeBytes = statSync(dbPath).size;
|
|
68
|
+
}
|
|
69
|
+
catch { /* no db yet */ }
|
|
70
|
+
const db = openDb();
|
|
71
|
+
const counts = {
|
|
72
|
+
entities: db.prepare('SELECT COUNT(*) as c FROM entities').get().c,
|
|
73
|
+
memories: db.prepare('SELECT COUNT(*) as c FROM memories').get().c,
|
|
74
|
+
file_edits: db.prepare('SELECT COUNT(*) as c FROM session_file_edits').get().c,
|
|
75
|
+
unique_files: db.prepare('SELECT COUNT(DISTINCT file_path) as c FROM session_file_edits').get().c,
|
|
76
|
+
sessions_seen: db.prepare('SELECT COUNT(DISTINCT session_id) as c FROM session_file_edits').get().c,
|
|
77
|
+
consolidations: db.prepare('SELECT COUNT(*) as c FROM consolidations').get().c,
|
|
78
|
+
events: db.prepare('SELECT COUNT(*) as c FROM events').get().c,
|
|
79
|
+
};
|
|
80
|
+
const layerBreakdown = db
|
|
81
|
+
.prepare("SELECT layer, COUNT(*) as c FROM memories GROUP BY layer ORDER BY c DESC")
|
|
82
|
+
.all();
|
|
83
|
+
const entityKinds = db
|
|
84
|
+
.prepare("SELECT kind, COUNT(*) as c FROM entities GROUP BY kind ORDER BY c DESC")
|
|
85
|
+
.all();
|
|
86
|
+
const pinned = db.prepare('SELECT COUNT(*) as c FROM memories WHERE importance >= 1.0').get().c;
|
|
87
|
+
const protectedCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE protected = 1').get().c;
|
|
88
|
+
const oldest = db.prepare('SELECT MIN(created_at) as t FROM memories').get().t;
|
|
89
|
+
const newest = db.prepare('SELECT MAX(created_at) as t FROM memories').get().t;
|
|
90
|
+
const topEntities = args.perEntity > 0
|
|
91
|
+
? db.prepare(`
|
|
92
|
+
SELECT e.name, e.kind, e.momentum_score, COUNT(m.id) as memory_count,
|
|
93
|
+
MAX(m.last_accessed_at) as last_access
|
|
94
|
+
FROM entities e
|
|
95
|
+
LEFT JOIN memories m ON m.entity_id = e.id
|
|
96
|
+
GROUP BY e.id
|
|
97
|
+
ORDER BY memory_count DESC, e.momentum_score DESC
|
|
98
|
+
LIMIT ?
|
|
99
|
+
`).all(args.perEntity)
|
|
100
|
+
: [];
|
|
101
|
+
const topFiles = db.prepare(`
|
|
102
|
+
SELECT file_path, COUNT(*) as edits, COUNT(DISTINCT session_id) as in_sessions
|
|
103
|
+
FROM session_file_edits
|
|
104
|
+
WHERE operation IN ('edit', 'write')
|
|
105
|
+
GROUP BY file_path
|
|
106
|
+
ORDER BY edits DESC
|
|
107
|
+
LIMIT 5
|
|
108
|
+
`).all();
|
|
109
|
+
const result = {
|
|
110
|
+
db_path: dbPath,
|
|
111
|
+
db_size: sizeBytes,
|
|
112
|
+
db_size_human: humanBytes(sizeBytes),
|
|
113
|
+
counts,
|
|
114
|
+
pinned,
|
|
115
|
+
caveat_protected: protectedCount,
|
|
116
|
+
layer_breakdown: layerBreakdown,
|
|
117
|
+
entity_kinds: entityKinds,
|
|
118
|
+
date_range: {
|
|
119
|
+
oldest: oldest ? new Date(oldest * 1000).toISOString() : null,
|
|
120
|
+
newest: newest ? new Date(newest * 1000).toISOString() : null,
|
|
121
|
+
},
|
|
122
|
+
top_entities: topEntities.map((e) => ({
|
|
123
|
+
name: e.name,
|
|
124
|
+
kind: e.kind,
|
|
125
|
+
momentum: Number((e.momentum_score ?? 0).toFixed(2)),
|
|
126
|
+
memory_count: e.memory_count,
|
|
127
|
+
last_access: e.last_access ? new Date(e.last_access * 1000).toISOString() : null,
|
|
128
|
+
})),
|
|
129
|
+
top_files: topFiles.map((f) => ({
|
|
130
|
+
path: f.file_path,
|
|
131
|
+
edits: f.edits,
|
|
132
|
+
in_sessions: f.in_sessions,
|
|
133
|
+
})),
|
|
134
|
+
};
|
|
135
|
+
if (args.json) {
|
|
136
|
+
console.log(JSON.stringify(result, null, 2));
|
|
137
|
+
db.close();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Human-readable output
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(' linksee-memory — local brain status');
|
|
143
|
+
console.log(' ' + '═'.repeat(55));
|
|
144
|
+
console.log(` DB: ${dbPath}`);
|
|
145
|
+
console.log(` Size: ${humanBytes(sizeBytes)}`);
|
|
146
|
+
console.log(` Oldest memory: ${humanAge(oldest)}`);
|
|
147
|
+
console.log(` Newest memory: ${humanAge(newest)}`);
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log(' Counts');
|
|
150
|
+
console.log(` entities: ${counts.entities}`);
|
|
151
|
+
console.log(` memories: ${counts.memories} (${pinned} pinned, ${protectedCount} caveat-protected)`);
|
|
152
|
+
console.log(` file edits: ${counts.file_edits} across ${counts.unique_files} unique files`);
|
|
153
|
+
console.log(` sessions seen: ${counts.sessions_seen}`);
|
|
154
|
+
console.log(` consolidations: ${counts.consolidations}`);
|
|
155
|
+
console.log('');
|
|
156
|
+
if (layerBreakdown.length > 0) {
|
|
157
|
+
console.log(' Memories by layer');
|
|
158
|
+
for (const r of layerBreakdown) {
|
|
159
|
+
const bar = '█'.repeat(Math.min(40, Math.round((r.c / counts.memories) * 40)));
|
|
160
|
+
console.log(` ${r.layer.padEnd(15)} ${String(r.c).padStart(5)} ${bar}`);
|
|
161
|
+
}
|
|
162
|
+
console.log('');
|
|
163
|
+
}
|
|
164
|
+
if (entityKinds.length > 0) {
|
|
165
|
+
console.log(' Entities by kind');
|
|
166
|
+
for (const r of entityKinds) {
|
|
167
|
+
console.log(` ${r.kind.padEnd(15)} ${r.c}`);
|
|
168
|
+
}
|
|
169
|
+
console.log('');
|
|
170
|
+
}
|
|
171
|
+
if (topEntities.length > 0) {
|
|
172
|
+
console.log(` Top ${topEntities.length} entities by memory count`);
|
|
173
|
+
for (const e of topEntities) {
|
|
174
|
+
console.log(` ${String(e.memory_count).padStart(4)} ${e.name.padEnd(30)} [${e.kind}] momentum ${Number(e.momentum_score ?? 0).toFixed(1)} last ${humanAge(e.last_access)}`);
|
|
175
|
+
}
|
|
176
|
+
console.log('');
|
|
177
|
+
}
|
|
178
|
+
if (topFiles.length > 0) {
|
|
179
|
+
console.log(' Top 5 most-edited files');
|
|
180
|
+
for (const f of topFiles) {
|
|
181
|
+
const p = f.file_path || '';
|
|
182
|
+
const shortPath = p.length > 65 ? '…' + p.slice(-64) : p;
|
|
183
|
+
console.log(` ${String(f.edits).padStart(4)} edits ${shortPath}`);
|
|
184
|
+
}
|
|
185
|
+
console.log('');
|
|
186
|
+
}
|
|
187
|
+
console.log(' Run with --json for machine-readable output.');
|
|
188
|
+
console.log('');
|
|
189
|
+
db.close();
|
|
190
|
+
}
|
|
191
|
+
main();
|
|
192
|
+
//# sourceMappingURL=stats.js.map
|
package/dist/db/migrate.js
CHANGED
|
@@ -20,10 +20,18 @@ export function runMigrations(db) {
|
|
|
20
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
21
21
|
const schemaPath = join(dirname(__filename), 'schema.sql');
|
|
22
22
|
const sql = readFileSync(schemaPath, 'utf8');
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
// Safely read current schema version. On a fresh DB the meta table doesn't
|
|
24
|
+
// exist yet, which is fine — treat that as "version 0, full schema apply".
|
|
25
|
+
let currentVersion = 0;
|
|
26
|
+
const metaTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
|
|
27
|
+
if (metaTable?.name) {
|
|
28
|
+
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
29
|
+
if (versionRow)
|
|
30
|
+
currentVersion = Number(versionRow.value) || 0;
|
|
31
|
+
}
|
|
32
|
+
// v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
|
|
33
|
+
// Only runs when upgrading an existing DB from schema v1-3.
|
|
34
|
+
if (currentVersion > 0 && currentVersion < 4) {
|
|
27
35
|
db.exec(`
|
|
28
36
|
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
29
37
|
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
@@ -32,8 +40,7 @@ export function runMigrations(db) {
|
|
|
32
40
|
`);
|
|
33
41
|
}
|
|
34
42
|
db.exec(sql);
|
|
35
|
-
|
|
36
|
-
if (versionRow && Number(versionRow.value) < 4) {
|
|
43
|
+
if (currentVersion > 0 && currentVersion < 4) {
|
|
37
44
|
db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
|
|
38
45
|
}
|
|
39
46
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// linksee-memory MCP server (stdio transport).
|
|
3
|
-
//
|
|
3
|
+
// Tools: remember / recall / recall_file / update_memory / list_entities /
|
|
4
|
+
// forget / consolidate / read_smart
|
|
4
5
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
7
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -9,33 +10,62 @@ import { computeHeat } from '../lib/heat-index.js';
|
|
|
9
10
|
import { decideForgetting } from '../lib/forgetting.js';
|
|
10
11
|
import { refreshMomentumForEntity } from '../lib/momentum.js';
|
|
11
12
|
import { consolidate as runConsolidate } from '../lib/consolidate.js';
|
|
13
|
+
import { isPastedExternalContent } from '../lib/session-parser.js';
|
|
12
14
|
import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
|
|
15
|
+
const SERVER_VERSION = '0.1.0';
|
|
13
16
|
const db = openDb();
|
|
14
17
|
runMigrations(db);
|
|
15
|
-
const server = new Server({ name: 'linksee-memory', version:
|
|
18
|
+
const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
19
|
+
// ============================================================
|
|
20
|
+
// Layer alias map — natural language → canonical layer
|
|
21
|
+
// (agents can say layer="decisions" and we resolve to "learning")
|
|
22
|
+
// ============================================================
|
|
23
|
+
const LAYER_ALIASES = {
|
|
24
|
+
// canonical — identity
|
|
25
|
+
goal: 'goal', context: 'context', emotion: 'emotion',
|
|
26
|
+
implementation: 'implementation', caveat: 'caveat', learning: 'learning',
|
|
27
|
+
// natural-language aliases
|
|
28
|
+
why: 'goal', goals: 'goal', target: 'goal', targets: 'goal', intent: 'goal',
|
|
29
|
+
background: 'context', reason: 'context', situation: 'context', timing: 'context',
|
|
30
|
+
tone: 'emotion', feelings: 'emotion', mood: 'emotion',
|
|
31
|
+
impl: 'implementation', success: 'implementation', failure: 'implementation',
|
|
32
|
+
how: 'implementation', tried: 'implementation', attempts: 'implementation',
|
|
33
|
+
warning: 'caveat', warnings: 'caveat', pain: 'caveat', rule: 'caveat',
|
|
34
|
+
rules: 'caveat', pitfall: 'caveat', pitfalls: 'caveat', dont: 'caveat',
|
|
35
|
+
decision: 'learning', decisions: 'learning', learned: 'learning',
|
|
36
|
+
insight: 'learning', insights: 'learning', growth: 'learning',
|
|
37
|
+
};
|
|
38
|
+
function resolveLayer(input) {
|
|
39
|
+
if (!input)
|
|
40
|
+
return undefined;
|
|
41
|
+
const k = String(input).toLowerCase().trim();
|
|
42
|
+
return LAYER_ALIASES[k] ?? k;
|
|
43
|
+
}
|
|
44
|
+
const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning'];
|
|
16
45
|
// ============================================================
|
|
17
46
|
// Tool schema declarations
|
|
18
47
|
// ============================================================
|
|
19
48
|
const TOOLS = [
|
|
20
49
|
{
|
|
21
50
|
name: 'remember',
|
|
22
|
-
description: 'Store a memory about an entity (person/company/project/concept/file) in one of 6 layers: goal (WHY), context (WHY-THIS-NOW), emotion (USER tone), implementation (HOW — success/failure), caveat (PAIN lesson, never forgotten), learning (GROWTH log). Use this when you discover non-obvious goals, unexpected failures, user preferences, or decisions worth preserving.',
|
|
51
|
+
description: 'Store a memory about an entity (person/company/project/concept/file) in one of 6 layers: goal (WHY), context (WHY-THIS-NOW), emotion (USER tone), implementation (HOW — success/failure), caveat (PAIN lesson, never forgotten), learning (GROWTH log). Use this when you discover non-obvious goals, unexpected failures, user preferences, or decisions worth preserving. Pasted assistant output or CI logs are rejected (use force=true only if you are sure).',
|
|
23
52
|
inputSchema: {
|
|
24
53
|
type: 'object',
|
|
25
54
|
properties: {
|
|
26
55
|
entity_name: { type: 'string', description: 'Name of the entity this memory is about' },
|
|
27
56
|
entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'] },
|
|
28
57
|
entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
|
|
29
|
-
layer: { type: 'string',
|
|
58
|
+
layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Common aliases (why, decisions, warnings, how, ...) are accepted.' },
|
|
30
59
|
content: { type: 'string', description: 'The memory content (plain text or JSON)' },
|
|
31
|
-
importance: { type: 'number', minimum: 0, maximum: 1 },
|
|
60
|
+
importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Use 1.0 to "pin" a memory (protects from forgetting even outside caveat layer).' },
|
|
61
|
+
force: { type: 'boolean', default: false, description: 'Bypass the paste-back/CI-log quality check. Only set when you are sure the content is original user or agent thought.' },
|
|
32
62
|
},
|
|
33
63
|
required: ['entity_name', 'entity_kind', 'layer', 'content'],
|
|
34
64
|
},
|
|
35
65
|
},
|
|
36
66
|
{
|
|
37
67
|
name: 'recall',
|
|
38
|
-
description: 'Retrieve memories relevant to the current context using full-text search (BM25), re-ranked by a composite score
|
|
68
|
+
description: 'Retrieve memories relevant to the current context using full-text search (BM25) + entity-name match, re-ranked by a composite score (relevance × heat × momentum × importance). Returns only what fits in the token budget, with match_reasons explaining WHY each memory was returned. Opportunistically refreshes stale momentum scores for entities in the result set. Supports pagination via offset/has_more. Layer aliases accepted. Use at the start of any task that might involve prior work.',
|
|
39
69
|
inputSchema: {
|
|
40
70
|
type: 'object',
|
|
41
71
|
properties: {
|
|
@@ -43,32 +73,64 @@ const TOOLS = [
|
|
|
43
73
|
entity_name: { type: 'string', description: 'Optional — narrow to a specific entity' },
|
|
44
74
|
layer: {
|
|
45
75
|
type: 'string',
|
|
46
|
-
|
|
76
|
+
description: 'Optional layer filter. Accepts aliases (decisions/warnings/how/etc.) as well as canonical names.',
|
|
47
77
|
},
|
|
48
|
-
|
|
78
|
+
band: { type: 'string', enum: ['hot', 'warm', 'cold', 'frozen'], description: 'Optional — only return memories whose heat_band matches.' },
|
|
79
|
+
max_tokens: { type: 'number', description: 'Approx token budget. Default 2000. Either max_tokens or limit stops iteration (whichever fires first).', default: 2000 },
|
|
80
|
+
limit: { type: 'number', description: 'Optional hard cap on number of memories. Stops at min(max_tokens-budget, limit).' },
|
|
81
|
+
offset: { type: 'number', description: 'Skip this many top results (pagination). Use has_more from prior response to decide next offset.', default: 0 },
|
|
82
|
+
mark_accessed: { type: 'boolean', default: true, description: 'Set false for preview / listing queries that should not bump heat.' },
|
|
49
83
|
},
|
|
50
84
|
required: ['query'],
|
|
51
85
|
},
|
|
52
86
|
},
|
|
87
|
+
{
|
|
88
|
+
name: 'update_memory',
|
|
89
|
+
description: 'Atomically edit an existing memory in-place. Preferred over forget+remember because it preserves memory_id, which matters for session_file_edits links and referential integrity. Use to correct facts, update deadlines in goal entries, refine caveats, or re-score importance. Caveat-layer memories can be updated but cannot have their protected flag removed.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
memory_id: { type: 'number', description: 'The memory.id to update' },
|
|
94
|
+
content: { type: 'string', description: 'New content (plain text or JSON). If omitted, content is kept.' },
|
|
95
|
+
layer: { type: 'string', description: 'Move to a different layer (aliases accepted). If omitted, layer is kept.' },
|
|
96
|
+
importance: { type: 'number', minimum: 0, maximum: 1, description: 'New importance 0-1. Set to 1.0 to pin.' },
|
|
97
|
+
},
|
|
98
|
+
required: ['memory_id'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'list_entities',
|
|
103
|
+
description: 'List the entities currently known to this memory store, sorted by recent activity. Use at the start of a new session ("what do I know about?") before issuing specific recall queries. Cheaper than recall for the "give me an overview" question.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Filter by entity kind.' },
|
|
108
|
+
min_memories: { type: 'number', description: 'Only include entities with at least N memories. Default 1.', default: 1 },
|
|
109
|
+
limit: { type: 'number', description: 'Max entities to return. Default 30.', default: 30 },
|
|
110
|
+
offset: { type: 'number', default: 0 },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
53
114
|
{
|
|
54
115
|
name: 'forget',
|
|
55
|
-
description: 'Explicitly delete a memory by id, OR run auto-forgetting across all memories based on forgettingRisk (importance + heat + age). Caveat and
|
|
116
|
+
description: 'Explicitly delete a memory by id, OR run auto-forgetting across all memories based on forgettingRisk (importance + heat + age). Caveat-layer, goal-layer, and importance=1.0 (pinned) memories are always preserved. Prefer update_memory for corrections — forget is destructive.',
|
|
56
117
|
inputSchema: {
|
|
57
118
|
type: 'object',
|
|
58
119
|
properties: {
|
|
59
120
|
memory_id: { type: 'number' },
|
|
60
|
-
dry_run: { type: 'boolean', default: false },
|
|
121
|
+
dry_run: { type: 'boolean', default: false, description: 'Report what would be deleted without actually deleting.' },
|
|
61
122
|
},
|
|
62
123
|
},
|
|
63
124
|
},
|
|
64
125
|
{
|
|
65
126
|
name: 'consolidate',
|
|
66
|
-
description: 'Sleep-mode compression. Clusters cold low-importance memories by (entity, layer), summarizes each cluster into a single protected learning-layer entry, deletes originals, and runs a forget-sweep. Run at session end or on demand.
|
|
127
|
+
description: 'Sleep-mode compression. Clusters cold low-importance memories by (entity, layer), summarizes each cluster into a single protected learning-layer entry, deletes originals, and runs a forget-sweep. Run at session end or on demand. Set dry_run=true to preview without writing.',
|
|
67
128
|
inputSchema: {
|
|
68
129
|
type: 'object',
|
|
69
130
|
properties: {
|
|
70
131
|
scope: { type: 'string', enum: ['all', 'session'], default: 'session' },
|
|
71
132
|
min_age_days: { type: 'number', description: 'Override the default 7-day minimum age for clustering (set to 0 to consolidate everything immediately, useful right after a bulk import).', default: 7 },
|
|
133
|
+
dry_run: { type: 'boolean', default: false, description: 'Preview what would be compressed without modifying the DB.' },
|
|
72
134
|
},
|
|
73
135
|
},
|
|
74
136
|
},
|
|
@@ -121,19 +183,37 @@ function upsertEntity(args) {
|
|
|
121
183
|
return Number(result.lastInsertRowid);
|
|
122
184
|
}
|
|
123
185
|
function handleRemember(args) {
|
|
186
|
+
// Layer alias → canonical
|
|
187
|
+
const layer = resolveLayer(args.layer);
|
|
188
|
+
if (!layer || !LAYER_ENUM.includes(layer)) {
|
|
189
|
+
return JSON.stringify({
|
|
190
|
+
ok: false,
|
|
191
|
+
error: `unknown layer "${args.layer}". Known: ${LAYER_ENUM.join(', ')} (aliases: decisions, warnings, how, why, ...)`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
// Quality check — reject pasted external content unless force=true
|
|
195
|
+
const rawContent = String(args.content ?? '');
|
|
196
|
+
if (!args.force && isPastedExternalContent(rawContent)) {
|
|
197
|
+
return JSON.stringify({
|
|
198
|
+
ok: false,
|
|
199
|
+
rejected: 'quality_check',
|
|
200
|
+
reason: 'Content looks like pasted assistant output, CI log, or external paste. Pass force:true if this really is original thought worth keeping.',
|
|
201
|
+
hint: 'If you meant to save an extracted insight from that paste, summarize it in your own words first.',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
124
204
|
const entityId = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
|
|
125
|
-
const importance = args.importance ?? 0.5;
|
|
205
|
+
const importance = Math.min(1, Math.max(0, Number(args.importance ?? 0.5)));
|
|
126
206
|
const result = db
|
|
127
|
-
.prepare('INSERT INTO memories (entity_id, layer, content, importance) VALUES (?, ?, ?, ?)')
|
|
128
|
-
.run(entityId,
|
|
129
|
-
db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer
|
|
130
|
-
// Refresh momentum — Day 2 addition. Cheap per-call since it's one entity.
|
|
207
|
+
.prepare('INSERT INTO memories (entity_id, layer, content, importance, protected) VALUES (?, ?, ?, ?, ?)')
|
|
208
|
+
.run(entityId, layer, rawContent, importance, importance >= 1.0 ? 1 : 0);
|
|
209
|
+
db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
|
|
131
210
|
const mom = refreshMomentumForEntity(db, entityId);
|
|
132
211
|
return JSON.stringify({
|
|
133
212
|
ok: true,
|
|
134
213
|
memory_id: Number(result.lastInsertRowid),
|
|
135
214
|
entity_id: entityId,
|
|
136
|
-
layer
|
|
215
|
+
layer,
|
|
216
|
+
pinned: importance >= 1.0,
|
|
137
217
|
momentum: { score: mom.score, band: mom.band },
|
|
138
218
|
});
|
|
139
219
|
}
|
|
@@ -197,28 +277,58 @@ function runLikeQuery(query, entityName, layer, limit) {
|
|
|
197
277
|
params.push(limit);
|
|
198
278
|
return db.prepare(sql).all(...params);
|
|
199
279
|
}
|
|
280
|
+
// Opportunistically refresh momentum_score cache for entities in a result set.
|
|
281
|
+
// Momentum is computed from events but stored in the entities row; without this,
|
|
282
|
+
// a row can become stale (e.g. no new remember() for weeks while events decay).
|
|
283
|
+
// Only refreshes entries older than MOMENTUM_STALE_SECS to avoid per-call cost.
|
|
284
|
+
const MOMENTUM_STALE_SECS = 3600; // 1 hour
|
|
285
|
+
function refreshStaleMomentum(entityIds) {
|
|
286
|
+
if (entityIds.length === 0)
|
|
287
|
+
return;
|
|
288
|
+
const unique = Array.from(new Set(entityIds));
|
|
289
|
+
const now = Math.floor(Date.now() / 1000);
|
|
290
|
+
const cutoff = now - MOMENTUM_STALE_SECS;
|
|
291
|
+
const placeholders = unique.map(() => '?').join(',');
|
|
292
|
+
const rows = db
|
|
293
|
+
.prepare(`SELECT id FROM entities WHERE id IN (${placeholders}) AND (momentum_at IS NULL OR momentum_at < ?)`)
|
|
294
|
+
.all(...unique, cutoff);
|
|
295
|
+
for (const r of rows) {
|
|
296
|
+
try {
|
|
297
|
+
refreshMomentumForEntity(db, r.id);
|
|
298
|
+
}
|
|
299
|
+
catch { /* non-fatal */ }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
200
302
|
function handleRecall(args) {
|
|
201
|
-
const maxTokens = args.max_tokens ?? 2000;
|
|
303
|
+
const maxTokens = Math.max(100, Number(args.max_tokens ?? 2000));
|
|
202
304
|
const approxTokensPerMemory = 100;
|
|
203
|
-
const
|
|
204
|
-
const
|
|
305
|
+
const tokenBudgetLimit = Math.max(1, Math.floor(maxTokens / approxTokensPerMemory));
|
|
306
|
+
const hardLimit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : tokenBudgetLimit;
|
|
307
|
+
const returnLimit = Math.min(tokenBudgetLimit, hardLimit);
|
|
308
|
+
const offset = Math.max(0, Number(args.offset ?? 0));
|
|
309
|
+
const markAccessed = args.mark_accessed !== false;
|
|
310
|
+
const layer = resolveLayer(args.layer);
|
|
311
|
+
const band = args.band;
|
|
312
|
+
// Fetch extra rows for composite re-rank, pagination, and band filtering
|
|
313
|
+
const fetchLimit = Math.max(returnLimit * 3, 30) + offset;
|
|
205
314
|
let rows = [];
|
|
206
315
|
let searchMethod = 'like';
|
|
207
316
|
const ftsQuery = toFtsQuery(args.query ?? '');
|
|
208
317
|
const canUseFts = !!ftsQuery && !args.entity_name;
|
|
209
318
|
if (canUseFts) {
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
// 2nd pass: entity-name LIKE — catches queries that ARE entity names
|
|
213
|
-
// (FTS only indexes memory content, not entity names)
|
|
214
|
-
const likeRows = runLikeQuery(args.query, undefined, args.layer, fetchLimit);
|
|
215
|
-
// merge, dedup by memory id (prefer the FTS row for its bm25_score)
|
|
319
|
+
const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit);
|
|
320
|
+
const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit);
|
|
216
321
|
const seen = new Map();
|
|
217
322
|
for (const r of ftsRows)
|
|
218
|
-
seen.set(r.id, r);
|
|
219
|
-
for (const r of likeRows)
|
|
220
|
-
if (
|
|
221
|
-
seen.
|
|
323
|
+
seen.set(r.id, { ...r, _via: 'fts' });
|
|
324
|
+
for (const r of likeRows) {
|
|
325
|
+
if (seen.has(r.id)) {
|
|
326
|
+
seen.get(r.id)._via = 'fts+like'; // both matched
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
seen.set(r.id, { ...r, _via: 'like' });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
222
332
|
rows = Array.from(seen.values());
|
|
223
333
|
if (ftsRows.length > 0 && likeRows.length > 0)
|
|
224
334
|
searchMethod = 'fts5+like';
|
|
@@ -228,13 +338,22 @@ function handleRecall(args) {
|
|
|
228
338
|
searchMethod = 'like';
|
|
229
339
|
}
|
|
230
340
|
else {
|
|
231
|
-
rows = runLikeQuery(args.query, args.entity_name,
|
|
341
|
+
rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit).map((r) => ({ ...r, _via: 'like' }));
|
|
232
342
|
searchMethod = 'like';
|
|
233
343
|
}
|
|
234
344
|
const useFts = searchMethod !== 'like';
|
|
235
345
|
const now = Math.floor(Date.now() / 1000);
|
|
236
|
-
//
|
|
237
|
-
|
|
346
|
+
// Opportunistically refresh momentum for entities about to be surfaced
|
|
347
|
+
refreshStaleMomentum(rows.map((r) => r.entity_id));
|
|
348
|
+
// Re-fetch momentum after refresh (cheap single-pass update)
|
|
349
|
+
if (rows.length > 0) {
|
|
350
|
+
const ids = Array.from(new Set(rows.map((r) => r.entity_id)));
|
|
351
|
+
const ph = ids.map(() => '?').join(',');
|
|
352
|
+
const fresh = db.prepare(`SELECT id, momentum_score FROM entities WHERE id IN (${ph})`).all(...ids);
|
|
353
|
+
const byId = new Map(fresh.map((f) => [f.id, f.momentum_score]));
|
|
354
|
+
for (const r of rows)
|
|
355
|
+
r.momentum_score = byId.get(r.entity_id) ?? r.momentum_score;
|
|
356
|
+
}
|
|
238
357
|
const bm25Values = rows.map((r) => r.bm25_score);
|
|
239
358
|
const minBm = Math.min(...bm25Values, 0);
|
|
240
359
|
const maxBm = Math.max(...bm25Values, 1);
|
|
@@ -248,53 +367,133 @@ function handleRecall(args) {
|
|
|
248
367
|
totalAccesses: r.access_count,
|
|
249
368
|
baseImportance: r.importance,
|
|
250
369
|
});
|
|
251
|
-
//
|
|
252
|
-
const relevance = useFts ? 1 - (r.bm25_score - minBm) / bmSpan : 0.5;
|
|
370
|
+
// Individual weight contributions (for transparency)
|
|
371
|
+
const relevance = useFts && r._via !== 'like' ? 1 - (r.bm25_score - minBm) / bmSpan : 0.5;
|
|
253
372
|
const heatNorm = heat.score / 100;
|
|
254
373
|
const momNorm = Math.min(1, (r.momentum_score ?? 0) / 10);
|
|
255
|
-
const
|
|
374
|
+
const importanceBoost = r.importance; // 0-1
|
|
375
|
+
// Composite: give a bit to importance so pinned (1.0) memories always rank high
|
|
376
|
+
const w_rel = 0.45, w_heat = 0.25, w_mom = 0.15, w_imp = 0.15;
|
|
377
|
+
const composite = w_rel * relevance + w_heat * heatNorm + w_mom * momNorm + w_imp * importanceBoost;
|
|
378
|
+
// match_reasons: human-readable WHY this row is here
|
|
379
|
+
const reasons = [];
|
|
380
|
+
if (r._via === 'fts' || r._via === 'fts+like')
|
|
381
|
+
reasons.push(`content_match_${r._via === 'fts+like' ? 'dual' : 'fts'}`);
|
|
382
|
+
if (r._via === 'like' || r._via === 'fts+like') {
|
|
383
|
+
if (args.entity_name || (args.query && String(r.entity_name || '').toLowerCase().includes(String(args.query).toLowerCase()))) {
|
|
384
|
+
reasons.push('entity_name_match');
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
reasons.push('content_substring');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (heat.band === 'hot')
|
|
391
|
+
reasons.push('heat:hot');
|
|
392
|
+
else if (heat.band === 'warm')
|
|
393
|
+
reasons.push('heat:warm');
|
|
394
|
+
if (r.momentum_score >= 5)
|
|
395
|
+
reasons.push('entity_active');
|
|
396
|
+
if (r.importance >= 1.0)
|
|
397
|
+
reasons.push('pinned');
|
|
398
|
+
else if (r.importance >= 0.8)
|
|
399
|
+
reasons.push('high_importance');
|
|
400
|
+
if (r.protected === 1 && r.layer === 'caveat')
|
|
401
|
+
reasons.push('caveat_protected');
|
|
256
402
|
return {
|
|
257
403
|
...r,
|
|
258
404
|
heat_score: heat.score,
|
|
259
405
|
heat_band: heat.band,
|
|
260
406
|
composite_score: composite,
|
|
261
407
|
relevance_score: relevance,
|
|
408
|
+
_reasons: reasons,
|
|
409
|
+
_breakdown: {
|
|
410
|
+
relevance: Number(relevance.toFixed(3)),
|
|
411
|
+
heat: Number(heatNorm.toFixed(3)),
|
|
412
|
+
momentum: Number(momNorm.toFixed(3)),
|
|
413
|
+
importance: Number(importanceBoost.toFixed(3)),
|
|
414
|
+
},
|
|
262
415
|
};
|
|
263
416
|
});
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
417
|
+
// Apply band filter AFTER scoring (needs heat.band)
|
|
418
|
+
const filtered = band ? scored.filter((s) => s.heat_band === band) : scored;
|
|
419
|
+
// Sort by composite
|
|
420
|
+
filtered.sort((a, b) => b.composite_score - a.composite_score);
|
|
421
|
+
// Pagination: skip offset, take returnLimit
|
|
422
|
+
const total = filtered.length;
|
|
423
|
+
const windowed = filtered.slice(offset, offset + returnLimit);
|
|
424
|
+
const hasMore = total > offset + windowed.length;
|
|
425
|
+
// Mark accessed (only for the returned window, and only if asked)
|
|
426
|
+
if (markAccessed && windowed.length > 0) {
|
|
427
|
+
const mark = db.prepare('UPDATE memories SET last_accessed_at = ?, access_count = access_count + 1 WHERE id = ?');
|
|
428
|
+
const tx = db.transaction((ids) => { for (const id of ids)
|
|
429
|
+
mark.run(now, id); });
|
|
430
|
+
tx(windowed.map((r) => r.id));
|
|
431
|
+
}
|
|
432
|
+
// Determine what stopped iteration — max_tokens vs limit vs offset+n=total
|
|
433
|
+
let stoppedBy = 'end';
|
|
434
|
+
if (windowed.length === returnLimit && total > offset + returnLimit) {
|
|
435
|
+
stoppedBy = hardLimit <= tokenBudgetLimit ? 'limit' : 'tokens';
|
|
274
436
|
}
|
|
275
437
|
return JSON.stringify({
|
|
276
438
|
ok: true,
|
|
277
|
-
count:
|
|
439
|
+
count: windowed.length,
|
|
440
|
+
total_candidates: total,
|
|
441
|
+
offset,
|
|
442
|
+
has_more: hasMore,
|
|
443
|
+
stopped_by: stoppedBy,
|
|
278
444
|
search: searchMethod,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
445
|
+
resolved_layer: layer ?? null,
|
|
446
|
+
memories: windowed.map((r) => {
|
|
447
|
+
let parsedContent = r.content;
|
|
448
|
+
try {
|
|
449
|
+
parsedContent = JSON.parse(r.content);
|
|
450
|
+
}
|
|
451
|
+
catch { /* leave as string */ }
|
|
452
|
+
return {
|
|
453
|
+
id: r.id,
|
|
454
|
+
entity: {
|
|
455
|
+
id: r.entity_id,
|
|
456
|
+
name: r.entity_name,
|
|
457
|
+
kind: r.entity_kind,
|
|
458
|
+
momentum: Number((r.momentum_score ?? 0).toFixed(2)),
|
|
459
|
+
},
|
|
460
|
+
layer: r.layer,
|
|
461
|
+
content: parsedContent,
|
|
462
|
+
content_raw: r.content,
|
|
463
|
+
importance: r.importance,
|
|
464
|
+
pinned: r.importance >= 1.0,
|
|
465
|
+
heat: Number(r.heat_score.toFixed(1)),
|
|
466
|
+
band: r.heat_band,
|
|
467
|
+
composite: Number(r.composite_score.toFixed(3)),
|
|
468
|
+
match_reasons: r._reasons,
|
|
469
|
+
score_breakdown: r._breakdown,
|
|
470
|
+
};
|
|
471
|
+
}),
|
|
289
472
|
});
|
|
290
473
|
}
|
|
291
474
|
function handleForget(args) {
|
|
292
475
|
if (args.memory_id) {
|
|
293
|
-
|
|
294
|
-
|
|
476
|
+
// Explicit delete by id — respect protected AND pinned (importance >= 1.0)
|
|
477
|
+
const target = db.prepare('SELECT id, layer, importance, protected FROM memories WHERE id = ?').get(args.memory_id);
|
|
478
|
+
if (!target) {
|
|
479
|
+
return JSON.stringify({ ok: false, error: `memory_id ${args.memory_id} not found` });
|
|
480
|
+
}
|
|
481
|
+
if (target.protected === 1 || target.importance >= 1.0) {
|
|
482
|
+
return JSON.stringify({
|
|
483
|
+
ok: false,
|
|
484
|
+
preserved: true,
|
|
485
|
+
reason: target.protected === 1 ? `${target.layer}-layer is auto-protected` : 'pinned (importance=1.0)',
|
|
486
|
+
hint: 'Use update_memory to lower importance below 1.0 first, then forget.',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const res = db.prepare('DELETE FROM memories WHERE id = ?').run(args.memory_id);
|
|
490
|
+
return JSON.stringify({ ok: true, deleted: res.changes, memory_id: args.memory_id });
|
|
295
491
|
}
|
|
492
|
+
// Auto-sweep — also respect importance=1.0 as protection
|
|
296
493
|
const rows = db
|
|
297
|
-
.prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
|
|
494
|
+
.prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
|
|
495
|
+
FROM memories
|
|
496
|
+
WHERE protected = 0 AND importance < 1.0`)
|
|
298
497
|
.all();
|
|
299
498
|
const now = Math.floor(Date.now() / 1000);
|
|
300
499
|
const actions = [];
|
|
@@ -317,30 +516,168 @@ function handleForget(args) {
|
|
|
317
516
|
if (action !== 'keep')
|
|
318
517
|
actions.push({ id: r.id, action });
|
|
319
518
|
}
|
|
519
|
+
const toDropIds = actions.filter((a) => a.action === 'drop').map((a) => a.id);
|
|
320
520
|
if (!args.dry_run) {
|
|
321
521
|
const del = db.prepare('DELETE FROM memories WHERE id = ?');
|
|
322
|
-
const tx = db.transaction((
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
del.run(a.id);
|
|
326
|
-
});
|
|
327
|
-
tx(actions);
|
|
522
|
+
const tx = db.transaction((ids) => { for (const id of ids)
|
|
523
|
+
del.run(id); });
|
|
524
|
+
tx(toDropIds);
|
|
328
525
|
}
|
|
329
526
|
return JSON.stringify({
|
|
330
527
|
ok: true,
|
|
331
528
|
dry_run: !!args.dry_run,
|
|
332
529
|
scanned: rows.length,
|
|
333
|
-
to_drop:
|
|
530
|
+
to_drop: toDropIds.length,
|
|
334
531
|
to_compress: actions.filter((a) => a.action === 'compress').length,
|
|
532
|
+
sample_ids_to_drop: toDropIds.slice(0, 10),
|
|
335
533
|
});
|
|
336
534
|
}
|
|
337
535
|
function handleConsolidate(args) {
|
|
536
|
+
const dryRun = !!args?.dry_run;
|
|
537
|
+
if (dryRun) {
|
|
538
|
+
// Preview: simulate by running against a transaction we roll back
|
|
539
|
+
// We don't have a clean "preview" path in lib/consolidate, so do a best-effort
|
|
540
|
+
// read-only audit: count candidates using the same rules.
|
|
541
|
+
const now = Math.floor(Date.now() / 1000);
|
|
542
|
+
const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
|
|
543
|
+
const candidates = db.prepare(`
|
|
544
|
+
SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
|
|
545
|
+
FROM memories m
|
|
546
|
+
JOIN entities e ON e.id = m.entity_id
|
|
547
|
+
WHERE m.protected = 0
|
|
548
|
+
AND m.importance < 1.0
|
|
549
|
+
AND m.layer IN ('context', 'emotion', 'implementation')
|
|
550
|
+
AND m.created_at <= ?
|
|
551
|
+
GROUP BY m.entity_id, m.layer
|
|
552
|
+
HAVING c >= 2
|
|
553
|
+
ORDER BY c DESC
|
|
554
|
+
`).all(ageCutoff);
|
|
555
|
+
const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
|
|
556
|
+
return JSON.stringify({
|
|
557
|
+
ok: true,
|
|
558
|
+
dry_run: true,
|
|
559
|
+
clusters: candidates.length,
|
|
560
|
+
memories_replaced_if_run: totalReplaced,
|
|
561
|
+
preview: candidates.slice(0, 20).map((c) => ({
|
|
562
|
+
entity: c.entity_name,
|
|
563
|
+
layer: c.layer,
|
|
564
|
+
count: c.c,
|
|
565
|
+
})),
|
|
566
|
+
hint: 'Set dry_run=false to actually consolidate.',
|
|
567
|
+
});
|
|
568
|
+
}
|
|
338
569
|
const result = runConsolidate(db, {
|
|
339
570
|
scope: args?.scope ?? 'session',
|
|
340
571
|
min_age_days: typeof args?.min_age_days === 'number' ? args.min_age_days : undefined,
|
|
341
572
|
});
|
|
342
573
|
return JSON.stringify({ ok: true, ...result });
|
|
343
574
|
}
|
|
575
|
+
function handleUpdateMemory(args) {
|
|
576
|
+
const memoryId = Number(args.memory_id);
|
|
577
|
+
if (!Number.isFinite(memoryId)) {
|
|
578
|
+
return JSON.stringify({ ok: false, error: 'memory_id (number) required' });
|
|
579
|
+
}
|
|
580
|
+
const existing = db.prepare('SELECT id, entity_id, layer, content, importance, protected FROM memories WHERE id = ?').get(memoryId);
|
|
581
|
+
if (!existing) {
|
|
582
|
+
return JSON.stringify({ ok: false, error: `memory_id ${memoryId} not found` });
|
|
583
|
+
}
|
|
584
|
+
const patch = {};
|
|
585
|
+
if (typeof args.content === 'string')
|
|
586
|
+
patch.content = args.content;
|
|
587
|
+
if (typeof args.layer === 'string') {
|
|
588
|
+
const resolved = resolveLayer(args.layer);
|
|
589
|
+
if (!resolved || !LAYER_ENUM.includes(resolved)) {
|
|
590
|
+
return JSON.stringify({ ok: false, error: `unknown layer "${args.layer}"` });
|
|
591
|
+
}
|
|
592
|
+
// Cannot demote a caveat out of caveat (caveat is auto-protected by trigger)
|
|
593
|
+
if (existing.layer === 'caveat' && resolved !== 'caveat' && existing.protected === 1) {
|
|
594
|
+
return JSON.stringify({
|
|
595
|
+
ok: false,
|
|
596
|
+
error: 'Cannot move a protected caveat memory to another layer. Create a new memory in the target layer instead.',
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
patch.layer = resolved;
|
|
600
|
+
}
|
|
601
|
+
if (args.importance !== undefined) {
|
|
602
|
+
const imp = Math.min(1, Math.max(0, Number(args.importance)));
|
|
603
|
+
patch.importance = imp;
|
|
604
|
+
patch.protected = imp >= 1.0 || existing.protected === 1 ? 1 : 0;
|
|
605
|
+
}
|
|
606
|
+
const keys = Object.keys(patch);
|
|
607
|
+
if (keys.length === 0) {
|
|
608
|
+
return JSON.stringify({ ok: false, error: 'no fields to update (provide content, layer, or importance)' });
|
|
609
|
+
}
|
|
610
|
+
const setClause = keys.map((k) => `${k} = ?`).join(', ');
|
|
611
|
+
const values = keys.map((k) => patch[k]);
|
|
612
|
+
db.prepare(`UPDATE memories SET ${setClause} WHERE id = ?`).run(...values, memoryId);
|
|
613
|
+
// Record the update as an event for audit trail
|
|
614
|
+
db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(existing.entity_id, 'memory_updated', JSON.stringify({ memory_id: memoryId, changed: keys }));
|
|
615
|
+
return JSON.stringify({
|
|
616
|
+
ok: true,
|
|
617
|
+
memory_id: memoryId,
|
|
618
|
+
updated_fields: keys,
|
|
619
|
+
pinned: (patch.importance ?? existing.importance) >= 1.0,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function handleListEntities(args) {
|
|
623
|
+
const kind = args?.kind;
|
|
624
|
+
const minMemories = Math.max(1, Number(args?.min_memories ?? 1));
|
|
625
|
+
const limit = Math.max(1, Math.min(200, Number(args?.limit ?? 30)));
|
|
626
|
+
const offset = Math.max(0, Number(args?.offset ?? 0));
|
|
627
|
+
let sql = `
|
|
628
|
+
SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
|
|
629
|
+
e.updated_at, e.created_at,
|
|
630
|
+
COUNT(m.id) as memory_count,
|
|
631
|
+
MAX(m.last_accessed_at) as last_memory_access,
|
|
632
|
+
SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
|
|
633
|
+
SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
|
|
634
|
+
SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
|
|
635
|
+
SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
|
|
636
|
+
SUM(CASE WHEN m.importance >= 1.0 THEN 1 ELSE 0 END) as pinned_count
|
|
637
|
+
FROM entities e
|
|
638
|
+
LEFT JOIN memories m ON m.entity_id = e.id
|
|
639
|
+
WHERE 1=1
|
|
640
|
+
`;
|
|
641
|
+
const params = [];
|
|
642
|
+
if (kind) {
|
|
643
|
+
sql += ' AND e.kind = ?';
|
|
644
|
+
params.push(kind);
|
|
645
|
+
}
|
|
646
|
+
sql += ' GROUP BY e.id';
|
|
647
|
+
if (minMemories > 1)
|
|
648
|
+
sql += ' HAVING memory_count >= ?';
|
|
649
|
+
if (minMemories > 1)
|
|
650
|
+
params.push(minMemories);
|
|
651
|
+
// Sort: active (high momentum) first, then most memories, then most recent
|
|
652
|
+
sql += ' ORDER BY (COALESCE(e.momentum_score,0) * 10 + memory_count * 0.5 + (last_memory_access / 86400.0 / 365) * 2) DESC';
|
|
653
|
+
sql += ' LIMIT ? OFFSET ?';
|
|
654
|
+
params.push(limit, offset);
|
|
655
|
+
const rows = db.prepare(sql).all(...params);
|
|
656
|
+
const totalRow = db.prepare(kind ? 'SELECT COUNT(*) as c FROM entities WHERE kind = ?' : 'SELECT COUNT(*) as c FROM entities').get(...(kind ? [kind] : []));
|
|
657
|
+
return JSON.stringify({
|
|
658
|
+
ok: true,
|
|
659
|
+
total: totalRow.c,
|
|
660
|
+
returned: rows.length,
|
|
661
|
+
offset,
|
|
662
|
+
has_more: offset + rows.length < totalRow.c,
|
|
663
|
+
entities: rows.map((r) => ({
|
|
664
|
+
id: r.id,
|
|
665
|
+
name: r.name,
|
|
666
|
+
kind: r.kind,
|
|
667
|
+
canonical_key: r.canonical_key,
|
|
668
|
+
momentum: Number((r.momentum_score ?? 0).toFixed(2)),
|
|
669
|
+
memory_count: r.memory_count,
|
|
670
|
+
last_memory_access: r.last_memory_access ? new Date(r.last_memory_access * 1000).toISOString() : null,
|
|
671
|
+
layer_breakdown: {
|
|
672
|
+
goal: r.goal_count,
|
|
673
|
+
caveat: r.caveat_count,
|
|
674
|
+
learning: r.learning_count,
|
|
675
|
+
implementation: r.impl_count,
|
|
676
|
+
},
|
|
677
|
+
pinned_count: r.pinned_count,
|
|
678
|
+
})),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
344
681
|
function handleRecallFile(args) {
|
|
345
682
|
const sub = String(args.path_substring ?? '').trim();
|
|
346
683
|
if (!sub)
|
|
@@ -420,6 +757,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
420
757
|
case 'recall':
|
|
421
758
|
text = handleRecall(args);
|
|
422
759
|
break;
|
|
760
|
+
case 'update_memory':
|
|
761
|
+
text = handleUpdateMemory(args);
|
|
762
|
+
break;
|
|
763
|
+
case 'list_entities':
|
|
764
|
+
text = handleListEntities(args);
|
|
765
|
+
break;
|
|
423
766
|
case 'forget':
|
|
424
767
|
text = handleForget(args);
|
|
425
768
|
break;
|
|
@@ -445,5 +788,5 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
445
788
|
});
|
|
446
789
|
const transport = new StdioServerTransport();
|
|
447
790
|
await server.connect(transport);
|
|
448
|
-
process.stderr.write(
|
|
791
|
+
process.stderr.write(`[linksee-memory] MCP server ready on stdio (v${SERVER_VERSION})\n`);
|
|
449
792
|
//# sourceMappingURL=server.js.map
|
package/dist/skill/SKILL.md
CHANGED
|
@@ -43,6 +43,27 @@ Claude Code は session が終わると全部忘れる。Michieさんが昨日
|
|
|
43
43
|
|
|
44
44
|
**重要:** `caveat` は自動で忘却保護される。痛みの記録は絶対消えない。
|
|
45
45
|
|
|
46
|
+
**v0.1.0: 別のpin方法** — `importance: 1.0` で remember すれば、caveat層じゃなくても auto-forget から保護される。「絶対忘れさせたくない goal」「重要な判断」等で使う:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
remember({
|
|
50
|
+
entity_name: "KanseiLink", entity_kind: "project",
|
|
51
|
+
layer: "goal", content: "Plugin Marketplace 申請中",
|
|
52
|
+
importance: 1.0 // pin
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Layer alias 対応** — canonical layer 名覚えなくても、自然な言い方で呼べる:
|
|
57
|
+
|
|
58
|
+
| 自然語 | → canonical |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `decisions` / `insights` / `learned` | `learning` |
|
|
61
|
+
| `warnings` / `rules` / `pitfalls` / `dont` | `caveat` |
|
|
62
|
+
| `how` / `tried` / `attempts` / `success` / `failure` | `implementation` |
|
|
63
|
+
| `why` / `intent` / `goals` / `targets` | `goal` |
|
|
64
|
+
| `background` / `reason` / `situation` / `timing` | `context` |
|
|
65
|
+
| `tone` / `feelings` / `mood` | `emotion` |
|
|
66
|
+
|
|
46
67
|
---
|
|
47
68
|
|
|
48
69
|
## 🔄 実行フロー(5つのタイミング)
|
|
@@ -51,6 +72,18 @@ Claude Code は session が終わると全部忘れる。Michieさんが昨日
|
|
|
51
72
|
|
|
52
73
|
新しい作業を始める**前**に、過去のコンテキストを注入する。
|
|
53
74
|
|
|
75
|
+
**v0.1.0 新フロー: 超-セッション冒頭は `list_entities` から**
|
|
76
|
+
|
|
77
|
+
会話の一番最初(ユーザーが最初の発話をしてきた時点)で、「自分が何を知ってるか」を把握する:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
mcp__linksee__list_entities({ min_memories: 5, limit: 10 })
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
これで返ってくる "momentum 高い entity" = 今まさに話題に上りそうなプロジェクト。各entityの `layer_breakdown` を見れば「このprojectはcaveatが多い」「goalが未完」等が分かる。
|
|
84
|
+
|
|
85
|
+
その後、具体的な作業が始まったら recall:
|
|
86
|
+
|
|
54
87
|
```
|
|
55
88
|
mcp__linksee__recall({
|
|
56
89
|
query: "<現タスクのキーワード。プロジェクト名 + 技術名>",
|
|
@@ -106,6 +139,20 @@ mcp__linksee__read_smart({
|
|
|
106
139
|
|
|
107
140
|
特に大きなファイル(1000行超えるもの)で効果絶大。
|
|
108
141
|
|
|
142
|
+
### ③.5 Updating existing memory — v0.1.0 新: forget+remember の代わりに update_memory
|
|
143
|
+
|
|
144
|
+
事実が変わった / 目標が更新された / caveat の細部を修正したい場合、**forget して remember すると memory_id が変わって session_file_edits のリンクが切れる**。代わりに `update_memory`:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
update_memory({
|
|
148
|
+
memory_id: 1234,
|
|
149
|
+
content: '{"primary": "Plugin Marketplace 申請中(審査7日目)", "deadline": "2026-04-25"}',
|
|
150
|
+
importance: 1.0 // pin 強化
|
|
151
|
+
})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`layer` も変更可能だが、caveat → 他への demotion は不可(auto-protectedのため)。
|
|
155
|
+
|
|
109
156
|
### ④ Failure — エラー発生時に caveat 記録
|
|
110
157
|
|
|
111
158
|
エラー・失敗・「うまくいかない」が発生した瞬間、すぐ記録する:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"mcpName": "io.github.michielinksee/linksee-memory",
|
|
5
5
|
"description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"linksee-memory": "./dist/mcp/server.js",
|
|
9
9
|
"linksee-memory-import": "./dist/bin/import-sessions.js",
|
|
10
10
|
"linksee-memory-sync": "./dist/bin/sync-session.js",
|
|
11
|
-
"linksee-memory-install-skill": "./dist/bin/install-skill.js"
|
|
11
|
+
"linksee-memory-install-skill": "./dist/bin/install-skill.js",
|
|
12
|
+
"linksee-memory-stats": "./dist/bin/stats.js"
|
|
12
13
|
},
|
|
13
14
|
"main": "./dist/mcp/server.js",
|
|
14
15
|
"files": [
|