@plur-ai/mcp 0.10.1 → 0.12.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/README.md CHANGED
@@ -52,6 +52,7 @@ Your agent gets these tools automatically:
52
52
  |------|-------------|
53
53
  | `plur_session_start` | Start a session — injects relevant engrams for your task |
54
54
  | `plur_learn` | Store a memory — correction, preference, convention, or decision |
55
+ | `plur_learn_batch` | Store many memories in one call — same dedup + policy as `plur_learn`, with per-item failure isolation |
55
56
  | `plur_recall_hybrid` | **Best default** — BM25 + embeddings merged via RRF. Zero cost. |
56
57
  | `plur_recall` | Keyword search (BM25 only, instant) |
57
58
  | `plur_inject_hybrid` | Load relevant memories for the current task |
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.1";
8
+ var VERSION = "0.12.0";
9
9
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
10
10
 
11
11
  Usage:
@@ -65,6 +65,12 @@ var PLUR_HOOKS = {
65
65
  matcher: "auto|manual",
66
66
  hooks: [{ type: "command", command: `${CLI} hook-inject --rehydrate`, timeout: 15 }]
67
67
  }],
68
+ // Auto-close the memory lifecycle at session end (Claude Code SessionEnd,
69
+ // shipped v1.0.85) — captures a closing episode and cleans up the session
70
+ // checkpoint even if the agent forgot to call plur_session_end (#217).
71
+ SessionEnd: [{
72
+ hooks: [{ type: "command", command: `${CLI} hook-session-end`, timeout: 5 }]
73
+ }],
68
74
  // --- Contextual injection ---
69
75
  PreToolUse: [
70
76
  { matcher: "EnterPlanMode", hooks: [{ type: "command", command: `${CLI} hook-inject --event plan_mode`, timeout: 10 }] },
@@ -98,7 +104,7 @@ Hooks inject engrams automatically on every first message \u2014 you do not need
98
104
  2. **Learn**: When corrected or discovering something new, call \`plur_learn\` immediately
99
105
  3. **Recall**: Before answering factual questions, call \`plur_recall_hybrid\` \u2014 check memory first
100
106
  4. **Feedback**: Rate injected engrams with \`plur_feedback\` (positive/negative) \u2014 trains relevance
101
- 5. **End**: Call \`plur_session_end\` with summary + engram_suggestions
107
+ 5. **End**: Call \`plur_session_end\` with summary + engram_suggestions \u2014 a SessionEnd hook auto-closes the lifecycle if you forget, but calling it yourself captures higher-quality learnings
102
108
 
103
109
  Do not ask permission to use these tools \u2014 they are your memory system.
104
110
 
@@ -279,7 +285,7 @@ if (arg === "init") {
279
285
  process.exit(0);
280
286
  }
281
287
  if (arg === "serve" || arg === void 0) {
282
- const { runStdio } = await import("./server-XG6IEACO.js");
288
+ const { runStdio } = await import("./server-6CNGJXSW.js");
283
289
  runStdio().catch((err) => {
284
290
  console.error("Failed to start PLUR MCP server:", err);
285
291
  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,9 +31,10 @@ function recordTelemetry(event) {
31
31
  }
32
32
 
33
33
  // src/version.ts
34
- var VERSION = "0.10.1";
34
+ var VERSION = "0.12.0";
35
35
 
36
36
  // src/tools.ts
37
+ import { z } from "zod";
37
38
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
38
39
  return async (prompt) => {
39
40
  const response = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
@@ -55,6 +56,73 @@ function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
55
56
  return data.choices?.[0]?.message?.content ?? "";
56
57
  };
57
58
  }
59
+ function jsonSchemaPropToZod(prop) {
60
+ if (!prop || typeof prop !== "object") return z.unknown();
61
+ const variants = prop.anyOf ?? prop.oneOf;
62
+ if (Array.isArray(variants) && variants.length > 0) {
63
+ const zodVariants = variants.map(jsonSchemaPropToZod);
64
+ if (zodVariants.length === 1) return zodVariants[0];
65
+ return z.union(zodVariants);
66
+ }
67
+ if (prop.type === "string") return prop.enum ? z.enum(prop.enum) : z.string();
68
+ if (prop.type === "number" || prop.type === "integer") return z.number();
69
+ if (prop.type === "boolean") return z.boolean();
70
+ if (prop.type === "array") {
71
+ const itemSchema = prop.items ? jsonSchemaPropToZod(prop.items) : z.unknown();
72
+ return z.preprocess((val) => {
73
+ if (typeof val !== "string") return val;
74
+ const trimmed = val.trim();
75
+ if (trimmed.startsWith("[")) {
76
+ try {
77
+ const parsed = JSON.parse(trimmed);
78
+ return Array.isArray(parsed) ? parsed : val;
79
+ } catch {
80
+ return val;
81
+ }
82
+ }
83
+ if (prop.items?.type === "string") {
84
+ return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
85
+ }
86
+ return val;
87
+ }, z.array(itemSchema));
88
+ }
89
+ if (prop.type === "object" && prop.properties) {
90
+ const shape = {};
91
+ for (const [k, p] of Object.entries(prop.properties)) {
92
+ const field = jsonSchemaPropToZod(p);
93
+ shape[k] = prop.required?.includes(k) ? field : field.optional();
94
+ }
95
+ return z.object(shape).passthrough();
96
+ }
97
+ return z.unknown();
98
+ }
99
+ function validateToolArgs(tool, rawArgs) {
100
+ const schema = tool.inputSchema;
101
+ if (!schema?.properties) return { ok: true, data: rawArgs };
102
+ const shape = {};
103
+ for (const [key, prop] of Object.entries(schema.properties)) {
104
+ const field = jsonSchemaPropToZod(prop);
105
+ shape[key] = schema.required?.includes(key) ? field : field.optional();
106
+ }
107
+ const parsed = z.object(shape).passthrough().safeParse(rawArgs);
108
+ if (!parsed.success) {
109
+ const receivedFields = Object.keys(rawArgs);
110
+ const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
111
+ const hasArrayParam = Object.values(schema.properties ?? {}).some((p) => p?.type === "array");
112
+ 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.' : "";
113
+ const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
114
+ return {
115
+ ok: false,
116
+ errorPayload: {
117
+ 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,
118
+ success: false,
119
+ received_fields: receivedFields,
120
+ _isError: true
121
+ }
122
+ };
123
+ }
124
+ return { ok: true, data: parsed.data };
125
+ }
58
126
  var PLUR_GUIDE_EMPTY = `## PLUR \u2014 Empty Store
59
127
 
60
128
  You have **0 engrams**. This session's learnings will be lost unless you store them.
@@ -111,7 +179,67 @@ mcpCanary.expect({
111
179
  description: "Learning from corrections",
112
180
  fix: "Call plur_learn when corrected. If using hooks, verify they are installed."
113
181
  });
114
- function getToolDefinitions() {
182
+ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
183
+ "plur_session_start",
184
+ "plur_session_end",
185
+ "plur_learn",
186
+ "plur_recall_hybrid",
187
+ "plur_feedback",
188
+ "plur_forget",
189
+ "plur_status",
190
+ "plur_doctor",
191
+ "plur_packs_uninstall",
192
+ "plur_tensions_purge"
193
+ ]);
194
+ function buildAdminDispatchTool(all) {
195
+ const byName = new Map(all.map((t) => [t.name, t]));
196
+ const adminActions = all.map((t) => t.name).filter((n) => !CURSOR_CORE_TOOL_NAMES.has(n)).sort();
197
+ return {
198
+ name: "plur_admin",
199
+ description: `Dispatch for less-common PLUR operations (packs, sync, tensions, stores, timeline, ingest, and more), collapsed into one tool so Cursor's ~40-tool-per-workspace limit is not exhausted by PLUR alone. Set "action" to the underlying tool name and "args" to that tool's normal arguments. Valid actions: ${adminActions.join(", ")}.`,
200
+ annotations: { title: "Admin dispatch", readOnlyHint: false },
201
+ inputSchema: {
202
+ type: "object",
203
+ properties: {
204
+ // No `enum` here — an invalid action must reach the handler's custom
205
+ // Unknown-action message with the full valid-actions list, not fail
206
+ // at top-level schema validation with a generic "Invalid arguments"
207
+ // error. If `enum: adminActions` were set, the top-level
208
+ // CallToolRequestSchema handler's Zod validation would reject
209
+ // unknown actions before this handler's `if (!target)` branch ever
210
+ // ran.
211
+ action: { type: "string", description: "Which underlying plur_* tool to invoke" },
212
+ args: { type: "object", description: "Arguments for the chosen action, matching that tool's normal input schema", additionalProperties: true }
213
+ },
214
+ required: ["action"]
215
+ },
216
+ handler: async (args, plur) => {
217
+ const action = args.action;
218
+ const target = byName.get(action);
219
+ if (!target) {
220
+ return { error: `Unknown action "${action}". Valid actions: ${adminActions.join(", ")}`, success: false, _isError: true };
221
+ }
222
+ const innerArgs = args.args ?? {};
223
+ const validated = validateToolArgs(target, innerArgs);
224
+ if (!validated.ok) {
225
+ return { ...validated.errorPayload, error: `${action}: ${validated.errorPayload.error}` };
226
+ }
227
+ try {
228
+ return await target.handler(validated.data, plur);
229
+ } catch (err) {
230
+ const message = err?.message ?? String(err);
231
+ throw new Error(`${action}: ${message}`);
232
+ }
233
+ }
234
+ };
235
+ }
236
+ function getToolDefinitions(profile = "full") {
237
+ const all = getAllToolDefinitions();
238
+ if (profile !== "cursor") return all;
239
+ const core = all.filter((t) => CURSOR_CORE_TOOL_NAMES.has(t.name));
240
+ return [...core, buildAdminDispatchTool(all)];
241
+ }
242
+ function getAllToolDefinitions() {
115
243
  return [
116
244
  {
117
245
  name: "plur_learn",
@@ -133,7 +261,10 @@ function getToolDefinitions() {
133
261
  source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
134
262
  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
263
  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)" }
264
+ locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
265
+ valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
266
+ 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)' },
267
+ 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
268
  },
138
269
  required: ["statement"]
139
270
  },
@@ -149,6 +280,9 @@ function getToolDefinitions() {
149
280
  commitment: args.commitment,
150
281
  locked_reason: args.locked_reason,
151
282
  pinned: args.pinned,
283
+ valid_from: args.valid_from,
284
+ valid_until: args.valid_until,
285
+ supersedes: args.supersedes,
152
286
  llm
153
287
  };
154
288
  const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
@@ -164,6 +298,14 @@ function getToolDefinitions() {
164
298
  const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
165
299
  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
300
  };
301
+ const temporalEcho = (engram) => {
302
+ const extracted = engram.structured_data?._expiry_extracted;
303
+ return {
304
+ ...engram.temporal?.valid_from ? { valid_from: engram.temporal.valid_from } : {},
305
+ ...engram.temporal?.valid_until ? { valid_until: engram.temporal.valid_until } : {},
306
+ ...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.` } : {}
307
+ };
308
+ };
167
309
  const statement = sanitizeStatement(args.statement);
168
310
  try {
169
311
  const engram = await plur.learnRouted(statement, context);
@@ -179,6 +321,7 @@ function getToolDefinitions() {
179
321
  type: engram.type,
180
322
  pinned: engram.pinned === true,
181
323
  decision: "ADD",
324
+ ...temporalEcho(engram),
182
325
  ...scopeHint(engram.scope, !!routed),
183
326
  ...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
184
327
  ...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 +339,7 @@ function getToolDefinitions() {
196
339
  scope: engram.scope,
197
340
  type: engram.type,
198
341
  decision: "ADD",
342
+ ...temporalEcho(engram),
199
343
  ...scopeHint(engram.scope, !!routedFallback),
200
344
  ...isOutbox ? { outbox: true } : {},
201
345
  warning: `Remote write failed (${err.message}); engram queued for retry.`
@@ -203,6 +347,82 @@ function getToolDefinitions() {
203
347
  }
204
348
  }
205
349
  },
350
+ {
351
+ name: "plur_learn_batch",
352
+ description: "Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision). Dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Returns the created/affected engram ids in input order, the per-item decisions, aggregate stats, and any per-item failures \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost. Note: unlike plur_learn, batch items take the LOCAL learn path \u2014 remote-scope auto-routing (learnRouted) is not applied per item, so for shared/remote-store writes prefer plur_learn or pass an explicit local scope. See plur-ai/plur#281.",
353
+ annotations: { title: "Learn (batch)", destructiveHint: false, idempotentHint: false },
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ engrams: {
358
+ type: "array",
359
+ description: "Engram objects to persist. Each requires `statement`; the other fields mirror plur_learn.",
360
+ items: {
361
+ type: "object",
362
+ properties: {
363
+ statement: { type: "string", description: "The knowledge assertion to store" },
364
+ type: { type: "string", enum: ["behavioral", "terminological", "procedural", "architectural"], description: "Category of the engram" },
365
+ scope: { type: "string", description: "Namespace, e.g. global, project:myapp" },
366
+ domain: { type: "string", description: "Domain tag, e.g. software.deployment" },
367
+ tags: { type: "array", items: { type: "string" }, description: "Searchable keyword tags \u2014 contribute to BM25/embedding recall" },
368
+ rationale: { type: "string", description: "Why this knowledge matters \u2014 also enters the search corpus" },
369
+ source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
370
+ pinned: { type: "boolean", description: "Always-load flag. Use sparingly: meta-rules, safety conventions, core principles." },
371
+ commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked"], description: "How firmly the user has committed (default: leaning)" },
372
+ valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid" },
373
+ valid_until: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge expires" }
374
+ },
375
+ required: ["statement"]
376
+ }
377
+ },
378
+ max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items use the cheap hash/cosine path. Pass a large number to opt out." }
379
+ },
380
+ required: ["engrams"]
381
+ },
382
+ handler: async (args, plur) => {
383
+ const llm = getLlmFunction();
384
+ const raw = Array.isArray(args.engrams) ? args.engrams : [];
385
+ if (raw.length === 0) {
386
+ return { ids: [], results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [], warning: "No engrams provided \u2014 pass a non-empty `engrams` array." };
387
+ }
388
+ const items = raw.map((e) => ({
389
+ statement: sanitizeStatement(e.statement),
390
+ context: {
391
+ type: e.type,
392
+ scope: e.scope,
393
+ domain: e.domain,
394
+ source: e.source,
395
+ tags: e.tags,
396
+ rationale: e.rationale,
397
+ commitment: e.commitment,
398
+ pinned: e.pinned,
399
+ valid_from: e.valid_from,
400
+ valid_until: e.valid_until
401
+ }
402
+ }));
403
+ const maxLlmCalls = typeof args.max_llm_calls === "number" ? args.max_llm_calls : void 0;
404
+ const { results, stats, failures } = await plur.learnBatch(
405
+ items,
406
+ llm,
407
+ maxLlmCalls !== void 0 ? { maxLlmCalls } : void 0
408
+ );
409
+ mcpCanary.signal("learn_activity");
410
+ recordTelemetry("learn");
411
+ return {
412
+ ids: results.map((r) => r.engram.id),
413
+ results: results.map((r) => ({
414
+ id: r.engram.id,
415
+ statement: r.engram.statement,
416
+ scope: r.engram.scope,
417
+ type: r.engram.type,
418
+ decision: r.decision,
419
+ ...r.existing_id ? { existing_id: r.existing_id } : {}
420
+ })),
421
+ stats,
422
+ ...failures.length > 0 ? { failures, warning: `${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.` } : {}
423
+ };
424
+ }
425
+ },
206
426
  {
207
427
  name: "plur_recall",
208
428
  description: "Query engrams by BM25 keyword matching \u2014 use plur_recall_hybrid for semantic similarity. Note: a project-scope filter also returns personal-family engrams (local, global, user:*, agent:*); an explicit scope=global recall returns ALL personal-family engrams \u2014 wider than scope=global INJECT, which is targeted to the global namespace only.",
@@ -226,14 +446,18 @@ function getToolDefinitions() {
226
446
  limit: args.limit
227
447
  });
228
448
  return {
229
- results: results.map((e) => ({
230
- id: e.id,
231
- statement: e.statement,
232
- type: e.type,
233
- scope: e.scope,
234
- domain: e.domain,
235
- retrieval_strength: e.activation.retrieval_strength
236
- })),
449
+ results: results.map((e) => {
450
+ const supersededBy = e.relations?.superseded_by;
451
+ const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
452
+ return {
453
+ id: e.id,
454
+ statement: e.statement + annotation,
455
+ type: e.type,
456
+ scope: e.scope,
457
+ domain: e.domain,
458
+ retrieval_strength: e.activation.retrieval_strength
459
+ };
460
+ }),
237
461
  count: results.length
238
462
  };
239
463
  }
@@ -289,9 +513,11 @@ function getToolDefinitions() {
289
513
  const response = {
290
514
  results: boundedResults.map((e) => {
291
515
  const raw = e;
516
+ const supersededBy = e.relations?.superseded_by;
517
+ const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
292
518
  const base = {
293
519
  id: e.id,
294
- statement: e.statement,
520
+ statement: e.statement + annotation,
295
521
  type: e.type,
296
522
  scope: e.scope,
297
523
  domain: e.domain,
@@ -310,6 +536,14 @@ function getToolDefinitions() {
310
536
  if (meta.mode === "hybrid-degraded") {
311
537
  response.warning = `Embedding layer unavailable \u2014 results are BM25-only. Run plur_doctor for diagnosis. Last error: ${meta.embedderError ?? "unknown"}`;
312
538
  }
539
+ if (resolveRerankerName() !== "off") {
540
+ response.reranked = meta.reranked ?? 0;
541
+ const rr = plur.rerankerStatus();
542
+ if (boundedResults.length > 0 && (meta.reranked ?? 0) === 0 && rr.lastError) {
543
+ const corruptNote = rr.lastErrorKind === "corrupt-cache" ? " The model cache looks corrupt (truncated download) \u2014 purge and re-download, see plur_doctor." : "";
544
+ 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.`;
545
+ }
546
+ }
313
547
  return response;
314
548
  }
315
549
  },
@@ -336,7 +570,9 @@ function getToolDefinitions() {
336
570
  consider: result.consider,
337
571
  count: result.count,
338
572
  tokens_used: result.tokens_used,
339
- injected_ids: result.injected_ids
573
+ injected_ids: result.injected_ids,
574
+ // #181: unresolved-tension warnings — flag contradicted context
575
+ ...result.warnings ? { warnings: result.warnings } : {}
340
576
  };
341
577
  }
342
578
  },
@@ -364,7 +600,9 @@ function getToolDefinitions() {
364
600
  count: result.count,
365
601
  tokens_used: result.tokens_used,
366
602
  injected_ids: result.injected_ids,
367
- mode: "hybrid"
603
+ mode: "hybrid",
604
+ // #181: unresolved-tension warnings — flag contradicted context
605
+ ...result.warnings ? { warnings: result.warnings } : {}
368
606
  };
369
607
  }
370
608
  },
@@ -709,6 +947,8 @@ function getToolDefinitions() {
709
947
  },
710
948
  handler: async (args, plur) => {
711
949
  const result = plur.sync(args.remote, { full: args.full === true });
950
+ await plur.waitForIndex();
951
+ const indexError = plur.lastIndexError();
712
952
  let outbox_result;
713
953
  try {
714
954
  outbox_result = await plur.flushOutbox();
@@ -716,6 +956,10 @@ function getToolDefinitions() {
716
956
  }
717
957
  return {
718
958
  ...result,
959
+ ...indexError ? {
960
+ index_error: indexError,
961
+ 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.`
962
+ } : {},
719
963
  ...outbox_result && (outbox_result.flushed > 0 || outbox_result.failed > 0) ? {
720
964
  outbox: {
721
965
  flushed: outbox_result.flushed,
@@ -907,6 +1151,15 @@ function getToolDefinitions() {
907
1151
  tension_count: status.tension_count,
908
1152
  versioned_engram_count: status.versioned_engram_count ?? 0,
909
1153
  outbox_count: status.outbox_count ?? 0,
1154
+ // Injection-provenance event/label counts (#452) — #202's volume gate.
1155
+ history_events: status.history_events ?? {
1156
+ co_injection: 0,
1157
+ injection_outcome: 0,
1158
+ outcome_positive: 0,
1159
+ outcome_negative: 0
1160
+ },
1161
+ // Last background index/reembed failure (#272) — absent when healthy.
1162
+ ...status.index_error ? { index_error: status.index_error } : {},
910
1163
  // Version check (issue #151)
911
1164
  ...versionCheck?.updateAvailable && versionCheck.latest ? {
912
1165
  update_available: {
@@ -921,17 +1174,19 @@ function getToolDefinitions() {
921
1174
  },
922
1175
  {
923
1176
  name: "plur_doctor",
924
- description: 'Diagnose the PLUR install. Reports whether the embedding model loaded, whether hybrid search is fully operational, and \u2014 for any configured enterprise/remote store \u2014 whether its auth is valid (probes /api/v1/me and decodes token expiry), so a dead or soon-to-expire token surfaces instead of hiding behind a "healthy" report. Run this first when recall feels off or team engrams stop syncing.',
1177
+ description: 'Diagnose the PLUR ENGINE (embedder, hybrid search, remote-store auth) \u2014 not hook/MCP wiring. Reports whether the embedding model loaded, whether hybrid search is fully operational, and \u2014 for any configured enterprise/remote store \u2014 whether its auth is valid (probes /api/v1/me and decodes token expiry), so a dead or soon-to-expire token surfaces instead of hiding behind a "healthy" report. Run this first when recall feels off or team engrams stop syncing. Does NOT check .cursor/mcp.json, .cursor/hooks.json, or the live MCP tool count \u2014 for that, run the `plur doctor` CLI command in a terminal (a different, more thorough check with the same name).',
925
1178
  annotations: { title: "Doctor", readOnlyHint: false, idempotentHint: false },
926
1179
  inputSchema: {
927
1180
  type: "object",
928
1181
  properties: {
929
- retry: { type: "boolean", description: "If true, reset cached embedder failure state and retry the model load before reporting" }
1182
+ retry: { type: "boolean", description: "If true, reset cached embedder failure state and retry the model load before reporting" },
1183
+ 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
1184
  }
931
1185
  },
932
1186
  handler: async (args, plur) => {
933
1187
  if (args.retry === true) {
934
1188
  plur.resetEmbedder();
1189
+ plur.resetReranker();
935
1190
  }
936
1191
  const status = plur.status();
937
1192
  const before = plur.embedderStatus();
@@ -984,6 +1239,91 @@ function getToolDefinitions() {
984
1239
  " \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
1240
  );
986
1241
  }
1242
+ const rerankerName = resolveRerankerName();
1243
+ if (rerankerName !== "off") {
1244
+ const adapter = getReranker(rerankerName);
1245
+ let rerankerOk = false;
1246
+ let rerankerDetail;
1247
+ const probeStart = Date.now();
1248
+ try {
1249
+ const scores = await adapter.scoreBatch("plur doctor probe", ["probe document"]);
1250
+ rerankerOk = scores.length === 1 && Number.isFinite(scores[0]);
1251
+ 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";
1252
+ 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`;
1253
+ } catch (err) {
1254
+ const message = err.message;
1255
+ const kind = classifyRerankerFailure(message);
1256
+ if (kind === "corrupt-cache") {
1257
+ rerankerDetail = `Model cache looks corrupt (${message}) \u2014 recall silently falls back to RRF-only`;
1258
+ remediation.push(
1259
+ `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.`
1260
+ );
1261
+ } else {
1262
+ rerankerDetail = `Failed to load: ${message} \u2014 recall silently falls back to RRF-only`;
1263
+ remediation.push(
1264
+ `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.`
1265
+ );
1266
+ }
1267
+ }
1268
+ checks.push({ check: "reranker available", ok: rerankerOk, detail: rerankerDetail });
1269
+ if (rerankerOk) {
1270
+ try {
1271
+ const fitResult = await plur.checkRerankerFit({ rerankerName });
1272
+ const sep = fitResult.separability.toFixed(3);
1273
+ checks.push({
1274
+ check: "reranker domain fit",
1275
+ ok: fitResult.fit,
1276
+ detail: fitResult.n_pairs === 0 ? "Not enough engrams to evaluate fit (< 2) \u2014 assuming fit" : fitResult.fit ? `Good fit \u2014 separability ${sep} on ${fitResult.n_pairs} pairs (threshold \u2265 0.05)` : `Poor fit \u2014 separability ${sep} on ${fitResult.n_pairs} pairs (threshold \u2265 0.05). Reranker may be net-negative on this store's domain mix.`
1277
+ });
1278
+ if (!fitResult.fit && fitResult.n_pairs > 0) {
1279
+ remediation.push(
1280
+ `Reranker "${rerankerName}" shows poor separability (${sep}) on this store's engrams \u2014 it may be scoring irrelevant pairs higher than relevant ones. Consider unsetting PLUR_RERANKER or switching to a different tier. The fit check compares same-domain vs cross-domain pair scores; low separability means the model lacks signal on your content.`
1281
+ );
1282
+ }
1283
+ } catch {
1284
+ }
1285
+ }
1286
+ try {
1287
+ let evalStatus;
1288
+ let freshlyRun = false;
1289
+ if (args.rerank_eval === true) {
1290
+ const run = await plur.rerankerSelfEval();
1291
+ evalStatus = { result: run.result, stale: false };
1292
+ freshlyRun = !run.cached;
1293
+ } else {
1294
+ evalStatus = plur.rerankerEvalStatus(rerankerName);
1295
+ }
1296
+ if (!evalStatus) {
1297
+ checks.push({
1298
+ check: "reranker per-store eval",
1299
+ ok: true,
1300
+ 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."
1301
+ });
1302
+ } else {
1303
+ const r = evalStatus.result;
1304
+ const harmful = r.verdict === "harmful";
1305
+ const sign = r.delta_mrr >= 0 ? "+" : "";
1306
+ const provenance = freshlyRun ? "measured now" : `cached ${r.evaluated_at}`;
1307
+ const staleNote = evalStatus.stale ? " [STALE \u2014 store changed since; re-run with rerank_eval:true]" : "";
1308
+ checks.push({
1309
+ check: "reranker per-store eval",
1310
+ ok: !harmful,
1311
+ 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)`
1312
+ });
1313
+ if (harmful) {
1314
+ remediation.push(
1315
+ `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.`
1316
+ );
1317
+ }
1318
+ }
1319
+ } catch (err) {
1320
+ checks.push({
1321
+ check: "reranker per-store eval",
1322
+ ok: false,
1323
+ detail: `self-eval failed: ${err.message}`
1324
+ });
1325
+ }
1326
+ }
987
1327
  const canaryStatuses = mcpCanary.status();
988
1328
  for (const cs of canaryStatuses) {
989
1329
  if (!cs.healthy) {
@@ -1039,7 +1379,7 @@ function getToolDefinitions() {
1039
1379
  required: ["task"]
1040
1380
  },
1041
1381
  handler: async (args, plur) => {
1042
- mcpCanary.tick();
1382
+ mcpCanary.reset();
1043
1383
  mcpCanary.signal("session_start_hook");
1044
1384
  const crypto = await import("crypto");
1045
1385
  const session_id = crypto.randomUUID();
@@ -1074,7 +1414,9 @@ function getToolDefinitions() {
1074
1414
  let engrams = null;
1075
1415
  try {
1076
1416
  const result = await plur.injectHybrid(task, {
1077
- scope: tags?.length ? `tags:${tags.join(",")}` : void 0
1417
+ scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1418
+ session_id
1419
+ // stamped on the co_injection provenance event (#452)
1078
1420
  });
1079
1421
  if (result.count > 0) {
1080
1422
  const lines = [];
@@ -1085,7 +1427,8 @@ function getToolDefinitions() {
1085
1427
  }
1086
1428
  } catch {
1087
1429
  const result = plur.inject(task, {
1088
- scope: tags?.length ? `tags:${tags.join(",")}` : void 0
1430
+ scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1431
+ session_id
1089
1432
  });
1090
1433
  if (result.count > 0) {
1091
1434
  const lines = [];
@@ -1394,11 +1737,27 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1394
1737
  if (discoveries.length === 0) {
1395
1738
  return { discovered: [], note: "No remote stores configured. Register one scope first with plur_stores_add, then discover the rest." };
1396
1739
  }
1740
+ const enrich = (d) => {
1741
+ if (!d.ok) return d;
1742
+ const byScope = new Map(d.metadata.map((m) => [m.scope, m]));
1743
+ const registeredSet = new Set(d.registered);
1744
+ const scopes = d.authorized.map((scope) => {
1745
+ const m = byScope.get(scope);
1746
+ return {
1747
+ scope,
1748
+ registered: registeredSet.has(scope),
1749
+ ...m?.description ? { description: m.description } : {},
1750
+ ...m && m.covers.length ? { covers: m.covers } : {}
1751
+ };
1752
+ });
1753
+ return { ...d, scopes };
1754
+ };
1755
+ const discovered = discoveries.map(enrich);
1397
1756
  if (!register) {
1398
- return { discovered: discoveries };
1757
+ return { discovered };
1399
1758
  }
1400
1759
  const registered = await plur.registerDiscoveredScopes({ url });
1401
- return { discovered: discoveries, registered };
1760
+ return { discovered, registered };
1402
1761
  }
1403
1762
  },
1404
1763
  {
@@ -1443,22 +1802,48 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1443
1802
  },
1444
1803
  {
1445
1804
  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 },
1805
+ 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.',
1806
+ annotations: { title: "Tensions", readOnlyHint: false, idempotentHint: true },
1448
1807
  inputSchema: {
1449
1808
  type: "object",
1450
1809
  properties: {
1451
1810
  scope: { type: "string", description: "Filter by scope" },
1452
1811
  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." },
1812
+ 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." },
1813
+ 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." },
1814
+ 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." },
1815
+ id: { type: "string", description: "Tension record id (T-YYYY-MMDD-NNN) for action mode" },
1816
+ winner: { type: "string", description: 'Engram id that wins the tension (action:"resolve" only). The other engram is retired.' },
1817
+ status: { type: "string", enum: ["detected", "confirmed", "dismissed", "resolved", "all"], description: "List-mode status filter. Default: unresolved records (detected + confirmed)." },
1454
1818
  llm_base_url: { type: "string", description: "OpenAI-compatible API base URL for scan mode (e.g. https://api.openai.com/v1)" },
1455
1819
  llm_api_key: { type: "string", description: "API key for the LLM (scan mode)" },
1456
1820
  llm_model: { type: "string", description: "Model name for scan mode (default: gpt-4o-mini)" },
1457
1821
  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)" }
1822
+ max_pairs: { type: "number", description: "Maximum candidate pairs to evaluate in scan mode (default: 50)" },
1823
+ batch_size: { type: "number", description: "Pairs judged per LLM call in scan mode (default: 5). Set to 1 for sequential single-pair judging." },
1824
+ 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
1825
  }
1460
1826
  },
1461
1827
  handler: async (args, plur) => {
1828
+ if (args.action) {
1829
+ const id = args.id;
1830
+ if (!id) throw new Error(`action:"${args.action}" requires id (tension record id, e.g. T-2026-0703-001)`);
1831
+ if (args.action === "confirm") {
1832
+ const record = plur.confirmTension(id);
1833
+ return { record, message: `Tension ${id} confirmed as a real conflict. Resolve it with action:"resolve" + winner:<engram_id>.` };
1834
+ }
1835
+ if (args.action === "dismiss") {
1836
+ const record = plur.dismissTension(id);
1837
+ return { record, message: `Tension ${id} dismissed \u2014 the pair is suppressed from future scans.` };
1838
+ }
1839
+ if (args.action === "resolve") {
1840
+ const winner = args.winner;
1841
+ if (!winner) throw new Error('action:"resolve" requires winner (the engram id to keep)');
1842
+ const { record, retired_id } = plur.resolveTension(id, winner);
1843
+ return { record, retired: retired_id, message: `Tension ${id} resolved: ${winner} wins, ${retired_id} retired.` };
1844
+ }
1845
+ throw new Error(`Unknown action: ${args.action}. Use confirm, dismiss, or resolve.`);
1846
+ }
1462
1847
  const engrams = plur.list({
1463
1848
  scope: args.scope,
1464
1849
  domain: args.domain
@@ -1472,22 +1857,37 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1472
1857
  count: 0
1473
1858
  };
1474
1859
  }
1860
+ const tensionsConfig = plur.getTensionsConfig();
1861
+ const persist = args.persist !== false;
1475
1862
  const result = await scanForTensions(engrams, llm, {
1476
1863
  min_confidence: args.min_confidence,
1477
- max_pairs: args.max_pairs
1864
+ max_pairs: args.max_pairs,
1865
+ batch_size: args.batch_size,
1866
+ temporal_domains: tensionsConfig.temporal_domains,
1867
+ snapshot_pairs: tensionsConfig.snapshot_pairs,
1868
+ temporal_discount: args.temporal_discount ?? tensionsConfig.temporal_discount,
1869
+ ...persist ? { exclude_pairs: new Set(plur.suppressedTensionPairKeys()) } : {}
1478
1870
  });
1871
+ const persisted = persist && result.tensions.length > 0 ? plur.recordTensions(result.tensions) : void 0;
1479
1872
  return {
1480
1873
  pairs_checked: result.pairs_checked,
1481
1874
  count: result.new_tensions,
1482
- tensions: result.tensions.map((t) => ({
1875
+ ...persisted ? { persisted_new: persisted.new_count } : {},
1876
+ tensions: result.tensions.map((t, i) => ({
1877
+ ...persisted ? { tension_id: persisted.records[i].id, category: persisted.records[i].category, status: persisted.records[i].status } : {},
1483
1878
  engram_a: { id: t.id_a, statement: t.statement_a },
1484
1879
  engram_b: { id: t.id_b, statement: t.statement_b },
1485
1880
  confidence: t.confidence,
1486
- reason: t.reason
1487
- }))
1881
+ reason: t.reason,
1882
+ ...t.days_apart !== void 0 ? { days_apart: t.days_apart } : {},
1883
+ ...t.raw_confidence !== void 0 ? { raw_confidence: t.raw_confidence } : {}
1884
+ })),
1885
+ ...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
1886
  };
1489
1887
  }
1490
- const tensions = [];
1888
+ const statusArg = args.status;
1889
+ const records = statusArg === "all" ? plur.listTensions() : plur.listTensions({ status: statusArg ? [statusArg] : ["detected", "confirmed"] });
1890
+ const legacy = [];
1491
1891
  const seen = /* @__PURE__ */ new Set();
1492
1892
  for (const engram of engrams) {
1493
1893
  if (!engram.relations?.conflicts?.length) continue;
@@ -1497,16 +1897,22 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1497
1897
  seen.add(pairKey);
1498
1898
  const other = engrams.find((e) => e.id === conflictId);
1499
1899
  if (!other) continue;
1500
- tensions.push({
1900
+ legacy.push({
1501
1901
  engram_a: { id: engram.id, statement: engram.statement, type: engram.type },
1502
1902
  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."
1903
+ detected_at: engram.activation.last_accessed
1505
1904
  });
1506
1905
  }
1507
1906
  }
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 } : {} };
1907
+ return {
1908
+ tensions: records,
1909
+ count: records.length,
1910
+ ...legacy.length > 0 ? {
1911
+ legacy_conflicts: legacy,
1912
+ 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."
1913
+ } : {},
1914
+ ...records.length === 0 && legacy.length === 0 ? { hint: "No persisted tensions. Run scan:true to detect contradictions." } : {}
1915
+ };
1510
1916
  }
1511
1917
  },
1512
1918
  {
@@ -1768,7 +2174,6 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1768
2174
  }
1769
2175
 
1770
2176
  // src/server.ts
1771
- import { z } from "zod";
1772
2177
  var INSTRUCTIONS = `PLUR is your persistent memory. Corrections, preferences, and conventions persist across sessions as engrams.
1773
2178
 
1774
2179
  PLUR is a GLOBAL tool \u2014 one MCP server, one engram store (~/.plur/), available in every project. Multi-project scoping uses domain/scope fields on engrams, not separate installations.
@@ -1886,34 +2291,9 @@ Use \`scope\` to namespace engrams per project:
1886
2291
 
1887
2292
  Override with \`PLUR_PATH\` environment variable.
1888
2293
  `;
1889
- function jsonSchemaPropToZod(prop) {
1890
- if (!prop || typeof prop !== "object") return z.unknown();
1891
- const variants = prop.anyOf ?? prop.oneOf;
1892
- if (Array.isArray(variants) && variants.length > 0) {
1893
- const zodVariants = variants.map(jsonSchemaPropToZod);
1894
- if (zodVariants.length === 1) return zodVariants[0];
1895
- return z.union(zodVariants);
1896
- }
1897
- if (prop.type === "string") return prop.enum ? z.enum(prop.enum) : z.string();
1898
- if (prop.type === "number" || prop.type === "integer") return z.number();
1899
- if (prop.type === "boolean") return z.boolean();
1900
- if (prop.type === "array") {
1901
- const itemSchema = prop.items ? jsonSchemaPropToZod(prop.items) : z.unknown();
1902
- return z.array(itemSchema);
1903
- }
1904
- if (prop.type === "object" && prop.properties) {
1905
- const shape = {};
1906
- for (const [k, p] of Object.entries(prop.properties)) {
1907
- const field = jsonSchemaPropToZod(p);
1908
- shape[k] = prop.required?.includes(k) ? field : field.optional();
1909
- }
1910
- return z.object(shape).passthrough();
1911
- }
1912
- return z.unknown();
1913
- }
1914
- async function createServer(plur) {
2294
+ async function createServer(plur, options) {
1915
2295
  const instance = plur ?? new Plur2();
1916
- const tools = getToolDefinitions();
2296
+ const tools = getToolDefinitions(options?.profile ?? "full");
1917
2297
  checkForUpdate("@plur-ai/mcp", VERSION, (r) => {
1918
2298
  if (r.updateAvailable) {
1919
2299
  console.error(`[plur] Update available: ${r.current} \u2192 ${r.latest}. Run: npx @plur-ai/mcp@latest`);
@@ -1947,32 +2327,29 @@ async function createServer(plur) {
1947
2327
  isError: true
1948
2328
  };
1949
2329
  }
2330
+ mcpCanary.tick();
1950
2331
  try {
1951
- const args = request.params.arguments ?? {};
1952
- const schema = tool.inputSchema;
1953
- if (schema?.properties) {
1954
- const shape = {};
1955
- for (const [key, prop] of Object.entries(schema.properties)) {
1956
- const field = jsonSchemaPropToZod(prop);
1957
- shape[key] = schema.required?.includes(key) ? field : field.optional();
1958
- }
1959
- const parsed = z.object(shape).passthrough().safeParse(args);
1960
- if (!parsed.success) {
1961
- const receivedFields = Object.keys(args);
1962
- const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
1963
- const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
1964
- return {
1965
- 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.`,
1967
- success: false,
1968
- received_fields: receivedFields
1969
- }) }],
1970
- isError: true
1971
- };
1972
- }
2332
+ let args = request.params.arguments ?? {};
2333
+ const validated = validateToolArgs(tool, args);
2334
+ if (!validated.ok) {
2335
+ return {
2336
+ content: [{ type: "text", text: JSON.stringify(validated.errorPayload) }],
2337
+ isError: true
2338
+ };
1973
2339
  }
2340
+ args = validated.data;
1974
2341
  const result = await tool.handler(args, instance);
1975
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2342
+ let payload = result;
2343
+ let resultIsError = false;
2344
+ if (result && typeof result === "object" && result._isError === true) {
2345
+ resultIsError = true;
2346
+ const { _isError, ...rest } = result;
2347
+ payload = rest;
2348
+ }
2349
+ return {
2350
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
2351
+ ...resultIsError ? { isError: true } : {}
2352
+ };
1976
2353
  } catch (err) {
1977
2354
  const message = err?.message ?? String(err);
1978
2355
  server.sendLoggingMessage({ level: "error", data: `Tool ${request.params.name} failed: ${message}` });
@@ -2001,11 +2378,16 @@ async function createServer(plur) {
2001
2378
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2002
2379
  const uri = request.params.uri;
2003
2380
  if (uri === "plur://guide") {
2381
+ const cursorNote = options?.profile === "cursor" ? `
2382
+
2383
+ ## Cursor tool profile
2384
+
2385
+ Most tools above are NOT directly callable in this session \u2014 only ${[...CURSOR_CORE_TOOL_NAMES].join(", ")} are top-level tools here. Everything else in this guide is reachable through **plur_admin**: call it with \`{ action: "<tool name above>", args: {...} }\`.` : "";
2004
2386
  return {
2005
2387
  contents: [{
2006
2388
  uri: "plur://guide",
2007
2389
  mimeType: "text/markdown",
2008
- text: GUIDE_RESOURCE
2390
+ text: GUIDE_RESOURCE + cursorNote
2009
2391
  }]
2010
2392
  };
2011
2393
  }
@@ -2093,7 +2475,8 @@ Please:
2093
2475
  return server;
2094
2476
  }
2095
2477
  async function runStdio() {
2096
- const server = await createServer();
2478
+ const profile = process.env.PLUR_TOOL_PROFILE === "cursor" ? "cursor" : "full";
2479
+ const server = await createServer(void 0, { profile });
2097
2480
  registerFlushOnExit({});
2098
2481
  const transport = new StdioServerTransport();
2099
2482
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plur-ai/mcp",
3
3
  "mcpName": "io.github.plur-ai/plur",
4
- "version": "0.10.1",
4
+ "version": "0.12.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "plur-mcp": "dist/index.js"
@@ -14,7 +14,7 @@
14
14
  "dependencies": {
15
15
  "@modelcontextprotocol/sdk": "^1.12.0",
16
16
  "zod": "^3.23.0",
17
- "@plur-ai/core": "0.10.0"
17
+ "@plur-ai/core": "0.12.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^25.5.0"