@plur-ai/mcp 0.16.1 → 0.17.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
@@ -42,7 +42,7 @@ Next session starts → relevant ones injected → agent remembers
42
42
  You rate the result → engram strengthens → quality improves
43
43
  ```
44
44
 
45
- Knowledge is stored as **engrams** — small assertions that strengthen with use and decay when irrelevant. Search is fully local (BM25 + embeddings), so memory recall costs nothing and works offline. [Benchmark methodology →](https://plur.ai/benchmark.html)
45
+ Knowledge is stored as **engrams** — small assertions that strengthen with use and decay when irrelevant. Search runs locally (BM25 + embeddings); with a PLUR Enterprise store configured, recall also makes one live, timeout-bounded call per relevant remote host and merges the team's engrams in — and tells you, per host, when that leg is degraded instead of failing silently. Without a remote store it is fully local, costs nothing, and works offline. [Benchmark methodology →](https://plur.ai/benchmark.html)
46
46
 
47
47
  ## Tools
48
48
 
@@ -63,7 +63,9 @@ 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 40 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 42 tools directly.
67
+
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`.
67
69
 
68
70
  ## Sync across machines
69
71
 
@@ -10,13 +10,13 @@ function recordTelemetry(event) {
10
10
  }
11
11
 
12
12
  // src/version.ts
13
- var VERSION = "0.16.1";
13
+ var VERSION = "0.17.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 } 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 } 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,15 +39,38 @@ function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
39
39
  return data.choices?.[0]?.message?.content ?? "";
40
40
  };
41
41
  }
42
+ function attachRemoteStoreDegradation(response, plur) {
43
+ let status;
44
+ try {
45
+ status = plur.remoteStoreStatus();
46
+ } catch {
47
+ return;
48
+ }
49
+ const degraded = status.filter((s) => s.status !== "ok" || (s.dropped_scopes?.length ?? 0) > 0);
50
+ if (degraded.length === 0) return;
51
+ response.remote_stores = degraded.map((s) => ({
52
+ host: s.host,
53
+ status: s.status,
54
+ ...s.dropped_scopes && s.dropped_scopes.length > 0 ? { dropped_scopes: s.dropped_scopes } : {}
55
+ }));
56
+ const line = `Remote store degradation \u2014 ${degraded.map((s) => mcpRemoteWarningLine(s)).join(" ")}`;
57
+ response.warning = typeof response.warning === "string" && response.warning.length > 0 ? `${response.warning} ${line}` : line;
58
+ }
42
59
  var recallHandler = async (args, plur) => {
43
60
  const mode = args.mode ?? "hybrid";
44
61
  if (mode === "keyword") {
45
62
  const results = await plur.recall(args.query, {
46
63
  scope: args.scope,
47
64
  domain: args.domain,
48
- limit: args.limit
65
+ limit: args.limit,
66
+ remote_timeout_ms: 2e3,
67
+ // MCP recall remote budget (#776)
68
+ // #243: session default scope (incl. mid-session plur_session_scope
69
+ // changes) establishes the remote dialing org context when no explicit
70
+ // scope filter is passed.
71
+ session: _resolveInjectionSession(args)
49
72
  });
50
- return {
73
+ const response2 = {
51
74
  results: results.map((e) => {
52
75
  const supersededBy = e.relations?.superseded_by;
53
76
  const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
@@ -63,6 +86,8 @@ var recallHandler = async (args, plur) => {
63
86
  count: results.length,
64
87
  mode: "keyword"
65
88
  };
89
+ attachRemoteStoreDegradation(response2, plur);
90
+ return response2;
66
91
  }
67
92
  const budget = args.budget;
68
93
  const cap = budget?.max_results ?? args.limit ?? 20;
@@ -70,7 +95,13 @@ var recallHandler = async (args, plur) => {
70
95
  const meta = await plur.recallHybridWithMeta(args.query, {
71
96
  scope: args.scope,
72
97
  domain: args.domain,
73
- limit: fetchLimit
98
+ limit: fetchLimit,
99
+ remote_timeout_ms: 2e3,
100
+ // MCP recall remote budget (#776)
101
+ // #243: session default scope (incl. mid-session plur_session_scope
102
+ // changes) establishes the remote dialing org context when no explicit
103
+ // scope filter is passed.
104
+ session: _resolveInjectionSession(args)
74
105
  });
75
106
  recordTelemetry("recall");
76
107
  const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
@@ -125,6 +156,7 @@ var recallHandler = async (args, plur) => {
125
156
  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.`;
126
157
  }
127
158
  }
159
+ attachRemoteStoreDegradation(response, plur);
128
160
  return response;
129
161
  };
130
162
  var RECALL_HYBRID_DEPRECATION = "plur_recall_hybrid is deprecated since 0.16 \u2014 use plur_recall (mode defaults to hybrid). This alias will be removed in 0.18.";
@@ -184,17 +216,26 @@ function validateToolArgs(tool, rawArgs) {
184
216
  const receivedFields = Object.keys(rawArgs);
185
217
  const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
186
218
  const hasArrayParam = Object.values(schema.properties ?? {}).some((p) => p?.type === "array");
187
- const missingArrayParams = parsed.error.issues.filter((i) => i.code === "invalid_type" && i.received === "undefined").map((i) => String(i.path[0] ?? "")).filter((k) => schema.properties?.[k]?.type === "array");
188
- const totalDrop = receivedFields.length === 0 && hasArrayParam;
189
- const partialDrop = missingArrayParams.length > 0;
190
- const arrayBugHint = totalDrop || partialDrop ? " Known client-side bug (plur-ai/plur#297): some MCP clients drop " + (partialDrop ? `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. ` : "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.' : "";
219
+ 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
+ const missingArrayParams = missingFields.filter((k) => schema.properties?.[k]?.type === "array");
221
+ const wholePayloadDrop = receivedFields.length === 0;
222
+ const partialDrop = !wholePayloadDrop && missingArrayParams.length > 0;
223
+ let dropHint = "";
224
+ if (wholePayloadDrop) {
225
+ 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) {
227
+ 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
+ }
191
229
  const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
230
+ 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.";
192
231
  return {
193
232
  ok: false,
194
233
  errorPayload: {
195
- 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,
234
+ error: `Invalid arguments: ${details}. ${receivedNote} ${disposition}${dropHint}`,
196
235
  success: false,
197
236
  received_fields: receivedFields,
237
+ missing_fields: missingFields,
238
+ ...wholePayloadDrop ? { drop: "whole_payload" } : partialDrop ? { drop: "partial" } : {},
198
239
  _isError: true
199
240
  }
200
241
  };
@@ -259,10 +300,16 @@ mcpCanary.expect({
259
300
  });
260
301
  var _sessionTelemetry = /* @__PURE__ */ new Map();
261
302
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
262
- function _cleanExpiredSessions() {
303
+ function _cleanExpiredSessions(plur) {
263
304
  const cutoff = Date.now() - SESSION_TTL_MS;
264
305
  for (const [id, state] of _sessionTelemetry) {
265
- if (new Date(state.started_at).getTime() < cutoff) _sessionTelemetry.delete(id);
306
+ if (new Date(state.started_at).getTime() < cutoff) {
307
+ _sessionTelemetry.delete(id);
308
+ try {
309
+ plur?.clearSessionScope({ session: id });
310
+ } catch {
311
+ }
312
+ }
266
313
  }
267
314
  }
268
315
  function _implicitSessionId() {
@@ -275,6 +322,16 @@ function _resolveInjectionSession(args) {
275
322
  if (typeof explicit === "string" && explicit.length > 0) return explicit;
276
323
  return _implicitSessionId();
277
324
  }
325
+ function _resolveScopeSession(args) {
326
+ const explicit = args.session_id;
327
+ if (typeof explicit === "string" && explicit.length > 0) {
328
+ return { session: explicit, ambiguous: false, open: _sessionTelemetry.size };
329
+ }
330
+ _cleanExpiredSessions();
331
+ const open = _sessionTelemetry.size;
332
+ if (open === 1) return { session: _sessionTelemetry.keys().next().value, ambiguous: false, open };
333
+ return { session: void 0, ambiguous: open > 1, open };
334
+ }
278
335
  function _recordInjectionTelemetry(session_id, injected_packs) {
279
336
  if (!session_id) return;
280
337
  const state = _sessionTelemetry.get(session_id);
@@ -298,12 +355,39 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
298
355
  "plur_packs_uninstall",
299
356
  "plur_tensions_purge"
300
357
  ]);
358
+ function summarizeToolDescription(description) {
359
+ const line = description.split("\n", 1)[0].trim();
360
+ if (line.length <= 200) return line;
361
+ const cut = line.lastIndexOf(". ", 200);
362
+ return cut > 40 ? line.slice(0, cut + 1) : `${line.slice(0, 199)}\u2026`;
363
+ }
364
+ function describeActionInventory(actions) {
365
+ const families = /* @__PURE__ */ new Map();
366
+ for (const name of actions) {
367
+ const family = name.replace(/^plur_/, "").split("_", 1)[0];
368
+ families.set(family, [...families.get(family) ?? [], name]);
369
+ }
370
+ const sorted = [...families.entries()].sort((a, b) => a[0].localeCompare(b[0]));
371
+ const grouped = [];
372
+ const misc = [];
373
+ for (const [family, members] of sorted) {
374
+ if (members.length > 1) grouped.push(`${family}: ${members.join(", ")}`);
375
+ else misc.push(members[0]);
376
+ }
377
+ if (misc.length > 0) grouped.push(`other: ${misc.join(", ")}`);
378
+ const full = `Actions, grouped \u2014 ${grouped.join(" \xB7 ")}.`;
379
+ if (full.length <= 1500) return full;
380
+ const counted = sorted.map(([family, members]) => `${family} (${members.length})`).join(", ");
381
+ return `${actions.length} actions in groups ${counted} \u2014 the full list is in { action: "help" }.`;
382
+ }
301
383
  function buildAdminDispatchTool(all) {
302
384
  const byName = new Map(all.map((t) => [t.name, t]));
303
385
  const adminActions = all.map((t) => t.name).filter((n) => !CURSOR_CORE_TOOL_NAMES.has(n)).sort();
386
+ const exampleAction = adminActions.includes("plur_recall_hybrid") ? "plur_recall_hybrid" : adminActions[0];
387
+ const exampleArgs = exampleAction === "plur_recall_hybrid" ? '{ query: "deploy checklist" }' : "{}";
304
388
  return {
305
389
  name: "plur_admin",
306
- 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(", ")}.`,
390
+ description: `Gateway to the ${adminActions.length} PLUR operations that are not top-level tools under the current profile (collapsed into one dispatch tool so Cursor's ~40-tool-per-workspace limit is not exhausted by PLUR alone). A plur_* name missing from tools/list means it moved HERE \u2014 not that the MCP is unavailable. Calling convention: { action: "<tool name>", args: { ...that tool's normal arguments } } \u2014 same arguments, same validation, same result as a direct call. Example: { action: "${exampleAction}", args: ${exampleArgs} }. Send { action: "help" } for every action's one-line description and argument schema. ${describeActionInventory(adminActions)}`,
307
391
  annotations: { title: "Admin dispatch", readOnlyHint: false },
308
392
  inputSchema: {
309
393
  type: "object",
@@ -315,16 +399,27 @@ function buildAdminDispatchTool(all) {
315
399
  // CallToolRequestSchema handler's Zod validation would reject
316
400
  // unknown actions before this handler's `if (!target)` branch ever
317
401
  // ran.
318
- action: { type: "string", description: "Which underlying plur_* tool to invoke" },
402
+ action: { type: "string", description: 'Which underlying plur_* tool to invoke, or "help" to list every action with its description and argument schema' },
319
403
  args: { type: "object", description: "Arguments for the chosen action, matching that tool's normal input schema", additionalProperties: true }
320
404
  },
321
405
  required: ["action"]
322
406
  },
323
407
  handler: async (args, plur) => {
324
408
  const action = args.action;
409
+ if (action === "help") {
410
+ return {
411
+ calling_convention: 'plur_admin { action: "<tool name>", args: { ... } } \u2014 same arguments, same validation, same result as calling that tool directly. A tools/list miss on one of these names means it is consolidated here, NOT that the MCP is unavailable.',
412
+ actions: adminActions.map((name) => {
413
+ const t = byName.get(name);
414
+ return { action: name, description: summarizeToolDescription(t.description), args_schema: t.inputSchema };
415
+ }),
416
+ standalone_tools: all.map((t) => t.name).filter((n) => CURSOR_CORE_TOOL_NAMES.has(n)).sort(),
417
+ standalone_note: "standalone_tools are exposed as top-level tools in every profile \u2014 call them directly, never through plur_admin (destructive ones are refused here so their risk annotations stay visible to your client)."
418
+ };
419
+ }
325
420
  const target = byName.get(action);
326
421
  if (!target) {
327
- return { error: `Unknown action "${action}". Valid actions: ${adminActions.join(", ")}`, success: false, _isError: true };
422
+ return { error: `Unknown action "${action}". Valid actions: help, ${adminActions.join(", ")}`, success: false, _isError: true };
328
423
  }
329
424
  if (target.annotations?.destructiveHint === true) {
330
425
  return {
@@ -347,6 +442,29 @@ function buildAdminDispatchTool(all) {
347
442
  }
348
443
  };
349
444
  }
445
+ function resolveToolProfile(env = process.env) {
446
+ const v = env.PLUR_TOOL_PROFILE;
447
+ return v === "full" ? "full" : v === "cursor" ? "cursor" : "lean";
448
+ }
449
+ var activeProfile = null;
450
+ function setActiveToolProfile(profile) {
451
+ activeProfile = profile;
452
+ }
453
+ function activeToolProfile() {
454
+ return activeProfile ?? resolveToolProfile();
455
+ }
456
+ function describeToolSurface(profile = activeToolProfile()) {
457
+ const all = getAllToolDefinitions();
458
+ const exposed = getToolDefinitions(profile);
459
+ const standalone = exposed.map((t) => t.name).sort();
460
+ const admin_actions = profile === "full" ? [] : all.map((t) => t.name).filter((n) => !CURSOR_CORE_TOOL_NAMES.has(n)).sort();
461
+ return {
462
+ profile,
463
+ standalone,
464
+ admin_actions,
465
+ note: admin_actions.length === 0 ? "All tools are exposed directly under this profile." : `Only the ${standalone.length} tools in "standalone" are callable by name. Everything in "admin_actions" is reachable as plur_admin { action: "<name>", args: {...} } \u2014 same arguments, same validation, same result. A name-lookup miss on one of those means it moved, NOT that the MCP is unavailable. Call plur_admin { action: "help" } for each action's description and argument schema. Set PLUR_TOOL_PROFILE=full to expose all tools directly.`
466
+ };
467
+ }
350
468
  function getToolDefinitions(profile = "lean") {
351
469
  const all = getAllToolDefinitions();
352
470
  if (profile === "full") return all;
@@ -386,7 +504,8 @@ function getAllToolDefinitions() {
386
504
  locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
387
505
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
388
506
  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)' },
389
- 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." }
507
+ 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)." }
390
509
  },
391
510
  required: ["statement"]
392
511
  },
@@ -405,6 +524,11 @@ function getAllToolDefinitions() {
405
524
  valid_from: args.valid_from,
406
525
  valid_until: args.valid_until,
407
526
  supersedes: args.supersedes,
527
+ // #243: resolve which session's default scope governs this write —
528
+ // explicit session_id first, else the lone open session. Never
529
+ // persisted on the engram (LearnContext.session selects a scope, it
530
+ // is not part of one).
531
+ session: _resolveInjectionSession(args),
408
532
  llm
409
533
  };
410
534
  const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
@@ -578,7 +702,7 @@ function getAllToolDefinitions() {
578
702
  },
579
703
  {
580
704
  name: "plur_recall",
581
- description: 'Search engrams by topic. Default mode is hybrid (BM25 + local embeddings via RRF) \u2014 set mode:"keyword" for BM25-only. No API calls, fully local. 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.',
705
+ description: 'Search engrams by topic. Default mode is hybrid (BM25 + local embeddings via RRF) \u2014 set mode:"keyword" for BM25-only. Local search plus, when a configured enterprise store is part of the current project/work, one live timeout-bounded recall per remote host merged in (a `remote_stores` block + warning appears when a host is degraded; no host configured or implicated = fully local). 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.',
582
706
  annotations: { title: "Recall", readOnlyHint: true, idempotentHint: true },
583
707
  inputSchema: {
584
708
  type: "object",
@@ -590,7 +714,8 @@ function getAllToolDefinitions() {
590
714
  limit: { type: "number", description: "Max results to return (default 20)" },
591
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" } } },
592
716
  caller_session_id: { type: "string", description: 'Session ID of calling agent for budget enforcement. Hybrid mode only \u2014 ignored when mode:"keyword".' },
593
- 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".' }
717
+ 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
+ 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)." }
594
719
  },
595
720
  required: ["query"]
596
721
  },
@@ -598,7 +723,7 @@ function getAllToolDefinitions() {
598
723
  },
599
724
  {
600
725
  name: "plur_recall_hybrid",
601
- description: "[Deprecated since 0.16 \u2014 use plur_recall (mode defaults to hybrid). Alias kept for backwards compatibility; removal earliest 0.18.] Hybrid search \u2014 BM25 + local embeddings merged via Reciprocal Rank Fusion. No API calls, fully local.",
726
+ description: "[Deprecated since 0.16 \u2014 use plur_recall (mode defaults to hybrid). Alias kept for backwards compatibility; removal earliest 0.18.] Hybrid search \u2014 BM25 + local embeddings merged via Reciprocal Rank Fusion, plus the live enterprise-store recall leg when one is configured and project-relevant.",
602
727
  annotations: { title: "Recall (hybrid) [deprecated alias]", readOnlyHint: true, idempotentHint: true },
603
728
  inputSchema: {
604
729
  type: "object",
@@ -609,7 +734,8 @@ function getAllToolDefinitions() {
609
734
  limit: { type: "number", description: "Max results to return (default 20)" },
610
735
  budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" }, ttl_seconds: { type: "number" } } },
611
736
  caller_session_id: { type: "string", description: "Session ID of calling agent for budget enforcement" },
612
- include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" }
737
+ include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" },
738
+ 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)." }
613
739
  },
614
740
  required: ["query"]
615
741
  },
@@ -678,7 +804,7 @@ function getAllToolDefinitions() {
678
804
  session_id
679
805
  });
680
806
  _recordInjectionTelemetry(session_id, result.injected_packs);
681
- return {
807
+ const response = {
682
808
  directives: result.directives,
683
809
  consider: result.consider,
684
810
  count: result.count,
@@ -688,6 +814,8 @@ function getAllToolDefinitions() {
688
814
  // #181: unresolved-tension warnings — flag contradicted context
689
815
  ...result.warnings ? { warnings: result.warnings } : {}
690
816
  };
817
+ attachRemoteStoreDegradation(response, plur);
818
+ return response;
691
819
  }
692
820
  },
693
821
  {
@@ -790,17 +918,17 @@ function getAllToolDefinitions() {
790
918
  const engram = await plur.getById(args.id);
791
919
  if (engram) {
792
920
  if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
793
- await plur.forget(args.id);
921
+ await plur.forget(args.id, void 0, { force: true });
794
922
  return { success: true, retired: { id: engram.id, statement: engram.statement } };
795
923
  }
796
- await plur.forget(args.id);
924
+ await plur.forget(args.id, void 0, { force: true });
797
925
  return { success: true, retired: { id: args.id } };
798
926
  }
799
927
  if (args.search) {
800
- const matches = await plur.recall(args.search, { limit: 100 });
928
+ const matches = await plur.recall(args.search, { limit: 100, remote: false });
801
929
  if (matches.length === 0) return { success: false, error: `No active engrams matching "${args.search}"` };
802
930
  if (matches.length === 1) {
803
- await plur.forget(matches[0].id);
931
+ await plur.forget(matches[0].id, void 0, { force: true });
804
932
  return { success: true, retired: { id: matches[0].id, statement: matches[0].statement } };
805
933
  }
806
934
  return {
@@ -985,6 +1113,11 @@ function getAllToolDefinitions() {
985
1113
  engram_count: p.engram_count,
986
1114
  integrity: p.integrity,
987
1115
  integrity_ok: p.integrity_ok,
1116
+ // 'ok' | 'modified' | 'unverified' (#805, F11). `integrity_ok`
1117
+ // collapses "cannot be checked" into the same `undefined` an absent
1118
+ // field has, so a caller reading only that cannot distinguish a
1119
+ // clean pack from one whose baseline was destroyed.
1120
+ integrity_status: p.integrity_status,
988
1121
  installed_at: p.installed_at,
989
1122
  source: p.source
990
1123
  })),
@@ -1030,7 +1163,7 @@ function getAllToolDefinitions() {
1030
1163
  remote_type: {
1031
1164
  type: "string",
1032
1165
  enum: ["personal", "shared"],
1033
- description: "What the sync remote is for (#640). personal (default): mirror everything non-local, private included \u2014 a solo user's own backup. shared: push ONLY shared-scope, non-private engrams \u2014 personal-family and private engrams never reach the remote. Persist the choice in config.yaml as sync.remote_type instead of passing it per call."
1166
+ description: "What the sync remote is for (#640). personal (default): mirror everything non-local, private included \u2014 a solo user's own backup. shared: push ONLY shared-scope, non-private engrams \u2014 personal-family and private engrams never reach the remote, and episode/candidate/tension records derived from non-pushed engrams are stripped too (#686). Persist the choice in config.yaml as sync.remote_type instead of passing it per call."
1034
1167
  }
1035
1168
  }
1036
1169
  },
@@ -1245,8 +1378,13 @@ function getAllToolDefinitions() {
1245
1378
  created_after: args.created_after
1246
1379
  });
1247
1380
  const versionCheck = getCachedUpdateCheck("@plur-ai/mcp");
1381
+ const tool_profile = activeToolProfile();
1248
1382
  return {
1249
1383
  version: VERSION,
1384
+ tool_profile,
1385
+ ...tool_profile !== "full" ? {
1386
+ tool_surface_note: `Tool profile "${tool_profile}": most plur_* operations are not top-level tools \u2014 call them as plur_admin { action: "<name>", args: {...} }; send { action: "help" } for the list. A name-lookup miss means a tool moved there, not that the MCP is down.`
1387
+ } : {},
1250
1388
  engram_count: status.engram_count,
1251
1389
  episode_count: status.episode_count,
1252
1390
  pack_count: status.pack_count,
@@ -1264,6 +1402,10 @@ function getAllToolDefinitions() {
1264
1402
  },
1265
1403
  // Last background index/reembed failure (#272) — absent when healthy.
1266
1404
  ...status.index_error ? { index_error: status.index_error } : {},
1405
+ // Artifacts that could not be read (audit 2026-08-03, finding 14).
1406
+ // Core reports these; this hand-built response dropped them, so an
1407
+ // agent asking for status saw a healthy-looking `pack_count: 0`.
1408
+ ...status.store_errors ? { store_errors: status.store_errors } : {},
1267
1409
  // Version check (issue #151)
1268
1410
  ...versionCheck?.updateAvailable && versionCheck.latest ? {
1269
1411
  update_available: {
@@ -1473,6 +1615,28 @@ function getAllToolDefinitions() {
1473
1615
  }
1474
1616
  } catch {
1475
1617
  }
1618
+ try {
1619
+ for (const s of plur.remoteStoreStatus()) {
1620
+ const fix = doctorRemoteRemediation(s);
1621
+ const degraded = s.status !== "ok" || (s.dropped_scopes?.length ?? 0) > 0;
1622
+ checks.push({
1623
+ 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)`
1626
+ });
1627
+ if (fix) remediation.push(fix);
1628
+ }
1629
+ for (const c of plur.remoteEndpointTokenConflicts()) {
1630
+ checks.push({
1631
+ check: `remote store tokens: ${c.url}`,
1632
+ ok: false,
1633
+ detail: `${c.tokens} distinct tokens configured for this endpoint \u2014 recall dials once per (url, token).`
1634
+ });
1635
+ remediation.push(`Remote ${c.url}: ${c.tokens} distinct tokens are configured across its store entries \u2014 consolidate to one token in ~/.plur/config.yaml so recall dials the host once.`);
1636
+ }
1637
+ } catch {
1638
+ }
1639
+ const tool_surface = describeToolSurface();
1476
1640
  return {
1477
1641
  ok: checks.every((c) => c.ok),
1478
1642
  checks,
@@ -1481,6 +1645,7 @@ function getAllToolDefinitions() {
1481
1645
  after_probe: after
1482
1646
  },
1483
1647
  capabilities: canaryStatuses,
1648
+ tool_surface,
1484
1649
  remediation: remediation.length > 0 ? remediation : ["All checks passed \u2014 PLUR is healthy."]
1485
1650
  };
1486
1651
  }
@@ -1505,7 +1670,7 @@ function getAllToolDefinitions() {
1505
1670
  const session_id = crypto.randomUUID();
1506
1671
  const task = args.task;
1507
1672
  const tags = args.tags;
1508
- _cleanExpiredSessions();
1673
+ _cleanExpiredSessions(plur);
1509
1674
  _sessionTelemetry.set(session_id, {
1510
1675
  pack_counts: {},
1511
1676
  injection_calls: 0,
@@ -1531,12 +1696,21 @@ function getAllToolDefinitions() {
1531
1696
  const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
1532
1697
  const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
1533
1698
  plur.setSessionScope(default_scope);
1534
- const status = await plur.status();
1699
+ plur.setSessionScope(default_scope, { session: session_id });
1700
+ {
1701
+ const t = _sessionTelemetry.get(session_id);
1702
+ if (t) {
1703
+ t.default_scope = default_scope;
1704
+ t.default_scope_source = scope_source;
1705
+ }
1706
+ }
1707
+ const status = await plur.status().catch(() => null);
1535
1708
  const store_stats = {
1536
- engram_count: status.engram_count,
1537
- episode_count: status.episode_count,
1538
- pack_count: status.pack_count
1709
+ engram_count: status?.engram_count ?? 0,
1710
+ episode_count: status?.episode_count ?? 0,
1711
+ pack_count: status?.pack_count ?? 0
1539
1712
  };
1713
+ const store_errors = status?.store_errors;
1540
1714
  await plur.warmRemoteCaches().catch(() => {
1541
1715
  });
1542
1716
  let engrams = null;
@@ -1545,7 +1719,9 @@ function getAllToolDefinitions() {
1545
1719
  scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1546
1720
  session_id,
1547
1721
  // stamped on the co_injection provenance event (#452)
1548
- source: "session_start"
1722
+ source: "session_start",
1723
+ remote_timeout_ms: 5e3
1724
+ // session_start warm budget (#776)
1549
1725
  });
1550
1726
  _recordInjectionTelemetry(session_id, result.injected_packs);
1551
1727
  if (result.count > 0) {
@@ -1654,10 +1830,17 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
1654
1830
  } catch {
1655
1831
  }
1656
1832
  }
1657
- return {
1833
+ const session_tool_profile = activeToolProfile();
1834
+ if (session_tool_profile !== "full") {
1835
+ guide += `
1836
+
1837
+ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by name \u2014 call them via plur_admin { action: "<tool name>", args: {...} } (send { action: "help" } for the full action list).`;
1838
+ }
1839
+ const sessionResponse = {
1658
1840
  session_id,
1659
1841
  engrams: engrams ?? [],
1660
1842
  store_stats,
1843
+ ...store_errors ? { store_errors } : {},
1661
1844
  guide,
1662
1845
  // Remote scope routing info (#229)
1663
1846
  ...remote_scopes.length > 0 ? { remote_scopes } : {},
@@ -1681,6 +1864,98 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
1681
1864
  // Version staleness warning (issue #151)
1682
1865
  ...version_warning ? { version_warning, version: VERSION } : {}
1683
1866
  };
1867
+ attachRemoteStoreDegradation(sessionResponse, plur);
1868
+ return sessionResponse;
1869
+ }
1870
+ },
1871
+ {
1872
+ name: "plur_session_scope",
1873
+ description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials; op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
1874
+ annotations: { title: "Session scope", destructiveHint: false, idempotentHint: true },
1875
+ inputSchema: {
1876
+ type: "object",
1877
+ properties: {
1878
+ op: {
1879
+ type: "string",
1880
+ enum: ["set", "show", "clear"],
1881
+ description: "set: replace the session default write scope; show: report the effective scope and its derivation; clear: revert to the session-start default."
1882
+ },
1883
+ scope: {
1884
+ type: "string",
1885
+ description: 'New session default scope (op:"set" only), e.g. "project:myapp" or "group:org/team". Must match a configured store scope to route writes to a remote store \u2014 other strings stay local under that namespace.'
1886
+ },
1887
+ reason: {
1888
+ type: "string",
1889
+ description: "Optional one-line explanation of why the scope is changing \u2014 logged on the session_scope_changed event for retrospective debugging."
1890
+ },
1891
+ session_id: {
1892
+ type: "string",
1893
+ description: "Session to target (from plur_session_start). Optional when one session is open; REQUIRED when several are \u2014 a scope change must never decide another session's writes."
1894
+ }
1895
+ },
1896
+ required: ["op"]
1897
+ },
1898
+ handler: async (args, plur) => {
1899
+ const op = args.op;
1900
+ if (op !== "set" && op !== "show" && op !== "clear") {
1901
+ throw new Error(`plur_session_scope: op must be "set", "show" or "clear", got ${JSON.stringify(args.op)}`);
1902
+ }
1903
+ const reason = args.reason;
1904
+ const { session, ambiguous, open } = _resolveScopeSession(args);
1905
+ const record = session ? _sessionTelemetry.get(session) : void 0;
1906
+ const remote_scopes = plur.getWritableRemoteScopes();
1907
+ const withCommon = (body) => ({
1908
+ op,
1909
+ ...body,
1910
+ ...session ? { session_id: session } : {},
1911
+ ...remote_scopes.length > 0 ? { remote_scopes } : {}
1912
+ });
1913
+ if (op === "show") {
1914
+ const scope = plur.getSessionScope({ session });
1915
+ const source = record ? record.scope_adjusted ? "session-adjusted" : record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : scope == null ? "none" : "process-default";
1916
+ return withCommon({
1917
+ scope,
1918
+ source,
1919
+ ...ambiguous ? {
1920
+ warning: `${open} sessions are open \u2014 this is the process-default slot, not a specific session's scope. Pass session_id (from plur_session_start) to inspect one.`
1921
+ } : {},
1922
+ guide: scope == null ? "No session default scope is set: unscoped plur_learn writes auto-route on a confident covers match or land at the unscoped default. Explicit per-call scope always wins." : `Unscoped plur_learn calls this session default to "${scope}"; recall dialing follows the same org context. Explicit per-call scope always wins.`
1923
+ });
1924
+ }
1925
+ if (ambiguous) {
1926
+ throw new Error(
1927
+ `plur_session_scope: ${open} sessions are open on this server \u2014 pass session_id (from plur_session_start) so the scope change targets the right session and cannot bleed into another one.`
1928
+ );
1929
+ }
1930
+ if (op === "set") {
1931
+ const scope = args.scope;
1932
+ if (typeof scope !== "string" || scope.trim().length === 0) {
1933
+ throw new Error('plur_session_scope: op:"set" requires a non-empty string "scope" (use op:"clear" to revert to the session-start default)');
1934
+ }
1935
+ if (!/^\S+$/.test(scope) || scope.length > 200) {
1936
+ throw new Error(`plur_session_scope: invalid scope ${JSON.stringify(scope)} \u2014 a scope is a single token without whitespace (e.g. "project:myapp", "group:org/team"), max 200 chars`);
1937
+ }
1938
+ const { previous: previous2, next: next2 } = plur.adjustSessionScope(scope, { session, reason, trigger: "set" });
1939
+ if (record) record.scope_adjusted = true;
1940
+ const remoteEntry = remote_scopes.find((s) => s.scope === scope);
1941
+ const warning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
1942
+ return withCommon({
1943
+ previous_scope: previous2,
1944
+ new_scope: next2,
1945
+ ...reason ? { reason } : {},
1946
+ ...warning ? { warning } : {}
1947
+ });
1948
+ }
1949
+ const restored = record !== void 0 ? record.default_scope ?? null : readProjectConfig().scope ?? null;
1950
+ const restored_source = record !== void 0 ? record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : restored != null ? "project-config" : "none";
1951
+ const { previous, next } = plur.adjustSessionScope(restored, { session, reason, trigger: "clear" });
1952
+ if (record) record.scope_adjusted = false;
1953
+ return withCommon({
1954
+ previous_scope: previous,
1955
+ new_scope: next,
1956
+ restored_source,
1957
+ ...reason ? { reason } : {}
1958
+ });
1684
1959
  }
1685
1960
  },
1686
1961
  {
@@ -1759,6 +2034,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1759
2034
  } : void 0;
1760
2035
  if (session_id) {
1761
2036
  _sessionTelemetry.delete(session_id);
2037
+ plur.clearSessionScope({ session: session_id });
1762
2038
  }
1763
2039
  try {
1764
2040
  const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
@@ -1824,7 +2100,15 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1824
2100
  requested_scope: requestedScope,
1825
2101
  note: `This path is already registered under scope "${result.scope}". A local store is keyed by its path, so the requested scope "${requestedScope}" was NOT added. Use a separate store file for a different scope, or remove the existing entry first.`
1826
2102
  } : {},
1827
- kind: url ? "remote" : "filesystem"
2103
+ kind: url ? "remote" : "filesystem",
2104
+ // Filesystem stores are read sources — plur_learn writes to the
2105
+ // primary engrams.yaml and carries the scope label there (#766).
2106
+ // Pre-populate the file via plur_packs_export or direct YAML edit
2107
+ // to inject team engrams; plur_learn with this scope will land them
2108
+ // in your primary store tagged with the scope.
2109
+ ...path && !scopeDropped && !url ? {
2110
+ note: `Store initialized at ${path}. plur_learn calls with scope "${result.scope}" are tagged with that scope but stored in your primary engrams.yaml. To share engrams across machines via this file, populate it via plur_packs_export or direct YAML and commit it to your repo.`
2111
+ } : {}
1828
2112
  };
1829
2113
  }
1830
2114
  },
@@ -1912,7 +2196,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1912
2196
  },
1913
2197
  {
1914
2198
  name: "plur_promote",
1915
- description: "Activate candidate engrams so they appear in injection results",
2199
+ description: "Activate candidate engrams so they appear in injection results. Status change only \u2014 it does NOT move an engram to another scope; to promote an engram into a team/shared scope use plur_rescope (#676).",
1916
2200
  annotations: { title: "Promote", destructiveHint: false, idempotentHint: true },
1917
2201
  inputSchema: {
1918
2202
  type: "object",
@@ -1944,12 +2228,45 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1944
2228
  engram.activation.retrieval_strength = 0.7;
1945
2229
  engram.activation.storage_strength = 1;
1946
2230
  engram.activation.last_accessed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1947
- await plur.updateEngram(engram);
2231
+ const written = await plur.updateEngram(engram);
2232
+ if (!written) {
2233
+ errors.push({ id, error: "Not persisted \u2014 the engram may have been removed or its store is read-only" });
2234
+ continue;
2235
+ }
1948
2236
  promoted.push({ id, statement: engram.statement });
1949
2237
  }
1950
2238
  return { promoted, errors, success: errors.length === 0 };
1951
2239
  }
1952
2240
  },
2241
+ {
2242
+ name: "plur_rescope",
2243
+ description: "Move existing engram(s) to a different scope (#676) \u2014 e.g. promote a personal/local engram into a team scope so it reaches the shared store. Bypasses the content-hash dedup that makes a plur_learn re-emit a silent no-op: rescope matches by id and moves the engram. Remote targets (a configured writable store scope): a copy is pushed via the routed write path (the server assigns the id, provenance is kept in the copy's source field) and the local original is soft-retired with a superseded_by link \u2014 set keep_local:true to keep it active. Local targets (local, global, project:*): the scope is rewritten in place, preserving id and activation. The target must be local/global/project:* or a scope with a configured writable store \u2014 anything else fails early (typo protection). Content is re-scanned for secrets/sensitive material before any shared/remote target and a hit blocks the move. Batch via ids; dry_run:true previews every decision without mutating anything. NOT candidate activation \u2014 that is plur_promote.",
2244
+ annotations: { title: "Rescope", destructiveHint: false, idempotentHint: true },
2245
+ inputSchema: {
2246
+ type: "object",
2247
+ properties: {
2248
+ id: { type: "string", description: "Single engram ID to move" },
2249
+ ids: { type: "array", items: { type: "string" }, description: "Multiple engram IDs to move to the same target scope (batch)" },
2250
+ target_scope: { type: "string", description: "Destination scope: local, global, project:<name>, or a scope with a configured writable store (e.g. group:org/team)" },
2251
+ keep_local: { type: "boolean", description: "Remote targets only: keep the local original active after the push (default false \u2014 it is retired with a superseded_by link so it stops injecting)" },
2252
+ dry_run: { type: "boolean", description: "Preview the per-engram outcome without mutating anything, local or remote" }
2253
+ },
2254
+ required: ["target_scope"]
2255
+ },
2256
+ handler: async (args, plur) => {
2257
+ const targetIds = args.ids ?? (args.id ? [args.id] : []);
2258
+ if (targetIds.length === 0) throw new Error("Provide id or ids");
2259
+ const { results, success } = await plur.rescope(targetIds, args.target_scope, {
2260
+ keep_local: args.keep_local,
2261
+ dry_run: args.dry_run
2262
+ });
2263
+ return {
2264
+ results,
2265
+ success,
2266
+ ...args.dry_run === true ? { dry_run: true, note: "Dry run \u2014 nothing was changed." } : {}
2267
+ };
2268
+ }
2269
+ },
1953
2270
  {
1954
2271
  name: "plur_tensions",
1955
2272
  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.',
@@ -2312,5 +2629,7 @@ export {
2312
2629
  validateToolArgs,
2313
2630
  mcpCanary,
2314
2631
  CURSOR_CORE_TOOL_NAMES,
2632
+ resolveToolProfile,
2633
+ setActiveToolProfile,
2315
2634
  getToolDefinitions
2316
2635
  };
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.16.1";
8
+ var VERSION = "0.17.0";
9
9
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
10
10
 
11
11
  Usage:
@@ -351,7 +351,7 @@ if (arg === "packs") {
351
351
  process.exit(0);
352
352
  }
353
353
  if (arg === "serve" || arg === void 0) {
354
- const { runStdio } = await import("./server-GMXICHML.js");
354
+ const { runStdio } = await import("./server-GLVXYLB5.js");
355
355
  runStdio().catch((err) => {
356
356
  console.error("Failed to start PLUR MCP server:", err);
357
357
  process.exit(1);
@@ -4,24 +4,51 @@ import {
4
4
  getToolDefinitions,
5
5
  mcpCanary,
6
6
  registerFlushOnExit,
7
+ resolveToolProfile,
8
+ setActiveToolProfile,
7
9
  validateToolArgs
8
- } from "./chunk-27IMBG4G.js";
10
+ } from "./chunk-JRKCEXLE.js";
9
11
 
10
12
  // src/server.ts
11
13
  import { Server, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
12
14
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
13
- import { existsSync, readFileSync, writeFileSync } from "fs";
14
- import { join } from "path";
15
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
16
+ import { join as join2 } from "path";
15
17
  import { homedir } from "os";
16
- import { Plur, checkForUpdate } from "@plur-ai/core";
18
+ import { Plur, checkForUpdate, VERSION_CHECK_SUCCESS_TTL_MS } from "@plur-ai/core";
19
+
20
+ // src/drop-log.ts
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
22
+ import { dirname, join } from "path";
23
+ import { atomicWrite, withLock } from "@plur-ai/core";
24
+ var PAYLOAD_DROP_LOG_MAX_ENTRIES = 100;
25
+ function payloadDropLogPath(storageRoot) {
26
+ return join(storageRoot, "logs", "payload-drops.jsonl");
27
+ }
28
+ function recordPayloadDrop(storageRoot, record) {
29
+ try {
30
+ const path = payloadDropLogPath(storageRoot);
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ withLock(path, () => {
33
+ appendFileSync(path, JSON.stringify(record) + "\n");
34
+ const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.length > 0);
35
+ if (lines.length > PAYLOAD_DROP_LOG_MAX_ENTRIES) {
36
+ atomicWrite(path, lines.slice(-PAYLOAD_DROP_LOG_MAX_ENTRIES).join("\n") + "\n", { durable: false });
37
+ }
38
+ });
39
+ } catch {
40
+ }
41
+ }
42
+
43
+ // src/server.ts
17
44
  function serverPidPath(baseDir) {
18
- return join(baseDir ?? join(homedir(), ".plur"), "server.pid");
45
+ return join2(baseDir ?? join2(homedir(), ".plur"), "server.pid");
19
46
  }
20
47
  function readEnterpriseToken(baseDir) {
21
- const configPath = join(baseDir ?? join(homedir(), ".plur"), "config.json");
22
- if (!existsSync(configPath)) return void 0;
48
+ const configPath = join2(baseDir ?? join2(homedir(), ".plur"), "config.json");
49
+ if (!existsSync2(configPath)) return void 0;
23
50
  try {
24
- const cfg = JSON.parse(readFileSync(configPath, "utf8"));
51
+ const cfg = JSON.parse(readFileSync2(configPath, "utf8"));
25
52
  const ent = cfg?.enterprise;
26
53
  if (!ent || typeof ent.url !== "string" || typeof ent.token !== "string") return void 0;
27
54
  return { url: ent.url, token: ent.token, username: ent.username };
@@ -40,7 +67,7 @@ var INSTRUCTIONS = `PLUR is your persistent memory. Corrections, preferences, an
40
67
 
41
68
  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.
42
69
 
43
- TOOL PROFILE: by default only the core session tools are exposed directly (lean profile). Every other plur_* operation is reachable via plur_admin: { action: "<tool name>", args: {...} } \u2014 same arguments and validation as a direct call. PLUR_TOOL_PROFILE=full exposes everything directly.
70
+ TOOL PROFILE: by default only the core session tools are exposed directly (lean profile). Every other plur_* operation is reachable via plur_admin: { action: "<tool name>", args: {...} } \u2014 same arguments and validation as a direct call. A plur_* name missing from tools/list means it MOVED behind plur_admin, not that the MCP is down \u2014 never conclude the server is unavailable from a name-lookup miss. Discover the live surface with plur_admin { action: "help" } (every action with description + argument schema) or plur_doctor (tool_surface). PLUR_TOOL_PROFILE=full exposes everything directly.
44
71
 
45
72
  SESSION LIFECYCLE:
46
73
  - With hooks installed (plur init): engrams are injected automatically on first message. You do NOT need to call plur_session_start \u2014 it happens via hooks. Just call plur_session_end before the conversation ends.
@@ -76,7 +103,7 @@ var GUIDE_RESOURCE = `# PLUR \u2014 Agent Guide
76
103
 
77
104
  ## What is PLUR?
78
105
 
79
- Persistent memory for AI agents. Corrections, preferences, and conventions are stored as **engrams** \u2014 small assertions that strengthen with use and decay when irrelevant (ACT-R model). Storage is plain YAML on disk. Search is fully local (BM25 + embeddings). Zero API calls.
106
+ Persistent memory for AI agents. Corrections, preferences, and conventions are stored as **engrams** \u2014 small assertions that strengthen with use and decay when irrelevant (ACT-R model). Storage is plain YAML on disk. Search runs locally (BM25 + embeddings); when an enterprise/remote store is configured AND relevant to the current project, recall additionally makes one live timeout-bounded call per remote host and merges the results \u2014 degradation is surfaced per host via a \`remote_stores\` block, never silent. With no remote store configured, search is fully local with zero API calls.
80
107
 
81
108
  ## Quick Start
82
109
 
@@ -110,7 +137,8 @@ Persistent memory for AI agents. Corrections, preferences, and conventions are s
110
137
  - **plur_recall** \u2014 hybrid search by default (BM25 + embeddings); pass mode:"keyword" for BM25-only
111
138
  - **plur_feedback** \u2014 rate an engram (trains relevance)
112
139
  - **plur_forget** \u2014 retire an outdated engram
113
- - **plur_promote** \u2014 activate a candidate engram
140
+ - **plur_promote** \u2014 activate a candidate engram (status only \u2014 never changes scope)
141
+ - **plur_rescope** \u2014 move an engram to another scope (e.g. promote a local engram into a team store)
114
142
 
115
143
  ### Context Injection
116
144
  - **plur_inject** \u2014 select engrams for a task (BM25)
@@ -156,14 +184,24 @@ Use \`scope\` to namespace engrams per project:
156
184
 
157
185
  Override with \`PLUR_PATH\` environment variable.
158
186
  `;
187
+ var versionRecheckTimer;
159
188
  async function createServer(plur, options) {
160
189
  const instance = plur ?? new Plur();
161
- const tools = getToolDefinitions(options?.profile ?? "lean");
162
- checkForUpdate("@plur-ai/mcp", VERSION, (r) => {
190
+ const profile = options?.profile ?? "lean";
191
+ setActiveToolProfile(profile);
192
+ const tools = getToolDefinitions(profile);
193
+ const announceUpdate = (r) => {
163
194
  if (r.updateAvailable) {
164
195
  console.error(`[plur] Update available: ${r.current} \u2192 ${r.latest}. Run: npx @plur-ai/mcp@latest`);
165
196
  }
166
- });
197
+ };
198
+ checkForUpdate("@plur-ai/mcp", VERSION, announceUpdate);
199
+ if (!versionRecheckTimer) {
200
+ versionRecheckTimer = setInterval(() => {
201
+ checkForUpdate("@plur-ai/mcp", VERSION, announceUpdate);
202
+ }, VERSION_CHECK_SUCCESS_TTL_MS);
203
+ versionRecheckTimer.unref?.();
204
+ }
167
205
  const server = new Server(
168
206
  { name: "plur-mcp", version: VERSION },
169
207
  {
@@ -205,9 +243,31 @@ async function createServer(plur, options) {
205
243
  }
206
244
  mcpCanary.tick();
207
245
  try {
208
- let args = request.params.arguments ?? {};
246
+ const rawArguments = request.params.arguments;
247
+ let args = rawArguments ?? {};
209
248
  const validated = validateToolArgs(tool, args);
210
249
  if (!validated.ok) {
250
+ const drop = validated.errorPayload.drop;
251
+ if (drop) {
252
+ recordPayloadDrop(instance.storageRoot, {
253
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
254
+ tool: request.params.name,
255
+ arguments_wire: drop === "whole_payload" ? rawArguments === void 0 ? "absent" : "empty_object" : "partial",
256
+ params_keys: Object.keys(request.params ?? {}),
257
+ received_fields: validated.errorPayload.received_fields,
258
+ missing_fields: validated.errorPayload.missing_fields,
259
+ request_id: request.id,
260
+ server_version: VERSION
261
+ });
262
+ try {
263
+ void Promise.resolve(server.sendLoggingMessage({
264
+ level: "warning",
265
+ data: `Payload drop (#772) on ${request.params.name}: arguments ${rawArguments === void 0 ? "key absent from frame" : `arrived with fields [${validated.errorPayload.received_fields.join(", ")}]`}. Recorded in ${payloadDropLogPath(instance.storageRoot)}.`
266
+ })).catch(() => {
267
+ });
268
+ } catch {
269
+ }
270
+ }
211
271
  return {
212
272
  content: [{ type: "text", text: JSON.stringify(validated.errorPayload) }],
213
273
  isError: true
@@ -351,8 +411,7 @@ Please:
351
411
  return server;
352
412
  }
353
413
  async function runStdio() {
354
- const envProfile = process.env.PLUR_TOOL_PROFILE;
355
- const profile = envProfile === "full" ? "full" : envProfile === "cursor" ? "cursor" : "lean";
414
+ const profile = resolveToolProfile();
356
415
  const server = await createServer(void 0, { profile });
357
416
  registerFlushOnExit({});
358
417
  try {
@@ -368,6 +427,7 @@ async function runStdio() {
368
427
  await server.connect(transport);
369
428
  }
370
429
  export {
430
+ GUIDE_RESOURCE,
371
431
  INSTRUCTIONS,
372
432
  clearPendingReload,
373
433
  createServer,
@@ -37,6 +37,8 @@ declare function validateToolArgs(tool: ToolDefinition, rawArgs: Record<string,
37
37
  error: string;
38
38
  success: false;
39
39
  received_fields: string[];
40
+ missing_fields: string[];
41
+ drop?: 'whole_payload' | 'partial';
40
42
  _isError: true;
41
43
  };
42
44
  };
@@ -2,7 +2,7 @@ import {
2
2
  CURSOR_CORE_TOOL_NAMES,
3
3
  getToolDefinitions,
4
4
  validateToolArgs
5
- } from "./chunk-27IMBG4G.js";
5
+ } from "./chunk-JRKCEXLE.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.16.1",
4
+ "version": "0.17.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.16.1"
19
+ "@plur-ai/core": "0.17.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^25.5.0"