linksee-memory 0.11.3 → 0.11.5
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 +4 -2
- package/dist/bin/export-report.d.ts +2 -0
- package/dist/bin/export-report.js +208 -0
- package/dist/bin/setup.js +54 -4
- package/dist/bin/stats.js +22 -0
- 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 +23 -3
- package/dist/mcp/server.js +38 -5
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -618,7 +618,7 @@ Use both.
|
|
|
618
618
|
linksee-memory runs locally and is built to read — and send — as little as possible.
|
|
619
619
|
|
|
620
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.**
|
|
621
|
+
- **Telemetry is opt-in and OFF by default.** `setup` asks once; nothing is sent unless you agree there (or 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
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
623
|
- **Clean MCP transport.** The server writes only JSON-RPC to stdout; all logs go to stderr.
|
|
624
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).
|
|
@@ -636,11 +636,13 @@ linksee-memory ships with **opt-in** anonymous telemetry that helps us understan
|
|
|
636
636
|
```bash
|
|
637
637
|
export LINKSEE_TELEMETRY=basic # opt in
|
|
638
638
|
export LINKSEE_TELEMETRY=off # opt out (or just unset the variable)
|
|
639
|
+
# `linksee-memory setup` also asks once and records your choice in
|
|
640
|
+
# ~/.linksee-memory/telemetry-consent (delete that file to be asked again).
|
|
639
641
|
```
|
|
640
642
|
|
|
641
643
|
### Exactly what gets sent (Level 1 contract)
|
|
642
644
|
|
|
643
|
-
After each Claude Code session ends, the Stop hook sends one POST to `https://
|
|
645
|
+
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:
|
|
644
646
|
|
|
645
647
|
| Field | Example | What it is |
|
|
646
648
|
|---|---|---|
|
|
@@ -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/setup.js
CHANGED
|
@@ -70,6 +70,9 @@ const GUARD_COMMAND = 'npx -y linksee-memory guard';
|
|
|
70
70
|
const PROJECT_DIR = process.cwd();
|
|
71
71
|
const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
|
|
72
72
|
const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
|
|
73
|
+
// Opt-in telemetry consent recorded at setup time (read by telemetry.ts getTelemetryMode).
|
|
74
|
+
const TELEMETRY_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(HOME, '.linksee-memory');
|
|
75
|
+
const TELEMETRY_CONSENT_FILE = join(TELEMETRY_DIR, 'telemetry-consent');
|
|
73
76
|
const CHECK = '\x1b[32m✓\x1b[0m';
|
|
74
77
|
const SKIP = '\x1b[33m○\x1b[0m';
|
|
75
78
|
const FAIL = '\x1b[31m✗\x1b[0m';
|
|
@@ -225,13 +228,15 @@ const GUARD_HOOKS = {
|
|
|
225
228
|
function guardWiredFor(s, ev) {
|
|
226
229
|
return (s.hooks?.[ev] ?? []).some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && h.command.includes('linksee-memory-guard')));
|
|
227
230
|
}
|
|
228
|
-
function askYesNo(question) {
|
|
231
|
+
function askYesNo(question, defaultYes = true) {
|
|
229
232
|
return new Promise((resolve) => {
|
|
230
233
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
231
|
-
rl.question(`${question} [Y/n] `, (ans) => {
|
|
234
|
+
rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, (ans) => {
|
|
232
235
|
rl.close();
|
|
233
236
|
const a = ans.trim().toLowerCase();
|
|
234
|
-
|
|
237
|
+
if (a === '')
|
|
238
|
+
return resolve(defaultYes);
|
|
239
|
+
resolve(a === 'y' || a === 'yes');
|
|
235
240
|
});
|
|
236
241
|
});
|
|
237
242
|
}
|
|
@@ -288,13 +293,58 @@ async function configureGuard() {
|
|
|
288
293
|
}
|
|
289
294
|
const guardConfigured = await configureGuard();
|
|
290
295
|
console.log('');
|
|
296
|
+
// ── Telemetry consent (opt-in, anonymous, off unless you agree) ──────────
|
|
297
|
+
async function configureTelemetryConsent() {
|
|
298
|
+
console.log(`${BOLD}Anonymous usage stats${RESET} ${DIM}(optional)${RESET}`);
|
|
299
|
+
const env = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
|
|
300
|
+
if (env) {
|
|
301
|
+
console.log(` ${SKIP} Controlled by LINKSEE_TELEMETRY=${env} ${DIM}(env overrides)${RESET}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (existsSync(TELEMETRY_CONSENT_FILE)) {
|
|
305
|
+
let cur = 'off';
|
|
306
|
+
try {
|
|
307
|
+
cur = (readFileSync(TELEMETRY_CONSENT_FILE, 'utf8').trim().toLowerCase()) || 'off';
|
|
308
|
+
}
|
|
309
|
+
catch { /* ignore */ }
|
|
310
|
+
console.log(` ${SKIP} Already chosen: ${cur === 'basic' ? 'on' : 'off'} ${DIM}(edit ${TELEMETRY_CONSENT_FILE} to change)${RESET}`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (dryRun) {
|
|
314
|
+
console.log(` ${DIM}[dry-run] Would ask whether to share anonymous usage stats${RESET}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const dnt = process.env.DO_NOT_TRACK === '1' || process.env.DO_NOT_TRACK === 'true';
|
|
318
|
+
if (dnt || autoYes || !process.stdin.isTTY) {
|
|
319
|
+
try {
|
|
320
|
+
mkdirSync(TELEMETRY_DIR, { recursive: true });
|
|
321
|
+
writeFileSync(TELEMETRY_CONSENT_FILE, 'off');
|
|
322
|
+
}
|
|
323
|
+
catch { /* best-effort */ }
|
|
324
|
+
const why = dnt ? 'DO_NOT_TRACK' : autoYes ? '--yes → off' : 'non-interactive';
|
|
325
|
+
console.log(` ${SKIP} Left off ${DIM}(${why})${RESET}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
console.log(` ${DIM}Helps us see which workflows actually work. Anonymous counts only —`);
|
|
329
|
+
console.log(` never your memory, code, prompts, file contents, entity names, or paths.`);
|
|
330
|
+
console.log(` Off unless you say yes; change anytime with LINKSEE_TELEMETRY=off.${RESET}`);
|
|
331
|
+
const ok = await askYesNo(' Share anonymous usage stats?', false);
|
|
332
|
+
try {
|
|
333
|
+
mkdirSync(TELEMETRY_DIR, { recursive: true });
|
|
334
|
+
writeFileSync(TELEMETRY_CONSENT_FILE, ok ? 'basic' : 'off');
|
|
335
|
+
}
|
|
336
|
+
catch { /* best-effort */ }
|
|
337
|
+
console.log(` ${ok ? CHECK : SKIP} Telemetry ${ok ? 'enabled — thank you!' : 'left off'}`);
|
|
338
|
+
}
|
|
339
|
+
await configureTelemetryConsent();
|
|
340
|
+
console.log('');
|
|
291
341
|
// ── Summary ──────────────────────────────────────────────
|
|
292
342
|
console.log(`${BOLD}Setup complete!${RESET}`);
|
|
293
343
|
console.log('');
|
|
294
344
|
console.log('How it works:');
|
|
295
345
|
console.log(` ${DIM}• Every session is auto-captured (decisions, caveats, learnings)${RESET}`);
|
|
296
346
|
console.log(` ${DIM}• Agent auto-recalls past context when starting a task${RESET}`);
|
|
297
|
-
console.log(` ${DIM}• Memory is local-first (
|
|
347
|
+
console.log(` ${DIM}• Memory is local-first (your memory never leaves your machine)${RESET}`);
|
|
298
348
|
console.log(` ${DIM}• Works across Claude Code, Cursor, Windsurf, Codex, Gemini (cross-agent)${RESET}`);
|
|
299
349
|
if (guardConfigured) {
|
|
300
350
|
console.log(` ${DIM}• Re-injection guard re-surfaces this project's accepted decisions before edits${RESET}`);
|
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/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');
|
|
@@ -25,6 +29,19 @@ export function getTelemetryMode() {
|
|
|
25
29
|
const v = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
|
|
26
30
|
if (v === 'basic' || v === 'on' || v === '1' || v === 'true')
|
|
27
31
|
return 'basic';
|
|
32
|
+
if (v)
|
|
33
|
+
return 'off'; // any explicit env value (off/0/false/no/…) always wins
|
|
34
|
+
// No env override → use the consent recorded at setup time. Stays off if absent,
|
|
35
|
+
// so "off by default" holds: nothing is sent unless the user agreed at setup.
|
|
36
|
+
try {
|
|
37
|
+
const consentFile = join(TELEMETRY_DIR, 'telemetry-consent');
|
|
38
|
+
if (existsSync(consentFile)) {
|
|
39
|
+
const c = readFileSync(consentFile, 'utf8').trim().toLowerCase();
|
|
40
|
+
if (c === 'basic' || c === 'on')
|
|
41
|
+
return 'basic';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch { /* ignore */ }
|
|
28
45
|
return 'off';
|
|
29
46
|
}
|
|
30
47
|
export function getOrCreateAnonId() {
|
|
@@ -97,6 +114,7 @@ export function buildPayload(db, sessionId, options = {}) {
|
|
|
97
114
|
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
115
|
if (tsRow && tsRow.start && tsRow.end)
|
|
99
116
|
durationSec = Math.max(0, tsRow.end - tsRow.start);
|
|
117
|
+
const anchorCounts = getSessionAnchorCounts(db, tsRow?.start ?? 0, tsRow?.end ?? 0);
|
|
100
118
|
return {
|
|
101
119
|
anon_id: getOrCreateAnonId(),
|
|
102
120
|
linksee_version: getLinkseeVersion(),
|
|
@@ -112,6 +130,8 @@ export function buildPayload(db, sessionId, options = {}) {
|
|
|
112
130
|
read_smart_calls: 0,
|
|
113
131
|
recall_calls: 0,
|
|
114
132
|
recall_file_calls: 0,
|
|
133
|
+
anchor_creates: anchorCounts.anchor_creates,
|
|
134
|
+
anchor_returns: anchorCounts.anchor_returns,
|
|
115
135
|
};
|
|
116
136
|
}
|
|
117
137
|
// 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)) {
|
|
@@ -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.5",
|
|
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": [
|