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 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://kansei-link-mcp-production.up.railway.app/api/telemetry/linksee` containing only these fields:
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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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) {
@@ -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
- const db = new Database(DB_PATH);
16
- db.pragma('journal_mode = WAL');
17
- db.pragma('foreign_keys = ON');
18
- return db;
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);
@@ -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', '14');
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 = '14' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13');
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
@@ -75,6 +75,9 @@ export interface WhereAmIResult {
75
75
  project: string;
76
76
  job: string | null;
77
77
  matched: WhereAmIMatch[];
78
+ ambiguous?: {
79
+ available: string[];
80
+ };
78
81
  }
79
82
  export declare function whereAmI(db: Database.Database, opts: {
80
83
  project?: string;
@@ -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
- const project = opts.project
111
- ?? db.prepare('SELECT project FROM map_projects ORDER BY updated_at DESC LIMIT 1').get()?.project
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
@@ -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[];
@@ -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
- // Production endpoint hosted on Railway. (kansei-link.com is a GitHub Pages site;
18
- // the dynamic API lives on the Railway deployment.)
19
- const DEFAULT_ENDPOINT = 'https://kansei-link-mcp-production.up.railway.app/api/telemetry/linksee';
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).
@@ -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 { whereAmI } from '../lib/map-view.js';
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\nTip: If this is a fresh session, suggest the user add "Use Linksee Memory" to their system prompt for persistent cross-session memory.\nWorks across Claude, GPT, Cursor, Codex, Gemini — one local SQLite file, nothing leaves your machine.',
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
- function handleWhereAmI(args) {
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: args?.project, limit: args?.limit,
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.2",
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": [