@plur-ai/mcp 0.13.0 → 0.15.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
@@ -46,25 +46,23 @@ Knowledge is stored as **engrams** — small assertions that strengthen with use
46
46
 
47
47
  ## Tools
48
48
 
49
- Your agent gets these tools automatically:
49
+ By default (lean profile), your agent gets 11 tools. Everything else is reachable through `plur_admin`:
50
50
 
51
51
  | Tool | What it does |
52
52
  |------|-------------|
53
53
  | `plur_session_start` | Start a session — injects relevant engrams for your task |
54
54
  | `plur_learn` | Store a memory — correction, preference, convention, or decision |
55
55
  | `plur_recall_hybrid` | **Best default** — BM25 + embeddings merged via RRF. Zero cost. |
56
- | `plur_recall` | Keyword search (BM25 only, instant) |
57
- | `plur_inject_hybrid` | Load relevant memories for the current task |
58
56
  | `plur_feedback` | Rate a memory — trains relevance over time |
59
57
  | `plur_forget` | Retire a memory (history preserved) |
60
58
  | `plur_session_end` | End a session — captures summary and new learnings |
61
- | `plur_capture` | Record a session event |
62
- | `plur_timeline` | Query session history |
63
- | `plur_ingest` | Extract learnings from text |
64
- | `plur_sync` | Sync memory across machines via git |
65
- | `plur_packs_install` | Install a shareable memory pack |
66
- | `plur_packs_list` | List installed packs |
67
59
  | `plur_status` | System health |
60
+ | `plur_doctor` | Diagnose embedder, hybrid search, and remote-store auth |
61
+ | `plur_packs_uninstall` | Remove an installed pack |
62
+ | `plur_tensions_purge` | Clear stale/resolved tensions |
63
+ | `plur_admin` | Dispatch to any other tool: `{ action: "plur_packs_install", args: {...} }` |
64
+
65
+ Less commonly needed tools (`plur_recall`, `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.
68
66
 
69
67
  ## Sync across machines
70
68
 
@@ -101,11 +99,9 @@ Default: `~/.plur/`. Everything is plain YAML — open it, read it, edit it.
101
99
 
102
100
  ## Benchmark
103
101
 
104
- | Metric | Score |
105
- |--------|-------|
106
- | LongMemEval overall | **86.7%** |
107
- | A/B win rate vs no memory | 89% |
108
- | House rules accuracy | 100% |
102
+ **Retrieval** (LongMemEval R@5): **76.7%** out-of-the-box · **97.0%** with openai-3-large embeddings
103
+
104
+ **Agent task impact:** Haiku + PLUR outperforms Opus *without* memory at ~10× less cost. House rules: **12–0** across Haiku, Sonnet, Opus. A/B win rate: **89%**.
109
105
 
110
106
  [Full methodology →](https://plur.ai/benchmark.html)
111
107
 
package/dist/index.js CHANGED
@@ -5,14 +5,17 @@ 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.13.0";
8
+ var VERSION = "0.15.0";
9
9
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
10
10
 
11
11
  Usage:
12
- plur-mcp Start the MCP server (stdio transport)
13
- plur-mcp init Set up PLUR: storage + MCP config + hooks + CLAUDE.md
14
- plur-mcp --help Show this help message
15
- plur-mcp --version Show version
12
+ plur-mcp Start the MCP server (stdio transport)
13
+ plur-mcp init Set up PLUR: storage + MCP config + hooks + CLAUDE.md
14
+ plur-mcp packs install <path> Install a knowledge pack from a local directory
15
+ plur-mcp packs list List installed knowledge packs
16
+ plur-mcp packs uninstall <name> Uninstall a knowledge pack by name
17
+ plur-mcp --help Show this help message
18
+ plur-mcp --version Show version
16
19
 
17
20
  Environment:
18
21
  PLUR_PATH Storage location (default: ~/.plur/)
@@ -65,6 +68,12 @@ var PLUR_HOOKS = {
65
68
  matcher: "auto|manual",
66
69
  hooks: [{ type: "command", command: `${CLI} hook-inject --rehydrate`, timeout: 15 }]
67
70
  }],
71
+ // Auto-close the memory lifecycle at session end (Claude Code SessionEnd,
72
+ // shipped v1.0.85) — captures a closing episode and cleans up the session
73
+ // checkpoint even if the agent forgot to call plur_session_end (#217).
74
+ SessionEnd: [{
75
+ hooks: [{ type: "command", command: `${CLI} hook-session-end`, timeout: 5 }]
76
+ }],
68
77
  // --- Contextual injection ---
69
78
  PreToolUse: [
70
79
  { matcher: "EnterPlanMode", hooks: [{ type: "command", command: `${CLI} hook-inject --event plan_mode`, timeout: 10 }] },
@@ -98,7 +107,7 @@ Hooks inject engrams automatically on every first message \u2014 you do not need
98
107
  2. **Learn**: When corrected or discovering something new, call \`plur_learn\` immediately
99
108
  3. **Recall**: Before answering factual questions, call \`plur_recall_hybrid\` \u2014 check memory first
100
109
  4. **Feedback**: Rate injected engrams with \`plur_feedback\` (positive/negative) \u2014 trains relevance
101
- 5. **End**: Call \`plur_session_end\` with summary + engram_suggestions
110
+ 5. **End**: Call \`plur_session_end\` with summary + engram_suggestions \u2014 a SessionEnd hook auto-closes the lifecycle if you forget, but calling it yourself captures higher-quality learnings
102
111
 
103
112
  Do not ask permission to use these tools \u2014 they are your memory system.
104
113
 
@@ -264,6 +273,65 @@ async function runInit() {
264
273
  `);
265
274
  }
266
275
  }
276
+ async function runPacks() {
277
+ const sub = process.argv[3];
278
+ const plurPath = process.env.PLUR_PATH ?? join(homedir(), ".plur");
279
+ const { Plur } = await import("@plur-ai/core");
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 = 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
+ }
334
+ }
267
335
  var arg = process.argv[2];
268
336
  if (arg === "--help" || arg === "-h") {
269
337
  process.stdout.write(HELP);
@@ -278,8 +346,12 @@ if (arg === "init") {
278
346
  await runInit();
279
347
  process.exit(0);
280
348
  }
349
+ if (arg === "packs") {
350
+ await runPacks();
351
+ process.exit(0);
352
+ }
281
353
  if (arg === "serve" || arg === void 0) {
282
- const { runStdio } = await import("./server-B6OJN4ZX.js");
354
+ const { runStdio } = await import("./server-7JVOIBJV.js");
283
355
  runStdio().catch((err) => {
284
356
  console.error("Failed to start PLUR MCP server:", err);
285
357
  process.exit(1);
@@ -1,23 +1,16 @@
1
1
  // src/server.ts
2
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import {
5
- ListToolsRequestSchema,
6
- CallToolRequestSchema,
7
- ListResourcesRequestSchema,
8
- ReadResourceRequestSchema,
9
- ListPromptsRequestSchema,
10
- GetPromptRequestSchema,
11
- ErrorCode,
12
- McpError
13
- } from "@modelcontextprotocol/sdk/types.js";
2
+ import { Server, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
4
+ import { existsSync as existsSync2, readFileSync, writeFileSync } from "fs";
5
+ import { join as join2 } from "path";
6
+ import { homedir as homedir2 } from "os";
14
7
  import { Plur as Plur2, checkForUpdate } from "@plur-ai/core";
15
8
 
16
9
  // src/tools.ts
17
10
  import { existsSync, unlinkSync } from "fs";
18
11
  import { join } from "path";
19
12
  import { homedir } from "os";
20
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName } from "@plur-ai/core";
13
+ 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";
21
14
 
22
15
  // src/telemetry.ts
23
16
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
@@ -31,7 +24,7 @@ function recordTelemetry(event) {
31
24
  }
32
25
 
33
26
  // src/version.ts
34
- var VERSION = "0.13.0";
27
+ var VERSION = "0.15.0";
35
28
 
36
29
  // src/tools.ts
37
30
  import { z } from "zod";
@@ -179,6 +172,24 @@ mcpCanary.expect({
179
172
  description: "Learning from corrections",
180
173
  fix: "Call plur_learn when corrected. If using hooks, verify they are installed."
181
174
  });
175
+ var _sessionTelemetry = /* @__PURE__ */ new Map();
176
+ var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
177
+ function _cleanExpiredSessions() {
178
+ const cutoff = Date.now() - SESSION_TTL_MS;
179
+ for (const [id, state] of _sessionTelemetry) {
180
+ if (new Date(state.started_at).getTime() < cutoff) _sessionTelemetry.delete(id);
181
+ }
182
+ }
183
+ var _activeSessionId;
184
+ function _recordInjectionTelemetry(session_id, injected_packs) {
185
+ if (!session_id || !injected_packs) return;
186
+ const state = _sessionTelemetry.get(session_id);
187
+ if (!state) return;
188
+ state.injection_calls++;
189
+ for (const [pack, count] of Object.entries(injected_packs)) {
190
+ state.pack_counts[pack] = (state.pack_counts[pack] ?? 0) + count;
191
+ }
192
+ }
182
193
  var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
183
194
  "plur_session_start",
184
195
  "plur_session_end",
@@ -187,6 +198,7 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
187
198
  "plur_feedback",
188
199
  "plur_forget",
189
200
  "plur_status",
201
+ "plur_receipt",
190
202
  "plur_doctor",
191
203
  "plur_packs_uninstall",
192
204
  "plur_tensions_purge"
@@ -219,6 +231,13 @@ function buildAdminDispatchTool(all) {
219
231
  if (!target) {
220
232
  return { error: `Unknown action "${action}". Valid actions: ${adminActions.join(", ")}`, success: false, _isError: true };
221
233
  }
234
+ if (target.annotations?.destructiveHint === true) {
235
+ return {
236
+ error: `"${action}" is a destructive operation and cannot be dispatched via plur_admin \u2014 call the ${action} tool directly (it is exposed in every profile) so your client sees its destructiveHint annotation.`,
237
+ success: false,
238
+ _isError: true
239
+ };
240
+ }
222
241
  const innerArgs = args.args ?? {};
223
242
  const validated = validateToolArgs(target, innerArgs);
224
243
  if (!validated.ok) {
@@ -233,12 +252,20 @@ function buildAdminDispatchTool(all) {
233
252
  }
234
253
  };
235
254
  }
236
- function getToolDefinitions(profile = "full") {
255
+ function getToolDefinitions(profile = "lean") {
237
256
  const all = getAllToolDefinitions();
238
- if (profile !== "cursor") return all;
257
+ if (profile === "full") return all;
239
258
  const core = all.filter((t) => CURSOR_CORE_TOOL_NAMES.has(t.name));
240
259
  return [...core, buildAdminDispatchTool(all)];
241
260
  }
261
+ function receiptSummary(r) {
262
+ if (r.coverage.source === "none") {
263
+ return r.window.windowed ? `No retrievals recorded in the last ${r.window.requested_days} days.` : "No retrieval history yet \u2014 logging begins once memory is used.";
264
+ }
265
+ const since = r.window.windowed ? `the last ${r.window.requested_days} days` : `${r.coverage.complete_from}`;
266
+ const pct = Math.round(r.retrieved.activation_rate * 100);
267
+ return `Since ${since}, ${r.retrieved.taught_pairs} times a memory the user taught was retrieved into context (across ${r.retrieved.retrievals} retrievals in ${r.window.sessions} sessions; ${r.retrieved.engrams} distinct engrams). Activation ${pct}% is store COVERAGE over the logging window, not a quality score \u2014 it is expected to be low and to fall as more engrams are added.`;
268
+ }
242
269
  function getAllToolDefinitions() {
243
270
  return [
244
271
  {
@@ -298,6 +325,18 @@ function getAllToolDefinitions() {
298
325
  const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
299
326
  return { scope_hint: `Stored at "${engramScope}" because no scope was passed, but a team store is configured (${scopes}). If this is team/engineering knowledge, re-learn it with an explicit scope so it reaches the shared store; keep genuinely personal notes at the default scope.` };
300
327
  };
328
+ const domainHint = (wasRouted) => {
329
+ if (typeof args.domain === "string" && args.domain.length > 0) return {};
330
+ if (explicitScope || wasRouted) return {};
331
+ let coversScopes = [];
332
+ try {
333
+ coversScopes = plur.listScopeMetadata().filter((md) => (md.covers?.length ?? 0) > 0).map((md) => md.scope);
334
+ } catch {
335
+ return {};
336
+ }
337
+ if (coversScopes.length === 0) return {};
338
+ return { domain_hint: `No domain set \u2014 without a dotted domain this engram cannot auto-route to a covers-declaring scope (${coversScopes.join(", ")}) and is harder to re-scope later. Set domain on every plur_learn, shape "<org>.<team>.<area>" (e.g. "plur.engineering.mcp") \u2014 see the domain convention in CLAUDE.md.` };
339
+ };
301
340
  const temporalEcho = (engram) => {
302
341
  const extracted = engram.structured_data?._expiry_extracted;
303
342
  return {
@@ -323,6 +362,7 @@ function getAllToolDefinitions() {
323
362
  decision: "ADD",
324
363
  ...temporalEcho(engram),
325
364
  ...scopeHint(engram.scope, !!routed),
365
+ ...domainHint(!!routed),
326
366
  ...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
327
367
  ...demoted ? { demoted: true, requested_scope: demoted.from, warning: `Sensitive content (${demoted.patterns}) detected \u2014 stored at "${demoted.to}"/private instead of the requested shared scope "${demoted.from}". If this is a false positive, re-scope deliberately.` } : {},
328
368
  ...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason }, info: `No scope was provided; auto-routed to "${routed.scope}" (confidence ${routed.confidence}) because its content matched that scope's covers. Pass an explicit scope to override.` } : {}
@@ -341,12 +381,106 @@ function getAllToolDefinitions() {
341
381
  decision: "ADD",
342
382
  ...temporalEcho(engram),
343
383
  ...scopeHint(engram.scope, !!routedFallback),
384
+ ...domainHint(!!routedFallback),
344
385
  ...isOutbox ? { outbox: true } : {},
345
386
  warning: `Remote write failed (${err.message}); engram queued for retry.`
346
387
  };
347
388
  }
348
389
  }
349
390
  },
391
+ {
392
+ name: "plur_learn_batch",
393
+ 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.",
394
+ annotations: { title: "Learn (batch)", destructiveHint: false, idempotentHint: false },
395
+ inputSchema: {
396
+ type: "object",
397
+ properties: {
398
+ engrams: {
399
+ type: "array",
400
+ description: "Engram objects to persist. Each requires `statement`; the other fields mirror plur_learn.",
401
+ items: {
402
+ type: "object",
403
+ properties: {
404
+ statement: { type: "string", description: "The knowledge assertion to store" },
405
+ type: { type: "string", enum: ["behavioral", "terminological", "procedural", "architectural"], description: "Category of the engram" },
406
+ scope: { type: "string", description: "Namespace, e.g. global, project:myapp" },
407
+ domain: { type: "string", description: "Domain tag, e.g. software.deployment" },
408
+ tags: { type: "array", items: { type: "string" }, description: "Searchable keyword tags \u2014 contribute to BM25/embedding recall" },
409
+ rationale: { type: "string", description: "Why this knowledge matters \u2014 also enters the search corpus" },
410
+ source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
411
+ pinned: { type: "boolean", description: "Always-load flag. Use sparingly: meta-rules, safety conventions, core principles." },
412
+ commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked"], description: "How firmly the user has committed (default: leaning)" },
413
+ valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid" },
414
+ valid_until: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge expires" }
415
+ },
416
+ required: ["statement"]
417
+ }
418
+ },
419
+ 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." }
420
+ },
421
+ required: ["engrams"]
422
+ },
423
+ handler: async (args, plur) => {
424
+ const llm = getLlmFunction();
425
+ const raw = Array.isArray(args.engrams) ? args.engrams : [];
426
+ if (raw.length === 0) {
427
+ return { ids: [], results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [], warning: "No engrams provided \u2014 pass a non-empty `engrams` array." };
428
+ }
429
+ const items = raw.map((e) => ({
430
+ statement: sanitizeStatement(e.statement),
431
+ context: {
432
+ type: e.type,
433
+ scope: e.scope,
434
+ domain: e.domain,
435
+ source: e.source,
436
+ tags: e.tags,
437
+ rationale: e.rationale,
438
+ commitment: e.commitment,
439
+ pinned: e.pinned,
440
+ valid_from: e.valid_from,
441
+ valid_until: e.valid_until
442
+ }
443
+ }));
444
+ const maxLlmCalls = typeof args.max_llm_calls === "number" ? args.max_llm_calls : void 0;
445
+ const { results, stats, failures } = await plur.learnBatch(
446
+ items,
447
+ llm,
448
+ maxLlmCalls !== void 0 ? { maxLlmCalls } : void 0
449
+ );
450
+ mcpCanary.signal("learn_activity");
451
+ recordTelemetry("learn");
452
+ const ids = raw.map(() => null);
453
+ for (const r of results) {
454
+ if (r.input_index !== void 0) ids[r.input_index] = r.engram.id;
455
+ }
456
+ let batchDomainHint = {};
457
+ const noDomainCount = raw.filter((e) => !(typeof e.domain === "string" && e.domain.length > 0) && !(typeof e.scope === "string" && e.scope.length > 0)).length;
458
+ if (noDomainCount > 0) {
459
+ try {
460
+ const coversScopes = plur.listScopeMetadata().filter((md) => (md.covers?.length ?? 0) > 0).map((md) => md.scope);
461
+ if (coversScopes.length > 0) {
462
+ batchDomainHint = { domain_hint: `${noDomainCount} of ${raw.length} item(s) had no domain and no explicit scope \u2014 they cannot auto-route to a covers-declaring scope (${coversScopes.join(", ")}) and are harder to re-scope later. Set domain on every item, shape "<org>.<team>.<area>" \u2014 see the domain convention in CLAUDE.md.` };
463
+ }
464
+ } catch {
465
+ }
466
+ }
467
+ return {
468
+ ids,
469
+ results: results.map((r) => ({
470
+ input_index: r.input_index,
471
+ id: r.engram.id,
472
+ statement: r.engram.statement,
473
+ scope: r.engram.scope,
474
+ type: r.engram.type,
475
+ decision: r.decision,
476
+ ...r.existing_id ? { existing_id: r.existing_id } : {}
477
+ })),
478
+ stats,
479
+ ...batchDomainHint,
480
+ ...failures.length > 0 ? { failures, warning: `${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.` } : {}
481
+ };
482
+ }
483
+ },
350
484
  {
351
485
  name: "plur_recall",
352
486
  description: "Query engrams by BM25 keyword matching \u2014 use plur_recall_hybrid for semantic similarity. Note: a project-scope filter also returns personal-family engrams (local, global, user:*, agent:*); an explicit scope=global recall returns ALL personal-family engrams \u2014 wider than scope=global INJECT, which is targeted to the global namespace only.",
@@ -370,14 +504,18 @@ function getAllToolDefinitions() {
370
504
  limit: args.limit
371
505
  });
372
506
  return {
373
- results: results.map((e) => ({
374
- id: e.id,
375
- statement: e.statement,
376
- type: e.type,
377
- scope: e.scope,
378
- domain: e.domain,
379
- retrieval_strength: e.activation.retrieval_strength
380
- })),
507
+ results: results.map((e) => {
508
+ const supersededBy = e.relations?.superseded_by;
509
+ const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
510
+ return {
511
+ id: e.id,
512
+ statement: e.statement + annotation,
513
+ type: e.type,
514
+ scope: e.scope,
515
+ domain: e.domain,
516
+ retrieval_strength: e.activation.retrieval_strength
517
+ };
518
+ }),
381
519
  count: results.length
382
520
  };
383
521
  }
@@ -433,9 +571,11 @@ function getAllToolDefinitions() {
433
571
  const response = {
434
572
  results: boundedResults.map((e) => {
435
573
  const raw = e;
574
+ const supersededBy = e.relations?.superseded_by;
575
+ const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
436
576
  const base = {
437
577
  id: e.id,
438
- statement: e.statement,
578
+ statement: e.statement + annotation,
439
579
  type: e.type,
440
580
  scope: e.scope,
441
581
  domain: e.domain,
@@ -481,8 +621,11 @@ function getAllToolDefinitions() {
481
621
  handler: async (args, plur) => {
482
622
  const result = plur.inject(args.task, {
483
623
  budget: args.budget,
484
- scope: args.scope
624
+ scope: args.scope,
625
+ source: "inject",
626
+ session_id: _activeSessionId
485
627
  });
628
+ _recordInjectionTelemetry(_activeSessionId, result.injected_packs);
486
629
  return {
487
630
  directives: result.directives,
488
631
  consider: result.consider,
@@ -510,8 +653,11 @@ function getAllToolDefinitions() {
510
653
  handler: async (args, plur) => {
511
654
  const result = await plur.injectHybrid(args.task, {
512
655
  budget: args.budget,
513
- scope: args.scope
656
+ scope: args.scope,
657
+ source: "inject",
658
+ session_id: _activeSessionId
514
659
  });
660
+ _recordInjectionTelemetry(_activeSessionId, result.injected_packs);
515
661
  return {
516
662
  directives: result.directives,
517
663
  consider: result.consider,
@@ -860,11 +1006,19 @@ function getAllToolDefinitions() {
860
1006
  full: {
861
1007
  type: "boolean",
862
1008
  description: "Full reindex: drop the derived index (PGLite/SQLite) and rebuild from YAML. YAML is never modified. Use to recover from an out-of-sync index."
1009
+ },
1010
+ remote_type: {
1011
+ type: "string",
1012
+ enum: ["personal", "shared"],
1013
+ 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."
863
1014
  }
864
1015
  }
865
1016
  },
866
1017
  handler: async (args, plur) => {
867
- const result = plur.sync(args.remote, { full: args.full === true });
1018
+ const result = plur.sync(args.remote, {
1019
+ full: args.full === true,
1020
+ ...args.remote_type === "personal" || args.remote_type === "shared" ? { remoteType: args.remote_type } : {}
1021
+ });
868
1022
  await plur.waitForIndex();
869
1023
  const indexError = plur.lastIndexError();
870
1024
  let outbox_result;
@@ -1050,14 +1204,20 @@ function getAllToolDefinitions() {
1050
1204
  },
1051
1205
  {
1052
1206
  name: "plur_status",
1053
- description: "Return system health \u2014 running version, engram count, episode count, pack count, storage root",
1207
+ description: "Return system health \u2014 running version, engram count, episode count, pack count, storage root. Optionally filter engram counts by domain prefix and/or creation date.",
1054
1208
  annotations: { title: "Status", readOnlyHint: true, idempotentHint: true },
1055
1209
  inputSchema: {
1056
1210
  type: "object",
1057
- properties: {}
1211
+ properties: {
1212
+ domain: { type: "string", description: 'Only count engrams whose domain starts with this prefix (e.g. "meridian")' },
1213
+ created_after: { type: "string", description: "ISO-8601 date (YYYY-MM-DD). Only count engrams learned on or after this date." }
1214
+ }
1058
1215
  },
1059
- handler: async (_args, plur) => {
1060
- const status = plur.status();
1216
+ handler: async (args, plur) => {
1217
+ const status = plur.status({
1218
+ domain: args.domain,
1219
+ created_after: args.created_after
1220
+ });
1061
1221
  const versionCheck = getCachedUpdateCheck("@plur-ai/mcp");
1062
1222
  return {
1063
1223
  version: VERSION,
@@ -1090,6 +1250,22 @@ function getAllToolDefinitions() {
1090
1250
  };
1091
1251
  }
1092
1252
  },
1253
+ {
1254
+ name: "plur_receipt",
1255
+ description: 'Counted report of what your memory retrieved for you: engrams stored, how many were retrieved and how often, which are most relied on, and how much of the store is dormant. Local and read-only; every figure is directly counted, never estimated. IMPORTANT when relaying to the user: `activation_rate` is COVERAGE over the logging window (\u2248 how much of the store was surfaced), NOT a quality or effectiveness score \u2014 it is naturally low and FALLS as more engrams are added, so never present it as "memory is N% effective". A `summary` line is included; prefer relaying that.',
1256
+ annotations: { title: "Memory receipt", readOnlyHint: true, idempotentHint: true },
1257
+ inputSchema: {
1258
+ type: "object",
1259
+ properties: {
1260
+ days: { type: "number", description: "Restrict to the last N days (integer). Omit for all recorded history." }
1261
+ }
1262
+ },
1263
+ handler: async (args, plur) => {
1264
+ const days = typeof args.days === "number" && Number.isFinite(args.days) && args.days >= 1 ? Math.floor(args.days) : void 0;
1265
+ const receipt = plur.receipt(days ? { days } : void 0);
1266
+ return { summary: receiptSummary(receipt), ...receipt };
1267
+ }
1268
+ },
1093
1269
  {
1094
1270
  name: "plur_doctor",
1095
1271
  description: 'Diagnose the PLUR ENGINE (embedder, hybrid search, remote-store auth) \u2014 not hook/MCP wiring. Reports whether the embedding model loaded, whether hybrid search is fully operational, and \u2014 for any configured enterprise/remote store \u2014 whether its auth is valid (probes /api/v1/me and decodes token expiry), so a dead or soon-to-expire token surfaces instead of hiding behind a "healthy" report. Run this first when recall feels off or team engrams stop syncing. Does NOT check .cursor/mcp.json, .cursor/hooks.json, or the live MCP tool count \u2014 for that, run the `plur doctor` CLI command in a terminal (a different, more thorough check with the same name).',
@@ -1184,6 +1360,23 @@ function getAllToolDefinitions() {
1184
1360
  }
1185
1361
  }
1186
1362
  checks.push({ check: "reranker available", ok: rerankerOk, detail: rerankerDetail });
1363
+ if (rerankerOk) {
1364
+ try {
1365
+ const fitResult = await plur.checkRerankerFit({ rerankerName });
1366
+ const sep = fitResult.separability.toFixed(3);
1367
+ checks.push({
1368
+ check: "reranker domain fit",
1369
+ ok: fitResult.fit,
1370
+ detail: fitResult.n_pairs === 0 ? "Not enough engrams to evaluate fit (< 2) \u2014 assuming fit" : fitResult.fit ? `Good fit \u2014 separability ${sep} on ${fitResult.n_pairs} pairs (threshold \u2265 0.05)` : `Poor fit \u2014 separability ${sep} on ${fitResult.n_pairs} pairs (threshold \u2265 0.05). Reranker may be net-negative on this store's domain mix.`
1371
+ });
1372
+ if (!fitResult.fit && fitResult.n_pairs > 0) {
1373
+ remediation.push(
1374
+ `Reranker "${rerankerName}" shows poor separability (${sep}) on this store's engrams \u2014 it may be scoring irrelevant pairs higher than relevant ones. Consider unsetting PLUR_RERANKER or switching to a different tier. The fit check compares same-domain vs cross-domain pair scores; low separability means the model lacks signal on your content.`
1375
+ );
1376
+ }
1377
+ } catch {
1378
+ }
1379
+ }
1187
1380
  try {
1188
1381
  let evalStatus;
1189
1382
  let freshlyRun = false;
@@ -1286,6 +1479,13 @@ function getAllToolDefinitions() {
1286
1479
  const session_id = crypto.randomUUID();
1287
1480
  const task = args.task;
1288
1481
  const tags = args.tags;
1482
+ _cleanExpiredSessions();
1483
+ _activeSessionId = session_id;
1484
+ _sessionTelemetry.set(session_id, {
1485
+ pack_counts: {},
1486
+ injection_calls: 0,
1487
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
1488
+ });
1289
1489
  let outbox_result;
1290
1490
  try {
1291
1491
  outbox_result = await plur.flushOutbox();
@@ -1316,9 +1516,11 @@ function getAllToolDefinitions() {
1316
1516
  try {
1317
1517
  const result = await plur.injectHybrid(task, {
1318
1518
  scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1319
- session_id
1519
+ session_id,
1320
1520
  // stamped on the co_injection provenance event (#452)
1521
+ source: "session_start"
1321
1522
  });
1523
+ _recordInjectionTelemetry(session_id, result.injected_packs);
1322
1524
  if (result.count > 0) {
1323
1525
  const lines = [];
1324
1526
  if (result.directives) lines.push("## DIRECTIVES\n", result.directives);
@@ -1329,8 +1531,10 @@ function getAllToolDefinitions() {
1329
1531
  } catch {
1330
1532
  const result = plur.inject(task, {
1331
1533
  scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1332
- session_id
1534
+ session_id,
1535
+ source: "session_start"
1333
1536
  });
1537
+ _recordInjectionTelemetry(session_id, result.injected_packs);
1334
1538
  if (result.count > 0) {
1335
1539
  const lines = [];
1336
1540
  if (result.directives) lines.push("## DIRECTIVES\n", result.directives);
@@ -1370,7 +1574,7 @@ Auto-detected project scope: "${default_scope}" (from .plur.yaml in the current
1370
1574
  } else if (scope_source === "none") {
1371
1575
  guide += `
1372
1576
 
1373
- \u26A0\uFE0F No project scope detected. plur_learn calls without explicit scope will be tagged "global" and will appear in EVERY project's future sessions. Create a .plur.yaml NOW to prevent this: scope: "project:<your-project-name>". (This is every project's PERSONAL recall context, NOT team shared stores \u2014 use an explicit shared scope like project:/group: to reach a team store.) Note: an explicit scope=global RECALL surfaces all your personal engrams, but scope=global INJECT is targeted to the global namespace only \u2014 don't be surprised if a local engram a global recall finds is absent from a global inject.`;
1577
+ \u26A0\uFE0F No project scope detected. plur_learn calls without explicit scope may AUTO-ROUTE to a registered team scope whose covers confidently match the engram's domain/tags (the response reports \`routed\` when that happens); otherwise they land at the unscoped default "global" and will appear in EVERY project's future sessions. Create a .plur.yaml NOW to prevent this: scope: "project:<your-project-name>". (This is every project's PERSONAL recall context, NOT team shared stores \u2014 use an explicit shared scope like project:/group: to reach a team store.) Note: an explicit scope=global RECALL surfaces all your personal engrams, but scope=global INJECT is targeted to the global namespace only \u2014 don't be surprised if a local engram a global recall finds is absent from a global inject.`;
1374
1578
  }
1375
1579
  if (remote_scopes.length > 0) {
1376
1580
  const safe = (x) => String(x ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
@@ -1385,9 +1589,10 @@ Auto-detected project scope: "${default_scope}" (from .plur.yaml in the current
1385
1589
 
1386
1590
  Session default scope is set to "${default_scope}". To route an engram to a remote enterprise store instead, pass scope explicitly to plur_learn (available remote scopes: ${scopeList}).` : `
1387
1591
 
1388
- Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: when an engram is relevant to the team (engineering patterns, architecture decisions, project conventions), set scope to the matching remote scope in plur_learn. Personal preferences, local project details, and corrections specific to your workflow can be left unscoped (they land at the unscoped default, "global" \u2014 the cross-project personal namespace). Do NOT let TEAM knowledge fall back to "global" \u2014 without an explicit scope it will, and it will never reach the shared store.`;
1592
+ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: when an engram is relevant to the team (engineering patterns, architecture decisions, project conventions), set scope to the matching remote scope in plur_learn. Personal preferences, local project details, and corrections specific to your workflow can be left unscoped \u2014 but note an unscoped write whose domain/tags confidently match a team scope's covers AUTO-ROUTES to that shared team store (the response reports \`routed\` when that happens); otherwise it lands at the unscoped default, "global" \u2014 the cross-project personal namespace. Do NOT rely on auto-routing for TEAM knowledge \u2014 set the matching scope explicitly; a weak or absent covers match falls back to "global" and never reaches the shared store.`;
1389
1593
  try {
1390
1594
  const discoveries = await plur.discoverRemoteScopes({ timeoutMs: 3e3 });
1595
+ plur.persistScopeMetadata(discoveries);
1391
1596
  const failures = discoveries.filter((d) => !d.ok);
1392
1597
  if (failures.length > 0) {
1393
1598
  const authExpired = failures.some((f) => /\b40[13]\b/.test(f.error ?? ""));
@@ -1399,12 +1604,11 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
1399
1604
 
1400
1605
  \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.`;
1401
1606
  }
1402
- const unregistered = [...new Set(discoveries.filter((d) => d.ok).flatMap((d) => d.unregistered))];
1403
- if (unregistered.length > 0) {
1404
- const list = unregistered.map((s) => `"${safe(s)}"`).join(", ");
1607
+ const offerable = [...new Set(discoveries.filter((d) => d.ok).flatMap((d) => d.unregistered))].filter(isSharedScope);
1608
+ if (offerable.length > 0) {
1405
1609
  guide += `
1406
1610
 
1407
- \u{1F50E} Your token is authorized for ${unregistered.length} more scope(s) not yet registered: ${list}. Call plur_scopes_discover with register:true to add them all in one step.`;
1611
+ \u{1F50E} ${offerable.length} authorized scope(s) not yet registered. Tell the user they can run \`plur scopes\` to register or dismiss them per-scope (dismissed scopes stop being offered; \`plur scopes --reoffer\` re-surfaces them).`;
1408
1612
  }
1409
1613
  } catch {
1410
1614
  }
@@ -1516,6 +1720,16 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1516
1720
  session_id,
1517
1721
  channel: "mcp"
1518
1722
  });
1723
+ const telemetry = session_id ? _sessionTelemetry.get(session_id) : void 0;
1724
+ const injection_summary = telemetry && telemetry.injection_calls > 0 ? {
1725
+ pack_counts: { ...telemetry.pack_counts },
1726
+ total_injections: telemetry.injection_calls,
1727
+ session_duration_ms: Date.now() - new Date(telemetry.started_at).getTime()
1728
+ } : void 0;
1729
+ if (session_id) {
1730
+ _sessionTelemetry.delete(session_id);
1731
+ if (_activeSessionId === session_id) _activeSessionId = void 0;
1732
+ }
1519
1733
  try {
1520
1734
  const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
1521
1735
  const sessionsDir = join(plurDir, "sessions");
@@ -1534,6 +1748,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1534
1748
  engrams_created,
1535
1749
  episode_id: episode.id,
1536
1750
  total_engrams: status.engram_count,
1751
+ ...injection_summary ? { injection_summary } : {},
1537
1752
  hint: engrams_created === 0 ? "No engrams captured this session. If any corrections, preferences, or patterns came up, consider calling plur_learn before ending." : void 0
1538
1753
  };
1539
1754
  }
@@ -1600,29 +1815,33 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1600
1815
  },
1601
1816
  {
1602
1817
  name: "plur_suggest_scope",
1603
- description: 'Suggest which registered scope(s) an engram belongs in, ranked by fit. Deterministic \u2014 no LLM, no network. Scores the statement keywords, optional domain (a dotted namespace like "plur.core.security"), and tags against the covers[] each scope declares. ADVISORY ONLY: this does not route or store anything; pass the chosen scope to plur_learn yourself. Returns candidates sorted by confidence (empty when nothing matches).',
1818
+ description: 'Suggest which registered scope(s) an engram belongs in, ranked by fit. Deterministic \u2014 no LLM, no network. Scores the statement keywords, optional domain (a dotted namespace like "plur.core.security"), and tags against the covers[] each scope declares. ADVISORY ONLY: this does not route or store anything; pass the chosen scope to plur_learn yourself. Returns candidates sorted by confidence (empty when nothing matches). Candidates below min_confidence (default: scope_routing.min_confidence config, else 0.15) are suppressed \u2014 a lone coincidental keyword scores \u22480.12 and is noise, not signal (#670); pass min_confidence: 0 to see every scored candidate.',
1604
1819
  annotations: { title: "Suggest scope", readOnlyHint: true, idempotentHint: true },
1605
1820
  inputSchema: {
1606
1821
  type: "object",
1607
1822
  properties: {
1608
1823
  statement: { type: "string", description: "The engram statement to route" },
1609
1824
  domain: { type: "string", description: 'Optional dotted namespace for the engram (e.g. "plur.core.security") \u2014 strongest routing signal' },
1610
- tags: { type: "array", items: { type: "string" }, description: "Optional tags on the engram" }
1825
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags on the engram" },
1826
+ min_confidence: { type: "number", minimum: 0, maximum: 1, description: "Suppress candidates below this confidence (0-1; out-of-range values are clamped). Default: scope_routing.min_confidence from config, else 0.15 \u2014 clips lone-keyword noise (\u22480.12) while keeping real multi-signal matches. Pass 0 for the unfiltered list." }
1611
1827
  },
1612
1828
  required: ["statement"]
1613
1829
  },
1614
1830
  handler: async (args, plur) => {
1831
+ const raw = args.min_confidence;
1832
+ const explicit = typeof raw === "number" && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : void 0;
1833
+ const minConfidence = explicit ?? plur.getScopeRoutingConfig().min_confidence ?? SUGGEST_DISPLAY_MIN_CONFIDENCE;
1615
1834
  const candidates = plur.suggestScope({
1616
1835
  statement: args.statement,
1617
1836
  domain: args.domain,
1618
1837
  tags: args.tags
1619
- });
1620
- return { candidates, count: candidates.length };
1838
+ }, { minConfidence });
1839
+ return { candidates, count: candidates.length, min_confidence: minConfidence };
1621
1840
  }
1622
1841
  },
1623
1842
  {
1624
1843
  name: "plur_scopes_discover",
1625
- description: "Discover which scopes your remote token is authorized for via the enterprise server (GET /api/v1/me), and which of those are not yet registered locally. Read-only by default; pass register:true to register all authorized-but-unregistered scopes in one step. Only shared-family scopes (group:/project:/space:/team:/org:/public) are auto-registered \u2014 personal-family scopes (global/local/user:*/agent:*) advertised by /me are skipped and surfaced in the result. Use this when you have access to multiple team scopes on one server.",
1844
+ description: "Discover which scopes your remote token is authorized for via the enterprise server (GET /api/v1/me), and which of those are not yet registered locally. Read-only by default; pass register:true to register all authorized-but-unregistered scopes in one step. Only shared-family scopes (group:/project:/space:/team:/org:/public) are auto-registered \u2014 personal-family scopes (global/local/user:*/agent:*) advertised by /me are skipped and surfaced in the result, and scopes the user has dismissed are respected (NOT registered by the batch path; register one individually via the CLI `plur scopes register <scope>` to override, which also clears the dismissal). Use this when you have access to multiple team scopes on one server.",
1626
1845
  annotations: { title: "Discover scopes", readOnlyHint: false, idempotentHint: true },
1627
1846
  inputSchema: {
1628
1847
  type: "object",
@@ -1638,11 +1857,27 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1638
1857
  if (discoveries.length === 0) {
1639
1858
  return { discovered: [], note: "No remote stores configured. Register one scope first with plur_stores_add, then discover the rest." };
1640
1859
  }
1860
+ const enrich = (d) => {
1861
+ if (!d.ok) return d;
1862
+ const byScope = new Map(d.metadata.map((m) => [m.scope, m]));
1863
+ const registeredSet = new Set(d.registered);
1864
+ const scopes = d.authorized.map((scope) => {
1865
+ const m = byScope.get(scope);
1866
+ return {
1867
+ scope,
1868
+ registered: registeredSet.has(scope),
1869
+ ...m?.description ? { description: m.description } : {},
1870
+ ...m && m.covers.length ? { covers: m.covers } : {}
1871
+ };
1872
+ });
1873
+ return { ...d, scopes };
1874
+ };
1875
+ const discovered = discoveries.map(enrich);
1641
1876
  if (!register) {
1642
- return { discovered: discoveries };
1877
+ return { discovered };
1643
1878
  }
1644
1879
  const registered = await plur.registerDiscoveredScopes({ url });
1645
- return { discovered: discoveries, registered };
1880
+ return { discovered, registered };
1646
1881
  }
1647
1882
  },
1648
1883
  {
@@ -1953,9 +2188,9 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1953
2188
  if (filterType) {
1954
2189
  engrams = engrams.filter((e) => e.type === filterType);
1955
2190
  }
1956
- const { homedir: homedir2 } = await import("os");
1957
- const { join: join2 } = await import("path");
1958
- const outputDir = args.output_dir || join2(homedir2(), "plur-packs", name);
2191
+ const { homedir: homedir3 } = await import("os");
2192
+ const { join: join3 } = await import("path");
2193
+ const outputDir = args.output_dir || join3(homedir3(), "plur-packs", name);
1959
2194
  const result = plur.exportPack(engrams, outputDir, {
1960
2195
  name,
1961
2196
  version: "1.0.0",
@@ -2005,23 +2240,6 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2005
2240
  };
2006
2241
  }
2007
2242
  },
2008
- {
2009
- name: "plur_batch_decay",
2010
- description: "Apply ACT-R decay to all local engrams. Run weekly. Only decays engrams in the local YAML store \u2014 remote-store engrams are not decayed client-side. Returns status transitions only.",
2011
- annotations: { title: "Batch decay", destructiveHint: false, idempotentHint: false },
2012
- inputSchema: {
2013
- type: "object",
2014
- properties: {
2015
- context_scope: { type: "string", description: "Scope to skip during decay (engrams in active scope are not decayed)" }
2016
- }
2017
- },
2018
- handler: async (args, plur) => {
2019
- const result = plur.batchDecay({
2020
- contextScope: args.context_scope
2021
- });
2022
- return result;
2023
- }
2024
- },
2025
2243
  {
2026
2244
  name: "plur_profile",
2027
2245
  description: "Generate or retrieve a cognitive profile \u2014 a narrative summary synthesized from stored engrams. Cached for 24h.",
@@ -2059,10 +2277,34 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2059
2277
  }
2060
2278
 
2061
2279
  // src/server.ts
2280
+ function serverPidPath(baseDir) {
2281
+ return join2(baseDir ?? join2(homedir2(), ".plur"), "server.pid");
2282
+ }
2283
+ function readEnterpriseToken(baseDir) {
2284
+ const configPath = join2(baseDir ?? join2(homedir2(), ".plur"), "config.json");
2285
+ if (!existsSync2(configPath)) return void 0;
2286
+ try {
2287
+ const cfg = JSON.parse(readFileSync(configPath, "utf8"));
2288
+ const ent = cfg?.enterprise;
2289
+ if (!ent || typeof ent.url !== "string" || typeof ent.token !== "string") return void 0;
2290
+ return { url: ent.url, token: ent.token, username: ent.username };
2291
+ } catch {
2292
+ return void 0;
2293
+ }
2294
+ }
2295
+ var _pendingReload = false;
2296
+ function isPendingReload() {
2297
+ return _pendingReload;
2298
+ }
2299
+ function clearPendingReload() {
2300
+ _pendingReload = false;
2301
+ }
2062
2302
  var INSTRUCTIONS = `PLUR is your persistent memory. Corrections, preferences, and conventions persist across sessions as engrams.
2063
2303
 
2064
2304
  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.
2065
2305
 
2306
+ 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.
2307
+
2066
2308
  SESSION LIFECYCLE:
2067
2309
  - 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.
2068
2310
  - Without hooks: call plur_session_start at the start, plur_session_end at the end.
@@ -2117,6 +2359,7 @@ Persistent memory for AI agents. Corrections, preferences, and conventions are s
2117
2359
  | A recalled engram was wrong or irrelevant | \`plur_feedback\` with "negative" |
2118
2360
  | User says "forget X" or a memory is outdated | \`plur_forget\` |
2119
2361
  | You need to check what's stored | \`plur_status\` or \`plur_packs_list\` |
2362
+ | User asks what memory did for them / is memory working | \`plur_receipt\` (relay its \`summary\`; activation_rate is coverage, not quality) |
2120
2363
  | End of session | \`plur_session_end\` with summary and suggestions |
2121
2364
 
2122
2365
  ## Tool Categories
@@ -2157,6 +2400,7 @@ Persistent memory for AI agents. Corrections, preferences, and conventions are s
2157
2400
  - **plur_sync** \u2014 sync engrams across devices via git
2158
2401
  - **plur_sync_status** \u2014 check sync state
2159
2402
  - **plur_status** \u2014 system health
2403
+ - **plur_receipt** \u2014 counted report of what memory retrieved for the user (local, read-only)
2160
2404
 
2161
2405
  ## Scoping
2162
2406
 
@@ -2178,7 +2422,7 @@ Override with \`PLUR_PATH\` environment variable.
2178
2422
  `;
2179
2423
  async function createServer(plur, options) {
2180
2424
  const instance = plur ?? new Plur2();
2181
- const tools = getToolDefinitions(options?.profile ?? "full");
2425
+ const tools = getToolDefinitions(options?.profile ?? "lean");
2182
2426
  checkForUpdate("@plur-ai/mcp", VERSION, (r) => {
2183
2427
  if (r.updateAvailable) {
2184
2428
  console.error(`[plur] Update available: ${r.current} \u2192 ${r.latest}. Run: npx @plur-ai/mcp@latest`);
@@ -2196,7 +2440,7 @@ async function createServer(plur, options) {
2196
2440
  instructions: INSTRUCTIONS
2197
2441
  }
2198
2442
  );
2199
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
2443
+ server.setRequestHandler("tools/list", async () => ({
2200
2444
  tools: tools.map((t) => ({
2201
2445
  name: t.name,
2202
2446
  description: t.description,
@@ -2204,9 +2448,20 @@ async function createServer(plur, options) {
2204
2448
  ...t.annotations && { annotations: t.annotations }
2205
2449
  }))
2206
2450
  }));
2207
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2451
+ server.setRequestHandler("tools/call", async (request) => {
2208
2452
  const tool = tools.find((t) => t.name === request.params.name);
2209
2453
  if (!tool) {
2454
+ const hidden = getToolDefinitions("full").find((t) => t.name === request.params.name);
2455
+ if (hidden) {
2456
+ return {
2457
+ content: [{ type: "text", text: JSON.stringify({
2458
+ error: `Tool "${request.params.name}" exists but is not directly callable under the current tool profile.`,
2459
+ success: false,
2460
+ hint: `Call it via plur_admin: { action: "${request.params.name}", args: { ... } } \u2014 same arguments, same validation, same result. To expose all tools directly, set PLUR_TOOL_PROFILE=full.`
2461
+ }) }],
2462
+ isError: true
2463
+ };
2464
+ }
2210
2465
  return {
2211
2466
  content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${request.params.name}`, success: false }) }],
2212
2467
  isError: true
@@ -2244,7 +2499,7 @@ async function createServer(plur, options) {
2244
2499
  };
2245
2500
  }
2246
2501
  });
2247
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({
2502
+ server.setRequestHandler("resources/list", async () => ({
2248
2503
  resources: [
2249
2504
  {
2250
2505
  uri: "plur://guide",
@@ -2260,14 +2515,14 @@ async function createServer(plur, options) {
2260
2515
  }
2261
2516
  ]
2262
2517
  }));
2263
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2518
+ server.setRequestHandler("resources/read", async (request) => {
2264
2519
  const uri = request.params.uri;
2265
2520
  if (uri === "plur://guide") {
2266
- const cursorNote = options?.profile === "cursor" ? `
2521
+ const cursorNote = options?.profile === "cursor" || options?.profile === "lean" || options?.profile == null ? `
2267
2522
 
2268
- ## Cursor tool profile
2523
+ ## Lean tool profile (default)
2269
2524
 
2270
- Most tools above are NOT directly callable in this session \u2014 only ${[...CURSOR_CORE_TOOL_NAMES].join(", ")} are top-level tools here. Everything else in this guide is reachable through **plur_admin**: call it with \`{ action: "<tool name above>", args: {...} }\`.` : "";
2525
+ Most tools above are NOT directly callable in this session \u2014 only ${[...CURSOR_CORE_TOOL_NAMES].join(", ")} are top-level tools here. Everything else in this guide is reachable through **plur_admin**: call it with \`{ action: "<tool name above>", args: {...} }\`. Set \`PLUR_TOOL_PROFILE=full\` to expose all ${getToolDefinitions("full").length} tools directly.` : "";
2271
2526
  return {
2272
2527
  contents: [{
2273
2528
  uri: "plur://guide",
@@ -2292,9 +2547,9 @@ Most tools above are NOT directly callable in this session \u2014 only ${[...CUR
2292
2547
  }]
2293
2548
  };
2294
2549
  }
2295
- throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${uri}`);
2550
+ throw new ProtocolError(ProtocolErrorCode.InvalidRequest, `Unknown resource: ${uri}`);
2296
2551
  });
2297
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({
2552
+ server.setRequestHandler("prompts/list", async () => ({
2298
2553
  prompts: [
2299
2554
  {
2300
2555
  name: "plur-getting-started",
@@ -2310,7 +2565,7 @@ Most tools above are NOT directly callable in this session \u2014 only ${[...CUR
2310
2565
  }
2311
2566
  ]
2312
2567
  }));
2313
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2568
+ server.setRequestHandler("prompts/get", async (request) => {
2314
2569
  const name = request.params.name;
2315
2570
  if (name === "plur-getting-started") {
2316
2571
  const status = instance.status();
@@ -2355,19 +2610,33 @@ Please:
2355
2610
  }]
2356
2611
  };
2357
2612
  }
2358
- throw new McpError(ErrorCode.InvalidRequest, `Unknown prompt: ${name}`);
2613
+ throw new ProtocolError(ProtocolErrorCode.InvalidRequest, `Unknown prompt: ${name}`);
2359
2614
  });
2360
2615
  return server;
2361
2616
  }
2362
2617
  async function runStdio() {
2363
- const profile = process.env.PLUR_TOOL_PROFILE === "cursor" ? "cursor" : "full";
2618
+ const envProfile = process.env.PLUR_TOOL_PROFILE;
2619
+ const profile = envProfile === "full" ? "full" : envProfile === "cursor" ? "cursor" : "lean";
2364
2620
  const server = await createServer(void 0, { profile });
2365
2621
  registerFlushOnExit({});
2622
+ try {
2623
+ writeFileSync(serverPidPath(), String(process.pid));
2624
+ } catch {
2625
+ }
2626
+ if (process.platform !== "win32") {
2627
+ process.on("SIGUSR1", () => {
2628
+ _pendingReload = true;
2629
+ });
2630
+ }
2366
2631
  const transport = new StdioServerTransport();
2367
2632
  await server.connect(transport);
2368
2633
  }
2369
2634
  export {
2370
2635
  INSTRUCTIONS,
2636
+ clearPendingReload,
2371
2637
  createServer,
2372
- runStdio
2638
+ isPendingReload,
2639
+ readEnterpriseToken,
2640
+ runStdio,
2641
+ serverPidPath
2373
2642
  };
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.13.0",
4
+ "version": "0.15.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "plur-mcp": "dist/index.js"
@@ -12,9 +12,11 @@
12
12
  "packs"
13
13
  ],
14
14
  "dependencies": {
15
- "@modelcontextprotocol/sdk": "^1.12.0",
15
+ "@modelcontextprotocol/server": "2.0.0-beta.4",
16
+ "@modelcontextprotocol/client": "2.0.0-beta.4",
17
+ "@modelcontextprotocol/core": "2.0.0-beta.4",
16
18
  "zod": "^3.23.0",
17
- "@plur-ai/core": "0.13.0"
19
+ "@plur-ai/core": "0.15.0"
18
20
  },
19
21
  "devDependencies": {
20
22
  "@types/node": "^25.5.0"