linksee-memory 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // linksee-memory MCP server (stdio transport).
3
- // Tools: remember / recall / read_smart / drift_status / check_decision / declare_anchor / resolve_drift / flag_proposals / dream / resolve_proposal
3
+ // Tools: remember / recall / read_smart / drift_status / check_decision / declare_anchor / resolve_drift / flag_proposals / dream / resolve_proposal / share_draft
4
4
  // v0.10.0 — Re-injection layer: pre-action guard (PreToolUse/SessionStart hooks) + extraction
5
5
  // quality gate + distillation routine (dream distill_queue) + harden/soften escalation
6
6
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -26,6 +26,7 @@ import { getReinjectionFriction, setGateMode } from '../lib/guard.js';
26
26
  import { logAnchorTouch } from '../lib/anchor-touch.js';
27
27
  import { triageDistillQueue, distillPending } from '../lib/distill-triage.js';
28
28
  import { whereAmI, listMapProjects } from '../lib/map-view.js';
29
+ import { buildShareDraft, KINDS, RUNTIMES } from '../lib/share-draft.js';
29
30
  import { readFileSync, existsSync } from 'node:fs';
30
31
  import { fileURLToPath } from 'node:url';
31
32
  import { dirname, join } from 'node:path';
@@ -62,6 +63,7 @@ const SUBCOMMANDS = {
62
63
  declare: 'declare-anchor.js',
63
64
  detect: 'detect-drift.js',
64
65
  export: 'export-report.js',
66
+ 'share-draft': 'share-draft.js',
65
67
  };
66
68
  const subcommand = process.argv[2];
67
69
  if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand)) {
@@ -72,6 +74,16 @@ if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand))
72
74
  }
73
75
  const db = openDb();
74
76
  runMigrations(db);
77
+ // Distill triage runs on EVERY start, before the first request. It is one indexed scan and a
78
+ // handful of JSON rewrites — cheap — and 0.16.0 had tucked it inside the auto-consolidate block
79
+ // below, which runs at most once a week and only after a 3-second timer. So it never ran.
80
+ try {
81
+ const t = triageDistillQueue(db);
82
+ if (t.noise + t.stale > 0) {
83
+ process.stderr.write(`[linksee-memory] distill triage: archived ${t.noise} noise + ${t.stale} stale, ${t.remaining} remain\n`);
84
+ }
85
+ }
86
+ catch { /* never block startup */ }
75
87
  // Auto-maintenance: consolidate stale memories on startup (non-blocking)
76
88
  setTimeout(() => {
77
89
  try {
@@ -91,13 +103,6 @@ setTimeout(() => {
91
103
  if (shouldRun) {
92
104
  runConsolidate(db, { scope: 'all', min_age_days: 7 });
93
105
  process.stderr.write('[linksee-memory] auto-consolidate complete\n');
94
- // Triage the distill queue by rule before any agent is asked to think about it.
95
- try {
96
- const t = triageDistillQueue(db);
97
- if (t.noise + t.stale > 0)
98
- process.stderr.write(`[linksee-memory] distill triage: archived ${t.noise} noise + ${t.stale} stale, ${t.remaining} remain\n`);
99
- }
100
- catch { /* never block startup */ }
101
106
  }
102
107
  }
103
108
  catch { /* non-fatal */ }
@@ -385,6 +390,35 @@ const TOOLS = [
385
390
  required: ['candidate_id', 'verdict', 'rationale'],
386
391
  },
387
392
  },
393
+ // ── Share a fix (opt-in, link only) ──────────────────────
394
+ {
395
+ name: 'share_draft',
396
+ description: 'Make a LINK to a reviewed draft of a field note for other agents (KanseiLINK note page). ' +
397
+ 'ONLY when the user says they want to share — never call it on your own.\n\n' +
398
+ 'Use after a SaaS / MCP / API connection got stuck and was fixed (or was not). You write the fields; ' +
399
+ 'this tool only checks them and builds the URL. Linksee sends nothing: the user opens the link, ' +
400
+ 'reviews the filled form, and only their click on that page sends it.\n\n' +
401
+ 'No values, only kinds: write "401 invalid_token", not the token. A field that looks like a key, ' +
402
+ 'token, JWT, e-mail, tenant URL (acme.slack.com), IP, local path (C:\, /Users/, /home/) or a long ' +
403
+ 'hex/base64 run stops the build and returns which field. The whole URL must fit in 2,000 characters ' +
404
+ '(a Japanese character takes 9) — if not, it asks you to shorten; it does not cut.\n\n' +
405
+ 'Returns { url, notice } — show the user both. Linksee records only that a draft was made (no field text).',
406
+ inputSchema: {
407
+ type: 'object',
408
+ properties: {
409
+ service: { type: 'string', description: 'KanseiLINK service_id, lowercase (e.g. freee, slack, google-drive). ^[a-z0-9_.-]{1,160}$' },
410
+ task: { type: 'string', maxLength: 300, description: 'What you were trying to do (≤300 chars)' },
411
+ symptom: { type: 'string', maxLength: 600, description: 'The KIND of error that came back — no values (≤600 chars)' },
412
+ tried: { type: 'string', maxLength: 2000, description: 'The steps you tried (≤2,000 chars)' },
413
+ result: { type: 'string', maxLength: 1000, description: 'How it was solved, or that it was not (≤1,000 chars)' },
414
+ conditions: { type: 'string', maxLength: 600, description: 'Optional. Tool + version, kind of environment (≤600 chars)' },
415
+ kind: { type: 'string', enum: [...KINDS], description: 'execution = you ran it · documentation = you read it · hypothesis = untested guess' },
416
+ runtime: { type: 'string', enum: [...RUNTIMES], description: 'Optional. Which AI wrote this' },
417
+ },
418
+ required: ['service', 'task', 'symptom', 'tried', 'result', 'kind'],
419
+ additionalProperties: false,
420
+ },
421
+ },
388
422
  ];
389
423
  // ============================================================
390
424
  // Handlers
@@ -576,15 +610,15 @@ function appendAxisFilters(sql, params, filters) {
576
610
  return sql;
577
611
  }
578
612
  function runFtsQuery(query, layer, limit, axis) {
579
- let sql = `
580
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
581
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
582
- m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
583
- bm25(memories_fts) as bm25_score
584
- FROM memories_fts
585
- JOIN memories m ON m.id = memories_fts.rowid
586
- JOIN entities e ON e.id = m.entity_id
587
- WHERE memories_fts MATCH ?
613
+ let sql = `
614
+ SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
615
+ m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
616
+ m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
617
+ bm25(memories_fts) as bm25_score
618
+ FROM memories_fts
619
+ JOIN memories m ON m.id = memories_fts.rowid
620
+ JOIN entities e ON e.id = m.entity_id
621
+ WHERE memories_fts MATCH ?
588
622
  `;
589
623
  const params = [query];
590
624
  if (layer) {
@@ -598,14 +632,14 @@ function runFtsQuery(query, layer, limit, axis) {
598
632
  return db.prepare(sql).all(...params);
599
633
  }
600
634
  function runLikeQuery(query, entityName, layer, limit, axis) {
601
- let sql = `
602
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
603
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
604
- m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
605
- 0 as bm25_score
606
- FROM memories m
607
- JOIN entities e ON e.id = m.entity_id
608
- WHERE 1=1
635
+ let sql = `
636
+ SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
637
+ m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
638
+ m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
639
+ 0 as bm25_score
640
+ FROM memories m
641
+ JOIN entities e ON e.id = m.entity_id
642
+ WHERE 1=1
609
643
  `;
610
644
  const params = [];
611
645
  if (entityName) {
@@ -936,8 +970,8 @@ function handleForget(args) {
936
970
  }
937
971
  // Auto-sweep — also respect pin (importance >= 0.9) as protection
938
972
  const rows = db
939
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
940
- FROM memories
973
+ .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
974
+ FROM memories
941
975
  WHERE protected = 0 AND importance < 0.9`)
942
976
  .all();
943
977
  const now = Math.floor(Date.now() / 1000);
@@ -986,17 +1020,17 @@ function handleConsolidate(args) {
986
1020
  // read-only audit: count candidates using the same rules.
987
1021
  const now = Math.floor(Date.now() / 1000);
988
1022
  const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
989
- const candidates = db.prepare(`
990
- SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
991
- FROM memories m
992
- JOIN entities e ON e.id = m.entity_id
993
- WHERE m.protected = 0
994
- AND m.importance < 0.9
995
- AND m.layer IN ('context', 'emotion', 'implementation')
996
- AND m.created_at <= ?
997
- GROUP BY m.entity_id, m.layer
998
- HAVING c >= 2
999
- ORDER BY c DESC
1023
+ const candidates = db.prepare(`
1024
+ SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
1025
+ FROM memories m
1026
+ JOIN entities e ON e.id = m.entity_id
1027
+ WHERE m.protected = 0
1028
+ AND m.importance < 0.9
1029
+ AND m.layer IN ('context', 'emotion', 'implementation')
1030
+ AND m.created_at <= ?
1031
+ GROUP BY m.entity_id, m.layer
1032
+ HAVING c >= 2
1033
+ ORDER BY c DESC
1000
1034
  `).all(ageCutoff);
1001
1035
  const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
1002
1036
  return JSON.stringify({
@@ -1070,19 +1104,19 @@ function handleListEntities(args) {
1070
1104
  const minMemories = Math.max(1, Number(args?.min_memories ?? 1));
1071
1105
  const limit = Math.max(1, Math.min(200, Number(args?.limit ?? 30)));
1072
1106
  const offset = Math.max(0, Number(args?.offset ?? 0));
1073
- let sql = `
1074
- SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
1075
- e.updated_at, e.created_at,
1076
- COUNT(m.id) as memory_count,
1077
- MAX(m.last_accessed_at) as last_memory_access,
1078
- SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
1079
- SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
1080
- SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
1081
- SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
1082
- SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
1083
- FROM entities e
1084
- LEFT JOIN memories m ON m.entity_id = e.id
1085
- WHERE 1=1
1107
+ let sql = `
1108
+ SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
1109
+ e.updated_at, e.created_at,
1110
+ COUNT(m.id) as memory_count,
1111
+ MAX(m.last_accessed_at) as last_memory_access,
1112
+ SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
1113
+ SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
1114
+ SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
1115
+ SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
1116
+ SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
1117
+ FROM entities e
1118
+ LEFT JOIN memories m ON m.entity_id = e.id
1119
+ WHERE 1=1
1086
1120
  `;
1087
1121
  const params = [];
1088
1122
  if (kind) {
@@ -1134,29 +1168,29 @@ function handleRecallFile(args) {
1134
1168
  return JSON.stringify({ ok: true, count: 0, note: 'No edits found for that path substring.' });
1135
1169
  }
1136
1170
  // Daily breakdown
1137
- const daily = db.prepare(`
1138
- SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
1139
- FROM session_file_edits WHERE file_path LIKE ?
1140
- GROUP BY day, operation ORDER BY day
1171
+ const daily = db.prepare(`
1172
+ SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
1173
+ FROM session_file_edits WHERE file_path LIKE ?
1174
+ GROUP BY day, operation ORDER BY day
1141
1175
  `).all(`%${sub}%`);
1142
1176
  // Distinct context_snippets (intents) — deduped, ordered by recency
1143
- const intents = db.prepare(`
1144
- SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
1145
- FROM session_file_edits
1146
- WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
1147
- GROUP BY context_snippet
1148
- ORDER BY last_at DESC
1149
- LIMIT ?
1177
+ const intents = db.prepare(`
1178
+ SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
1179
+ FROM session_file_edits
1180
+ WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
1181
+ GROUP BY context_snippet
1182
+ ORDER BY last_at DESC
1183
+ LIMIT ?
1150
1184
  `).all(`%${sub}%`, maxIntents);
1151
1185
  // Linked memories
1152
- const memories = db.prepare(`
1153
- SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
1154
- FROM session_file_edits sfe
1155
- JOIN memories m ON m.id = sfe.memory_id
1156
- JOIN entities e ON e.id = m.entity_id
1157
- WHERE sfe.file_path LIKE ?
1158
- ORDER BY m.importance DESC
1159
- LIMIT 20
1186
+ const memories = db.prepare(`
1187
+ SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
1188
+ FROM session_file_edits sfe
1189
+ JOIN memories m ON m.id = sfe.memory_id
1190
+ JOIN entities e ON e.id = m.entity_id
1191
+ WHERE sfe.file_path LIKE ?
1192
+ ORDER BY m.importance DESC
1193
+ LIMIT 20
1160
1194
  `).all(`%${sub}%`);
1161
1195
  // Distinct file paths matched (the substring may match multiple files)
1162
1196
  const paths = db.prepare(`SELECT file_path, COUNT(*) as edits FROM session_file_edits WHERE file_path LIKE ? GROUP BY file_path ORDER BY edits DESC`).all(`%${sub}%`);
@@ -1223,10 +1257,10 @@ async function handleConsolidateWithSampling(args) {
1223
1257
  const ageCutoff = Math.floor(Date.now() / 1000) - (typeof args?.min_age_days === 'number' ? args.min_age_days : 7) * 86400;
1224
1258
  const snapshot = new Map();
1225
1259
  const candidateRows = db
1226
- .prepare(`SELECT m.id, m.content, e.name as entity_name
1227
- FROM memories m JOIN entities e ON e.id = m.entity_id
1228
- WHERE m.protected = 0
1229
- AND m.layer IN ('context','emotion','implementation')
1260
+ .prepare(`SELECT m.id, m.content, e.name as entity_name
1261
+ FROM memories m JOIN entities e ON e.id = m.entity_id
1262
+ WHERE m.protected = 0
1263
+ AND m.layer IN ('context','emotion','implementation')
1230
1264
  AND m.created_at <= ?`)
1231
1265
  .all(ageCutoff);
1232
1266
  for (const r of candidateRows)
@@ -1470,7 +1504,7 @@ async function handleRecallUnified(args) {
1470
1504
  // Folded surfaces (roadmap 5): locating yourself and the triage session are ways of
1471
1505
  // recalling, not separate tools.
1472
1506
  if (args?.dream)
1473
- return handleDream({ domain: args.domain });
1507
+ return handleDream({ domain: args.domain, distill: args.distill });
1474
1508
  if (args?.where !== undefined && args?.where !== false) {
1475
1509
  return handleWhereAmI({ query: args.where === true ? undefined : String(args.where), project: args.project, limit: args.limit });
1476
1510
  }
@@ -1592,8 +1626,8 @@ function resolveProjectFromRecentEdits() {
1592
1626
  if (projects.length <= 1)
1593
1627
  return { project: projects[0] };
1594
1628
  const rows = db
1595
- .prepare(`SELECT file_path FROM session_file_edits
1596
- WHERE occurred_at > unixepoch() - 86400
1629
+ .prepare(`SELECT file_path FROM session_file_edits
1630
+ WHERE occurred_at > unixepoch() - 86400
1597
1631
  ORDER BY occurred_at DESC LIMIT 50`)
1598
1632
  .all();
1599
1633
  if (rows.length === 0)
@@ -1781,25 +1815,25 @@ function handleFlagProposals(args) {
1781
1815
  const sessionContext = args.session_context ?? 'conversation session';
1782
1816
  const PROPOSAL_TAG = 'orphaned_proposal';
1783
1817
  const now = Math.floor(Date.now() / 1000);
1784
- const insertAnchor = db.prepare(`
1785
- INSERT INTO drift_anchors (
1786
- kind, statement, rationale, domain, decision_mode,
1787
- confidence, lifecycle, status, owner,
1788
- evidence_refs, created_at, updated_at
1789
- ) VALUES (
1790
- 'decision', ?, ?, ?, 'hypothesis',
1791
- ?, 'active', 'active', 'agent',
1792
- ?, ?, ?
1793
- )
1818
+ const insertAnchor = db.prepare(`
1819
+ INSERT INTO drift_anchors (
1820
+ kind, statement, rationale, domain, decision_mode,
1821
+ confidence, lifecycle, status, owner,
1822
+ evidence_refs, created_at, updated_at
1823
+ ) VALUES (
1824
+ 'decision', ?, ?, ?, 'hypothesis',
1825
+ ?, 'active', 'active', 'agent',
1826
+ ?, ?, ?
1827
+ )
1794
1828
  `);
1795
- const insertCandidate = db.prepare(`
1796
- INSERT INTO memory_write_candidates (
1797
- scope, candidate_type, target_node_id, rationale,
1798
- confidence, evidence_refs, status, created_at
1799
- ) VALUES (
1800
- 'orphaned_proposal', 'update_node', ?, ?,
1801
- ?, ?, 'pending_review', ?
1802
- )
1829
+ const insertCandidate = db.prepare(`
1830
+ INSERT INTO memory_write_candidates (
1831
+ scope, candidate_type, target_node_id, rationale,
1832
+ confidence, evidence_refs, status, created_at
1833
+ ) VALUES (
1834
+ 'orphaned_proposal', 'update_node', ?, ?,
1835
+ ?, ?, 'pending_review', ?
1836
+ )
1803
1837
  `);
1804
1838
  const results = [];
1805
1839
  const txn = db.transaction(() => {
@@ -1843,13 +1877,13 @@ function handleFlagProposals(args) {
1843
1877
  function handleDream(args) {
1844
1878
  const domainFilter = args?.domain;
1845
1879
  // 1. Fetch active North Star anchor(s)
1846
- const northStars = db.prepare(`
1847
- SELECT id, statement, rationale, domain, confidence, lifecycle,
1848
- datetime(review_after, 'unixepoch') as review_date,
1849
- datetime(created_at, 'unixepoch') as declared_at
1850
- FROM drift_anchors
1851
- WHERE node_type = 'north_star' AND status = 'active'
1852
- ORDER BY created_at DESC
1880
+ const northStars = db.prepare(`
1881
+ SELECT id, statement, rationale, domain, confidence, lifecycle,
1882
+ datetime(review_after, 'unixepoch') as review_date,
1883
+ datetime(created_at, 'unixepoch') as declared_at
1884
+ FROM drift_anchors
1885
+ WHERE node_type = 'north_star' AND status = 'active'
1886
+ ORDER BY created_at DESC
1853
1887
  `).all();
1854
1888
  // Re-injection friction — the active-observability loop closing back into reflection.
1855
1889
  // "Re-surfaced N×, yet still contradicted in reality" is the machine evidence behind #15443.
@@ -1867,23 +1901,23 @@ function handleDream(args) {
1867
1901
  const distillLimit = Math.max(1, Math.min(25, Number(args?.distill ?? 8) || 8));
1868
1902
  let distillQueue = [];
1869
1903
  try {
1870
- const rows = db.prepare(`
1871
- SELECT m.id, m.layer, m.content, datetime(m.created_at, 'unixepoch') AS created, e.name AS entity
1872
- FROM memories m JOIN entities e ON e.id = m.entity_id
1873
- WHERE m.layer IN ('learning', 'caveat')
1874
- AND json_valid(m.content)
1875
- -- settle window: the Stop hook extracts every turn, so the newest rows belong to a
1876
- -- session still in motion. Don't ask the agent to distill the conversation it is in.
1877
- AND m.created_at < unixepoch() - 1800
1878
- AND json_extract(m.content, '$.distill_verdict') IS NULL
1879
- AND (json_extract(m.content, '$.needs_distill') = 1
1880
- OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
1881
- OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
1882
- ORDER BY (m.layer = 'caveat') DESC,
1883
- (e.name <> 'Local') DESC,
1884
- length(json_extract(m.content, '$.what')) DESC,
1885
- m.created_at DESC
1886
- LIMIT ${distillLimit}
1904
+ const rows = db.prepare(`
1905
+ SELECT m.id, m.layer, m.content, datetime(m.created_at, 'unixepoch') AS created, e.name AS entity
1906
+ FROM memories m JOIN entities e ON e.id = m.entity_id
1907
+ WHERE m.layer IN ('learning', 'caveat')
1908
+ AND json_valid(m.content)
1909
+ -- settle window: the Stop hook extracts every turn, so the newest rows belong to a
1910
+ -- session still in motion. Don't ask the agent to distill the conversation it is in.
1911
+ AND m.created_at < unixepoch() - 1800
1912
+ AND json_extract(m.content, '$.distill_verdict') IS NULL
1913
+ AND (json_extract(m.content, '$.needs_distill') = 1
1914
+ OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
1915
+ OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
1916
+ ORDER BY (m.layer = 'caveat') DESC,
1917
+ (e.name <> 'Local') DESC,
1918
+ length(json_extract(m.content, '$.what')) DESC,
1919
+ m.created_at DESC
1920
+ LIMIT ${distillLimit}
1887
1921
  `).all();
1888
1922
  distillQueue = rows.map((r) => {
1889
1923
  let c = {};
@@ -1923,26 +1957,26 @@ function handleDream(args) {
1923
1957
  }
1924
1958
  // 2. Fetch pending orphaned proposals
1925
1959
  const candidateQuery = domainFilter
1926
- ? `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1927
- mc.rationale as candidate_rationale, mc.confidence,
1928
- mc.evidence_refs, mc.status,
1929
- datetime(mc.created_at, 'unixepoch') as flagged_at,
1930
- da.statement, da.rationale as anchor_rationale, da.domain,
1931
- da.evidence_refs as anchor_evidence
1932
- FROM memory_write_candidates mc
1933
- JOIN drift_anchors da ON mc.target_node_id = da.id
1934
- WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1935
- AND da.domain = ?
1960
+ ? `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1961
+ mc.rationale as candidate_rationale, mc.confidence,
1962
+ mc.evidence_refs, mc.status,
1963
+ datetime(mc.created_at, 'unixepoch') as flagged_at,
1964
+ da.statement, da.rationale as anchor_rationale, da.domain,
1965
+ da.evidence_refs as anchor_evidence
1966
+ FROM memory_write_candidates mc
1967
+ JOIN drift_anchors da ON mc.target_node_id = da.id
1968
+ WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1969
+ AND da.domain = ?
1936
1970
  ORDER BY mc.created_at DESC`
1937
- : `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1938
- mc.rationale as candidate_rationale, mc.confidence,
1939
- mc.evidence_refs, mc.status,
1940
- datetime(mc.created_at, 'unixepoch') as flagged_at,
1941
- da.statement, da.rationale as anchor_rationale, da.domain,
1942
- da.evidence_refs as anchor_evidence
1943
- FROM memory_write_candidates mc
1944
- JOIN drift_anchors da ON mc.target_node_id = da.id
1945
- WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1971
+ : `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1972
+ mc.rationale as candidate_rationale, mc.confidence,
1973
+ mc.evidence_refs, mc.status,
1974
+ datetime(mc.created_at, 'unixepoch') as flagged_at,
1975
+ da.statement, da.rationale as anchor_rationale, da.domain,
1976
+ da.evidence_refs as anchor_evidence
1977
+ FROM memory_write_candidates mc
1978
+ JOIN drift_anchors da ON mc.target_node_id = da.id
1979
+ WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1946
1980
  ORDER BY mc.created_at DESC`;
1947
1981
  const candidates = domainFilter
1948
1982
  ? db.prepare(candidateQuery).all(domainFilter)
@@ -2004,7 +2038,9 @@ function handleDream(args) {
2004
2038
  friction,
2005
2039
  friction_total: friction.length,
2006
2040
  distill_queue: distillQueue,
2007
- distill_total: distillQueue.length,
2041
+ distill_total: distillPending(db),
2042
+ distill_shown: distillQueue.length,
2043
+ distill_hint: 'Ordered by value (caveats first, named entities, longer text). Pass distill: N (max 25) for a longer drain. Rules already set a distill_verdict on acknowledgements, bare paths and 90-day-old never-recalled items — you only see what needs judgement.',
2008
2044
  guide: guideParts.join(' '),
2009
2045
  });
2010
2046
  }
@@ -2022,12 +2058,12 @@ function handleResolveProposal(args) {
2022
2058
  throw new Error('verdict must be "surface" or "dismiss"');
2023
2059
  }
2024
2060
  // Verify candidate exists and is pending
2025
- const candidate = db.prepare(`
2026
- SELECT mc.id, mc.target_node_id, mc.status, mc.evidence_refs,
2027
- da.statement
2028
- FROM memory_write_candidates mc
2029
- JOIN drift_anchors da ON mc.target_node_id = da.id
2030
- WHERE mc.id = ? AND mc.scope = 'orphaned_proposal'
2061
+ const candidate = db.prepare(`
2062
+ SELECT mc.id, mc.target_node_id, mc.status, mc.evidence_refs,
2063
+ da.statement
2064
+ FROM memory_write_candidates mc
2065
+ JOIN drift_anchors da ON mc.target_node_id = da.id
2066
+ WHERE mc.id = ? AND mc.scope = 'orphaned_proposal'
2031
2067
  `).get(candidateId);
2032
2068
  if (!candidate) {
2033
2069
  throw new Error(`Candidate #${candidateId} not found or not an orphaned proposal`);
@@ -2092,6 +2128,18 @@ const LEGACY_TOOLS = {
2092
2128
  dream: 'recall({ dream: true }) — recall() with no arguments already includes the counts',
2093
2129
  resolve_proposal: "resolve_drift({ candidate_id, action: 'surface' | 'dismiss', rationale })",
2094
2130
  };
2131
+ // share_draft: check fields + build the link. No network. Records only that a draft was made.
2132
+ function handleShareDraft(args) {
2133
+ const r = buildShareDraft(args);
2134
+ if (!r.ok)
2135
+ return JSON.stringify(r, null, 2);
2136
+ try {
2137
+ // events row with no entity and no payload: the fact and the time, nothing from the fields.
2138
+ db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (NULL, ?, NULL)').run('share_draft_created');
2139
+ }
2140
+ catch { /* the record must never break the tool */ }
2141
+ return JSON.stringify(r, null, 2);
2142
+ }
2095
2143
  server.setRequestHandler(ListToolsRequestSchema, async () => {
2096
2144
  const showLegacy = process.env.LINKSEE_LEGACY_TOOLS === '1';
2097
2145
  const tools = TOOLS
@@ -2141,6 +2189,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
2141
2189
  case 'resolve_proposal':
2142
2190
  text = handleResolveProposal(args);
2143
2191
  break;
2192
+ // Share a fix (link only)
2193
+ case 'share_draft':
2194
+ text = handleShareDraft(args);
2195
+ break;
2144
2196
  default: {
2145
2197
  const migrations = {
2146
2198
  update_memory: 'remember({ memory_id: <id>, content: "...", importance: 0.8 })',