@plur-ai/mcp 0.17.1 → 0.18.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
@@ -63,7 +63,7 @@ By default (lean profile), your agent gets 12 tools. Everything else is reachabl
63
63
  | `plur_tensions_purge` | Clear stale/resolved tensions |
64
64
  | `plur_admin` | Dispatch to any other tool: `{ action: "plur_packs_install", args: {...} }` |
65
65
 
66
- Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_learn_batch`, `plur_ingest`, `plur_sync`, `plur_packs_install`, `plur_packs_list`, `plur_capture`, `plur_timeline`, and more) are all reachable via `plur_admin`. Set `PLUR_TOOL_PROFILE=full` to expose all 42 tools directly.
66
+ Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_learn_batch`, `plur_ingest`, `plur_sync`, `plur_packs_install`, `plur_packs_list`, `plur_capture`, `plur_timeline`, and more) are all reachable via `plur_admin`. Set `PLUR_TOOL_PROFILE=full` to expose all 43 tools directly.
67
67
 
68
68
  A `plur_*` name missing from `tools/list` means it moved behind the gateway, not that the server is down. `plur_admin { action: "help" }` returns every action with a one-line description and its argument schema; `plur_doctor` reports the same inventory as `tool_surface`.
69
69
 
@@ -10,13 +10,13 @@ function recordTelemetry(event) {
10
10
  }
11
11
 
12
12
  // src/version.ts
13
- var VERSION = "0.17.1";
13
+ var VERSION = "0.18.0";
14
14
 
15
15
  // src/tools.ts
16
16
  import { existsSync, unlinkSync } from "fs";
17
17
  import { join } from "path";
18
18
  import { homedir } from "os";
19
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation } from "@plur-ai/core";
19
+ import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES } from "@plur-ai/core";
20
20
  import { z } from "zod";
21
21
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
22
22
  return async (prompt) => {
@@ -39,10 +39,21 @@ function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
39
39
  return data.choices?.[0]?.message?.content ?? "";
40
40
  };
41
41
  }
42
+ function formatAge(ageMs) {
43
+ if (typeof ageMs !== "number" || !Number.isFinite(ageMs)) return "(age unknown)";
44
+ const s = Math.floor(ageMs / 1e3);
45
+ if (s < 10) return "just now";
46
+ if (s < 60) return `${s}s ago`;
47
+ const m = Math.floor(s / 60);
48
+ if (m < 60) return `${m}m ago`;
49
+ const h = Math.floor(m / 60);
50
+ if (h < 24) return `${h}h ago`;
51
+ return `${Math.floor(h / 24)}d ago`;
52
+ }
42
53
  function attachRemoteStoreDegradation(response, plur) {
43
54
  let status;
44
55
  try {
45
- status = plur.remoteStoreStatus();
56
+ status = plur.remoteStoreStatus({ freshOnly: true });
46
57
  } catch {
47
58
  return;
48
59
  }
@@ -74,13 +85,25 @@ var recallHandler = async (args, plur) => {
74
85
  results: results.map((e) => {
75
86
  const supersededBy = e.relations?.superseded_by;
76
87
  const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
88
+ const raw = e;
89
+ const measuredUnder = raw.measured_under;
90
+ const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
77
91
  return {
78
92
  id: e.id,
79
- statement: e.statement + annotation,
93
+ statement: e.statement + annotation + measuredAnnotation,
80
94
  type: e.type,
81
95
  scope: e.scope,
82
96
  domain: e.domain,
83
- retrieval_strength: e.activation.retrieval_strength
97
+ retrieval_strength: e.activation.retrieval_strength,
98
+ // SAME FACT, not same record (#852 follow-up). A stable SHA-256 of
99
+ // the normalized statement: two engrams sharing it assert the same
100
+ // thing, in different stores or under different ids. Use it to match
101
+ // across stores and to spot a restatement you already hold — NOT as
102
+ // an identifier. Statements mutate (UPDATE, MERGE, procedure
103
+ // evolution), so this changes when the content does; `id` is what
104
+ // stays fixed.
105
+ content_hash: e.content_hash,
106
+ ...measuredUnder ? { measured_under: measuredUnder } : {}
84
107
  };
85
108
  }),
86
109
  count: results.length,
@@ -127,13 +150,19 @@ var recallHandler = async (args, plur) => {
127
150
  const raw = e;
128
151
  const supersededBy = e.relations?.superseded_by;
129
152
  const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
153
+ const measuredUnder = raw.measured_under;
154
+ const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
130
155
  const base = {
131
156
  id: e.id,
132
- statement: e.statement + annotation,
157
+ statement: e.statement + annotation + measuredAnnotation,
133
158
  type: e.type,
134
159
  scope: e.scope,
135
160
  domain: e.domain,
136
- retrieval_strength: e.activation.retrieval_strength
161
+ retrieval_strength: e.activation.retrieval_strength,
162
+ // Same fact, not same record — see the note on the other recall
163
+ // formatter. Both shapes carry it or an agent gets it only sometimes.
164
+ content_hash: raw.content_hash,
165
+ ...measuredUnder ? { measured_under: measuredUnder } : {}
137
166
  };
138
167
  if (includeEpisodes && raw.episode_ids?.length > 0) {
139
168
  const episodes = plur.timeline({ search: "" });
@@ -219,17 +248,19 @@ function validateToolArgs(tool, rawArgs) {
219
248
  const missingFields = parsed.error.issues.filter((i) => i.code === "invalid_type" && i.received === "undefined").map((i) => String(i.path[0] ?? "")).filter((k) => k.length > 0);
220
249
  const missingArrayParams = missingFields.filter((k) => schema.properties?.[k]?.type === "array");
221
250
  const wholePayloadDrop = receivedFields.length === 0;
222
- const partialDrop = !wholePayloadDrop && missingArrayParams.length > 0;
251
+ const partialDrop = !wholePayloadDrop && missingFields.length > 0;
252
+ const arrayShapedDrop = partialDrop && missingArrayParams.length > 0;
223
253
  let dropHint = "";
224
254
  if (wholePayloadDrop) {
225
255
  dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them 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.' : "");
226
- } else if (partialDrop) {
256
+ } else if (arrayShapedDrop) {
227
257
  dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. 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.`;
228
258
  }
229
259
  const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
230
260
  const disposition = wholePayloadDrop ? "The request reached the server but its arguments did not \u2014 do not abandon the call, and do not rewrite the payload: it was never evaluated. Retry the IDENTICAL call; it usually succeeds. If you issued several tool calls in one message, send them one per message \u2014 batching is the strongest correlate of this drop (#772)." : "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.";
231
261
  return {
232
262
  ok: false,
263
+ missingArrayParams,
233
264
  errorPayload: {
234
265
  error: `Invalid arguments: ${details}. ${receivedNote} ${disposition}${dropHint}`,
235
266
  success: false,
@@ -483,7 +514,7 @@ function getAllToolDefinitions() {
483
514
  return [
484
515
  {
485
516
  name: "plur_learn",
486
- description: "Create an engram \u2014 record a reusable learning, preference, or correction. Multi-agent note: in an orchestration that spawns subagents, have the PARENT session own plur_learn writes \u2014 spawned subagents should return their findings as text for the parent to persist, rather than each calling plur_learn (tool availability is not guaranteed in every subagent context). See plur-ai/plur#281.",
517
+ description: "Create an engram \u2014 record a reusable learning, preference, or correction. A write is never suppressed by similarity: exact content-hash duplicates NOOP, and anything merely SIMILAR is written and reported back in `dedup.near_duplicates` (closest existing engrams and their cosine scores) so you can supersede or merge deliberately. High similarity is a reason to look, not a decision \u2014 cosine cannot tell a duplicate from a correction of it. Multi-agent note: in an orchestration that spawns subagents, have the PARENT session own plur_learn writes \u2014 spawned subagents should return their findings as text for the parent to persist, rather than each calling plur_learn (tool availability is not guaranteed in every subagent context). See plur-ai/plur#281.",
487
518
  annotations: { title: "Learn", destructiveHint: false, idempotentHint: false },
488
519
  inputSchema: {
489
520
  type: "object",
@@ -500,12 +531,23 @@ function getAllToolDefinitions() {
500
531
  rationale: { type: "string", description: "Why this knowledge matters \u2014 also enters the search corpus, helps recall by intent not just statement" },
501
532
  source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
502
533
  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." },
503
- commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked"], description: "How firmly the user has committed to this belief (default: leaning)" },
534
+ commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked", "draft"], description: "How firmly the user has committed to this belief (default: leaning). `draft` marks the engram as pending human approval \u2014 core stores and recalls it normally; enforcement is left to deployments with a review queue." },
504
535
  locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
505
536
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
506
537
  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)' },
507
538
  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." },
508
- session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open; pass it when several are (#243)." }
539
+ session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open; pass it when several are (#243)." },
540
+ measured_under: {
541
+ type: "object",
542
+ description: "Measurement context for numeric or benchmark-derived claims (#869). Records the conditions under which the asserted value was measured \u2014 model, source_type, hardware, dataset, date. When present, differing-condition measurements are stored as refinements rather than tensions. Omit for non-numeric engrams.",
543
+ properties: {
544
+ model: { type: "string", description: 'Model or system variant (e.g. "claude-opus-4", "gpt-4o")' },
545
+ source_type: { type: "string", description: 'Source environment type (e.g. "local-git", "gitlab", "bench", "production")' },
546
+ hardware: { type: "string", description: 'Hardware or runtime tier (e.g. "M3-Pro-36GB", "A100", "CI-runner")' },
547
+ dataset: { type: "string", description: 'Dataset or workload identifier (e.g. "LongMemEval-S", "plur-bench-2026-Q2")' },
548
+ date: { type: "string", description: "ISO date (YYYY-MM-DD) the measurement was taken" }
549
+ }
550
+ }
509
551
  },
510
552
  required: ["statement"]
511
553
  },
@@ -524,6 +566,7 @@ function getAllToolDefinitions() {
524
566
  valid_from: args.valid_from,
525
567
  valid_until: args.valid_until,
526
568
  supersedes: args.supersedes,
569
+ measured_under: args.measured_under,
527
570
  // #243: resolve which session's default scope governs this write —
528
571
  // explicit session_id first, else the lone open session. Never
529
572
  // persisted on the engram (LearnContext.session selects a scope, it
@@ -572,13 +615,23 @@ function getAllToolDefinitions() {
572
615
  const routed = engram.structured_data?._routed;
573
616
  mcpCanary.signal("learn_activity");
574
617
  recordTelemetry("learn");
618
+ const dedup = isOutbox ? void 0 : await plur.nearDuplicates(statement, context, engram.id);
575
619
  return {
576
- id: engram.id,
620
+ // #914: report the id in the form plur_recall hands back, so a
621
+ // caller that records what it just learned and passes it to
622
+ // plur_feedback / plur_forget is holding an id shape the read side
623
+ // actually produces. An outbox engram is excluded: it is sitting in
624
+ // the LOCAL store under a local id until the retry lands, and that
625
+ // is the id recall returns for it.
626
+ id: isOutbox ? engram.id : plur.readIdFor(engram),
577
627
  statement: engram.statement,
578
628
  scope: engram.scope,
579
629
  type: engram.type,
580
630
  pinned: engram.pinned === true,
631
+ // See the note on recall results: same fact, not same record.
632
+ content_hash: engram.content_hash,
581
633
  decision: "ADD",
634
+ ...dedup?.near_duplicates?.length ? { dedup } : {},
582
635
  ...temporalEcho(engram),
583
636
  ...scopeHint(engram.scope, !!routed),
584
637
  ...domainHint(!!routed),
@@ -609,7 +662,7 @@ function getAllToolDefinitions() {
609
662
  },
610
663
  {
611
664
  name: "plur_learn_batch",
612
- 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 `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \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.",
665
+ 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, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \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.',
613
666
  annotations: { title: "Learn (batch)", destructiveHint: false, idempotentHint: false },
614
667
  inputSchema: {
615
668
  type: "object",
@@ -628,14 +681,25 @@ function getAllToolDefinitions() {
628
681
  rationale: { type: "string", description: "Why this knowledge matters \u2014 also enters the search corpus" },
629
682
  source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
630
683
  pinned: { type: "boolean", description: "Always-load flag. Use sparingly: meta-rules, safety conventions, core principles." },
631
- commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked"], description: "How firmly the user has committed (default: leaning)" },
684
+ commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked", "draft"], description: "How firmly the user has committed (default: leaning). `draft` marks the engram as pending human approval \u2014 core stores and recalls it normally; enforcement is left to deployments with a review queue." },
632
685
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid" },
633
- valid_until: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge expires" }
686
+ valid_until: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge expires" },
687
+ measured_under: {
688
+ type: "object",
689
+ description: "Measurement context for numeric/benchmark claims (#869). Fields: model, source_type, hardware, dataset, date.",
690
+ properties: {
691
+ model: { type: "string" },
692
+ source_type: { type: "string" },
693
+ hardware: { type: "string" },
694
+ dataset: { type: "string" },
695
+ date: { type: "string" }
696
+ }
697
+ }
634
698
  },
635
699
  required: ["statement"]
636
700
  }
637
701
  },
638
- 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." }
702
+ max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." }
639
703
  },
640
704
  required: ["engrams"]
641
705
  },
@@ -657,7 +721,8 @@ function getAllToolDefinitions() {
657
721
  commitment: e.commitment,
658
722
  pinned: e.pinned,
659
723
  valid_from: e.valid_from,
660
- valid_until: e.valid_until
724
+ valid_until: e.valid_until,
725
+ measured_under: e.measured_under
661
726
  }
662
727
  }));
663
728
  const maxLlmCalls = typeof args.max_llm_calls === "number" ? args.max_llm_calls : void 0;
@@ -673,7 +738,12 @@ function getAllToolDefinitions() {
673
738
  if (r.input_index !== void 0) ids[r.input_index] = r.engram.id;
674
739
  }
675
740
  let batchDomainHint = {};
676
- const noDomainCount = raw.filter((e) => !(typeof e.domain === "string" && e.domain.length > 0) && !(typeof e.scope === "string" && e.scope.length > 0)).length;
741
+ const routedInputs = /* @__PURE__ */ new Set();
742
+ for (const r of results) {
743
+ const routed = r.engram.structured_data?._routed;
744
+ if (routed !== void 0 && r.input_index !== void 0) routedInputs.add(r.input_index);
745
+ }
746
+ const noDomainCount = raw.filter((e, i) => !(typeof e.domain === "string" && e.domain.length > 0) && !(typeof e.scope === "string" && e.scope.length > 0) && !routedInputs.has(i)).length;
677
747
  if (noDomainCount > 0) {
678
748
  try {
679
749
  const coversScopes = plur.listScopeMetadata().filter((md) => (md.covers?.length ?? 0) > 0).map((md) => md.scope);
@@ -692,7 +762,11 @@ function getAllToolDefinitions() {
692
762
  scope: r.engram.scope,
693
763
  type: r.engram.type,
694
764
  decision: r.decision,
695
- ...r.existing_id ? { existing_id: r.existing_id } : {}
765
+ ...r.existing_id ? { existing_id: r.existing_id } : {},
766
+ // #856 audit: `dedup` was computed and then dropped here, so the
767
+ // reporting it exists for reached no caller — "anything below the
768
+ // bar is still reported" was not observable anywhere.
769
+ ...r.dedup ? { dedup: r.dedup } : {}
696
770
  })),
697
771
  stats,
698
772
  ...batchDomainHint,
@@ -712,7 +786,13 @@ function getAllToolDefinitions() {
712
786
  scope: { type: "string", description: "Filter by scope (also includes global)" },
713
787
  domain: { type: "string", description: "Filter by domain prefix" },
714
788
  limit: { type: "number", description: "Max results to return (default 20)" },
715
- budget: { type: "object", description: 'Budget constraints for sub-agents. Hybrid mode only \u2014 ignored when mode:"keyword".', properties: { max_tokens: { type: "number" }, max_results: { type: "number" }, ttl_seconds: { type: "number" } } },
789
+ // `ttl_seconds` was declared here and never read (#703). A schema
790
+ // field an agent can set and no handler consults is a lie the tool
791
+ // tells about itself — it reads as "caching is configurable" and
792
+ // nothing caches. Removed rather than documented: there is no
793
+ // behaviour to describe, and describing "accepted and ignored"
794
+ // still costs every caller a decision.
795
+ budget: { type: "object", description: 'Budget constraints for sub-agents. Hybrid mode only \u2014 ignored when mode:"keyword".', properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
716
796
  caller_session_id: { type: "string", description: 'Session ID of calling agent for budget enforcement. Hybrid mode only \u2014 ignored when mode:"keyword".' },
717
797
  include_episodes: { type: "boolean", description: 'If true, include linked episode summaries for each engram (SP2 episodic anchoring). Hybrid mode only \u2014 ignored when mode:"keyword".' },
718
798
  session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243)." }
@@ -732,7 +812,7 @@ function getAllToolDefinitions() {
732
812
  scope: { type: "string", description: "Filter by scope (also includes global)" },
733
813
  domain: { type: "string", description: "Filter by domain prefix" },
734
814
  limit: { type: "number", description: "Max results to return (default 20)" },
735
- budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" }, ttl_seconds: { type: "number" } } },
815
+ budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
736
816
  caller_session_id: { type: "string", description: "Session ID of calling agent for budget enforcement" },
737
817
  include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" },
738
818
  session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243)." }
@@ -831,13 +911,18 @@ function getAllToolDefinitions() {
831
911
  enum: ["positive", "negative", "neutral"],
832
912
  description: "Feedback signal (single mode)"
833
913
  },
914
+ scope: {
915
+ type: "string",
916
+ description: 'Store scope to target directly, e.g. "primary" for the local store or a remote scope like "group:plur/plur-ai/engineering". Required when the same engram ID exists in multiple stores (#850).'
917
+ },
834
918
  signals: {
835
919
  type: "array",
836
920
  items: {
837
921
  type: "object",
838
922
  properties: {
839
923
  id: { type: "string", description: "Engram ID" },
840
- signal: { type: "string", enum: ["positive", "negative", "neutral"] }
924
+ signal: { type: "string", enum: ["positive", "negative", "neutral"] },
925
+ scope: { type: "string", description: "Store scope to target directly (optional, same semantics as top-level scope)." }
841
926
  },
842
927
  required: ["id", "signal"]
843
928
  },
@@ -849,9 +934,9 @@ function getAllToolDefinitions() {
849
934
  if (args.signals && Array.isArray(args.signals)) {
850
935
  const results = [];
851
936
  const summary = { positive: 0, negative: 0, neutral: 0 };
852
- for (const { id, signal } of args.signals) {
937
+ for (const { id, signal, scope } of args.signals) {
853
938
  try {
854
- await plur.feedback(id, signal);
939
+ await plur.feedback(id, signal, scope);
855
940
  results.push({ id, signal, success: true });
856
941
  summary[signal]++;
857
942
  } catch (err) {
@@ -861,7 +946,7 @@ function getAllToolDefinitions() {
861
946
  return { mode: "batch", results, summary };
862
947
  }
863
948
  try {
864
- await plur.feedback(args.id, args.signal);
949
+ await plur.feedback(args.id, args.signal, args.scope);
865
950
  return { success: true, id: args.id, signal: args.signal };
866
951
  } catch (err) {
867
952
  if (err.message?.includes("readonly store")) {
@@ -910,19 +995,21 @@ function getAllToolDefinitions() {
910
995
  type: "object",
911
996
  properties: {
912
997
  id: { type: "string", description: "Exact engram ID to retire" },
913
- search: { type: "string", description: "Search term to find engram to retire" }
998
+ search: { type: "string", description: "Search term to find engram to retire" },
999
+ scope: { type: "string", description: 'Which store holds it (#831). Ids are minted per store, so one id can name several unrelated engrams. Pass "primary" to stay on disk \u2014 the local primary store and any local secondary stores, never a remote \u2014 or a remote scope (e.g. "group:plur/plur-ai/engineering") to target that server. A scope matching no configured store is rejected, not guessed at. Omit it and an id resolving in two places is refused.' }
914
1000
  }
915
1001
  },
916
1002
  handler: async (args, plur) => {
917
1003
  if (args.id) {
918
- const engram = await plur.getById(args.id);
1004
+ const scope = args.scope;
1005
+ const engram = scope ? void 0 : await plur.getById(args.id);
919
1006
  if (engram) {
920
1007
  if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
921
1008
  await plur.forget(args.id, void 0, { force: true });
922
1009
  return { success: true, retired: { id: engram.id, statement: engram.statement } };
923
1010
  }
924
- await plur.forget(args.id, void 0, { force: true });
925
- return { success: true, retired: { id: args.id } };
1011
+ await plur.forget(args.id, void 0, { force: true, ...scope ? { scope } : {} });
1012
+ return { success: true, retired: { id: args.id, ...scope ? { scope } : {} } };
926
1013
  }
927
1014
  if (args.search) {
928
1015
  const matches = await plur.recall(args.search, { limit: 100, remote: false });
@@ -1147,7 +1234,7 @@ function getAllToolDefinitions() {
1147
1234
  },
1148
1235
  {
1149
1236
  name: "plur_sync",
1150
- description: "Sync engrams via git AND refresh the derived index from YAML. Initializes repo on first call, commits and pushes/pulls on subsequent calls. Provide a remote URL on first call to enable cross-device sync. Pass full=true to drop-and-rebuild the index from YAML (recovery path; YAML stays untouched).",
1237
+ description: "Sync engrams via git AND refresh the derived index from YAML. Initializes repo on first call, commits and pushes/pulls on subsequent calls. Provide a remote URL on first call to enable cross-device sync. Pass full=true to drop-and-rebuild the index from YAML (recovery path; YAML stays untouched). Also flushes the remote-write outbox \u2014 retries team-scoped writes that were queued while their remote store was unreachable; use plur_outbox to inspect what is queued.",
1151
1238
  annotations: { title: "Sync", openWorldHint: true, destructiveHint: false, idempotentHint: true },
1152
1239
  inputSchema: {
1153
1240
  type: "object",
@@ -1201,6 +1288,31 @@ function getAllToolDefinitions() {
1201
1288
  };
1202
1289
  }
1203
1290
  },
1291
+ {
1292
+ name: "plur_outbox",
1293
+ description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable. Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
1294
+ annotations: { title: "Outbox", readOnlyHint: false, idempotentHint: false },
1295
+ inputSchema: {
1296
+ type: "object",
1297
+ properties: {
1298
+ flush: { type: "boolean", description: "Retry every queued write now, instead of only reporting them. Defaults to false." }
1299
+ }
1300
+ },
1301
+ handler: async (args, plur) => {
1302
+ const before = await plur.listOutbox();
1303
+ if (args.flush !== true) {
1304
+ return { pending: before.length, entries: before };
1305
+ }
1306
+ const result = await plur.flushOutbox();
1307
+ return {
1308
+ pending: await plur.outboxCount(),
1309
+ flushed: result.flushed,
1310
+ failed: result.failed,
1311
+ ...result.expired_warnings.length > 0 ? { expired_warnings: result.expired_warnings } : {},
1312
+ attempted: before
1313
+ };
1314
+ }
1315
+ },
1204
1316
  {
1205
1317
  name: "plur_sync_status",
1206
1318
  description: "Check git sync status \u2014 whether repo is initialized, has remote, is dirty, ahead/behind counts",
@@ -1593,9 +1705,11 @@ function getAllToolDefinitions() {
1593
1705
  if (cs.warning) remediation.push(cs.warning);
1594
1706
  }
1595
1707
  }
1708
+ const probedOkHosts = /* @__PURE__ */ new Set();
1596
1709
  try {
1597
1710
  const remotes = await plur.checkRemoteHealth({ timeoutMs: 5e3 });
1598
1711
  for (const h of remotes) {
1712
+ if (h.status === "ok") probedOkHosts.add(normalizeEndpointUrl(h.url));
1599
1713
  const expiresNote = typeof h.tokenExpiresInDays === "number" ? ` \u2014 token ${h.tokenExpiresInDays < 0 ? `expired ${-h.tokenExpiresInDays}d ago` : `expires in ${h.tokenExpiresInDays}d`}` : "";
1600
1714
  if (h.status === "ok") {
1601
1715
  const soon = typeof h.tokenExpiresInDays === "number" && h.tokenExpiresInDays <= 7;
@@ -1617,13 +1731,26 @@ function getAllToolDefinitions() {
1617
1731
  }
1618
1732
  try {
1619
1733
  for (const s of plur.remoteStoreStatus()) {
1620
- const fix = doctorRemoteRemediation(s);
1621
- const degraded = s.status !== "ok" || (s.dropped_scopes?.length ?? 0) > 0;
1734
+ const dropped = s.dropped_scopes?.length ? ` (dropped scopes: ${s.dropped_scopes.join(", ")})` : "";
1735
+ const failed = s.status !== "ok" || (s.dropped_scopes?.length ?? 0) > 0;
1736
+ const stale = (s.age_ms ?? 0) > REMOTE_STATUS_TTL_MS;
1737
+ const contradicted = probedOkHosts.has(s.host) && PROBE_CLEARABLE_STATES.has(s.status);
1738
+ const historical = failed && (stale || contradicted);
1739
+ if (historical) {
1740
+ const why = contradicted ? "the /me probe above just reached this host, so this is not the current state" : "older than the status TTL, so this is not the current state";
1741
+ checks.push({
1742
+ check: `remote recall: ${s.host}`,
1743
+ ok: true,
1744
+ detail: `Last live recall: ${s.status}${dropped} ${formatAge(s.age_ms)} \u2014 ${why}. Recalls at that time served local results only.`
1745
+ });
1746
+ continue;
1747
+ }
1622
1748
  checks.push({
1623
1749
  check: `remote recall: ${s.host}`,
1624
- ok: !degraded,
1625
- detail: degraded ? `Last live recall: ${s.status}${s.dropped_scopes?.length ? ` (dropped scopes: ${s.dropped_scopes.join(", ")})` : ""} \u2014 recent recalls served local results only.` : `Last live recall ok (${s.count} row(s) in ${s.ms}ms)`
1750
+ ok: !failed,
1751
+ detail: failed ? `Last live recall: ${s.status}${dropped} ${formatAge(s.age_ms)} \u2014 recent recalls served local results only.` : `Last live recall ok (${s.count} row(s) in ${s.ms}ms) ${formatAge(s.age_ms)}`
1626
1752
  });
1753
+ const fix = doctorRemoteRemediation(s);
1627
1754
  if (fix) remediation.push(fix);
1628
1755
  }
1629
1756
  for (const c of plur.remoteEndpointTokenConflicts()) {
@@ -1799,13 +1926,13 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
1799
1926
  const failures = discoveries.filter((d) => !d.ok);
1800
1927
  if (failures.length > 0) {
1801
1928
  const authExpired = failures.some((f) => /\b40[13]\b/.test(f.error ?? ""));
1802
- const pending = outbox_result?.failed ?? 0;
1929
+ const pending = await plur.outboxCount().catch(() => outbox_result?.failed ?? 0);
1803
1930
  const urls = [...new Set(failures.map((f) => f.url))].join(", ");
1804
1931
  guide += authExpired ? `
1805
1932
 
1806
1933
  \u26A0\uFE0F ENTERPRISE STORE AUTH FAILED (token expired/invalid): ${urls}. Team-scoped engrams are NOT syncing` + (pending > 0 ? ` \u2014 ${pending} queued in the outbox` : "") + `. Reauth: open <host>/auth/github (or <host>/me/api-keys) in a browser, paste the token into ~/.plur/config.yaml, then restart Claude/MCP. Queued engrams flush on the next session_start.` : `
1807
1934
 
1808
- \u26A0\uFE0F ENTERPRISE STORE UNREACHABLE: ${urls}. Reads fall back to local; team-scoped writes queue in the outbox` + (pending > 0 ? ` (${pending} pending)` : "") + ` until it recovers. Check connectivity/VPN.`;
1935
+ \u26A0\uFE0F ENTERPRISE STORE UNREACHABLE: ${urls}. Reads fall back to local; team-scoped writes queue in the outbox` + (pending > 0 ? ` (${pending} pending \u2014 inspect with plur_outbox, retry with plur_outbox {flush:true})` : "") + ` until it recovers. Check connectivity/VPN.`;
1809
1936
  }
1810
1937
  const offerable = [...new Set(discoveries.filter((d) => d.ok).flatMap((d) => d.unregistered))].filter(isSharedScope);
1811
1938
  if (offerable.length > 0) {
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.17.1";
8
+ var VERSION = "0.18.0";
9
9
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
10
10
 
11
11
  Usage:
@@ -274,63 +274,14 @@ async function runInit() {
274
274
  }
275
275
  }
276
276
  async function runPacks() {
277
- const sub = process.argv[3];
278
277
  const plurPath = process.env.PLUR_PATH ?? join(homedir(), ".plur");
279
278
  const { Plur } = await import("@plur-ai/core");
279
+ const { packsCommand } = await import("./packs-cli-YQTKUTWY.js");
280
280
  const plur = new Plur({ path: plurPath });
281
- if (sub === "install") {
282
- const source = process.argv[4];
283
- if (!source) {
284
- process.stderr.write("Usage: plur-mcp packs install <path>\n");
285
- process.exit(1);
286
- }
287
- try {
288
- const result = await plur.installPack(source);
289
- process.stdout.write(`Installed pack '${result.name}' (${result.installed} engrams)
290
- `);
291
- } catch (err) {
292
- process.stderr.write(`Error: ${err.message}
293
- `);
294
- process.exit(1);
295
- }
296
- } else if (sub === "list") {
297
- const packs = plur.listPacks();
298
- if (packs.length === 0) {
299
- process.stdout.write("No packs installed.\n");
300
- } else {
301
- for (const pack of packs) {
302
- const version = pack.manifest?.version ? ` v${pack.manifest.version}` : "";
303
- process.stdout.write(`${pack.name}${version} (${pack.engram_count} engrams)
304
- `);
305
- }
306
- }
307
- } else if (sub === "uninstall") {
308
- const name = process.argv[4];
309
- if (!name) {
310
- process.stderr.write("Usage: plur-mcp packs uninstall <name>\n");
311
- process.exit(1);
312
- }
313
- try {
314
- const result = plur.uninstallPack(name);
315
- if (result.removed) {
316
- process.stdout.write(`Uninstalled pack '${result.name}' (${result.engram_count} engrams removed)
317
- `);
318
- } else {
319
- process.stderr.write(`Pack '${name}' not found.
320
- `);
321
- process.exit(1);
322
- }
323
- } catch (err) {
324
- process.stderr.write(`Error: ${err.message}
325
- `);
326
- process.exit(1);
327
- }
328
- } else {
329
- process.stderr.write(`Unknown packs subcommand: ${sub ?? "(none)"}
330
- Available: install, list, uninstall
331
- `);
332
- process.exit(1);
333
- }
281
+ const result = await packsCommand(process.argv.slice(3), plur);
282
+ if (result.stdout) process.stdout.write(result.stdout);
283
+ if (result.stderr) process.stderr.write(result.stderr);
284
+ if (result.exitCode !== 0) process.exit(result.exitCode);
334
285
  }
335
286
  var arg = process.argv[2];
336
287
  if (arg === "--help" || arg === "-h") {
@@ -351,7 +302,7 @@ if (arg === "packs") {
351
302
  process.exit(0);
352
303
  }
353
304
  if (arg === "serve" || arg === void 0) {
354
- const { runStdio } = await import("./server-JSZAATIC.js");
305
+ const { runStdio } = await import("./server-AUVPSGTD.js");
355
306
  runStdio().catch((err) => {
356
307
  console.error("Failed to start PLUR MCP server:", err);
357
308
  process.exit(1);
@@ -0,0 +1,43 @@
1
+ // src/packs-cli.ts
2
+ var ok = (stdout) => ({ stdout, stderr: "", exitCode: 0 });
3
+ var fail = (stderr) => ({ stdout: "", stderr, exitCode: 1 });
4
+ async function packsCommand(args, plur) {
5
+ const [sub, arg] = args;
6
+ if (sub === "install") {
7
+ if (!arg) return fail("Usage: plur-mcp packs install <path>\n");
8
+ try {
9
+ const result = await plur.installPack(arg);
10
+ return ok(`Installed pack '${result.name}' (${result.installed} engrams)
11
+ `);
12
+ } catch (err) {
13
+ return fail(`Error: ${err.message}
14
+ `);
15
+ }
16
+ }
17
+ if (sub === "list") {
18
+ const packs = plur.listPacks();
19
+ if (packs.length === 0) return ok("No packs installed.\n");
20
+ return ok(packs.map((p) => {
21
+ const version = p.manifest?.version ? ` v${p.manifest.version}` : "";
22
+ return `${p.name}${version} (${p.engram_count} engrams)
23
+ `;
24
+ }).join(""));
25
+ }
26
+ if (sub === "uninstall") {
27
+ if (!arg) return fail("Usage: plur-mcp packs uninstall <name>\n");
28
+ try {
29
+ const result = plur.uninstallPack(arg);
30
+ return ok(`Uninstalled pack '${result.name}' (${result.engram_count} engrams removed)
31
+ `);
32
+ } catch (err) {
33
+ return fail(`Error: ${err.message}
34
+ `);
35
+ }
36
+ }
37
+ return fail(`Unknown packs subcommand: ${sub ?? "(none)"}
38
+ Available: install, list, uninstall
39
+ `);
40
+ }
41
+ export {
42
+ packsCommand
43
+ };
@@ -7,7 +7,7 @@ import {
7
7
  resolveToolProfile,
8
8
  setActiveToolProfile,
9
9
  validateToolArgs
10
- } from "./chunk-6N2B7J6A.js";
10
+ } from "./chunk-QEGZYXTD.js";
11
11
 
12
12
  // src/server.ts
13
13
  import { Server, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
@@ -21,7 +21,7 @@ import { Plur, checkForUpdate, VERSION_CHECK_SUCCESS_TTL_MS } from "@plur-ai/cor
21
21
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
22
22
  import { dirname, join } from "path";
23
23
  import { atomicWrite, withLock } from "@plur-ai/core";
24
- var PAYLOAD_DROP_LOG_MAX_ENTRIES = 100;
24
+ var PAYLOAD_DROP_LOG_MAX_ENTRIES = 500;
25
25
  function payloadDropLogPath(storageRoot) {
26
26
  return join(storageRoot, "logs", "payload-drops.jsonl");
27
27
  }
@@ -256,6 +256,7 @@ async function createServer(plur, options) {
256
256
  params_keys: Object.keys(request.params ?? {}),
257
257
  received_fields: validated.errorPayload.received_fields,
258
258
  missing_fields: validated.errorPayload.missing_fields,
259
+ missing_array_params: validated.missingArrayParams,
259
260
  request_id: request.id,
260
261
  server_version: VERSION
261
262
  });
@@ -33,6 +33,12 @@ declare function validateToolArgs(tool: ToolDefinition, rawArgs: Record<string,
33
33
  data: Record<string, unknown>;
34
34
  } | {
35
35
  ok: false;
36
+ /**
37
+ * Missing field NAMES that are array-typed. Sits OUTSIDE `errorPayload` so
38
+ * the forensic log can separate the #297 array shape from a scalar-only miss
39
+ * without adding a field to the client-visible error response.
40
+ */
41
+ missingArrayParams: string[];
36
42
  errorPayload: {
37
43
  error: string;
38
44
  success: false;
@@ -2,7 +2,7 @@ import {
2
2
  CURSOR_CORE_TOOL_NAMES,
3
3
  getToolDefinitions,
4
4
  validateToolArgs
5
- } from "./chunk-6N2B7J6A.js";
5
+ } from "./chunk-QEGZYXTD.js";
6
6
 
7
7
  // src/tools-export.ts
8
8
  function getToolSchemas(profile) {
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.17.1",
4
+ "version": "0.18.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "plur-mcp": "dist/index.js"
@@ -16,7 +16,7 @@
16
16
  "@modelcontextprotocol/client": "2.0.0-beta.4",
17
17
  "@modelcontextprotocol/core": "2.0.0-beta.4",
18
18
  "zod": "^3.23.0",
19
- "@plur-ai/core": "0.17.1"
19
+ "@plur-ai/core": "0.18.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^25.5.0"