linksee-memory 0.7.2 → 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
4
- // v0.7.0 — unified 3-tool surface (Context7-style simplification)
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';
@@ -19,7 +20,10 @@ import { PROMPTS, getPrompt } from './prompts.js';
19
20
  import { fetchRoots, isInsideRoots } from './roots.js';
20
21
  import { sampleConsolidation } from './sampling.js';
21
22
  import { confirmForget } from './elicitation.js';
22
- const SERVER_VERSION = '0.7.0';
23
+ import { getTruthView, getDecisionDetail, resolveDrift } from '../lib/truth-engine.js';
24
+ import { declareAnchor, setNodeFields } from '../lib/drift-anchors.js';
25
+ import { getReinjectionFriction, setGateMode } from '../lib/guard.js';
26
+ const SERVER_VERSION = '0.10.0';
23
27
  const db = openDb();
24
28
  runMigrations(db);
25
29
  // Auto-maintenance: consolidate stale memories on startup (non-blocking)
@@ -142,6 +146,148 @@ const TOOLS = [
142
146
  required: ['path'],
143
147
  },
144
148
  },
149
+ // ── Drift tools (v0.8.0) ─────────────────────────────────────────────────
150
+ {
151
+ name: 'drift_status',
152
+ description: 'Check what\'s drifting right now — the "Intent Datadog" for your product decisions.\n\nReturns a structured truth map showing which decisions/constraints/hypotheses are:\n🔴 drift (unaccounted divergence from intent)\n🟡 review (soft signal, awaiting human decision)\n⚪ held (acknowledged, time-boxed, not forgotten)\n🔵 aligned (reality matches intent)\n\nNodes are classified into 4 species:\n• hypothesis → Decision Cards (decision journal format)\n• constraint → Rules (pass/fail checklist)\n• commitment → Heartbeats (cadence monitoring)\n• source_of_truth → Reference (stable anchors)\n\nWHEN TO CALL:\n• At session start — "what needs my attention?"\n• Before making a decision — check for existing anchors on the topic\n• After completing work — verify drift state changed\n• When the user asks about product health / what\'s broken / what\'s stale',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ domain: { type: 'string', description: 'Filter by domain (strategy, product, engineering, growth, etc.)' },
157
+ decision_mode: { type: 'string', description: 'Filter by decision_mode (hypothesis, constraint, commitment, source_of_truth)' },
158
+ },
159
+ },
160
+ },
161
+ {
162
+ name: 'check_decision',
163
+ description: 'Deep-dive into a specific decision/anchor — its state, premises, drift edges, and pending candidates.\n\nReturns the full context for one truth-map node: what was decided, why, what reality says, whether it\'s drifting, and what actions are pending.\n\nWHEN TO CALL:\n• When the user asks about a specific decision ("what happened with X?")\n• Before resolving a drift signal — understand the full picture first\n• When reviewing premises of a decision ("is this still true?")',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ anchor_id: { type: 'number', description: 'The drift_anchor ID to inspect' },
168
+ },
169
+ required: ['anchor_id'],
170
+ },
171
+ },
172
+ {
173
+ name: 'declare_anchor',
174
+ description: 'Declare a new decision, constraint, or prohibition as a truth-map anchor.\n\nAnchors are NORMATIVE claims: "we decided X", "Y is forbidden", "Z must always hold."\nThe drift detector later checks these against committed reality.\n\ndeclare-don\'t-mine: anchors come ONLY from explicit human declaration, never from pattern extraction.\n\nWHEN TO CALL:\n• When the user makes a product decision ("let\'s go with approach A")\n• When a constraint is established ("never do X")\n• When a commitment is made ("we ship weekly")\n• When the user says "anchor this" / "record this decision"',
175
+ inputSchema: {
176
+ type: 'object',
177
+ properties: {
178
+ kind: { type: 'string', enum: ['prohibition', 'decision', 'constraint'], description: 'Anchor type' },
179
+ statement: { type: 'string', description: 'The normative claim (>= 8 chars)' },
180
+ rationale: { type: 'string', description: 'Why this was decided' },
181
+ affects: { type: 'array', items: { type: 'string' }, description: 'Path globs that scope this anchor' },
182
+ detect_terms: { type: 'array', items: { type: 'string' }, description: 'Keywords for scoping' },
183
+ violation_signal: { type: 'array', items: { type: 'string' }, description: 'Terms whose presence = violation (required for prohibition/decision)' },
184
+ tier: { type: 'string', enum: ['human', 'explicit'], description: 'Declaration tier (default: human)' },
185
+ // v9 ProjectCoreNode fields
186
+ node_type: { type: 'string', description: 'Node type label' },
187
+ domain: { type: 'string', description: 'Domain (strategy, product, engineering, etc.)' },
188
+ decision_mode: { type: 'string', enum: ['hypothesis', 'constraint', 'commitment', 'source_of_truth'], description: 'Classification for 4-species display' },
189
+ confidence: { type: 'number', minimum: 0, maximum: 1, description: 'Confidence level (0.0-1.0)' },
190
+ lifecycle: { type: 'string', description: 'Lifecycle state (active, at_risk, retired)' },
191
+ review_after: { type: 'string', description: 'ISO date for next review (e.g. "2026-07-04")' },
192
+ },
193
+ required: ['kind', 'statement'],
194
+ },
195
+ },
196
+ {
197
+ name: 'resolve_drift',
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',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ anchor_id: { type: 'number', description: 'The drift_anchor ID to resolve' },
203
+ action: { type: 'string', enum: ['fix', 'supersede', 'acknowledge', 'dismiss', 'harden', 'soften'], description: 'Resolution action' },
204
+ rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
205
+ review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
206
+ superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
207
+ },
208
+ required: ['anchor_id', 'action'],
209
+ },
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
+ },
145
291
  ];
146
292
  // ============================================================
147
293
  // Handlers
@@ -1067,6 +1213,407 @@ async function handleRecallUnified(args) {
1067
1213
  return handleRecall(args);
1068
1214
  }
1069
1215
  // ============================================================
1216
+ // Drift tool handlers (v0.8.0)
1217
+ // ============================================================
1218
+ function handleDriftStatus(args) {
1219
+ const view = getTruthView(db, {
1220
+ domain: args?.domain,
1221
+ decision_mode: args?.decision_mode,
1222
+ });
1223
+ // Build a concise triage line
1224
+ const { by_state, nodes } = view.counts;
1225
+ const triage = [
1226
+ by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
1227
+ by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
1228
+ by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
1229
+ `🔵 ${by_state.aligned} aligned`,
1230
+ ].filter(Boolean).join(' · ');
1231
+ return JSON.stringify({
1232
+ ok: true,
1233
+ triage: `${nodes} anchors: ${triage}`,
1234
+ nextReopen: view.nextReopen,
1235
+ attention: view.attention,
1236
+ alignedByDomain: view.alignedByDomain,
1237
+ candidates: view.candidates,
1238
+ counts: view.counts,
1239
+ });
1240
+ }
1241
+ function handleCheckDecision(args) {
1242
+ if (!args?.anchor_id)
1243
+ throw new Error('anchor_id is required');
1244
+ const detail = getDecisionDetail(db, args.anchor_id);
1245
+ if (!detail) {
1246
+ return JSON.stringify({ ok: false, error: `Anchor ${args.anchor_id} not found or not active` });
1247
+ }
1248
+ return JSON.stringify({ ok: true, decision: detail });
1249
+ }
1250
+ function handleDeclareAnchor(args) {
1251
+ if (!args?.kind || !args?.statement) {
1252
+ throw new Error('kind and statement are required');
1253
+ }
1254
+ // Create the base anchor
1255
+ const anchor = declareAnchor(db, {
1256
+ kind: args.kind,
1257
+ statement: args.statement,
1258
+ rationale: args.rationale,
1259
+ affects: args.affects,
1260
+ detect_terms: args.detect_terms,
1261
+ violation_signal: args.violation_signal,
1262
+ tier: args.tier ?? 'human',
1263
+ });
1264
+ // Apply v9 ProjectCoreNode fields if provided
1265
+ const nodeFields = {};
1266
+ if (args.node_type !== undefined)
1267
+ nodeFields.node_type = args.node_type;
1268
+ if (args.domain !== undefined)
1269
+ nodeFields.domain = args.domain;
1270
+ if (args.decision_mode !== undefined)
1271
+ nodeFields.decision_mode = args.decision_mode;
1272
+ if (args.confidence !== undefined)
1273
+ nodeFields.confidence = args.confidence;
1274
+ if (args.lifecycle !== undefined)
1275
+ nodeFields.lifecycle = args.lifecycle;
1276
+ if (args.review_after !== undefined) {
1277
+ nodeFields.review_after = Math.floor(new Date(args.review_after).getTime() / 1000);
1278
+ }
1279
+ if (Object.keys(nodeFields).length > 0) {
1280
+ setNodeFields(db, anchor.id, nodeFields);
1281
+ }
1282
+ return JSON.stringify({
1283
+ ok: true,
1284
+ anchor_id: anchor.id,
1285
+ statement: anchor.statement,
1286
+ kind: anchor.kind,
1287
+ message: `Anchor #${anchor.id} declared: "${anchor.statement.substring(0, 80)}"`,
1288
+ });
1289
+ }
1290
+ function handleResolveDrift(args) {
1291
+ if (!args?.anchor_id || !args?.action) {
1292
+ throw new Error('anchor_id and action are required');
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
+ }
1306
+ const result = resolveDrift(db, {
1307
+ anchor_id: args.anchor_id,
1308
+ action: args.action,
1309
+ rationale: args.rationale,
1310
+ review_after: args.review_after,
1311
+ superseded_by: args.superseded_by,
1312
+ });
1313
+ return JSON.stringify(result);
1314
+ }
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
+ // ============================================================
1070
1617
  // MCP wiring
1071
1618
  // ============================================================
1072
1619
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
@@ -1084,6 +1631,29 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1084
1631
  case 'read_smart':
1085
1632
  text = handleReadSmart(args);
1086
1633
  break;
1634
+ // Drift tools (v0.8.0)
1635
+ case 'drift_status':
1636
+ text = handleDriftStatus(args);
1637
+ break;
1638
+ case 'check_decision':
1639
+ text = handleCheckDecision(args);
1640
+ break;
1641
+ case 'declare_anchor':
1642
+ text = handleDeclareAnchor(args);
1643
+ break;
1644
+ case 'resolve_drift':
1645
+ text = handleResolveDrift(args);
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;
1087
1657
  default: {
1088
1658
  const migrations = {
1089
1659
  update_memory: 'remember({ memory_id: <id>, content: "...", importance: 0.8 })',