@plur-ai/mcp 0.10.0 → 0.11.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.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSy
5
5
  import { join } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { homedir, platform } from "os";
8
- var VERSION = "0.10.0";
8
+ var VERSION = "0.11.0";
9
9
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
10
10
 
11
11
  Usage:
@@ -279,7 +279,7 @@ if (arg === "init") {
279
279
  process.exit(0);
280
280
  }
281
281
  if (arg === "serve" || arg === void 0) {
282
- const { runStdio } = await import("./server-EQD6L2BP.js");
282
+ const { runStdio } = await import("./server-M7GBKD4M.js");
283
283
  runStdio().catch((err) => {
284
284
  console.error("Failed to start PLUR MCP server:", err);
285
285
  process.exit(1);
@@ -17,7 +17,7 @@ import { Plur as Plur2, checkForUpdate } from "@plur-ai/core";
17
17
  import { existsSync, unlinkSync } from "fs";
18
18
  import { join } from "path";
19
19
  import { homedir } from "os";
20
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope } from "@plur-ai/core";
20
+ import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName } from "@plur-ai/core";
21
21
 
22
22
  // src/telemetry.ts
23
23
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
@@ -31,7 +31,7 @@ function recordTelemetry(event) {
31
31
  }
32
32
 
33
33
  // src/version.ts
34
- var VERSION = "0.10.0";
34
+ var VERSION = "0.11.0";
35
35
 
36
36
  // src/tools.ts
37
37
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
@@ -133,7 +133,10 @@ function getToolDefinitions() {
133
133
  source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
134
134
  pinned: { type: "boolean", description: "Always-load flag. If true, this engram bypasses the keyword-relevance gate at injection time. Use sparingly: meta-rules, safety conventions, core operating principles only." },
135
135
  commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked"], description: "How firmly the user has committed to this belief (default: leaning)" },
136
- locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" }
136
+ locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
137
+ valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
138
+ valid_until: { type: "string", description: 'ISO date (YYYY-MM-DD) the knowledge expires \u2014 inject/recall skip the engram after this date. Set this for any time-bound fact (offers, deadlines, temporary endpoints). When omitted, an explicit expiry phrase in the statement ("valid until 31 May 2026") is auto-parsed and echoed back (#347)' },
139
+ supersedes: { type: "array", items: { type: "string" }, description: "Engram IDs this statement intentionally replaces (#240). Writes relations.supersedes on the new engram and the reverse superseded_by edge on each local target. Supersedes-linked pairs are skipped by tension scans \u2014 an intentional update is not a contradiction. Use when updating a standing fact (new version, changed rule) rather than contradicting it." }
137
140
  },
138
141
  required: ["statement"]
139
142
  },
@@ -149,6 +152,9 @@ function getToolDefinitions() {
149
152
  commitment: args.commitment,
150
153
  locked_reason: args.locked_reason,
151
154
  pinned: args.pinned,
155
+ valid_from: args.valid_from,
156
+ valid_until: args.valid_until,
157
+ supersedes: args.supersedes,
152
158
  llm
153
159
  };
154
160
  const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
@@ -164,6 +170,14 @@ function getToolDefinitions() {
164
170
  const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
165
171
  return { scope_hint: `Stored at "${engramScope}" because no scope was passed, but a team store is configured (${scopes}). If this is team/engineering knowledge, re-learn it with an explicit scope so it reaches the shared store; keep genuinely personal notes at the default scope.` };
166
172
  };
173
+ const temporalEcho = (engram) => {
174
+ const extracted = engram.structured_data?._expiry_extracted;
175
+ return {
176
+ ...engram.temporal?.valid_from ? { valid_from: engram.temporal.valid_from } : {},
177
+ ...engram.temporal?.valid_until ? { valid_until: engram.temporal.valid_until } : {},
178
+ ...extracted ? { expiry_note: `Parsed expiry phrase "${extracted.phrase}" from the statement \u2192 temporal.valid_until=${extracted.valid_until}. The engram stops injecting/recalling after that date. If this is wrong, re-learn with an explicit valid_until.` } : {}
179
+ };
180
+ };
167
181
  const statement = sanitizeStatement(args.statement);
168
182
  try {
169
183
  const engram = await plur.learnRouted(statement, context);
@@ -179,6 +193,7 @@ function getToolDefinitions() {
179
193
  type: engram.type,
180
194
  pinned: engram.pinned === true,
181
195
  decision: "ADD",
196
+ ...temporalEcho(engram),
182
197
  ...scopeHint(engram.scope, !!routed),
183
198
  ...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
184
199
  ...demoted ? { demoted: true, requested_scope: demoted.from, warning: `Sensitive content (${demoted.patterns}) detected \u2014 stored at "${demoted.to}"/private instead of the requested shared scope "${demoted.from}". If this is a false positive, re-scope deliberately.` } : {},
@@ -196,6 +211,7 @@ function getToolDefinitions() {
196
211
  scope: engram.scope,
197
212
  type: engram.type,
198
213
  decision: "ADD",
214
+ ...temporalEcho(engram),
199
215
  ...scopeHint(engram.scope, !!routedFallback),
200
216
  ...isOutbox ? { outbox: true } : {},
201
217
  warning: `Remote write failed (${err.message}); engram queued for retry.`
@@ -310,6 +326,14 @@ function getToolDefinitions() {
310
326
  if (meta.mode === "hybrid-degraded") {
311
327
  response.warning = `Embedding layer unavailable \u2014 results are BM25-only. Run plur_doctor for diagnosis. Last error: ${meta.embedderError ?? "unknown"}`;
312
328
  }
329
+ if (resolveRerankerName() !== "off") {
330
+ response.reranked = meta.reranked ?? 0;
331
+ const rr = plur.rerankerStatus();
332
+ if (boundedResults.length > 0 && (meta.reranked ?? 0) === 0 && rr.lastError) {
333
+ const corruptNote = rr.lastErrorKind === "corrupt-cache" ? " The model cache looks corrupt (truncated download) \u2014 purge and re-download, see plur_doctor." : "";
334
+ response.reranker_warning = `PLUR_RERANKER is set but the reranker did not engage \u2014 results are RRF-only (fusion order, no cross-encoder rerank).${corruptNote} Last error: ${rr.lastError}. Run plur_doctor for diagnosis.`;
335
+ }
336
+ }
313
337
  return response;
314
338
  }
315
339
  },
@@ -336,7 +360,9 @@ function getToolDefinitions() {
336
360
  consider: result.consider,
337
361
  count: result.count,
338
362
  tokens_used: result.tokens_used,
339
- injected_ids: result.injected_ids
363
+ injected_ids: result.injected_ids,
364
+ // #181: unresolved-tension warnings — flag contradicted context
365
+ ...result.warnings ? { warnings: result.warnings } : {}
340
366
  };
341
367
  }
342
368
  },
@@ -364,7 +390,9 @@ function getToolDefinitions() {
364
390
  count: result.count,
365
391
  tokens_used: result.tokens_used,
366
392
  injected_ids: result.injected_ids,
367
- mode: "hybrid"
393
+ mode: "hybrid",
394
+ // #181: unresolved-tension warnings — flag contradicted context
395
+ ...result.warnings ? { warnings: result.warnings } : {}
368
396
  };
369
397
  }
370
398
  },
@@ -709,6 +737,8 @@ function getToolDefinitions() {
709
737
  },
710
738
  handler: async (args, plur) => {
711
739
  const result = plur.sync(args.remote, { full: args.full === true });
740
+ await plur.waitForIndex();
741
+ const indexError = plur.lastIndexError();
712
742
  let outbox_result;
713
743
  try {
714
744
  outbox_result = await plur.flushOutbox();
@@ -716,6 +746,10 @@ function getToolDefinitions() {
716
746
  }
717
747
  return {
718
748
  ...result,
749
+ ...indexError ? {
750
+ index_error: indexError,
751
+ warning: `Index ${indexError.op} failed \u2014 ${indexError.message}. YAML is still the source of truth; run plur_sync with full=true to rebuild the index.`
752
+ } : {},
719
753
  ...outbox_result && (outbox_result.flushed > 0 || outbox_result.failed > 0) ? {
720
754
  outbox: {
721
755
  flushed: outbox_result.flushed,
@@ -907,6 +941,15 @@ function getToolDefinitions() {
907
941
  tension_count: status.tension_count,
908
942
  versioned_engram_count: status.versioned_engram_count ?? 0,
909
943
  outbox_count: status.outbox_count ?? 0,
944
+ // Injection-provenance event/label counts (#452) — #202's volume gate.
945
+ history_events: status.history_events ?? {
946
+ co_injection: 0,
947
+ injection_outcome: 0,
948
+ outcome_positive: 0,
949
+ outcome_negative: 0
950
+ },
951
+ // Last background index/reembed failure (#272) — absent when healthy.
952
+ ...status.index_error ? { index_error: status.index_error } : {},
910
953
  // Version check (issue #151)
911
954
  ...versionCheck?.updateAvailable && versionCheck.latest ? {
912
955
  update_available: {
@@ -926,12 +969,14 @@ function getToolDefinitions() {
926
969
  inputSchema: {
927
970
  type: "object",
928
971
  properties: {
929
- retry: { type: "boolean", description: "If true, reset cached embedder failure state and retry the model load before reporting" }
972
+ retry: { type: "boolean", description: "If true, reset cached embedder failure state and retry the model load before reporting" },
973
+ rerank_eval: { type: "boolean", description: "If true and a reranker is configured (PLUR_RERANKER), run the per-store self-eval gate (#451): probes synthesized from this store's own engrams compare rerank-on vs RRF-only ordering. Verdict is cached in the store and advisory \u2014 it never auto-disables reranking. Costs one cross-encoder pass per probe (~20 probes)." }
930
974
  }
931
975
  },
932
976
  handler: async (args, plur) => {
933
977
  if (args.retry === true) {
934
978
  plur.resetEmbedder();
979
+ plur.resetReranker();
935
980
  }
936
981
  const status = plur.status();
937
982
  const before = plur.embedderStatus();
@@ -984,6 +1029,74 @@ function getToolDefinitions() {
984
1029
  " \u2022 Or opt out: set PLUR_DISABLE_EMBEDDINGS=1, or write `embeddings: { enabled: false }` to ~/.plur/config.yaml \u2014 hybrid search will run BM25-only"
985
1030
  );
986
1031
  }
1032
+ const rerankerName = resolveRerankerName();
1033
+ if (rerankerName !== "off") {
1034
+ const adapter = getReranker(rerankerName);
1035
+ let rerankerOk = false;
1036
+ let rerankerDetail;
1037
+ const probeStart = Date.now();
1038
+ try {
1039
+ const scores = await adapter.scoreBatch("plur doctor probe", ["probe document"]);
1040
+ rerankerOk = scores.length === 1 && Number.isFinite(scores[0]);
1041
+ const latencyNote = rerankerName === "bge-reranker-v2-m3" ? "seconds-scale per recall on CPU is expected \u2014 #220" : "ms-scale per recall on CPU is expected \u2014 #451";
1042
+ rerankerDetail = rerankerOk ? `${rerankerName} loaded and scoring (probe ${Date.now() - probeStart}ms; ${latencyNote})` : `Probe returned malformed scores (${JSON.stringify(scores)}) \u2014 recall silently falls back to RRF-only`;
1043
+ } catch (err) {
1044
+ const message = err.message;
1045
+ const kind = classifyRerankerFailure(message);
1046
+ if (kind === "corrupt-cache") {
1047
+ rerankerDetail = `Model cache looks corrupt (${message}) \u2014 recall silently falls back to RRF-only`;
1048
+ remediation.push(
1049
+ `Reranker model cache is corrupt \u2014 the classic symptom of a truncated download (#340). Delete ~/.cache/huggingface/hub/${hfCacheDirName(adapter.modelId)}/ and run plur_doctor with retry:true \u2014 the model will redownload via the classic (non-Xet) path.`
1050
+ );
1051
+ } else {
1052
+ rerankerDetail = `Failed to load: ${message} \u2014 recall silently falls back to RRF-only`;
1053
+ remediation.push(
1054
+ `Reranker "${rerankerName}" is unavailable while PLUR_RERANKER requests it \u2014 recall degrades to RRF order without it. Check connectivity to huggingface.co (first-run download), or unset PLUR_RERANKER to opt out deliberately.`
1055
+ );
1056
+ }
1057
+ }
1058
+ checks.push({ check: "reranker available", ok: rerankerOk, detail: rerankerDetail });
1059
+ try {
1060
+ let evalStatus;
1061
+ let freshlyRun = false;
1062
+ if (args.rerank_eval === true) {
1063
+ const run = await plur.rerankerSelfEval();
1064
+ evalStatus = { result: run.result, stale: false };
1065
+ freshlyRun = !run.cached;
1066
+ } else {
1067
+ evalStatus = plur.rerankerEvalStatus(rerankerName);
1068
+ }
1069
+ if (!evalStatus) {
1070
+ checks.push({
1071
+ check: "reranker per-store eval",
1072
+ ok: true,
1073
+ detail: "Not yet evaluated on this store \u2014 cross-encoders can be net-negative out-of-domain (#451). Run plur_doctor with rerank_eval:true for the advisory self-check before trusting reranked order."
1074
+ });
1075
+ } else {
1076
+ const r = evalStatus.result;
1077
+ const harmful = r.verdict === "harmful";
1078
+ const sign = r.delta_mrr >= 0 ? "+" : "";
1079
+ const provenance = freshlyRun ? "measured now" : `cached ${r.evaluated_at}`;
1080
+ const staleNote = evalStatus.stale ? " [STALE \u2014 store changed since; re-run with rerank_eval:true]" : "";
1081
+ checks.push({
1082
+ check: "reranker per-store eval",
1083
+ ok: !harmful,
1084
+ detail: `${r.verdict} on this store (${provenance}${staleNote}): \u0394MRR ${sign}${r.delta_mrr.toFixed(3)} vs RRF-only over ${r.scored_probes} probes (hit@1 ${(r.rrf_hit1 * 100).toFixed(0)}%\u2192${(r.rerank_hit1 * 100).toFixed(0)}%, ${r.promotions} promoted / ${r.demotions} demoted, ~${r.mean_rerank_ms.toFixed(0)}ms/probe)`
1085
+ });
1086
+ if (harmful) {
1087
+ remediation.push(
1088
+ `Per-store self-eval measured reranker "${rerankerName}" as net-negative on THIS store (\u0394MRR ${r.delta_mrr.toFixed(3)}; it demoted known-relevant engrams in ${r.demotions}/${r.scored_probes} probes). This gate is advisory \u2014 reranking remains enabled. Unset PLUR_RERANKER to opt out for this store, or re-run plur_doctor with rerank_eval:true after the store grows/changes.`
1089
+ );
1090
+ }
1091
+ }
1092
+ } catch (err) {
1093
+ checks.push({
1094
+ check: "reranker per-store eval",
1095
+ ok: false,
1096
+ detail: `self-eval failed: ${err.message}`
1097
+ });
1098
+ }
1099
+ }
987
1100
  const canaryStatuses = mcpCanary.status();
988
1101
  for (const cs of canaryStatuses) {
989
1102
  if (!cs.healthy) {
@@ -1039,7 +1152,7 @@ function getToolDefinitions() {
1039
1152
  required: ["task"]
1040
1153
  },
1041
1154
  handler: async (args, plur) => {
1042
- mcpCanary.tick();
1155
+ mcpCanary.reset();
1043
1156
  mcpCanary.signal("session_start_hook");
1044
1157
  const crypto = await import("crypto");
1045
1158
  const session_id = crypto.randomUUID();
@@ -1074,7 +1187,9 @@ function getToolDefinitions() {
1074
1187
  let engrams = null;
1075
1188
  try {
1076
1189
  const result = await plur.injectHybrid(task, {
1077
- scope: tags?.length ? `tags:${tags.join(",")}` : void 0
1190
+ scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1191
+ session_id
1192
+ // stamped on the co_injection provenance event (#452)
1078
1193
  });
1079
1194
  if (result.count > 0) {
1080
1195
  const lines = [];
@@ -1085,7 +1200,8 @@ function getToolDefinitions() {
1085
1200
  }
1086
1201
  } catch {
1087
1202
  const result = plur.inject(task, {
1088
- scope: tags?.length ? `tags:${tags.join(",")}` : void 0
1203
+ scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1204
+ session_id
1089
1205
  });
1090
1206
  if (result.count > 0) {
1091
1207
  const lines = [];
@@ -1443,22 +1559,48 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1443
1559
  },
1444
1560
  {
1445
1561
  name: "plur_tensions",
1446
- description: "List or scan for engram pairs that have conflicting knowledge. Without scan mode, shows previously detected conflicts. With scan:true, runs an active LLM-powered contradiction scan and returns only high-confidence tensions.",
1447
- annotations: { title: "Tensions", readOnlyHint: true, idempotentHint: true },
1562
+ description: 'Tension lifecycle (#181). Default: list persisted tension records (unresolved first). scan:true runs an LLM contradiction scan, persists NEW detections as records, and skips already-recorded pairs. Lifecycle actions: action:"confirm" (real conflict), action:"dismiss" (false positive \u2014 pair suppressed from future scans), action:"resolve" + winner:<engram_id> (loser engram retired). Scan requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args.',
1563
+ annotations: { title: "Tensions", readOnlyHint: false, idempotentHint: true },
1448
1564
  inputSchema: {
1449
1565
  type: "object",
1450
1566
  properties: {
1451
1567
  scope: { type: "string", description: "Filter by scope" },
1452
1568
  domain: { type: "string", description: "Filter by domain prefix" },
1453
- scan: { type: "boolean", description: "Run an active contradiction scan using an LLM judge. Requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args." },
1569
+ scan: { type: "boolean", description: "Run an active contradiction scan using an LLM judge. New detections are persisted as tension records; recorded pairs (any status) are skipped. Requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args." },
1570
+ persist: { type: "boolean", description: "Persist scan detections as tension records (default true). Set false for a dry-run scan that also ignores the recorded-pair suppress list." },
1571
+ action: { type: "string", enum: ["confirm", "dismiss", "resolve"], description: "Lifecycle action on a persisted tension record (requires id). confirm: mark real. dismiss: false positive, suppress the pair. resolve: pick winner (requires winner), the losing engram is retired." },
1572
+ id: { type: "string", description: "Tension record id (T-YYYY-MMDD-NNN) for action mode" },
1573
+ winner: { type: "string", description: 'Engram id that wins the tension (action:"resolve" only). The other engram is retired.' },
1574
+ status: { type: "string", enum: ["detected", "confirmed", "dismissed", "resolved", "all"], description: "List-mode status filter. Default: unresolved records (detected + confirmed)." },
1454
1575
  llm_base_url: { type: "string", description: "OpenAI-compatible API base URL for scan mode (e.g. https://api.openai.com/v1)" },
1455
1576
  llm_api_key: { type: "string", description: "API key for the LLM (scan mode)" },
1456
1577
  llm_model: { type: "string", description: "Model name for scan mode (default: gpt-4o-mini)" },
1457
1578
  min_confidence: { type: "number", description: "Minimum confidence threshold for scan mode (0\u20131, default: 0.7)" },
1458
- max_pairs: { type: "number", description: "Maximum candidate pairs to evaluate in scan mode (default: 50)" }
1579
+ max_pairs: { type: "number", description: "Maximum candidate pairs to evaluate in scan mode (default: 50)" },
1580
+ batch_size: { type: "number", description: "Pairs judged per LLM call in scan mode (default: 5). Set to 1 for sequential single-pair judging." },
1581
+ temporal_discount: { type: "boolean", description: "Multiply judge confidence by a days-apart ladder (same day x1.0 ... 15+ days x0.3) in scan mode (#240). Overrides the config default (tensions.temporal_discount, off by default). The judge prompt already carries recorded dates; enable this only when date-aware judging alone leaves too many temporal-evolution false positives." }
1459
1582
  }
1460
1583
  },
1461
1584
  handler: async (args, plur) => {
1585
+ if (args.action) {
1586
+ const id = args.id;
1587
+ if (!id) throw new Error(`action:"${args.action}" requires id (tension record id, e.g. T-2026-0703-001)`);
1588
+ if (args.action === "confirm") {
1589
+ const record = plur.confirmTension(id);
1590
+ return { record, message: `Tension ${id} confirmed as a real conflict. Resolve it with action:"resolve" + winner:<engram_id>.` };
1591
+ }
1592
+ if (args.action === "dismiss") {
1593
+ const record = plur.dismissTension(id);
1594
+ return { record, message: `Tension ${id} dismissed \u2014 the pair is suppressed from future scans.` };
1595
+ }
1596
+ if (args.action === "resolve") {
1597
+ const winner = args.winner;
1598
+ if (!winner) throw new Error('action:"resolve" requires winner (the engram id to keep)');
1599
+ const { record, retired_id } = plur.resolveTension(id, winner);
1600
+ return { record, retired: retired_id, message: `Tension ${id} resolved: ${winner} wins, ${retired_id} retired.` };
1601
+ }
1602
+ throw new Error(`Unknown action: ${args.action}. Use confirm, dismiss, or resolve.`);
1603
+ }
1462
1604
  const engrams = plur.list({
1463
1605
  scope: args.scope,
1464
1606
  domain: args.domain
@@ -1472,22 +1614,37 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1472
1614
  count: 0
1473
1615
  };
1474
1616
  }
1617
+ const tensionsConfig = plur.getTensionsConfig();
1618
+ const persist = args.persist !== false;
1475
1619
  const result = await scanForTensions(engrams, llm, {
1476
1620
  min_confidence: args.min_confidence,
1477
- max_pairs: args.max_pairs
1621
+ max_pairs: args.max_pairs,
1622
+ batch_size: args.batch_size,
1623
+ temporal_domains: tensionsConfig.temporal_domains,
1624
+ snapshot_pairs: tensionsConfig.snapshot_pairs,
1625
+ temporal_discount: args.temporal_discount ?? tensionsConfig.temporal_discount,
1626
+ ...persist ? { exclude_pairs: new Set(plur.suppressedTensionPairKeys()) } : {}
1478
1627
  });
1628
+ const persisted = persist && result.tensions.length > 0 ? plur.recordTensions(result.tensions) : void 0;
1479
1629
  return {
1480
1630
  pairs_checked: result.pairs_checked,
1481
1631
  count: result.new_tensions,
1482
- tensions: result.tensions.map((t) => ({
1632
+ ...persisted ? { persisted_new: persisted.new_count } : {},
1633
+ tensions: result.tensions.map((t, i) => ({
1634
+ ...persisted ? { tension_id: persisted.records[i].id, category: persisted.records[i].category, status: persisted.records[i].status } : {},
1483
1635
  engram_a: { id: t.id_a, statement: t.statement_a },
1484
1636
  engram_b: { id: t.id_b, statement: t.statement_b },
1485
1637
  confidence: t.confidence,
1486
- reason: t.reason
1487
- }))
1638
+ reason: t.reason,
1639
+ ...t.days_apart !== void 0 ? { days_apart: t.days_apart } : {},
1640
+ ...t.raw_confidence !== void 0 ? { raw_confidence: t.raw_confidence } : {}
1641
+ })),
1642
+ ...persisted && persisted.new_count > 0 ? { next_steps: 'Review each tension: action:"confirm" (real), action:"dismiss" (false positive), or action:"resolve" + winner:<engram_id> (retire the loser).' } : {}
1488
1643
  };
1489
1644
  }
1490
- const tensions = [];
1645
+ const statusArg = args.status;
1646
+ const records = statusArg === "all" ? plur.listTensions() : plur.listTensions({ status: statusArg ? [statusArg] : ["detected", "confirmed"] });
1647
+ const legacy = [];
1491
1648
  const seen = /* @__PURE__ */ new Set();
1492
1649
  for (const engram of engrams) {
1493
1650
  if (!engram.relations?.conflicts?.length) continue;
@@ -1497,16 +1654,22 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1497
1654
  seen.add(pairKey);
1498
1655
  const other = engrams.find((e) => e.id === conflictId);
1499
1656
  if (!other) continue;
1500
- tensions.push({
1657
+ legacy.push({
1501
1658
  engram_a: { id: engram.id, statement: engram.statement, type: engram.type },
1502
1659
  engram_b: { id: other.id, statement: other.statement, type: other.type },
1503
- detected_at: engram.activation.last_accessed,
1504
- purge_hint: "These conflicts are from the legacy detection system. Run plur_tensions_purge to clear them, then use scan:true for active contradiction detection."
1660
+ detected_at: engram.activation.last_accessed
1505
1661
  });
1506
1662
  }
1507
1663
  }
1508
- const purge_hint = tensions.length > 0 ? "These are legacy conflict relations. Run plur_tensions_purge to clear them." : void 0;
1509
- return { tensions, count: tensions.length, ...purge_hint ? { purge_hint } : {} };
1664
+ return {
1665
+ tensions: records,
1666
+ count: records.length,
1667
+ ...legacy.length > 0 ? {
1668
+ legacy_conflicts: legacy,
1669
+ purge_hint: "legacy_conflicts are unvalidated relations.conflicts refs (importer heuristics or pre-#138 residue) \u2014 run scan:true to judge them, or plur_tensions_purge to clear them."
1670
+ } : {},
1671
+ ...records.length === 0 && legacy.length === 0 ? { hint: "No persisted tensions. Run scan:true to detect contradictions." } : {}
1672
+ };
1510
1673
  }
1511
1674
  },
1512
1675
  {
@@ -1899,7 +2062,22 @@ function jsonSchemaPropToZod(prop) {
1899
2062
  if (prop.type === "boolean") return z.boolean();
1900
2063
  if (prop.type === "array") {
1901
2064
  const itemSchema = prop.items ? jsonSchemaPropToZod(prop.items) : z.unknown();
1902
- return z.array(itemSchema);
2065
+ return z.preprocess((val) => {
2066
+ if (typeof val !== "string") return val;
2067
+ const trimmed = val.trim();
2068
+ if (trimmed.startsWith("[")) {
2069
+ try {
2070
+ const parsed = JSON.parse(trimmed);
2071
+ return Array.isArray(parsed) ? parsed : val;
2072
+ } catch {
2073
+ return val;
2074
+ }
2075
+ }
2076
+ if (prop.items?.type === "string") {
2077
+ return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2078
+ }
2079
+ return val;
2080
+ }, z.array(itemSchema));
1903
2081
  }
1904
2082
  if (prop.type === "object" && prop.properties) {
1905
2083
  const shape = {};
@@ -1947,8 +2125,9 @@ async function createServer(plur) {
1947
2125
  isError: true
1948
2126
  };
1949
2127
  }
2128
+ mcpCanary.tick();
1950
2129
  try {
1951
- const args = request.params.arguments ?? {};
2130
+ let args = request.params.arguments ?? {};
1952
2131
  const schema = tool.inputSchema;
1953
2132
  if (schema?.properties) {
1954
2133
  const shape = {};
@@ -1961,15 +2140,18 @@ async function createServer(plur) {
1961
2140
  const receivedFields = Object.keys(args);
1962
2141
  const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
1963
2142
  const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
2143
+ const hasArrayParam = Object.values(schema.properties).some((p) => p?.type === "array");
2144
+ const arrayBugHint = receivedFields.length === 0 && hasArrayParam ? ' Known client-side bug (plur-ai/plur#297): some MCP clients drop the entire arguments payload when an array-typed parameter is included. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.' : "";
1964
2145
  return {
1965
2146
  content: [{ type: "text", text: JSON.stringify({
1966
- error: `Invalid arguments: ${details}. ${receivedNote} The call reached the server \u2014 this is a malformed-arguments error, not a transport failure. Fix the field(s) named above and retry; do not abandon the call.`,
2147
+ error: `Invalid arguments: ${details}. ${receivedNote} The call reached the server \u2014 this is a malformed-arguments error, not a transport failure. Fix the field(s) named above and retry; do not abandon the call.` + arrayBugHint,
1967
2148
  success: false,
1968
2149
  received_fields: receivedFields
1969
2150
  }) }],
1970
2151
  isError: true
1971
2152
  };
1972
2153
  }
2154
+ args = parsed.data;
1973
2155
  }
1974
2156
  const result = await tool.handler(args, instance);
1975
2157
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@plur-ai/mcp",
3
- "version": "0.10.0",
3
+ "mcpName": "io.github.plur-ai/plur",
4
+ "version": "0.11.0",
4
5
  "type": "module",
5
6
  "bin": {
6
7
  "plur-mcp": "dist/index.js"
@@ -13,7 +14,7 @@
13
14
  "dependencies": {
14
15
  "@modelcontextprotocol/sdk": "^1.12.0",
15
16
  "zod": "^3.23.0",
16
- "@plur-ai/core": "0.10.0"
17
+ "@plur-ai/core": "0.11.0"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/node": "^25.5.0"