linksee-memory 0.8.0 → 0.10.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,7 +1,8 @@
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
4
- // v0.8.0 — drift MCP tools (P0: agents can now query & act on drift state)
3
+ // Tools: remember / recall / read_smart / drift_status / check_decision / declare_anchor / resolve_drift / flag_proposals / dream / resolve_proposal
4
+ // v0.10.0 — Re-injection layer: pre-action guard (PreToolUse/SessionStart hooks) + extraction
5
+ // quality gate + distillation routine (dream distill_queue) + harden/soften escalation
5
6
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
8
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
@@ -21,7 +22,8 @@ import { sampleConsolidation } from './sampling.js';
21
22
  import { confirmForget } from './elicitation.js';
22
23
  import { getTruthView, getDecisionDetail, resolveDrift } from '../lib/truth-engine.js';
23
24
  import { declareAnchor, setNodeFields } from '../lib/drift-anchors.js';
24
- const SERVER_VERSION = '0.8.0';
25
+ import { getReinjectionFriction, setGateMode } from '../lib/guard.js';
26
+ const SERVER_VERSION = '0.10.0';
25
27
  const db = openDb();
26
28
  runMigrations(db);
27
29
  // Auto-maintenance: consolidate stale memories on startup (non-blocking)
@@ -193,12 +195,12 @@ const TOOLS = [
193
195
  },
194
196
  {
195
197
  name: 'resolve_drift',
196
- description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n4 resolution actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
198
+ description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
197
199
  inputSchema: {
198
200
  type: 'object',
199
201
  properties: {
200
202
  anchor_id: { type: 'number', description: 'The drift_anchor ID to resolve' },
201
- action: { type: 'string', enum: ['fix', 'supersede', 'acknowledge', 'dismiss'], description: 'Resolution action' },
203
+ action: { type: 'string', enum: ['fix', 'supersede', 'acknowledge', 'dismiss', 'harden', 'soften'], description: 'Resolution action' },
202
204
  rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
203
205
  review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
204
206
  superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
@@ -206,6 +208,86 @@ const TOOLS = [
206
208
  required: ['anchor_id', 'action'],
207
209
  },
208
210
  },
211
+ {
212
+ name: 'flag_proposals',
213
+ description: 'Record orphaned proposals — options you presented that the user never addressed.\n\n' +
214
+ 'Conversations are tree-shaped but experienced linearly. When you present 3 options and the user ' +
215
+ 'engages with only 1, the other 2 become "orphaned proposals" — unresolved decision branches that ' +
216
+ 'both you and the user lose track of.\n\n' +
217
+ 'WHEN TO CALL:\n' +
218
+ '• When you notice the user engaged with only some of the options you presented\n' +
219
+ '• When the conversation shifted topic and earlier proposals were never resolved\n' +
220
+ '• At session end, review what you proposed vs what was addressed\n' +
221
+ '• When the user says "what else did we discuss?" or "何か忘れてない?"\n\n' +
222
+ 'Each proposal becomes a review-state anchor on the dashboard — visible until the user decides.\n' +
223
+ 'This is declaration, not mining: YOU are the curator recognizing what went unaddressed.',
224
+ inputSchema: {
225
+ type: 'object',
226
+ properties: {
227
+ proposals: {
228
+ type: 'array',
229
+ description: 'Array of unresolved proposals (1-10 items)',
230
+ items: {
231
+ type: 'object',
232
+ properties: {
233
+ statement: { type: 'string', description: 'The proposal itself — what was suggested but not addressed (prefix with [未解決] for dashboard clarity)' },
234
+ rationale: { type: 'string', description: 'Context: what discussion it came from, what the user chose instead, why this matters' },
235
+ domain: { type: 'string', description: 'Topic domain (e.g. monetization, product, engineering, strategy, growth, roadmap)' },
236
+ confidence: { type: 'number', minimum: 0, maximum: 1, description: 'How confident you are this is worth revisiting (0.3-0.7 typical)' },
237
+ siblings: { type: 'array', items: { type: 'string' }, description: 'Other options from the same proposal set (for context)' },
238
+ decided: { type: 'string', description: 'What the user chose or engaged with instead' },
239
+ },
240
+ required: ['statement', 'rationale', 'domain'],
241
+ },
242
+ },
243
+ session_context: { type: 'string', description: 'Brief description of the conversation/session where these proposals arose' },
244
+ },
245
+ required: ['proposals'],
246
+ },
247
+ },
248
+ // ── Dreaming Memory (v0.9.0) ──────────────────────────────
249
+ {
250
+ name: 'dream',
251
+ description: 'Dreaming Memory — consolidate orphaned proposals against the North Star.\n\n' +
252
+ 'Returns the project\'s North Star (direction/goals/ICP/phase) alongside unresolved proposals ' +
253
+ 'that agents flagged during conversations. YOUR job as the evaluating agent is to decide:\n\n' +
254
+ '• surface — genuinely important unresolved fork point given the current direction\n' +
255
+ '• dismiss — outdated, already implicitly resolved, or irrelevant to current goals\n\n' +
256
+ 'Think like a General Doctor doing triage: the North Star is the patient\'s chart, ' +
257
+ 'each proposal is a symptom. Not every symptom needs treatment.\n\n' +
258
+ 'WHEN TO CALL:\n' +
259
+ '• At session start to triage accumulated proposals\n' +
260
+ '• When the user asks "何か見落としてない?" or "what should we revisit?"\n' +
261
+ '• Periodically to prevent proposal backlog from growing stale\n\n' +
262
+ 'After evaluation, call resolve_proposal for each candidate with your verdict.\n\n' +
263
+ 'ALSO RETURNS: `distill_queue` — auto-captured memories whose content is still a RAW user utterance. ' +
264
+ 'Rewrite each via remember(memory_id, content) per the guide in the response (one-line what, true why, ' +
265
+ '"distilled": true). Drain up to 8 per call — the SessionStart digest reminds you while the queue is non-empty. ' +
266
+ 'And `friction` — anchors re-surfaced at the gate yet still contradicted (resolve_drift action:"harden" to enforce).',
267
+ inputSchema: {
268
+ type: 'object',
269
+ properties: {
270
+ domain: { type: 'string', description: 'Filter proposals by domain (e.g. strategy, product, engineering)' },
271
+ },
272
+ },
273
+ },
274
+ {
275
+ name: 'resolve_proposal',
276
+ description: 'Record your evaluation verdict for an orphaned proposal after dreaming.\n\n' +
277
+ 'Call this after `dream` for each candidate you evaluated against the North Star.\n' +
278
+ '• surface — Keep visible on dashboard for human decision\n' +
279
+ '• dismiss — Remove from dashboard (outdated/irrelevant/implicitly resolved)\n\n' +
280
+ 'Always reference the North Star criteria in your rationale.',
281
+ inputSchema: {
282
+ type: 'object',
283
+ properties: {
284
+ candidate_id: { type: 'number', description: 'The candidate ID from dream results' },
285
+ verdict: { type: 'string', enum: ['surface', 'dismiss'], description: 'Your evaluation verdict' },
286
+ rationale: { type: 'string', description: 'Why — must reference North Star criteria' },
287
+ },
288
+ required: ['candidate_id', 'verdict', 'rationale'],
289
+ },
290
+ },
209
291
  ];
210
292
  // ============================================================
211
293
  // Handlers
@@ -1209,6 +1291,18 @@ function handleResolveDrift(args) {
1209
1291
  if (!args?.anchor_id || !args?.action) {
1210
1292
  throw new Error('anchor_id and action are required');
1211
1293
  }
1294
+ // Enforcement-policy actions — apply the dream "escalate_to_hard" recommendation in ONE call.
1295
+ // Folded into resolve_drift (not a new tool) to honor anchor #1. Intercepted here so the WIP
1296
+ // truth-engine.ts resolveDrift() stays untouched.
1297
+ if (args.action === 'harden' || args.action === 'soften') {
1298
+ const mode = args.action === 'harden' ? 'hard' : 'soft';
1299
+ const res = setGateMode(db, args.anchor_id, mode);
1300
+ return JSON.stringify({
1301
+ ...res,
1302
+ action: args.action,
1303
+ message: `anchor #${args.anchor_id} gate_mode → '${mode}'${args.rationale ? ` (${args.rationale})` : ''}. Future PreToolUse on this anchor will ${mode === 'hard' ? 'BLOCK' : 'soft-warn'}.`,
1304
+ });
1305
+ }
1212
1306
  const result = resolveDrift(db, {
1213
1307
  anchor_id: args.anchor_id,
1214
1308
  action: args.action,
@@ -1219,6 +1313,307 @@ function handleResolveDrift(args) {
1219
1313
  return JSON.stringify(result);
1220
1314
  }
1221
1315
  // ============================================================
1316
+ // flag_proposals — orphaned proposal detection (v0.9.0 prototype)
1317
+ // Agent explicitly declares unresolved proposals from the conversation.
1318
+ // Each proposal → anchor(hypothesis) + pending_review candidate → "review" state on dashboard.
1319
+ // ============================================================
1320
+ function handleFlagProposals(args) {
1321
+ const proposals = args?.proposals;
1322
+ if (!Array.isArray(proposals) || proposals.length === 0) {
1323
+ return JSON.stringify({ ok: false, error: 'proposals array is required (1-10 items)' });
1324
+ }
1325
+ if (proposals.length > 10) {
1326
+ return JSON.stringify({ ok: false, error: 'Max 10 proposals per call (batch in multiple calls if needed)' });
1327
+ }
1328
+ const sessionContext = args.session_context ?? 'conversation session';
1329
+ const PROPOSAL_TAG = 'orphaned_proposal';
1330
+ const now = Math.floor(Date.now() / 1000);
1331
+ const insertAnchor = db.prepare(`
1332
+ INSERT INTO drift_anchors (
1333
+ kind, statement, rationale, domain, decision_mode,
1334
+ confidence, lifecycle, status, owner,
1335
+ evidence_refs, created_at, updated_at
1336
+ ) VALUES (
1337
+ 'decision', ?, ?, ?, 'hypothesis',
1338
+ ?, 'active', 'active', 'agent',
1339
+ ?, ?, ?
1340
+ )
1341
+ `);
1342
+ const insertCandidate = db.prepare(`
1343
+ INSERT INTO memory_write_candidates (
1344
+ scope, candidate_type, target_node_id, rationale,
1345
+ confidence, evidence_refs, status, created_at
1346
+ ) VALUES (
1347
+ 'orphaned_proposal', 'update_node', ?, ?,
1348
+ ?, ?, 'pending_review', ?
1349
+ )
1350
+ `);
1351
+ const results = [];
1352
+ const txn = db.transaction(() => {
1353
+ for (const p of proposals) {
1354
+ const statement = String(p.statement ?? '').trim();
1355
+ if (statement.length < 10)
1356
+ continue; // skip junk
1357
+ const rationale = String(p.rationale ?? '').trim();
1358
+ const domain = String(p.domain ?? 'general').trim();
1359
+ const confidence = Math.max(0, Math.min(1, Number(p.confidence) || 0.5));
1360
+ const evidenceRefs = JSON.stringify([{
1361
+ type: 'proposal_set',
1362
+ tag: PROPOSAL_TAG,
1363
+ session_context: sessionContext,
1364
+ decided: p.decided ?? null,
1365
+ siblings: p.siblings ?? [],
1366
+ flagged_at: new Date().toISOString(),
1367
+ }]);
1368
+ // 1. Create anchor
1369
+ const anchorResult = insertAnchor.run(statement, rationale, domain, confidence, evidenceRefs, now, now);
1370
+ const anchorId = Number(anchorResult.lastInsertRowid);
1371
+ // 2. Create pending_review candidate → triggers "review" state
1372
+ insertCandidate.run(anchorId, `Orphaned proposal: ${sessionContext}`, confidence, evidenceRefs, now);
1373
+ results.push({ anchor_id: anchorId, statement, domain });
1374
+ }
1375
+ });
1376
+ txn();
1377
+ return JSON.stringify({
1378
+ ok: true,
1379
+ flagged: results.length,
1380
+ proposals: results,
1381
+ message: `Flagged ${results.length} orphaned proposal(s) for review. They will appear as "review" items on the dashboard.`,
1382
+ tag: PROPOSAL_TAG,
1383
+ hint: 'User can resolve each via resolve_drift(anchor_id, action) or on the dashboard.',
1384
+ });
1385
+ }
1386
+ // ============================================================
1387
+ // dream — Dreaming Memory: North Star + orphaned proposals for agent evaluation
1388
+ // The agent IS the Doctor. MCP just provides data + update path.
1389
+ // ============================================================
1390
+ function handleDream(args) {
1391
+ const domainFilter = args?.domain;
1392
+ // 1. Fetch active North Star anchor(s)
1393
+ const northStars = db.prepare(`
1394
+ SELECT id, statement, rationale, domain, confidence, lifecycle,
1395
+ datetime(review_after, 'unixepoch') as review_date,
1396
+ datetime(created_at, 'unixepoch') as declared_at
1397
+ FROM drift_anchors
1398
+ WHERE node_type = 'north_star' AND status = 'active'
1399
+ ORDER BY created_at DESC
1400
+ `).all();
1401
+ // Re-injection friction — the active-observability loop closing back into reflection.
1402
+ // "Re-surfaced N×, yet still contradicted in reality" is the machine evidence behind #15443.
1403
+ let friction = [];
1404
+ try {
1405
+ friction = getReinjectionFriction(db, { minContradicts: 3 });
1406
+ }
1407
+ catch {
1408
+ /* additive — never break dream on a friction-query error */
1409
+ }
1410
+ // Distillation queue — the quality gate's LLM half. The hook-path extractor stores RAW
1411
+ // utterances (no LLM there); the agent rewrites them here into clean what/why via
1412
+ // remember(memory_id, content). Matches needs_distill (new) AND the legacy hardcoded
1413
+ // why-strings so the existing backlog is drainable without a backfill write.
1414
+ let distillQueue = [];
1415
+ try {
1416
+ const rows = db.prepare(`
1417
+ SELECT m.id, m.layer, m.content, datetime(m.created_at, 'unixepoch') AS created, e.name AS entity
1418
+ FROM memories m JOIN entities e ON e.id = m.entity_id
1419
+ WHERE m.layer IN ('learning', 'caveat')
1420
+ AND json_valid(m.content)
1421
+ AND (json_extract(m.content, '$.needs_distill') = 1
1422
+ OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
1423
+ OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
1424
+ ORDER BY m.created_at DESC LIMIT 8
1425
+ `).all();
1426
+ distillQueue = rows.map((r) => {
1427
+ let c = {};
1428
+ try {
1429
+ c = JSON.parse(r.content);
1430
+ }
1431
+ catch { /* keep empty */ }
1432
+ return {
1433
+ memory_id: r.id,
1434
+ layer: r.layer,
1435
+ entity: r.entity,
1436
+ raw_what: String(c.what ?? '').slice(0, 220),
1437
+ context_hint: c.context_hint ? String(c.context_hint).slice(0, 220) : undefined,
1438
+ affects: c.affects,
1439
+ created: r.created,
1440
+ };
1441
+ });
1442
+ }
1443
+ catch {
1444
+ /* additive — never break dream on a distill-query error */
1445
+ }
1446
+ if (northStars.length === 0) {
1447
+ return JSON.stringify({
1448
+ ok: true,
1449
+ north_star: null,
1450
+ candidates: [],
1451
+ friction,
1452
+ friction_total: friction.length,
1453
+ distill_queue: distillQueue,
1454
+ distill_total: distillQueue.length,
1455
+ message: friction.length > 0
1456
+ ? 'No North Star declared yet — but the re-injection layer surfaced friction below: anchors being violated despite being re-surfaced. Declare a North Star (declare_anchor node_type:"north_star"), and act on the friction items.'
1457
+ : 'No North Star declared yet. Declare one with declare_anchor(node_type: "north_star") before dreaming.',
1458
+ });
1459
+ }
1460
+ // 2. Fetch pending orphaned proposals
1461
+ const candidateQuery = domainFilter
1462
+ ? `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1463
+ mc.rationale as candidate_rationale, mc.confidence,
1464
+ mc.evidence_refs, mc.status,
1465
+ datetime(mc.created_at, 'unixepoch') as flagged_at,
1466
+ da.statement, da.rationale as anchor_rationale, da.domain,
1467
+ da.evidence_refs as anchor_evidence
1468
+ FROM memory_write_candidates mc
1469
+ JOIN drift_anchors da ON mc.target_node_id = da.id
1470
+ WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1471
+ AND da.domain = ?
1472
+ ORDER BY mc.created_at DESC`
1473
+ : `SELECT mc.id as candidate_id, mc.target_node_id as anchor_id,
1474
+ mc.rationale as candidate_rationale, mc.confidence,
1475
+ mc.evidence_refs, mc.status,
1476
+ datetime(mc.created_at, 'unixepoch') as flagged_at,
1477
+ da.statement, da.rationale as anchor_rationale, da.domain,
1478
+ da.evidence_refs as anchor_evidence
1479
+ FROM memory_write_candidates mc
1480
+ JOIN drift_anchors da ON mc.target_node_id = da.id
1481
+ WHERE mc.scope = 'orphaned_proposal' AND mc.status = 'pending_review'
1482
+ ORDER BY mc.created_at DESC`;
1483
+ const candidates = domainFilter
1484
+ ? db.prepare(candidateQuery).all(domainFilter)
1485
+ : db.prepare(candidateQuery).all();
1486
+ // 3. Enrich with proposal context from evidence_refs
1487
+ const enrichedCandidates = candidates.map(c => {
1488
+ let context = {};
1489
+ try {
1490
+ const refs = JSON.parse(c.anchor_evidence || '[]');
1491
+ const proposalRef = refs.find((r) => r.tag === 'orphaned_proposal');
1492
+ if (proposalRef) {
1493
+ context = {
1494
+ decided: proposalRef.decided,
1495
+ siblings: proposalRef.siblings,
1496
+ session_context: proposalRef.session_context,
1497
+ };
1498
+ }
1499
+ }
1500
+ catch { /* malformed JSON — skip enrichment */ }
1501
+ return {
1502
+ candidate_id: c.candidate_id,
1503
+ anchor_id: c.anchor_id,
1504
+ statement: c.statement,
1505
+ rationale: c.anchor_rationale,
1506
+ domain: c.domain,
1507
+ confidence: c.confidence,
1508
+ flagged_at: c.flagged_at,
1509
+ ...context,
1510
+ };
1511
+ });
1512
+ const guideParts = [];
1513
+ if (enrichedCandidates.length > 0) {
1514
+ guideParts.push('Evaluate each candidate against the North Star: "Given our current direction/ICP/phase, is this ' +
1515
+ 'unresolved branch still important?" Then call resolve_proposal(candidate_id, verdict, rationale) for each.');
1516
+ }
1517
+ if (friction.length > 0) {
1518
+ guideParts.push(`⚠ FRICTION: ${friction.length} accepted anchor(s) keep being re-surfaced at the gate (see "friction"). ` +
1519
+ 'For each suggested_action="escalate_to_hard", call resolve_drift(anchor_id, action:"harden") to make the ' +
1520
+ 'gate BLOCK it; for "review_or_supersede", call resolve_drift(anchor_id, action:"supersede", …) if the rule ' +
1521
+ 'has outrun reality.');
1522
+ }
1523
+ if (distillQueue.length > 0) {
1524
+ guideParts.push(`🧪 DISTILL: ${distillQueue.length} auto-extracted memories hold RAW user utterances (see "distill_queue"). ` +
1525
+ 'For each: rewrite into ONE clean decision/warning using raw_what + context_hint (resolve references like ' +
1526
+ '"option a"), then save via remember(memory_id, content) with the full structured JSON — a one-line `what` ' +
1527
+ '(the actual decision, not the chat), a true `why`, the original affects, `"distilled": true` (REQUIRED — ' +
1528
+ 'this marker is what protects your rewrite from the next session re-import; omit it and the raw utterance ' +
1529
+ 'resurrects), and NO needs_distill field. ' +
1530
+ 'If raw_what carries no real decision/warning, set its type to "note" and state to "superseded" instead.');
1531
+ }
1532
+ if (guideParts.length === 0)
1533
+ guideParts.push('No pending proposals, no gate friction, nothing to distill. The dream is clear.');
1534
+ return JSON.stringify({
1535
+ ok: true,
1536
+ north_star: northStars[0],
1537
+ all_north_stars: northStars.length > 1 ? northStars : undefined,
1538
+ candidates: enrichedCandidates,
1539
+ total: enrichedCandidates.length,
1540
+ friction,
1541
+ friction_total: friction.length,
1542
+ distill_queue: distillQueue,
1543
+ distill_total: distillQueue.length,
1544
+ guide: guideParts.join(' '),
1545
+ });
1546
+ }
1547
+ // ============================================================
1548
+ // resolve_proposal — Agent writes back surface/dismiss verdict
1549
+ // ============================================================
1550
+ function handleResolveProposal(args) {
1551
+ const candidateId = args?.candidate_id;
1552
+ const verdict = args?.verdict;
1553
+ const rationale = args?.rationale;
1554
+ if (!candidateId || !verdict || !rationale) {
1555
+ throw new Error('candidate_id, verdict, and rationale are required');
1556
+ }
1557
+ if (!['surface', 'dismiss'].includes(verdict)) {
1558
+ throw new Error('verdict must be "surface" or "dismiss"');
1559
+ }
1560
+ // Verify candidate exists and is pending
1561
+ const candidate = db.prepare(`
1562
+ SELECT mc.id, mc.target_node_id, mc.status, mc.evidence_refs,
1563
+ da.statement
1564
+ FROM memory_write_candidates mc
1565
+ JOIN drift_anchors da ON mc.target_node_id = da.id
1566
+ WHERE mc.id = ? AND mc.scope = 'orphaned_proposal'
1567
+ `).get(candidateId);
1568
+ if (!candidate) {
1569
+ throw new Error(`Candidate #${candidateId} not found or not an orphaned proposal`);
1570
+ }
1571
+ if (candidate.status !== 'pending_review') {
1572
+ return JSON.stringify({
1573
+ ok: false,
1574
+ error: `Candidate #${candidateId} is already "${candidate.status}" — cannot re-evaluate`,
1575
+ });
1576
+ }
1577
+ const now = Math.floor(Date.now() / 1000);
1578
+ const doctorNote = {
1579
+ type: 'doctor_evaluation',
1580
+ verdict,
1581
+ rationale,
1582
+ evaluated_at: new Date().toISOString(),
1583
+ };
1584
+ // Append doctor evaluation to evidence trail
1585
+ let refs = [];
1586
+ try {
1587
+ refs = JSON.parse(candidate.evidence_refs || '[]');
1588
+ }
1589
+ catch { /* keep empty */ }
1590
+ refs.push(doctorNote);
1591
+ const updatedRefs = JSON.stringify(refs);
1592
+ const txn = db.transaction(() => {
1593
+ if (verdict === 'dismiss') {
1594
+ // Reject candidate + retire anchor (removes from dashboard)
1595
+ db.prepare('UPDATE memory_write_candidates SET status = ?, evidence_refs = ? WHERE id = ?').run('rejected', updatedRefs, candidateId);
1596
+ db.prepare('UPDATE drift_anchors SET status = ?, lifecycle = ?, updated_at = ? WHERE id = ?').run('retired', 'deprecated', now, candidate.target_node_id);
1597
+ }
1598
+ else {
1599
+ // Surface: keep pending_review, record doctor's endorsement
1600
+ db.prepare('UPDATE memory_write_candidates SET evidence_refs = ? WHERE id = ?').run(updatedRefs, candidateId);
1601
+ }
1602
+ });
1603
+ txn();
1604
+ const shortStmt = candidate.statement.substring(0, 60);
1605
+ return JSON.stringify({
1606
+ ok: true,
1607
+ candidate_id: candidateId,
1608
+ anchor_id: candidate.target_node_id,
1609
+ verdict,
1610
+ statement: candidate.statement,
1611
+ message: verdict === 'dismiss'
1612
+ ? `Dismissed: "${shortStmt}…" — removed from dashboard`
1613
+ : `Surfaced: "${shortStmt}…" — kept for human review on dashboard`,
1614
+ });
1615
+ }
1616
+ // ============================================================
1222
1617
  // MCP wiring
1223
1618
  // ============================================================
1224
1619
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
@@ -1249,6 +1644,16 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1249
1644
  case 'resolve_drift':
1250
1645
  text = handleResolveDrift(args);
1251
1646
  break;
1647
+ case 'flag_proposals':
1648
+ text = handleFlagProposals(args);
1649
+ break;
1650
+ // Dreaming Memory (v0.9.0)
1651
+ case 'dream':
1652
+ text = handleDream(args);
1653
+ break;
1654
+ case 'resolve_proposal':
1655
+ text = handleResolveProposal(args);
1656
+ break;
1252
1657
  default: {
1253
1658
  const migrations = {
1254
1659
  update_memory: 'remember({ memory_id: <id>, content: "...", importance: 0.8 })',