linksee-memory 0.11.2 → 0.11.4
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 +40 -1
- package/dist/bin/export-report.d.ts +2 -0
- package/dist/bin/export-report.js +208 -0
- package/dist/bin/stats.js +22 -0
- package/dist/db/migrate.js +42 -5
- package/dist/db/schema.sql +21 -2
- package/dist/lib/anchor-touch.d.ts +47 -0
- package/dist/lib/anchor-touch.js +114 -0
- package/dist/lib/map-view.d.ts +3 -0
- package/dist/lib/map-view.js +14 -3
- package/dist/lib/telemetry.d.ts +2 -0
- package/dist/lib/telemetry.js +10 -3
- package/dist/mcp/server.js +39 -6
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -419,6 +419,26 @@ Add to `~/.gemini/settings.json`:
|
|
|
419
419
|
|
|
420
420
|
</details>
|
|
421
421
|
|
|
422
|
+
<details>
|
|
423
|
+
<summary><strong>Claude Desktop</strong></summary>
|
|
424
|
+
|
|
425
|
+
Add the same stdio command to `claude_desktop_config.json`:
|
|
426
|
+
|
|
427
|
+
```json
|
|
428
|
+
{
|
|
429
|
+
"mcpServers": {
|
|
430
|
+
"linksee": {
|
|
431
|
+
"command": "npx",
|
|
432
|
+
"args": ["-y", "linksee-memory"]
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Config file: macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%\Claude\`. Restart Claude Desktop.
|
|
439
|
+
|
|
440
|
+
</details>
|
|
441
|
+
|
|
422
442
|
All editors share the same `~/.linksee-memory/memory.db`. A decision made in Claude Code is recalled in Cursor. A caveat recorded in Windsurf prevents the same mistake in Codex.
|
|
423
443
|
|
|
424
444
|
### Database location
|
|
@@ -593,6 +613,20 @@ Claude Code ships a built-in memory feature at `~/.claude/projects/<path>/memory
|
|
|
593
613
|
|
|
594
614
|
Use both.
|
|
595
615
|
|
|
616
|
+
## Security & privacy
|
|
617
|
+
|
|
618
|
+
linksee-memory runs locally and is built to read — and send — as little as possible.
|
|
619
|
+
|
|
620
|
+
- **Local-first.** Memory is one SQLite file at `~/.linksee-memory/memory.db`. No account, no cloud, no API key.
|
|
621
|
+
- **Telemetry is opt-in and OFF by default.** Nothing is sent unless you set `LINKSEE_TELEMETRY=basic`. Even then it never sends your source code, file contents, prompts, conversation, entity/project names, or the memory DB — only anonymous counters ([details](#telemetry-opt-in-off-by-default)).
|
|
622
|
+
- **No automatic repo crawling.** linksee reads: memory you explicitly save, your `map.yaml`, the specific files a map reality-check points at, the local SQLite DB, and — when the Stop hook fires — your Claude Code session transcript (locally, to capture what happened). It does **not** crawl your repo, read `.env`/secrets/`node_modules`, or touch your home directory on its own.
|
|
623
|
+
- **Clean MCP transport.** The server writes only JSON-RPC to stdout; all logs go to stderr.
|
|
624
|
+
- **Hooks are documented and removable.** `setup` adds a Stop hook (session capture) and an optional guard hook. They make no network calls by default, are time-bounded, fail-open (a hook error never breaks your session), and are listed under [Uninstall](#uninstall).
|
|
625
|
+
- **No shell-injection surface.** Subcommands run via `spawn` with array args and `shell: false`, from a fixed allowlist; `map.yaml` is parsed with the safe `yaml` parser (no arbitrary tag execution).
|
|
626
|
+
- **Supply chain.** MIT, published from a single owner. `npx -y linksee-memory` runs the published package — pin a version in CI if you need reproducibility.
|
|
627
|
+
|
|
628
|
+
Found a security issue? See [SECURITY.md](SECURITY.md).
|
|
629
|
+
|
|
596
630
|
## Telemetry (opt-in, off by default)
|
|
597
631
|
|
|
598
632
|
linksee-memory ships with **opt-in** anonymous telemetry that helps us understand which MCP servers and workflows actually work in the wild. **Nothing is sent unless you explicitly enable it.** No conversation content, no file content, no entity names, no project paths — ever.
|
|
@@ -606,7 +640,7 @@ export LINKSEE_TELEMETRY=off # opt out (or just unset the variable)
|
|
|
606
640
|
|
|
607
641
|
### Exactly what gets sent (Level 1 contract)
|
|
608
642
|
|
|
609
|
-
After each Claude Code session ends, the Stop hook sends one POST to `https://
|
|
643
|
+
After each Claude Code session ends, the Stop hook sends one POST to `https://linksee-site.vercel.app/api/telemetry/linksee` containing only these fields:
|
|
610
644
|
|
|
611
645
|
| Field | Example | What it is |
|
|
612
646
|
|---|---|---|
|
|
@@ -804,6 +838,11 @@ After install, in a new Claude session ask: *"Can you remember that I prefer Typ
|
|
|
804
838
|
|
|
805
839
|
## Changelog
|
|
806
840
|
|
|
841
|
+
### v0.11.3 — Robustness + MCP hygiene (2026-06-16)
|
|
842
|
+
|
|
843
|
+
- **Corrupt-database recovery:** if `~/.linksee-memory/memory.db` is unreadable, linksee preserves it as `memory.db.corrupt-<timestamp>` and starts a fresh one (with a clear message) instead of crashing with a raw SQLite error. Old memories stay recoverable in the backup.
|
|
844
|
+
- **`recall` tool description** no longer suggests editing your system prompt — cleaner MCP citizenship.
|
|
845
|
+
|
|
807
846
|
### v0.11.2 — More cold-start hardening (2026-06-16)
|
|
808
847
|
|
|
809
848
|
- **`stats` works on a fresh database** instead of crashing with `no such table` — it ensures the schema exists first (it may be the first command a new user runs).
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory-export — export a project's memory as a shareable Markdown report.
|
|
3
|
+
// Usage:
|
|
4
|
+
// npx -y linksee-memory export <project> → Markdown to stdout
|
|
5
|
+
// npx -y linksee-memory export <project> --out file.md
|
|
6
|
+
//
|
|
7
|
+
// The point (cold-start killer + quiet team sharing): pull your decisions + the WHY
|
|
8
|
+
// behind them + what's drifting OUT as a readable artifact you can paste into Notion or
|
|
9
|
+
// Slack — for a team that never opens the dashboard. Read-only. Surfaces drift on
|
|
10
|
+
// purpose, so the day-1 value pulls toward the core instead of away from it.
|
|
11
|
+
import { writeFileSync } from 'node:fs';
|
|
12
|
+
import { openDb, runMigrations } from '../db/migrate.js';
|
|
13
|
+
import { getAnchorRetention } from '../lib/anchor-touch.js';
|
|
14
|
+
function parseArgs() {
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
const a = { project: null, out: null, help: false };
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const v = argv[i];
|
|
19
|
+
if (v === '--out' || v === '-o')
|
|
20
|
+
a.out = argv[++i] ?? null;
|
|
21
|
+
else if (v === '-h' || v === '--help')
|
|
22
|
+
a.help = true;
|
|
23
|
+
else if (!v.startsWith('-') && a.project === null)
|
|
24
|
+
a.project = v;
|
|
25
|
+
}
|
|
26
|
+
return a;
|
|
27
|
+
}
|
|
28
|
+
const fmtDate = (unix) => (unix ? new Date(unix * 1000).toISOString().slice(0, 10) : '—');
|
|
29
|
+
const clip = (s, n = 240) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
|
30
|
+
// memories.content is either plain text or a JSON blob {what, why, title, …}. Pull the
|
|
31
|
+
// human-meaningful pair (what + why) so the report reads like prose, not a data dump.
|
|
32
|
+
function parseContent(raw) {
|
|
33
|
+
try {
|
|
34
|
+
const j = JSON.parse(raw);
|
|
35
|
+
if (j && typeof j === 'object') {
|
|
36
|
+
const what = String(j.what ?? j.title ?? j.learned ?? j.rule_or_warning ?? '').trim();
|
|
37
|
+
const whyRaw = j.why ?? j.from_incident ?? null;
|
|
38
|
+
const why = whyRaw ? String(whyRaw).trim() : null;
|
|
39
|
+
if (what)
|
|
40
|
+
return { what, why };
|
|
41
|
+
// pure machine log (intent/session capture) with no human field → empty → dropped as noise
|
|
42
|
+
if (j.intent || j.when || j.session_id || j.at)
|
|
43
|
+
return { what: '', why: null };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch { /* plain text — fall through */ }
|
|
47
|
+
return { what: raw.replace(/\s+/g, ' ').trim(), why: null };
|
|
48
|
+
}
|
|
49
|
+
function main() {
|
|
50
|
+
const args = parseArgs();
|
|
51
|
+
if (args.help) {
|
|
52
|
+
console.log(`linksee-memory-export — export a project's memory as a Markdown report
|
|
53
|
+
|
|
54
|
+
<project> Entity/project name to export (default: highest-momentum project)
|
|
55
|
+
--out, -o <file> Write to a file instead of stdout
|
|
56
|
+
-h, --help This message
|
|
57
|
+
`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const db = openDb();
|
|
61
|
+
runMigrations(db);
|
|
62
|
+
// Resolve the project entity (by name / normalized name / canonical key; else top project).
|
|
63
|
+
let entity = args.project
|
|
64
|
+
? db.prepare(`SELECT id, name, kind, momentum_score FROM entities
|
|
65
|
+
WHERE name = ? OR normalized_name = ? OR canonical_key = ? LIMIT 1`).get(args.project, args.project.toLowerCase(), args.project)
|
|
66
|
+
: null;
|
|
67
|
+
if (!entity) {
|
|
68
|
+
entity = db.prepare(`SELECT id, name, kind, momentum_score FROM entities WHERE kind = 'project'
|
|
69
|
+
ORDER BY momentum_score DESC LIMIT 1`).get();
|
|
70
|
+
}
|
|
71
|
+
if (!entity) {
|
|
72
|
+
console.error('No project found. Pass a project name: linksee export <project>');
|
|
73
|
+
db.close();
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
78
|
+
// Decisions & WHY. Scope to THIS project when its Map links anchors to nodes
|
|
79
|
+
// (map_nodes.anchor_id); otherwise fall back to the project-wide truth map. No schema
|
|
80
|
+
// change — we reuse the existing map↔anchor linkage so the report isn't polluted by
|
|
81
|
+
// other projects' decisions (B fix, 2026-06-18).
|
|
82
|
+
const SELECT_ANCHORS = 'SELECT id, kind, statement, rationale, decision_mode, domain, lifecycle, confidence, review_after, updated_at FROM drift_anchors';
|
|
83
|
+
const ORDER_ANCHORS = "ORDER BY (lifecycle != 'active') DESC, updated_at DESC";
|
|
84
|
+
const mapProject = db.prepare('SELECT project FROM map_projects WHERE project = ? OR LOWER(project) = LOWER(?) LIMIT 1').get(entity.name, entity.name)?.project;
|
|
85
|
+
const scopedIds = mapProject
|
|
86
|
+
? db.prepare('SELECT DISTINCT anchor_id FROM map_nodes WHERE project = ? AND anchor_id IS NOT NULL')
|
|
87
|
+
.all(mapProject).map((r) => r.anchor_id)
|
|
88
|
+
: [];
|
|
89
|
+
const scopedAnchors = scopedIds.length
|
|
90
|
+
? db.prepare(`${SELECT_ANCHORS} WHERE status = 'active' AND id IN (${scopedIds.map(() => '?').join(',')}) ${ORDER_ANCHORS}`).all(...scopedIds)
|
|
91
|
+
: [];
|
|
92
|
+
// Use the project-scoped set ONLY if the Map wires enough decisions to it. A hand-written
|
|
93
|
+
// map links just a handful of anchors → too sparse to scope by → fall back to the
|
|
94
|
+
// project-wide truth map (clearly labeled). The proper fix is a `project` column on anchors.
|
|
95
|
+
const anchorScoped = scopedAnchors.length >= 4;
|
|
96
|
+
const anchors = (anchorScoped
|
|
97
|
+
? scopedAnchors
|
|
98
|
+
: db.prepare(`${SELECT_ANCHORS} WHERE status = 'active' ${ORDER_ANCHORS}`).all());
|
|
99
|
+
const needsAttention = anchors.filter((a) => ['at_risk', 'experiment', 'superseded', 'paused', 'deprecated'].includes(a.lifecycle)
|
|
100
|
+
|| (a.review_after && a.review_after < nowS));
|
|
101
|
+
// Key memories for this entity, grouped by layer — filtered to what's worth SHARING.
|
|
102
|
+
// Drop the two noise sources a shareable report must not leak: raw session-intent pastes
|
|
103
|
+
// (un-distilled first-message captures) and auto edit-logs ("edit foo.ts (4 ops)").
|
|
104
|
+
const SESSION_INTENT = 'Session intent — first user message';
|
|
105
|
+
const EDIT_LOG = /^(edit|write|read|write\+edit)\b.*\(\d+\s*ops?\)/i;
|
|
106
|
+
const isNoise = (what, why) => !what.trim()
|
|
107
|
+
|| why === SESSION_INTENT
|
|
108
|
+
|| EDIT_LOG.test(what)
|
|
109
|
+
|| /\(\d+\s*ops?\)\s*$/.test(what)
|
|
110
|
+
|| /^\{[\s\S]*"(intent|session_id|when)"/.test(what);
|
|
111
|
+
const mems = db.prepare(`SELECT layer, content, importance, protected, created_at FROM memories
|
|
112
|
+
WHERE entity_id = ? ORDER BY importance DESC, created_at DESC`).all(entity.id)
|
|
113
|
+
.map((m) => ({ ...m, parsed: parseContent(m.content) }))
|
|
114
|
+
.filter((m) => !isNoise(m.parsed.what, m.parsed.why))
|
|
115
|
+
// internal layers (implementation/context) only surface their explicitly-pinned notes here
|
|
116
|
+
.filter((m) => !['implementation', 'context'].includes(m.layer) || m.protected || m.importance >= 0.9);
|
|
117
|
+
const byLayer = {};
|
|
118
|
+
for (const m of mems)
|
|
119
|
+
(byLayer[m.layer] ??= []).push(m);
|
|
120
|
+
const retention = getAnchorRetention(db);
|
|
121
|
+
// ── Render Markdown ──────────────────────────────────────────────────────────
|
|
122
|
+
const L = [];
|
|
123
|
+
L.push(`# ${entity.name} — Memory Report`);
|
|
124
|
+
L.push('');
|
|
125
|
+
L.push(`> A snapshot of the decisions, the *why* behind them, and what's drifting — exported from `
|
|
126
|
+
+ `Linksee Memory on ${fmtDate(nowS)}. Paste it into Notion / Slack to share with anyone who never `
|
|
127
|
+
+ `opens the dashboard.`);
|
|
128
|
+
L.push('');
|
|
129
|
+
// Attention FIRST — the core-dependent hook (day-1 value points at the moat, not away).
|
|
130
|
+
L.push(`## ⚠️ Needs attention (${needsAttention.length})`);
|
|
131
|
+
if (needsAttention.length === 0) {
|
|
132
|
+
L.push('Nothing drifting right now — every active decision still matches reality. ✅');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
for (const a of needsAttention) {
|
|
136
|
+
const overdue = a.review_after && a.review_after < nowS ? ', review overdue' : '';
|
|
137
|
+
L.push(`- **#${a.id} ${clip(a.statement, 160)}** _(${a.lifecycle}${overdue})_`);
|
|
138
|
+
if (a.rationale)
|
|
139
|
+
L.push(` - why: ${clip(a.rationale, 200)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
L.push('');
|
|
143
|
+
// Decisions & the why.
|
|
144
|
+
const modeLabel = {
|
|
145
|
+
constraint: 'constraint', commitment: 'commitment', hypothesis: 'hypothesis',
|
|
146
|
+
source_of_truth: 'source-of-truth', preference: 'preference', metric: 'metric',
|
|
147
|
+
};
|
|
148
|
+
L.push(`## Decisions & the why (${anchors.length})${anchorScoped ? '' : ' _— project-wide truth map (this project has no scoped Map)_'}`);
|
|
149
|
+
for (const a of anchors) {
|
|
150
|
+
const tag = a.decision_mode ? (modeLabel[a.decision_mode] ?? a.decision_mode) : a.kind;
|
|
151
|
+
L.push(`- \`${tag}\` **${clip(a.statement, 200)}**`);
|
|
152
|
+
if (a.rationale)
|
|
153
|
+
L.push(` - why: ${clip(a.rationale, 240)}`);
|
|
154
|
+
}
|
|
155
|
+
L.push('');
|
|
156
|
+
// Memory by layer.
|
|
157
|
+
const layerOrder = ['goal', 'learning', 'caveat', 'implementation', 'context', 'emotion'];
|
|
158
|
+
const layerTitle = {
|
|
159
|
+
goal: '🎯 Goals', learning: '💡 Learnings & decisions', caveat: '⚠️ Caveats (hard-won)',
|
|
160
|
+
implementation: '🔧 Implementation notes', context: '📎 Context', emotion: '🫧 Signals',
|
|
161
|
+
};
|
|
162
|
+
L.push('## Memory by layer');
|
|
163
|
+
for (const layer of layerOrder) {
|
|
164
|
+
const items = byLayer[layer];
|
|
165
|
+
if (!items || items.length === 0)
|
|
166
|
+
continue;
|
|
167
|
+
L.push('');
|
|
168
|
+
L.push(`### ${layerTitle[layer] ?? layer} (${items.length})`);
|
|
169
|
+
for (const m of items.slice(0, 12)) {
|
|
170
|
+
const { what, why } = m.parsed;
|
|
171
|
+
const pin = m.protected || m.importance >= 0.9 ? '📌 ' : '';
|
|
172
|
+
L.push(`- ${pin}${clip(what, 220)}`);
|
|
173
|
+
if (why)
|
|
174
|
+
L.push(` - why: ${clip(why, 200)}`);
|
|
175
|
+
}
|
|
176
|
+
if (items.length > 12)
|
|
177
|
+
L.push(`- …and ${items.length - 12} more`);
|
|
178
|
+
}
|
|
179
|
+
L.push('');
|
|
180
|
+
// Decision trajectory (the D7 bridge metric — proof the memory is being used, not just stored).
|
|
181
|
+
L.push('## Decision trajectory');
|
|
182
|
+
if (retention.totalAnchors === 0) {
|
|
183
|
+
L.push('No decisions recorded yet.');
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const pct = Math.round(retention.retentionRate * 100);
|
|
187
|
+
L.push(`- ${retention.totalAnchors} decisions across ${retention.activeDays} active day(s)`);
|
|
188
|
+
L.push(`- ${retention.retainedAnchors}/${retention.totalAnchors} revisited within 7 days (${pct}%)`);
|
|
189
|
+
L.push(`- first: ${fmtDate(retention.firstAnchorAt)} · last: ${fmtDate(retention.lastAnchorAt)}`);
|
|
190
|
+
}
|
|
191
|
+
L.push('');
|
|
192
|
+
L.push('---');
|
|
193
|
+
L.push(`_Generated by **Linksee Memory** · \`linksee export ${entity.name}\` · local-first — your data never left your machine._`);
|
|
194
|
+
if (needsAttention.length > 0) {
|
|
195
|
+
L.push(`_⚠️ ${needsAttention.length} decision(s) need attention — ask your agent "what's drifting?" or run \`linksee drift\`._`);
|
|
196
|
+
}
|
|
197
|
+
const md = L.join('\n') + '\n';
|
|
198
|
+
if (args.out) {
|
|
199
|
+
writeFileSync(args.out, md, 'utf8');
|
|
200
|
+
console.error(`Wrote ${md.length} chars → ${args.out}`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
process.stdout.write(md);
|
|
204
|
+
}
|
|
205
|
+
db.close();
|
|
206
|
+
}
|
|
207
|
+
main();
|
|
208
|
+
//# sourceMappingURL=export-report.js.map
|
package/dist/bin/stats.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Safe to run anytime (read-only).
|
|
9
9
|
import { statSync } from 'node:fs';
|
|
10
10
|
import { openDb, runMigrations, getDbPath } from '../db/migrate.js';
|
|
11
|
+
import { getAnchorRetention } from '../lib/anchor-touch.js';
|
|
11
12
|
function parseArgs() {
|
|
12
13
|
const argv = process.argv.slice(2);
|
|
13
14
|
const a = { json: false, perEntity: 5, help: false };
|
|
@@ -107,6 +108,7 @@ function main() {
|
|
|
107
108
|
ORDER BY edits DESC
|
|
108
109
|
LIMIT 5
|
|
109
110
|
`).all();
|
|
111
|
+
const anchorRetention = getAnchorRetention(db);
|
|
110
112
|
const result = {
|
|
111
113
|
db_path: dbPath,
|
|
112
114
|
db_size: sizeBytes,
|
|
@@ -132,6 +134,7 @@ function main() {
|
|
|
132
134
|
edits: f.edits,
|
|
133
135
|
in_sessions: f.in_sessions,
|
|
134
136
|
})),
|
|
137
|
+
anchor_retention: anchorRetention,
|
|
135
138
|
};
|
|
136
139
|
if (args.json) {
|
|
137
140
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -154,6 +157,25 @@ function main() {
|
|
|
154
157
|
console.log(` sessions seen: ${counts.sessions_seen}`);
|
|
155
158
|
console.log(` consolidations: ${counts.consolidations}`);
|
|
156
159
|
console.log('');
|
|
160
|
+
// Decision trajectory — the D7 bridge metric (this install). Founder-sales read-out
|
|
161
|
+
// + the seed of the "your decision trajectory" digest.
|
|
162
|
+
console.log(' Decision trajectory (D7 bridge metric — this install)');
|
|
163
|
+
if (anchorRetention.totalAnchors === 0) {
|
|
164
|
+
console.log(' no decisions recorded yet — declare one or ask "what\'s drifting?" to start.');
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const ar = anchorRetention;
|
|
168
|
+
const pct = Math.round(ar.retentionRate * 100);
|
|
169
|
+
console.log(` decisions recorded: ${ar.totalAnchors} across ${ar.activeDays} active day(s)`);
|
|
170
|
+
console.log(` revisited within 7d: ${ar.retainedAnchors} of ${ar.totalAnchors} (${pct}%)`);
|
|
171
|
+
console.log(` first decision: ${humanAge(ar.firstAnchorAt)} · last: ${humanAge(ar.lastAnchorAt)}`);
|
|
172
|
+
console.log(` return interactions: ${ar.returnInteractions} (inspect / drift-status / resolve / guard re-surface)`);
|
|
173
|
+
if (ar.mostRevisited) {
|
|
174
|
+
const s = ar.mostRevisited.statement.length > 48 ? ar.mostRevisited.statement.slice(0, 47) + '…' : ar.mostRevisited.statement;
|
|
175
|
+
console.log(` most revisited: #${ar.mostRevisited.anchor_id} "${s}" (x${ar.mostRevisited.touches})`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
console.log('');
|
|
157
179
|
if (layerBreakdown.length > 0) {
|
|
158
180
|
console.log(' Memories by layer');
|
|
159
181
|
for (const r of layerBreakdown) {
|
package/dist/db/migrate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { mkdirSync, renameSync, existsSync } 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';
|
|
@@ -10,12 +10,49 @@ const DB_PATH = join(DEFAULT_DB_DIR, 'memory.db');
|
|
|
10
10
|
export function getDbPath() {
|
|
11
11
|
return DB_PATH;
|
|
12
12
|
}
|
|
13
|
+
function openAt(path) {
|
|
14
|
+
const db = new Database(path);
|
|
15
|
+
try {
|
|
16
|
+
db.pragma('journal_mode = WAL'); // first real read of the file header — throws if it isn't a DB
|
|
17
|
+
db.pragma('foreign_keys = ON');
|
|
18
|
+
return db;
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
try {
|
|
22
|
+
db.close();
|
|
23
|
+
}
|
|
24
|
+
catch { /* ignore */ } // release the handle so a corrupt file can be renamed (Windows locks it otherwise)
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
export function openDb() {
|
|
14
29
|
mkdirSync(DEFAULT_DB_DIR, { recursive: true });
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
try {
|
|
31
|
+
return openAt(DB_PATH);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
35
|
+
// A corrupt / non-database file throws on the first pragma. Don't crash with a raw
|
|
36
|
+
// stack trace: preserve the bad file (so it can be recovered) and start a fresh DB.
|
|
37
|
+
if (/not a database|file is encrypted|malformed|disk image/i.test(msg) && existsSync(DB_PATH)) {
|
|
38
|
+
const backup = `${DB_PATH}.corrupt-${Date.now()}`;
|
|
39
|
+
try {
|
|
40
|
+
renameSync(DB_PATH, backup);
|
|
41
|
+
}
|
|
42
|
+
catch { /* best effort */ }
|
|
43
|
+
for (const ext of ['-wal', '-shm']) {
|
|
44
|
+
try {
|
|
45
|
+
if (existsSync(DB_PATH + ext))
|
|
46
|
+
renameSync(DB_PATH + ext, backup + ext);
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
process.stderr.write(`[linksee-memory] the memory database was unreadable (${msg}). ` +
|
|
51
|
+
`Moved it to ${backup} and started a fresh one — your old memories are preserved there for recovery.\n`);
|
|
52
|
+
return openAt(DB_PATH);
|
|
53
|
+
}
|
|
54
|
+
throw err; // not a corruption we recognize — surface it
|
|
55
|
+
}
|
|
19
56
|
}
|
|
20
57
|
export function runMigrations(db) {
|
|
21
58
|
const __filename = fileURLToPath(import.meta.url);
|
package/dist/db/schema.sql
CHANGED
|
@@ -358,6 +358,25 @@ CREATE TABLE IF NOT EXISTS injection_log (
|
|
|
358
358
|
CREATE INDEX IF NOT EXISTS idx_injlog_anchor ON injection_log(anchor_id, occurred_at);
|
|
359
359
|
CREATE INDEX IF NOT EXISTS idx_injlog_session ON injection_log(session_id, occurred_at);
|
|
360
360
|
|
|
361
|
+
-- ============================================================
|
|
362
|
+
-- v15: anchor_touch_log — the D7 retention metric for decision-writers.
|
|
363
|
+
-- Bridge metric: of installs that record a decision, what fraction COME BACK and
|
|
364
|
+
-- interact with a PRIOR decision within 7 days. Captures the READ/inspect signals
|
|
365
|
+
-- (check_decision, drift_status) that nothing else logs, plus create/resolve. Guard
|
|
366
|
+
-- re-surfaces already live in injection_log and are UNIONed in queries. anchor_id is
|
|
367
|
+
-- NULL for whole-set reviews (drift_status). No content, ever — only ids + a verb + a ts.
|
|
368
|
+
-- ============================================================
|
|
369
|
+
CREATE TABLE IF NOT EXISTS anchor_touch_log (
|
|
370
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
371
|
+
anchor_id INTEGER REFERENCES drift_anchors(id) ON DELETE CASCADE,
|
|
372
|
+
session_id TEXT,
|
|
373
|
+
tool TEXT NOT NULL, -- declare_anchor | check_decision | drift_status | resolve_drift
|
|
374
|
+
interaction TEXT NOT NULL CHECK (interaction IN ('create','inspect','review','resolve','resurface')),
|
|
375
|
+
occurred_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
376
|
+
);
|
|
377
|
+
CREATE INDEX IF NOT EXISTS idx_anchor_touch_time ON anchor_touch_log(occurred_at);
|
|
378
|
+
CREATE INDEX IF NOT EXISTS idx_anchor_touch_anchor ON anchor_touch_log(anchor_id, occurred_at);
|
|
379
|
+
|
|
361
380
|
-- ============================================================
|
|
362
381
|
-- v11: Current Truth Map — journey-spine topology (Product Drift OS spec v3).
|
|
363
382
|
-- map.yaml (git) is the desired-state SOURCE OF TRUTH (anchor #58); these tables
|
|
@@ -437,6 +456,6 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
437
456
|
value TEXT NOT NULL
|
|
438
457
|
);
|
|
439
458
|
|
|
440
|
-
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '
|
|
459
|
+
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '15');
|
|
441
460
|
INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
|
|
442
|
-
UPDATE meta SET value = '
|
|
461
|
+
UPDATE meta SET value = '15' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type AnchorInteraction = 'create' | 'inspect' | 'review' | 'resolve' | 'resurface';
|
|
3
|
+
/** Record one interaction with a decision/anchor. Best-effort — never throws. */
|
|
4
|
+
export declare function logAnchorTouch(db: Database.Database, t: {
|
|
5
|
+
anchorId?: number | null;
|
|
6
|
+
sessionId?: string | null;
|
|
7
|
+
tool: string;
|
|
8
|
+
interaction: AnchorInteraction;
|
|
9
|
+
}): void;
|
|
10
|
+
/**
|
|
11
|
+
* Per-session counts for telemetry — COUNTS ONLY, no content.
|
|
12
|
+
* Window = [startSec, endSec] (unix seconds, the session's edit span).
|
|
13
|
+
* The server turns these per-session counts into the fleet D7 rate by ordering an
|
|
14
|
+
* install's sessions: D0 = first session with anchor_creates>0; retained if a later
|
|
15
|
+
* session within 7 days has anchor_returns>0.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getSessionAnchorCounts(db: Database.Database, startSec: number, endSec: number): {
|
|
18
|
+
anchor_creates: number;
|
|
19
|
+
anchor_returns: number;
|
|
20
|
+
};
|
|
21
|
+
export interface AnchorRetention {
|
|
22
|
+
totalAnchors: number;
|
|
23
|
+
firstAnchorAt: number | null;
|
|
24
|
+
lastAnchorAt: number | null;
|
|
25
|
+
returnInteractions: number;
|
|
26
|
+
activeDays: number;
|
|
27
|
+
retainedAnchors: number;
|
|
28
|
+
retentionRate: number;
|
|
29
|
+
mostRevisited: {
|
|
30
|
+
anchor_id: number;
|
|
31
|
+
statement: string;
|
|
32
|
+
touches: number;
|
|
33
|
+
} | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Local, single-install decision-trajectory view. The source for `stats` (the
|
|
37
|
+
* founder-sales 1:1 read-out) and the seed of the "your decision trajectory" digest.
|
|
38
|
+
*
|
|
39
|
+
* The local readout is PER-DECISION retention (recency-independent): of the decisions
|
|
40
|
+
* you've recorded, what fraction did you come back to within 7 days of recording each.
|
|
41
|
+
* The fleet GATE rate (install-cohort "returned within 7d of the FIRST decision") is a
|
|
42
|
+
* different, cohort-entry metric — computed server-side from the per-session telemetry
|
|
43
|
+
* counts in getSessionAnchorCounts(), not here.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getAnchorRetention(db: Database.Database, opts?: {
|
|
46
|
+
windowDays?: number;
|
|
47
|
+
}): AnchorRetention;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Decision-trajectory instrumentation — the D7 retention metric for decision-writers.
|
|
2
|
+
//
|
|
3
|
+
// The bridge metric (Linksee Memory strategy): of installs that record a decision
|
|
4
|
+
// (an anchor), what fraction COME BACK and interact with a PRIOR decision within
|
|
5
|
+
// 7 days. This single number gates cross-tool expansion.
|
|
6
|
+
//
|
|
7
|
+
// What nothing else captured: the READ/inspect signals (check_decision, drift_status).
|
|
8
|
+
// Creations live in drift_anchors.created_at; guard re-surfaces live in injection_log;
|
|
9
|
+
// this table fills the gap and unifies them for the metric.
|
|
10
|
+
//
|
|
11
|
+
// PRIVACY: logs only {anchor_id, optional session_id, a verb, a timestamp}. NEVER any
|
|
12
|
+
// statement text, rationale, or content. Best-effort: a failure here must NEVER break
|
|
13
|
+
// the tool call that triggered it.
|
|
14
|
+
const DAY = 86400;
|
|
15
|
+
/** Record one interaction with a decision/anchor. Best-effort — never throws. */
|
|
16
|
+
export function logAnchorTouch(db, t) {
|
|
17
|
+
try {
|
|
18
|
+
db.prepare(`INSERT INTO anchor_touch_log (anchor_id, session_id, tool, interaction) VALUES (?, ?, ?, ?)`).run(t.anchorId ?? null, t.sessionId ?? null, t.tool, t.interaction);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* instrumentation must never break a tool call */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Per-session counts for telemetry — COUNTS ONLY, no content.
|
|
26
|
+
* Window = [startSec, endSec] (unix seconds, the session's edit span).
|
|
27
|
+
* The server turns these per-session counts into the fleet D7 rate by ordering an
|
|
28
|
+
* install's sessions: D0 = first session with anchor_creates>0; retained if a later
|
|
29
|
+
* session within 7 days has anchor_returns>0.
|
|
30
|
+
*/
|
|
31
|
+
export function getSessionAnchorCounts(db, startSec, endSec) {
|
|
32
|
+
try {
|
|
33
|
+
if (!startSec || !endSec || endSec < startSec)
|
|
34
|
+
return { anchor_creates: 0, anchor_returns: 0 };
|
|
35
|
+
const creates = db.prepare(`SELECT COUNT(*) AS c FROM drift_anchors WHERE created_at BETWEEN ? AND ?`).get(startSec, endSec).c;
|
|
36
|
+
const touchReturns = db.prepare(`SELECT COUNT(*) AS c FROM anchor_touch_log WHERE interaction != 'create' AND occurred_at BETWEEN ? AND ?`).get(startSec, endSec).c;
|
|
37
|
+
const gateReturns = db.prepare(`SELECT COUNT(*) AS c FROM injection_log WHERE occurred_at BETWEEN ? AND ?`).get(startSec, endSec).c;
|
|
38
|
+
return { anchor_creates: creates, anchor_returns: touchReturns + gateReturns };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { anchor_creates: 0, anchor_returns: 0 };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Local, single-install decision-trajectory view. The source for `stats` (the
|
|
46
|
+
* founder-sales 1:1 read-out) and the seed of the "your decision trajectory" digest.
|
|
47
|
+
*
|
|
48
|
+
* The local readout is PER-DECISION retention (recency-independent): of the decisions
|
|
49
|
+
* you've recorded, what fraction did you come back to within 7 days of recording each.
|
|
50
|
+
* The fleet GATE rate (install-cohort "returned within 7d of the FIRST decision") is a
|
|
51
|
+
* different, cohort-entry metric — computed server-side from the per-session telemetry
|
|
52
|
+
* counts in getSessionAnchorCounts(), not here.
|
|
53
|
+
*/
|
|
54
|
+
export function getAnchorRetention(db, opts = {}) {
|
|
55
|
+
const windowDays = opts.windowDays ?? 7;
|
|
56
|
+
const empty = {
|
|
57
|
+
totalAnchors: 0, firstAnchorAt: null, lastAnchorAt: null, returnInteractions: 0,
|
|
58
|
+
activeDays: 0, retainedAnchors: 0, retentionRate: 0, mostRevisited: null,
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const creates = db.prepare(`SELECT created_at FROM drift_anchors ORDER BY created_at`).all().map((r) => r.created_at).filter((t) => t != null);
|
|
62
|
+
if (creates.length === 0)
|
|
63
|
+
return empty;
|
|
64
|
+
const first = creates[0];
|
|
65
|
+
const last = creates[creates.length - 1];
|
|
66
|
+
// "Came back" activity = non-create touches + guard re-surfaces.
|
|
67
|
+
const touchTs = db.prepare(`SELECT occurred_at FROM anchor_touch_log WHERE interaction != 'create'`).all().map((r) => r.occurred_at);
|
|
68
|
+
const gateTs = db.prepare(`SELECT occurred_at FROM injection_log`).all().map((r) => r.occurred_at);
|
|
69
|
+
const returnTs = [...touchTs, ...gateTs].filter((t) => t != null);
|
|
70
|
+
const returnInteractions = returnTs.filter((t) => t > first).length;
|
|
71
|
+
// Per-decision D7: a decision is "retained" if any activity (a return interaction
|
|
72
|
+
// OR recording a LATER decision) falls on a later calendar day within 7d of it.
|
|
73
|
+
const day = (t) => Math.floor(t / DAY);
|
|
74
|
+
const activity = [...returnTs, ...creates].sort((a, b) => a - b);
|
|
75
|
+
let retainedAnchors = 0;
|
|
76
|
+
for (const t0 of creates) {
|
|
77
|
+
const horizon = t0 + windowDays * DAY;
|
|
78
|
+
if (activity.some((e) => e <= horizon && day(e) > day(t0)))
|
|
79
|
+
retainedAnchors++;
|
|
80
|
+
}
|
|
81
|
+
const activeDays = db.prepare(`SELECT COUNT(*) AS c FROM (
|
|
82
|
+
SELECT DISTINCT CAST(created_at / 86400 AS INT) AS d FROM drift_anchors
|
|
83
|
+
UNION
|
|
84
|
+
SELECT DISTINCT CAST(occurred_at / 86400 AS INT) FROM anchor_touch_log WHERE interaction != 'create'
|
|
85
|
+
UNION
|
|
86
|
+
SELECT DISTINCT CAST(occurred_at / 86400 AS INT) FROM injection_log
|
|
87
|
+
)`).get().c;
|
|
88
|
+
let mostRevisited = null;
|
|
89
|
+
const mv = db.prepare(`SELECT anchor_id, COUNT(*) AS touches FROM (
|
|
90
|
+
SELECT anchor_id FROM anchor_touch_log WHERE interaction != 'create' AND anchor_id IS NOT NULL
|
|
91
|
+
UNION ALL
|
|
92
|
+
SELECT anchor_id FROM injection_log WHERE anchor_id IS NOT NULL
|
|
93
|
+
) GROUP BY anchor_id ORDER BY touches DESC LIMIT 1`).get();
|
|
94
|
+
if (mv && mv.touches > 0) {
|
|
95
|
+
const a = db.prepare(`SELECT statement FROM drift_anchors WHERE id = ?`).get(mv.anchor_id);
|
|
96
|
+
if (a)
|
|
97
|
+
mostRevisited = { anchor_id: mv.anchor_id, statement: a.statement, touches: mv.touches };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
totalAnchors: creates.length,
|
|
101
|
+
firstAnchorAt: first,
|
|
102
|
+
lastAnchorAt: last,
|
|
103
|
+
returnInteractions,
|
|
104
|
+
activeDays,
|
|
105
|
+
retainedAnchors,
|
|
106
|
+
retentionRate: creates.length ? retainedAnchors / creates.length : 0,
|
|
107
|
+
mostRevisited,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return empty;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=anchor-touch.js.map
|
package/dist/lib/map-view.d.ts
CHANGED
package/dist/lib/map-view.js
CHANGED
|
@@ -107,9 +107,20 @@ function queryTerms(q) {
|
|
|
107
107
|
return q.toLowerCase().split(/[\s、。,.\/_()「」"'`::]+/).map((t) => t.trim()).filter((t) => t.length >= 2);
|
|
108
108
|
}
|
|
109
109
|
export function whereAmI(db, opts) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
// Fix ② (2026-06-17): never silently grab the most-recently-touched map. When the
|
|
111
|
+
// caller didn't name a project, only auto-pick if exactly ONE map exists; with
|
|
112
|
+
// multiple maps, surface the ambiguity so the caller picks (the no-arg "per-turn
|
|
113
|
+
// re-anchor" was returning a stale/demo map — e.g. the notekeeper sample).
|
|
114
|
+
// ① (cwd/roots match → infer the project you're actually in) lands next.
|
|
115
|
+
let project = opts.project ?? '';
|
|
116
|
+
if (!project) {
|
|
117
|
+
const available = listMapProjects(db);
|
|
118
|
+
if (available.length === 1)
|
|
119
|
+
project = available[0];
|
|
120
|
+
else if (available.length > 1)
|
|
121
|
+
return { project: '', job: null, matched: [], ambiguous: { available } };
|
|
122
|
+
// available.length === 0 → project stays '' → caller emits "no map imported yet"
|
|
123
|
+
}
|
|
113
124
|
const meta = project ? getProjectMeta(db, project) : undefined;
|
|
114
125
|
const stageLabel = (stageId) => stageId ? meta?.stages.find((s) => s.id === stageId)?.label ?? stageId : null;
|
|
115
126
|
const anchorOf = (n) => n.anchor_id != null
|
package/dist/lib/telemetry.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ interface TelemetryPayload {
|
|
|
17
17
|
read_smart_calls: number;
|
|
18
18
|
recall_calls: number;
|
|
19
19
|
recall_file_calls: number;
|
|
20
|
+
anchor_creates: number;
|
|
21
|
+
anchor_returns: number;
|
|
20
22
|
}
|
|
21
23
|
export declare function buildPayload(db: Database.Database, sessionId: string, options?: {
|
|
22
24
|
mcpServersInUse?: string[];
|
package/dist/lib/telemetry.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// - NEVER sends conversation content, user messages, file content,
|
|
7
7
|
// entity names, project paths, or any layer text (goal/context/emotion/
|
|
8
8
|
// impl/caveat/learning content is not included).
|
|
9
|
+
// - anchor_creates / anchor_returns are COUNTS only (decisions recorded /
|
|
10
|
+
// revisited this session, for the D7 retention metric) — never any
|
|
11
|
+
// statement text or anchor content.
|
|
9
12
|
// - Anonymous UUID generated locally on first opt-in; stored at
|
|
10
13
|
// ~/.linksee-memory/telemetry-id. User can delete it any time.
|
|
11
14
|
// - Disable any time: LINKSEE_TELEMETRY=off (or unset the variable).
|
|
@@ -14,9 +17,10 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
14
17
|
import { join, extname, join as pathJoin, dirname as pathDirname } from 'node:path';
|
|
15
18
|
import { homedir } from 'node:os';
|
|
16
19
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
+
import { getSessionAnchorCounts } from './anchor-touch.js';
|
|
21
|
+
// Production endpoint: opt-in telemetry collector on linksee-site (Vercel).
|
|
22
|
+
// Migrated 2026-06 off the retired Railway deployment (which had gone 404).
|
|
23
|
+
const DEFAULT_ENDPOINT = 'https://linksee-site.vercel.app/api/telemetry/linksee';
|
|
20
24
|
// Allow override for testing or self-hosting
|
|
21
25
|
const ENDPOINT = process.env.LINKSEE_TELEMETRY_URL || DEFAULT_ENDPOINT;
|
|
22
26
|
const TELEMETRY_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
|
|
@@ -97,6 +101,7 @@ export function buildPayload(db, sessionId, options = {}) {
|
|
|
97
101
|
const tsRow = db.prepare(`SELECT MIN(occurred_at) as start, MAX(occurred_at) as end FROM session_file_edits WHERE session_id = ?`).get(sessionId);
|
|
98
102
|
if (tsRow && tsRow.start && tsRow.end)
|
|
99
103
|
durationSec = Math.max(0, tsRow.end - tsRow.start);
|
|
104
|
+
const anchorCounts = getSessionAnchorCounts(db, tsRow?.start ?? 0, tsRow?.end ?? 0);
|
|
100
105
|
return {
|
|
101
106
|
anon_id: getOrCreateAnonId(),
|
|
102
107
|
linksee_version: getLinkseeVersion(),
|
|
@@ -112,6 +117,8 @@ export function buildPayload(db, sessionId, options = {}) {
|
|
|
112
117
|
read_smart_calls: 0,
|
|
113
118
|
recall_calls: 0,
|
|
114
119
|
recall_file_calls: 0,
|
|
120
|
+
anchor_creates: anchorCounts.anchor_creates,
|
|
121
|
+
anchor_returns: anchorCounts.anchor_returns,
|
|
115
122
|
};
|
|
116
123
|
}
|
|
117
124
|
// Fire-and-forget POST. Any failure is silent (logged by caller).
|
package/dist/mcp/server.js
CHANGED
|
@@ -17,13 +17,14 @@ import { normalizeEntityName } from '../lib/normalize.js';
|
|
|
17
17
|
import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
|
|
18
18
|
import { STATIC_RESOURCES, RESOURCE_TEMPLATES, readResource } from './resources.js';
|
|
19
19
|
import { PROMPTS, getPrompt } from './prompts.js';
|
|
20
|
-
import { fetchRoots, isInsideRoots } from './roots.js';
|
|
20
|
+
import { fetchRoots, isInsideRoots, rootPathFromUri } from './roots.js';
|
|
21
21
|
import { sampleConsolidation } from './sampling.js';
|
|
22
22
|
import { confirmForget } from './elicitation.js';
|
|
23
23
|
import { getTruthView, getDecisionDetail, resolveDrift } from '../lib/truth-engine.js';
|
|
24
24
|
import { declareAnchor, setNodeFields } from '../lib/drift-anchors.js';
|
|
25
25
|
import { getReinjectionFriction, setGateMode } from '../lib/guard.js';
|
|
26
|
-
import {
|
|
26
|
+
import { logAnchorTouch } from '../lib/anchor-touch.js';
|
|
27
|
+
import { whereAmI, listMapProjects } from '../lib/map-view.js';
|
|
27
28
|
import { readFileSync, existsSync } from 'node:fs';
|
|
28
29
|
import { fileURLToPath } from 'node:url';
|
|
29
30
|
import { dirname, join } from 'node:path';
|
|
@@ -59,6 +60,7 @@ const SUBCOMMANDS = {
|
|
|
59
60
|
'install-skill': 'install-skill.js',
|
|
60
61
|
declare: 'declare-anchor.js',
|
|
61
62
|
detect: 'detect-drift.js',
|
|
63
|
+
export: 'export-report.js',
|
|
62
64
|
};
|
|
63
65
|
const subcommand = process.argv[2];
|
|
64
66
|
if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand)) {
|
|
@@ -153,7 +155,7 @@ const TOOLS = [
|
|
|
153
155
|
},
|
|
154
156
|
{
|
|
155
157
|
name: 'recall',
|
|
156
|
-
description: 'Your persistent memory across all AI tools. CALL THIS BEFORE STARTING ANY TASK to check for past caveats (pain records), decisions, and learnings — prevents repeating mistakes across sessions.\n\nTypical usage: recall({ query: "keywords" }) for search, recall({ path: "file.ts" }) for file history, recall() for overview.\n\nWHEN TO CALL:\n• Before starting any new task or touching a file\n• When the user mentions "before" / "前に" / "last time" / "remember when"\n• When an error occurs — check if you\'ve seen it before\n• When making a decision — check for prior decisions on the same topic\n\nTHREE MODES (auto-detected):\n• Search (default): provide query → returns memories ranked by relevance + heat\n• File history: provide path → returns complete edit history with user-intent context\n• Overview: omit all params → returns entity list sorted by momentum\n\
|
|
158
|
+
description: 'Your persistent memory across all AI tools. CALL THIS BEFORE STARTING ANY TASK to check for past caveats (pain records), decisions, and learnings — prevents repeating mistakes across sessions.\n\nTypical usage: recall({ query: "keywords" }) for search, recall({ path: "file.ts" }) for file history, recall() for overview.\n\nWHEN TO CALL:\n• Before starting any new task or touching a file\n• When the user mentions "before" / "前に" / "last time" / "remember when"\n• When an error occurs — check if you\'ve seen it before\n• When making a decision — check for prior decisions on the same topic\n\nTHREE MODES (auto-detected):\n• Search (default): provide query → returns memories ranked by relevance + heat\n• File history: provide path → returns complete edit history with user-intent context\n• Overview: omit all params → returns entity list sorted by momentum\n\nWorks across Claude, GPT, Cursor, Codex, Gemini — one local SQLite file, nothing leaves your machine.',
|
|
157
159
|
inputSchema: {
|
|
158
160
|
type: 'object',
|
|
159
161
|
properties: {
|
|
@@ -1276,6 +1278,7 @@ function handleDriftStatus(args) {
|
|
|
1276
1278
|
domain: args?.domain,
|
|
1277
1279
|
decision_mode: args?.decision_mode,
|
|
1278
1280
|
});
|
|
1281
|
+
logAnchorTouch(db, { tool: 'drift_status', interaction: 'review' });
|
|
1279
1282
|
// Build a concise triage line
|
|
1280
1283
|
const { by_state, nodes } = view.counts;
|
|
1281
1284
|
const triage = [
|
|
@@ -1294,10 +1297,37 @@ function handleDriftStatus(args) {
|
|
|
1294
1297
|
counts: view.counts,
|
|
1295
1298
|
});
|
|
1296
1299
|
}
|
|
1297
|
-
|
|
1300
|
+
// Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
|
|
1301
|
+
// caller didn't name one — pick the project whose slug matches the repo you're working in,
|
|
1302
|
+
// so the no-arg "per-turn re-anchor" lands on the right map (not a stale/demo one).
|
|
1303
|
+
// map_projects has no path column, so we match the slug as a path segment of a root.
|
|
1304
|
+
async function resolveProjectFromRoots() {
|
|
1305
|
+
const projects = listMapProjects(db);
|
|
1306
|
+
if (projects.length <= 1)
|
|
1307
|
+
return projects[0]; // 0 → undefined ("no map"); 1 → use it
|
|
1308
|
+
const roots = await fetchRoots(server);
|
|
1309
|
+
if (roots.length === 0)
|
|
1310
|
+
return undefined; // client gave no roots → let ② disambiguate
|
|
1311
|
+
const segs = roots.map((r) => rootPathFromUri(r.uri).toLowerCase().replace(/\\/g, '/').replace(/\/+$/, ''));
|
|
1312
|
+
const matches = projects.filter((p) => {
|
|
1313
|
+
const slug = p.toLowerCase();
|
|
1314
|
+
return segs.some((path) => path === slug || path.endsWith('/' + slug) || path.split('/').includes(slug));
|
|
1315
|
+
});
|
|
1316
|
+
return matches.length === 1 ? matches[0] : undefined; // unique repo match only; else ② handles it
|
|
1317
|
+
}
|
|
1318
|
+
async function handleWhereAmI(args) {
|
|
1319
|
+
// ① resolve project from cwd/roots when not explicitly passed; ② (inside whereAmI) is the fallback.
|
|
1320
|
+
const project = args?.project ?? (await resolveProjectFromRoots());
|
|
1298
1321
|
const res = whereAmI(db, {
|
|
1299
|
-
query: args?.query, node_id: args?.node_id, project
|
|
1322
|
+
query: args?.query, node_id: args?.node_id, project, limit: args?.limit,
|
|
1300
1323
|
});
|
|
1324
|
+
if (res.ambiguous) {
|
|
1325
|
+
return JSON.stringify({
|
|
1326
|
+
ok: true, located: false, reason: 'ambiguous_project',
|
|
1327
|
+
available_projects: res.ambiguous.available,
|
|
1328
|
+
hint: `Multiple maps imported — pass project: one of [${res.ambiguous.available.join(', ')}]. (No-arg can't yet tell which repo you mean; cwd/roots match is coming.)`,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1301
1331
|
if (res.matched.length === 0) {
|
|
1302
1332
|
return JSON.stringify({
|
|
1303
1333
|
ok: true, project: res.project, located: false,
|
|
@@ -1329,6 +1359,7 @@ function handleCheckDecision(args) {
|
|
|
1329
1359
|
if (!detail) {
|
|
1330
1360
|
return JSON.stringify({ ok: false, error: `Anchor ${args.anchor_id} not found or not active` });
|
|
1331
1361
|
}
|
|
1362
|
+
logAnchorTouch(db, { anchorId: args.anchor_id, tool: 'check_decision', interaction: 'inspect' });
|
|
1332
1363
|
return JSON.stringify({ ok: true, decision: detail });
|
|
1333
1364
|
}
|
|
1334
1365
|
function handleDeclareAnchor(args) {
|
|
@@ -1363,6 +1394,7 @@ function handleDeclareAnchor(args) {
|
|
|
1363
1394
|
if (Object.keys(nodeFields).length > 0) {
|
|
1364
1395
|
setNodeFields(db, anchor.id, nodeFields);
|
|
1365
1396
|
}
|
|
1397
|
+
logAnchorTouch(db, { anchorId: anchor.id, tool: 'declare_anchor', interaction: 'create' });
|
|
1366
1398
|
return JSON.stringify({
|
|
1367
1399
|
ok: true,
|
|
1368
1400
|
anchor_id: anchor.id,
|
|
@@ -1375,6 +1407,7 @@ function handleResolveDrift(args) {
|
|
|
1375
1407
|
if (!args?.anchor_id || !args?.action) {
|
|
1376
1408
|
throw new Error('anchor_id and action are required');
|
|
1377
1409
|
}
|
|
1410
|
+
logAnchorTouch(db, { anchorId: args.anchor_id, tool: 'resolve_drift', interaction: 'resolve' });
|
|
1378
1411
|
// Enforcement-policy actions — apply the dream "escalate_to_hard" recommendation in ONE call.
|
|
1379
1412
|
// Folded into resolve_drift (not a new tool) to honor anchor #1. Intercepted here so the WIP
|
|
1380
1413
|
// truth-engine.ts resolveDrift() stays untouched.
|
|
@@ -1720,7 +1753,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1720
1753
|
text = handleDriftStatus(args);
|
|
1721
1754
|
break;
|
|
1722
1755
|
case 'where_am_i':
|
|
1723
|
-
text = handleWhereAmI(args);
|
|
1756
|
+
text = await handleWhereAmI(args);
|
|
1724
1757
|
break;
|
|
1725
1758
|
case 'check_decision':
|
|
1726
1759
|
text = handleCheckDecision(args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"mcpName": "io.github.michielinksee/linksee-memory",
|
|
5
5
|
"description": "Local-first agent memory MCP — cross-agent brain with drift detection, 6-layer structured memory + token-saving file diff cache",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"linksee-memory-declare": "dist/bin/declare-anchor.js",
|
|
15
15
|
"linksee-memory-detect": "dist/bin/detect-drift.js",
|
|
16
16
|
"linksee-memory-guard": "dist/bin/guard-hook.js",
|
|
17
|
-
"linksee-memory-map": "dist/bin/map-import.js"
|
|
17
|
+
"linksee-memory-map": "dist/bin/map-import.js",
|
|
18
|
+
"linksee-memory-export": "dist/bin/export-report.js"
|
|
18
19
|
},
|
|
19
20
|
"main": "./dist/mcp/server.js",
|
|
20
21
|
"files": [
|