linksee-memory 0.16.1 → 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)) {
@@ -388,6 +390,35 @@ const TOOLS = [
388
390
  required: ['candidate_id', 'verdict', 'rationale'],
389
391
  },
390
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
+ },
391
422
  ];
392
423
  // ============================================================
393
424
  // Handlers
@@ -579,15 +610,15 @@ function appendAxisFilters(sql, params, filters) {
579
610
  return sql;
580
611
  }
581
612
  function runFtsQuery(query, layer, limit, axis) {
582
- let sql = `
583
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
584
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
585
- m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
586
- bm25(memories_fts) as bm25_score
587
- FROM memories_fts
588
- JOIN memories m ON m.id = memories_fts.rowid
589
- JOIN entities e ON e.id = m.entity_id
590
- 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 ?
591
622
  `;
592
623
  const params = [query];
593
624
  if (layer) {
@@ -601,14 +632,14 @@ function runFtsQuery(query, layer, limit, axis) {
601
632
  return db.prepare(sql).all(...params);
602
633
  }
603
634
  function runLikeQuery(query, entityName, layer, limit, axis) {
604
- let sql = `
605
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
606
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
607
- m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
608
- 0 as bm25_score
609
- FROM memories m
610
- JOIN entities e ON e.id = m.entity_id
611
- 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
612
643
  `;
613
644
  const params = [];
614
645
  if (entityName) {
@@ -939,8 +970,8 @@ function handleForget(args) {
939
970
  }
940
971
  // Auto-sweep — also respect pin (importance >= 0.9) as protection
941
972
  const rows = db
942
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
943
- FROM memories
973
+ .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
974
+ FROM memories
944
975
  WHERE protected = 0 AND importance < 0.9`)
945
976
  .all();
946
977
  const now = Math.floor(Date.now() / 1000);
@@ -989,17 +1020,17 @@ function handleConsolidate(args) {
989
1020
  // read-only audit: count candidates using the same rules.
990
1021
  const now = Math.floor(Date.now() / 1000);
991
1022
  const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
992
- const candidates = db.prepare(`
993
- SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
994
- FROM memories m
995
- JOIN entities e ON e.id = m.entity_id
996
- WHERE m.protected = 0
997
- AND m.importance < 0.9
998
- AND m.layer IN ('context', 'emotion', 'implementation')
999
- AND m.created_at <= ?
1000
- GROUP BY m.entity_id, m.layer
1001
- HAVING c >= 2
1002
- 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
1003
1034
  `).all(ageCutoff);
1004
1035
  const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
1005
1036
  return JSON.stringify({
@@ -1073,19 +1104,19 @@ function handleListEntities(args) {
1073
1104
  const minMemories = Math.max(1, Number(args?.min_memories ?? 1));
1074
1105
  const limit = Math.max(1, Math.min(200, Number(args?.limit ?? 30)));
1075
1106
  const offset = Math.max(0, Number(args?.offset ?? 0));
1076
- let sql = `
1077
- SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
1078
- e.updated_at, e.created_at,
1079
- COUNT(m.id) as memory_count,
1080
- MAX(m.last_accessed_at) as last_memory_access,
1081
- SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
1082
- SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
1083
- SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
1084
- SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
1085
- SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
1086
- FROM entities e
1087
- LEFT JOIN memories m ON m.entity_id = e.id
1088
- 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
1089
1120
  `;
1090
1121
  const params = [];
1091
1122
  if (kind) {
@@ -1137,29 +1168,29 @@ function handleRecallFile(args) {
1137
1168
  return JSON.stringify({ ok: true, count: 0, note: 'No edits found for that path substring.' });
1138
1169
  }
1139
1170
  // Daily breakdown
1140
- const daily = db.prepare(`
1141
- SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
1142
- FROM session_file_edits WHERE file_path LIKE ?
1143
- 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
1144
1175
  `).all(`%${sub}%`);
1145
1176
  // Distinct context_snippets (intents) — deduped, ordered by recency
1146
- const intents = db.prepare(`
1147
- SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
1148
- FROM session_file_edits
1149
- WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
1150
- GROUP BY context_snippet
1151
- ORDER BY last_at DESC
1152
- 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 ?
1153
1184
  `).all(`%${sub}%`, maxIntents);
1154
1185
  // Linked memories
1155
- const memories = db.prepare(`
1156
- SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
1157
- FROM session_file_edits sfe
1158
- JOIN memories m ON m.id = sfe.memory_id
1159
- JOIN entities e ON e.id = m.entity_id
1160
- WHERE sfe.file_path LIKE ?
1161
- ORDER BY m.importance DESC
1162
- 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
1163
1194
  `).all(`%${sub}%`);
1164
1195
  // Distinct file paths matched (the substring may match multiple files)
1165
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}%`);
@@ -1226,10 +1257,10 @@ async function handleConsolidateWithSampling(args) {
1226
1257
  const ageCutoff = Math.floor(Date.now() / 1000) - (typeof args?.min_age_days === 'number' ? args.min_age_days : 7) * 86400;
1227
1258
  const snapshot = new Map();
1228
1259
  const candidateRows = db
1229
- .prepare(`SELECT m.id, m.content, e.name as entity_name
1230
- FROM memories m JOIN entities e ON e.id = m.entity_id
1231
- WHERE m.protected = 0
1232
- 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')
1233
1264
  AND m.created_at <= ?`)
1234
1265
  .all(ageCutoff);
1235
1266
  for (const r of candidateRows)
@@ -1595,8 +1626,8 @@ function resolveProjectFromRecentEdits() {
1595
1626
  if (projects.length <= 1)
1596
1627
  return { project: projects[0] };
1597
1628
  const rows = db
1598
- .prepare(`SELECT file_path FROM session_file_edits
1599
- WHERE occurred_at > unixepoch() - 86400
1629
+ .prepare(`SELECT file_path FROM session_file_edits
1630
+ WHERE occurred_at > unixepoch() - 86400
1600
1631
  ORDER BY occurred_at DESC LIMIT 50`)
1601
1632
  .all();
1602
1633
  if (rows.length === 0)
@@ -1784,25 +1815,25 @@ function handleFlagProposals(args) {
1784
1815
  const sessionContext = args.session_context ?? 'conversation session';
1785
1816
  const PROPOSAL_TAG = 'orphaned_proposal';
1786
1817
  const now = Math.floor(Date.now() / 1000);
1787
- const insertAnchor = db.prepare(`
1788
- INSERT INTO drift_anchors (
1789
- kind, statement, rationale, domain, decision_mode,
1790
- confidence, lifecycle, status, owner,
1791
- evidence_refs, created_at, updated_at
1792
- ) VALUES (
1793
- 'decision', ?, ?, ?, 'hypothesis',
1794
- ?, 'active', 'active', 'agent',
1795
- ?, ?, ?
1796
- )
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
+ )
1797
1828
  `);
1798
- const insertCandidate = db.prepare(`
1799
- INSERT INTO memory_write_candidates (
1800
- scope, candidate_type, target_node_id, rationale,
1801
- confidence, evidence_refs, status, created_at
1802
- ) VALUES (
1803
- 'orphaned_proposal', 'update_node', ?, ?,
1804
- ?, ?, 'pending_review', ?
1805
- )
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
+ )
1806
1837
  `);
1807
1838
  const results = [];
1808
1839
  const txn = db.transaction(() => {
@@ -1846,13 +1877,13 @@ function handleFlagProposals(args) {
1846
1877
  function handleDream(args) {
1847
1878
  const domainFilter = args?.domain;
1848
1879
  // 1. Fetch active North Star anchor(s)
1849
- const northStars = db.prepare(`
1850
- SELECT id, statement, rationale, domain, confidence, lifecycle,
1851
- datetime(review_after, 'unixepoch') as review_date,
1852
- datetime(created_at, 'unixepoch') as declared_at
1853
- FROM drift_anchors
1854
- WHERE node_type = 'north_star' AND status = 'active'
1855
- 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
1856
1887
  `).all();
1857
1888
  // Re-injection friction — the active-observability loop closing back into reflection.
1858
1889
  // "Re-surfaced N×, yet still contradicted in reality" is the machine evidence behind #15443.
@@ -1870,23 +1901,23 @@ function handleDream(args) {
1870
1901
  const distillLimit = Math.max(1, Math.min(25, Number(args?.distill ?? 8) || 8));
1871
1902
  let distillQueue = [];
1872
1903
  try {
1873
- const rows = db.prepare(`
1874
- SELECT m.id, m.layer, m.content, datetime(m.created_at, 'unixepoch') AS created, e.name AS entity
1875
- FROM memories m JOIN entities e ON e.id = m.entity_id
1876
- WHERE m.layer IN ('learning', 'caveat')
1877
- AND json_valid(m.content)
1878
- -- settle window: the Stop hook extracts every turn, so the newest rows belong to a
1879
- -- session still in motion. Don't ask the agent to distill the conversation it is in.
1880
- AND m.created_at < unixepoch() - 1800
1881
- AND json_extract(m.content, '$.distill_verdict') IS NULL
1882
- AND (json_extract(m.content, '$.needs_distill') = 1
1883
- OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
1884
- OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
1885
- ORDER BY (m.layer = 'caveat') DESC,
1886
- (e.name <> 'Local') DESC,
1887
- length(json_extract(m.content, '$.what')) DESC,
1888
- m.created_at DESC
1889
- 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}
1890
1921
  `).all();
1891
1922
  distillQueue = rows.map((r) => {
1892
1923
  let c = {};
@@ -1926,26 +1957,26 @@ function handleDream(args) {
1926
1957
  }
1927
1958
  // 2. Fetch pending orphaned proposals
1928
1959
  const candidateQuery = domainFilter
1929
- ? `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1930
- mc.rationale as candidate_rationale, mc.confidence,
1931
- mc.evidence_refs, mc.status,
1932
- datetime(mc.created_at, 'unixepoch') as flagged_at,
1933
- da.statement, da.rationale as anchor_rationale, da.domain,
1934
- da.evidence_refs as anchor_evidence
1935
- FROM memory_write_candidates mc
1936
- JOIN drift_anchors da ON mc.target_node_id = da.id
1937
- WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1938
- 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 = ?
1939
1970
  ORDER BY mc.created_at DESC`
1940
- : `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1941
- mc.rationale as candidate_rationale, mc.confidence,
1942
- mc.evidence_refs, mc.status,
1943
- datetime(mc.created_at, 'unixepoch') as flagged_at,
1944
- da.statement, da.rationale as anchor_rationale, da.domain,
1945
- da.evidence_refs as anchor_evidence
1946
- FROM memory_write_candidates mc
1947
- JOIN drift_anchors da ON mc.target_node_id = da.id
1948
- 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'
1949
1980
  ORDER BY mc.created_at DESC`;
1950
1981
  const candidates = domainFilter
1951
1982
  ? db.prepare(candidateQuery).all(domainFilter)
@@ -2027,12 +2058,12 @@ function handleResolveProposal(args) {
2027
2058
  throw new Error('verdict must be "surface" or "dismiss"');
2028
2059
  }
2029
2060
  // Verify candidate exists and is pending
2030
- const candidate = db.prepare(`
2031
- SELECT mc.id, mc.target_node_id, mc.status, mc.evidence_refs,
2032
- da.statement
2033
- FROM memory_write_candidates mc
2034
- JOIN drift_anchors da ON mc.target_node_id = da.id
2035
- 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'
2036
2067
  `).get(candidateId);
2037
2068
  if (!candidate) {
2038
2069
  throw new Error(`Candidate #${candidateId} not found or not an orphaned proposal`);
@@ -2097,6 +2128,18 @@ const LEGACY_TOOLS = {
2097
2128
  dream: 'recall({ dream: true }) — recall() with no arguments already includes the counts',
2098
2129
  resolve_proposal: "resolve_drift({ candidate_id, action: 'surface' | 'dismiss', rationale })",
2099
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
+ }
2100
2143
  server.setRequestHandler(ListToolsRequestSchema, async () => {
2101
2144
  const showLegacy = process.env.LINKSEE_LEGACY_TOOLS === '1';
2102
2145
  const tools = TOOLS
@@ -2146,6 +2189,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
2146
2189
  case 'resolve_proposal':
2147
2190
  text = handleResolveProposal(args);
2148
2191
  break;
2192
+ // Share a fix (link only)
2193
+ case 'share_draft':
2194
+ text = handleShareDraft(args);
2195
+ break;
2149
2196
  default: {
2150
2197
  const migrations = {
2151
2198
  update_memory: 'remember({ memory_id: <id>, content: "...", importance: 0.8 })',