@plur-ai/mcp 0.14.0 → 0.16.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.
@@ -1,27 +1,3 @@
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";
14
- import { existsSync as existsSync2, readFileSync, writeFileSync } from "fs";
15
- import { join as join2 } from "path";
16
- import { homedir as homedir2 } from "os";
17
- import { Plur as Plur2, checkForUpdate } from "@plur-ai/core";
18
-
19
- // src/tools.ts
20
- import { existsSync, unlinkSync } from "fs";
21
- import { join } from "path";
22
- import { homedir } from "os";
23
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName } from "@plur-ai/core";
24
-
25
1
  // src/telemetry.ts
26
2
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
27
3
  function recordTelemetry(event) {
@@ -34,9 +10,13 @@ function recordTelemetry(event) {
34
10
  }
35
11
 
36
12
  // src/version.ts
37
- var VERSION = "0.14.0";
13
+ var VERSION = "0.16.0";
38
14
 
39
15
  // src/tools.ts
16
+ import { existsSync, unlinkSync } from "fs";
17
+ import { join } from "path";
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";
40
20
  import { z } from "zod";
41
21
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
42
22
  return async (prompt) => {
@@ -59,6 +39,95 @@ function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
59
39
  return data.choices?.[0]?.message?.content ?? "";
60
40
  };
61
41
  }
42
+ var recallHandler = async (args, plur) => {
43
+ const mode = args.mode ?? "hybrid";
44
+ if (mode === "keyword") {
45
+ const results = await plur.recall(args.query, {
46
+ scope: args.scope,
47
+ domain: args.domain,
48
+ limit: args.limit
49
+ });
50
+ return {
51
+ results: results.map((e) => {
52
+ const supersededBy = e.relations?.superseded_by;
53
+ const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
54
+ return {
55
+ id: e.id,
56
+ statement: e.statement + annotation,
57
+ type: e.type,
58
+ scope: e.scope,
59
+ domain: e.domain,
60
+ retrieval_strength: e.activation.retrieval_strength
61
+ };
62
+ }),
63
+ count: results.length,
64
+ mode: "keyword"
65
+ };
66
+ }
67
+ const budget = args.budget;
68
+ const cap = budget?.max_results ?? args.limit ?? 20;
69
+ const fetchLimit = budget?.max_results != null ? cap + 1 : cap;
70
+ const meta = await plur.recallHybridWithMeta(args.query, {
71
+ scope: args.scope,
72
+ domain: args.domain,
73
+ limit: fetchLimit
74
+ });
75
+ recordTelemetry("recall");
76
+ const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
77
+ let truncated = truncatedByCount;
78
+ let boundedResults = truncatedByCount ? meta.engrams.slice(0, cap) : meta.engrams;
79
+ if (budget?.max_tokens) {
80
+ let tokenCount = 0;
81
+ const withinBudget = [];
82
+ for (const e of boundedResults) {
83
+ const tokens = Math.ceil(e.statement.length / 4) + 20;
84
+ if (tokenCount + tokens > budget.max_tokens) {
85
+ truncated = true;
86
+ break;
87
+ }
88
+ withinBudget.push(e);
89
+ tokenCount += tokens;
90
+ }
91
+ boundedResults = withinBudget;
92
+ }
93
+ const includeEpisodes = args.include_episodes === true;
94
+ const response = {
95
+ results: boundedResults.map((e) => {
96
+ const raw = e;
97
+ const supersededBy = e.relations?.superseded_by;
98
+ const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
99
+ const base = {
100
+ id: e.id,
101
+ statement: e.statement + annotation,
102
+ type: e.type,
103
+ scope: e.scope,
104
+ domain: e.domain,
105
+ retrieval_strength: e.activation.retrieval_strength
106
+ };
107
+ if (includeEpisodes && raw.episode_ids?.length > 0) {
108
+ const episodes = plur.timeline({ search: "" });
109
+ base.episodes = episodes.filter((ep) => raw.episode_ids.includes(ep.id)).map((ep) => ({ id: ep.id, summary: ep.summary, timestamp: ep.timestamp }));
110
+ }
111
+ return base;
112
+ }),
113
+ count: boundedResults.length,
114
+ truncated,
115
+ mode: meta.mode
116
+ };
117
+ if (meta.mode === "hybrid-degraded") {
118
+ response.warning = `Embedding layer unavailable \u2014 results are BM25-only. Run plur_doctor for diagnosis. Last error: ${meta.embedderError ?? "unknown"}`;
119
+ }
120
+ if (resolveRerankerName() !== "off") {
121
+ response.reranked = meta.reranked ?? 0;
122
+ const rr = plur.rerankerStatus();
123
+ if (boundedResults.length > 0 && (meta.reranked ?? 0) === 0 && rr.lastError) {
124
+ const corruptNote = rr.lastErrorKind === "corrupt-cache" ? " The model cache looks corrupt (truncated download) \u2014 purge and re-download, see plur_doctor." : "";
125
+ 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
+ }
127
+ }
128
+ return response;
129
+ };
130
+ 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.";
62
131
  function jsonSchemaPropToZod(prop) {
63
132
  if (!prop || typeof prop !== "object") return z.unknown();
64
133
  const variants = prop.anyOf ?? prop.oneOf;
@@ -83,7 +152,10 @@ function jsonSchemaPropToZod(prop) {
83
152
  return val;
84
153
  }
85
154
  }
86
- if (prop.items?.type === "string") {
155
+ const items = prop.items;
156
+ const itemVariants = items?.anyOf ?? items?.oneOf;
157
+ const itemsAcceptString = items?.type === "string" || Array.isArray(itemVariants) && itemVariants.some((v) => v?.type === "string");
158
+ if (itemsAcceptString) {
87
159
  return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
88
160
  }
89
161
  return val;
@@ -112,7 +184,10 @@ function validateToolArgs(tool, rawArgs) {
112
184
  const receivedFields = Object.keys(rawArgs);
113
185
  const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
114
186
  const hasArrayParam = Object.values(schema.properties ?? {}).some((p) => p?.type === "array");
115
- const arrayBugHint = receivedFields.length === 0 && hasArrayParam ? ' Known client-side bug (plur-ai/plur#297): some MCP clients drop the entire arguments payload when an array-typed parameter is included. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.' : "";
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.' : "";
116
191
  const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
117
192
  return {
118
193
  ok: false,
@@ -153,7 +228,7 @@ var PLUR_GUIDE = `## PLUR Quick Start
153
228
 
154
229
  ### Core Tools
155
230
  - **plur_learn** \u2014 record corrections, preferences, patterns (CALL THIS OFTEN)
156
- - **plur_recall_hybrid** \u2014 search engrams by topic
231
+ - **plur_recall** \u2014 search engrams by topic (default: hybrid BM25 + embeddings; use mode:"keyword" for BM25-only)
157
232
  - **plur_forget** \u2014 retire an outdated engram`;
158
233
  function getLlmFunction() {
159
234
  const openaiKey = process.env.OPENAI_API_KEY;
@@ -190,12 +265,22 @@ function _cleanExpiredSessions() {
190
265
  if (new Date(state.started_at).getTime() < cutoff) _sessionTelemetry.delete(id);
191
266
  }
192
267
  }
193
- var _activeSessionId;
268
+ function _implicitSessionId() {
269
+ _cleanExpiredSessions();
270
+ if (_sessionTelemetry.size !== 1) return void 0;
271
+ return _sessionTelemetry.keys().next().value;
272
+ }
273
+ function _resolveInjectionSession(args) {
274
+ const explicit = args.session_id;
275
+ if (typeof explicit === "string" && explicit.length > 0) return explicit;
276
+ return _implicitSessionId();
277
+ }
194
278
  function _recordInjectionTelemetry(session_id, injected_packs) {
195
- if (!session_id || !injected_packs) return;
279
+ if (!session_id) return;
196
280
  const state = _sessionTelemetry.get(session_id);
197
281
  if (!state) return;
198
282
  state.injection_calls++;
283
+ if (!injected_packs) return;
199
284
  for (const [pack, count] of Object.entries(injected_packs)) {
200
285
  state.pack_counts[pack] = (state.pack_counts[pack] ?? 0) + count;
201
286
  }
@@ -204,10 +289,11 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
204
289
  "plur_session_start",
205
290
  "plur_session_end",
206
291
  "plur_learn",
207
- "plur_recall_hybrid",
292
+ "plur_recall",
208
293
  "plur_feedback",
209
294
  "plur_forget",
210
295
  "plur_status",
296
+ "plur_receipt",
211
297
  "plur_doctor",
212
298
  "plur_packs_uninstall",
213
299
  "plur_tensions_purge"
@@ -240,6 +326,13 @@ function buildAdminDispatchTool(all) {
240
326
  if (!target) {
241
327
  return { error: `Unknown action "${action}". Valid actions: ${adminActions.join(", ")}`, success: false, _isError: true };
242
328
  }
329
+ if (target.annotations?.destructiveHint === true) {
330
+ return {
331
+ 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.`,
332
+ success: false,
333
+ _isError: true
334
+ };
335
+ }
243
336
  const innerArgs = args.args ?? {};
244
337
  const validated = validateToolArgs(target, innerArgs);
245
338
  if (!validated.ok) {
@@ -254,12 +347,20 @@ function buildAdminDispatchTool(all) {
254
347
  }
255
348
  };
256
349
  }
257
- function getToolDefinitions(profile = "full") {
350
+ function getToolDefinitions(profile = "lean") {
258
351
  const all = getAllToolDefinitions();
259
- if (profile !== "cursor") return all;
352
+ if (profile === "full") return all;
260
353
  const core = all.filter((t) => CURSOR_CORE_TOOL_NAMES.has(t.name));
261
354
  return [...core, buildAdminDispatchTool(all)];
262
355
  }
356
+ function receiptSummary(r) {
357
+ if (r.coverage.source === "none") {
358
+ 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.";
359
+ }
360
+ const since = r.window.windowed ? `the last ${r.window.requested_days} days` : `${r.coverage.complete_from}`;
361
+ const pct = Math.round(r.retrieved.activation_rate * 100);
362
+ 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.`;
363
+ }
263
364
  function getAllToolDefinitions() {
264
365
  return [
265
366
  {
@@ -319,6 +420,18 @@ function getAllToolDefinitions() {
319
420
  const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
320
421
  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.` };
321
422
  };
423
+ const domainHint = (wasRouted) => {
424
+ if (typeof args.domain === "string" && args.domain.length > 0) return {};
425
+ if (explicitScope || wasRouted) return {};
426
+ let coversScopes = [];
427
+ try {
428
+ coversScopes = plur.listScopeMetadata().filter((md) => (md.covers?.length ?? 0) > 0).map((md) => md.scope);
429
+ } catch {
430
+ return {};
431
+ }
432
+ if (coversScopes.length === 0) return {};
433
+ 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.` };
434
+ };
322
435
  const temporalEcho = (engram) => {
323
436
  const extracted = engram.structured_data?._expiry_extracted;
324
437
  return {
@@ -344,12 +457,13 @@ function getAllToolDefinitions() {
344
457
  decision: "ADD",
345
458
  ...temporalEcho(engram),
346
459
  ...scopeHint(engram.scope, !!routed),
460
+ ...domainHint(!!routed),
347
461
  ...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
348
462
  ...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.` } : {},
349
463
  ...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.` } : {}
350
464
  };
351
465
  } catch (err) {
352
- const engram = plur.learn(statement, context);
466
+ const engram = await plur.learn(statement, context);
353
467
  const isOutbox = !!engram.structured_data?._outbox;
354
468
  const routedFallback = engram.structured_data?._routed;
355
469
  mcpCanary.signal("learn_activity");
@@ -362,6 +476,7 @@ function getAllToolDefinitions() {
362
476
  decision: "ADD",
363
477
  ...temporalEcho(engram),
364
478
  ...scopeHint(engram.scope, !!routedFallback),
479
+ ...domainHint(!!routedFallback),
365
480
  ...isOutbox ? { outbox: true } : {},
366
481
  warning: `Remote write failed (${err.message}); engram queued for retry.`
367
482
  };
@@ -433,6 +548,17 @@ function getAllToolDefinitions() {
433
548
  for (const r of results) {
434
549
  if (r.input_index !== void 0) ids[r.input_index] = r.engram.id;
435
550
  }
551
+ let batchDomainHint = {};
552
+ const noDomainCount = raw.filter((e) => !(typeof e.domain === "string" && e.domain.length > 0) && !(typeof e.scope === "string" && e.scope.length > 0)).length;
553
+ if (noDomainCount > 0) {
554
+ try {
555
+ const coversScopes = plur.listScopeMetadata().filter((md) => (md.covers?.length ?? 0) > 0).map((md) => md.scope);
556
+ if (coversScopes.length > 0) {
557
+ 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.` };
558
+ }
559
+ } catch {
560
+ }
561
+ }
436
562
  return {
437
563
  ids,
438
564
  results: results.map((r) => ({
@@ -445,53 +571,35 @@ function getAllToolDefinitions() {
445
571
  ...r.existing_id ? { existing_id: r.existing_id } : {}
446
572
  })),
447
573
  stats,
574
+ ...batchDomainHint,
448
575
  ...failures.length > 0 ? { failures, warning: `${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.` } : {}
449
576
  };
450
577
  }
451
578
  },
452
579
  {
453
580
  name: "plur_recall",
454
- 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.",
455
- annotations: { title: "Recall (BM25)", readOnlyHint: true, idempotentHint: true },
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.',
582
+ annotations: { title: "Recall", readOnlyHint: true, idempotentHint: true },
456
583
  inputSchema: {
457
584
  type: "object",
458
585
  properties: {
459
586
  query: { type: "string", description: "Search query to find relevant engrams" },
587
+ mode: { type: "string", enum: ["hybrid", "keyword"], description: "Search mode \u2014 hybrid (default): BM25 + embeddings via RRF; keyword: BM25-only (faster, embeddings-independent). budget, caller_session_id and include_episodes apply to hybrid mode only \u2014 in keyword mode use limit to bound results." },
460
588
  scope: { type: "string", description: "Filter by scope (also includes global)" },
461
589
  domain: { type: "string", description: "Filter by domain prefix" },
462
590
  limit: { type: "number", description: "Max results to return (default 20)" },
463
- budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
464
- caller_session_id: { type: "string", description: "Caller session ID for budget enforcement" }
591
+ 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
+ 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".' }
465
594
  },
466
595
  required: ["query"]
467
596
  },
468
- handler: async (args, plur) => {
469
- const results = plur.recall(args.query, {
470
- scope: args.scope,
471
- domain: args.domain,
472
- limit: args.limit
473
- });
474
- return {
475
- results: results.map((e) => {
476
- const supersededBy = e.relations?.superseded_by;
477
- const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
478
- return {
479
- id: e.id,
480
- statement: e.statement + annotation,
481
- type: e.type,
482
- scope: e.scope,
483
- domain: e.domain,
484
- retrieval_strength: e.activation.retrieval_strength
485
- };
486
- }),
487
- count: results.length
488
- };
489
- }
597
+ handler: recallHandler
490
598
  },
491
599
  {
492
600
  name: "plur_recall_hybrid",
493
- description: "Hybrid search \u2014 BM25 + local embeddings merged via Reciprocal Rank Fusion. No API calls, fully local. Best default for most use cases.",
494
- annotations: { title: "Recall (hybrid)", readOnlyHint: true, idempotentHint: true },
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.",
602
+ annotations: { title: "Recall (hybrid) [deprecated alias]", readOnlyHint: true, idempotentHint: true },
495
603
  inputSchema: {
496
604
  type: "object",
497
605
  properties: {
@@ -505,72 +613,12 @@ function getAllToolDefinitions() {
505
613
  },
506
614
  required: ["query"]
507
615
  },
616
+ // True forwarder — delegates to the canonical plur_recall handler and
617
+ // prepends the deprecation notice. No duplicated budget/episode/
618
+ // reranker logic, so a fix to plur_recall reaches this alias too.
508
619
  handler: async (args, plur) => {
509
- const budget = args.budget;
510
- const effectiveLimit = budget?.max_results ?? args.limit ?? 20;
511
- const meta = await plur.recallHybridWithMeta(args.query, {
512
- scope: args.scope,
513
- domain: args.domain,
514
- limit: effectiveLimit
515
- });
516
- recordTelemetry("recall");
517
- const results = meta.engrams;
518
- let truncated = false;
519
- let boundedResults = results;
520
- if (budget?.max_results && results.length > budget.max_results) {
521
- boundedResults = results.slice(0, budget.max_results);
522
- truncated = true;
523
- }
524
- if (budget?.max_tokens) {
525
- let tokenCount = 0;
526
- const withinBudget = [];
527
- for (const e of boundedResults) {
528
- const tokens = Math.ceil(e.statement.length / 4) + 20;
529
- if (tokenCount + tokens > budget.max_tokens) {
530
- truncated = true;
531
- break;
532
- }
533
- withinBudget.push(e);
534
- tokenCount += tokens;
535
- }
536
- boundedResults = withinBudget;
537
- }
538
- const includeEpisodes = args.include_episodes === true;
539
- const response = {
540
- results: boundedResults.map((e) => {
541
- const raw = e;
542
- const supersededBy = e.relations?.superseded_by;
543
- const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
544
- const base = {
545
- id: e.id,
546
- statement: e.statement + annotation,
547
- type: e.type,
548
- scope: e.scope,
549
- domain: e.domain,
550
- retrieval_strength: e.activation.retrieval_strength
551
- };
552
- if (includeEpisodes && raw.episode_ids?.length > 0) {
553
- const episodes = plur.timeline({ search: "" });
554
- base.episodes = episodes.filter((ep) => raw.episode_ids.includes(ep.id)).map((ep) => ({ id: ep.id, summary: ep.summary, timestamp: ep.timestamp }));
555
- }
556
- return base;
557
- }),
558
- count: boundedResults.length,
559
- truncated,
560
- mode: meta.mode
561
- };
562
- if (meta.mode === "hybrid-degraded") {
563
- response.warning = `Embedding layer unavailable \u2014 results are BM25-only. Run plur_doctor for diagnosis. Last error: ${meta.embedderError ?? "unknown"}`;
564
- }
565
- if (resolveRerankerName() !== "off") {
566
- response.reranked = meta.reranked ?? 0;
567
- const rr = plur.rerankerStatus();
568
- if (boundedResults.length > 0 && (meta.reranked ?? 0) === 0 && rr.lastError) {
569
- const corruptNote = rr.lastErrorKind === "corrupt-cache" ? " The model cache looks corrupt (truncated download) \u2014 purge and re-download, see plur_doctor." : "";
570
- 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.`;
571
- }
572
- }
573
- return response;
620
+ const result = await recallHandler({ ...args, mode: "hybrid" }, plur);
621
+ return { deprecated: RECALL_HYBRID_DEPRECATION, ...result };
574
622
  }
575
623
  },
576
624
  {
@@ -582,16 +630,20 @@ function getAllToolDefinitions() {
582
630
  properties: {
583
631
  task: { type: "string", description: "The task description to inject context for" },
584
632
  budget: { type: "number", description: "Token budget for injection (default 2000)" },
585
- scope: { type: "string", description: "Scope filter for engram selection" }
633
+ scope: { type: "string", description: "Scope filter for engram selection" },
634
+ session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open; required for correct attribution when several are." }
586
635
  },
587
636
  required: ["task"]
588
637
  },
589
638
  handler: async (args, plur) => {
590
- const result = plur.inject(args.task, {
639
+ const session_id = _resolveInjectionSession(args);
640
+ const result = await plur.inject(args.task, {
591
641
  budget: args.budget,
592
- scope: args.scope
642
+ scope: args.scope,
643
+ source: "inject",
644
+ session_id
593
645
  });
594
- _recordInjectionTelemetry(_activeSessionId, result.injected_packs);
646
+ _recordInjectionTelemetry(session_id, result.injected_packs);
595
647
  return {
596
648
  directives: result.directives,
597
649
  consider: result.consider,
@@ -612,16 +664,20 @@ function getAllToolDefinitions() {
612
664
  properties: {
613
665
  task: { type: "string", description: "The task description to inject context for" },
614
666
  budget: { type: "number", description: "Token budget for injection (default 2000)" },
615
- scope: { type: "string", description: "Scope filter for engram selection" }
667
+ scope: { type: "string", description: "Scope filter for engram selection" },
668
+ session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open; required for correct attribution when several are." }
616
669
  },
617
670
  required: ["task"]
618
671
  },
619
672
  handler: async (args, plur) => {
673
+ const session_id = _resolveInjectionSession(args);
620
674
  const result = await plur.injectHybrid(args.task, {
621
675
  budget: args.budget,
622
- scope: args.scope
676
+ scope: args.scope,
677
+ source: "inject",
678
+ session_id
623
679
  });
624
- _recordInjectionTelemetry(_activeSessionId, result.injected_packs);
680
+ _recordInjectionTelemetry(session_id, result.injected_packs);
625
681
  return {
626
682
  directives: result.directives,
627
683
  consider: result.consider,
@@ -701,7 +757,7 @@ function getAllToolDefinitions() {
701
757
  },
702
758
  handler: async (args, plur) => {
703
759
  if (args.list === true) {
704
- const pinned = plur.listPinned();
760
+ const pinned = await plur.listPinned();
705
761
  return {
706
762
  count: pinned.length,
707
763
  pinned: pinned.map((e) => ({ id: e.id, statement: e.statement, scope: e.scope, domain: e.domain }))
@@ -731,7 +787,7 @@ function getAllToolDefinitions() {
731
787
  },
732
788
  handler: async (args, plur) => {
733
789
  if (args.id) {
734
- const engram = plur.getById(args.id);
790
+ const engram = await plur.getById(args.id);
735
791
  if (engram) {
736
792
  if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
737
793
  await plur.forget(args.id);
@@ -741,7 +797,7 @@ function getAllToolDefinitions() {
741
797
  return { success: true, retired: { id: args.id } };
742
798
  }
743
799
  if (args.search) {
744
- const matches = plur.recall(args.search, { limit: 100 });
800
+ const matches = await plur.recall(args.search, { limit: 100 });
745
801
  if (matches.length === 0) return { success: false, error: `No active engrams matching "${args.search}"` };
746
802
  if (matches.length === 1) {
747
803
  await plur.forget(matches[0].id);
@@ -840,7 +896,7 @@ function getAllToolDefinitions() {
840
896
  required: ["content"]
841
897
  },
842
898
  handler: async (args, plur) => {
843
- const candidates = plur.ingest(args.content, {
899
+ const candidates = await plur.ingest(args.content, {
844
900
  source: args.source,
845
901
  extract_only: args.extract_only,
846
902
  scope: args.scope,
@@ -859,32 +915,32 @@ function getAllToolDefinitions() {
859
915
  },
860
916
  {
861
917
  name: "plur_packs_preview",
862
- description: "Preview a pack before installing \u2014 shows manifest, engram list, security scan, and warnings. Always call this before plur_packs_install to let the user review what they are importing.",
918
+ description: "Preview a pack before installing \u2014 shows manifest, engram list, security scan, and warnings. Always call this before plur_packs_install to let the user review what they are importing. Accepts a local directory path or an https:// URL pointing to a .tar.gz archive.",
863
919
  annotations: { title: "Preview pack", readOnlyHint: true, idempotentHint: true },
864
920
  inputSchema: {
865
921
  type: "object",
866
922
  properties: {
867
- source: { type: "string", description: "Path to the pack directory to preview" }
923
+ source: { type: "string", description: "Path to the pack directory, or an https:// URL to a .tar.gz pack archive" }
868
924
  },
869
925
  required: ["source"]
870
926
  },
871
927
  handler: async (args, plur) => {
872
- return plur.previewPack(args.source);
928
+ return await plur.previewPack(args.source);
873
929
  }
874
930
  },
875
931
  {
876
932
  name: "plur_packs_install",
877
- description: "Install an engram pack from a directory path. Runs a mandatory security scan (blocks if secrets found), detects conflicts with existing engrams, and records install metadata in the registry. Call plur_packs_preview first to show the user what the pack contains.",
933
+ description: "Install an engram pack from a local directory path or an https:// URL pointing to a .tar.gz archive. Runs a mandatory security scan (blocks if secrets found), detects conflicts with existing engrams, and records install metadata in the registry. Call plur_packs_preview first to show the user what the pack contains.",
878
934
  annotations: { title: "Install pack", destructiveHint: false, idempotentHint: true },
879
935
  inputSchema: {
880
936
  type: "object",
881
937
  properties: {
882
- source: { type: "string", description: "Path to the pack directory to install" }
938
+ source: { type: "string", description: "Path to the pack directory, or an https:// URL to a .tar.gz pack archive" }
883
939
  },
884
940
  required: ["source"]
885
941
  },
886
942
  handler: async (args, plur) => {
887
- const result = plur.installPack(args.source);
943
+ const result = await plur.installPack(args.source);
888
944
  return {
889
945
  installed: result.installed,
890
946
  name: result.name,
@@ -970,17 +1026,27 @@ function getAllToolDefinitions() {
970
1026
  full: {
971
1027
  type: "boolean",
972
1028
  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."
1029
+ },
1030
+ remote_type: {
1031
+ type: "string",
1032
+ 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."
973
1034
  }
974
1035
  }
975
1036
  },
976
1037
  handler: async (args, plur) => {
977
- const result = plur.sync(args.remote, { full: args.full === true });
1038
+ const result = await plur.sync(args.remote, {
1039
+ full: args.full === true,
1040
+ ...args.remote_type === "personal" || args.remote_type === "shared" ? { remoteType: args.remote_type } : {}
1041
+ });
978
1042
  await plur.waitForIndex();
979
1043
  const indexError = plur.lastIndexError();
980
1044
  let outbox_result;
1045
+ let outbox_error;
981
1046
  try {
982
1047
  outbox_result = await plur.flushOutbox();
983
- } catch {
1048
+ } catch (err) {
1049
+ outbox_error = err.message;
984
1050
  }
985
1051
  return {
986
1052
  ...result,
@@ -994,6 +1060,10 @@ function getAllToolDefinitions() {
994
1060
  pending: outbox_result.failed,
995
1061
  warnings: outbox_result.expired_warnings
996
1062
  }
1063
+ } : {},
1064
+ ...outbox_error ? {
1065
+ outbox_error,
1066
+ outbox_warning: `The outbox flush failed \u2014 ${outbox_error}. Engrams routed to a remote store are still queued locally and were NOT pushed. They retry on the next session_start or plur_sync.`
997
1067
  } : {}
998
1068
  };
999
1069
  }
@@ -1033,11 +1103,11 @@ function getAllToolDefinitions() {
1033
1103
  args.llm_api_key,
1034
1104
  args.llm_model
1035
1105
  );
1036
- const sourceEngrams = plur.list({
1106
+ const sourceEngrams = await plur.list({
1037
1107
  domain: args.domain,
1038
1108
  scope: args.scope
1039
1109
  });
1040
- const existingMetas = plur.list().filter((e) => e.id.startsWith("META-"));
1110
+ const existingMetas = (await plur.list()).filter((e) => e.id.startsWith("META-"));
1041
1111
  const result = await extractMetaEngrams(sourceEngrams, llm, {
1042
1112
  run_validation: args.run_validation,
1043
1113
  existing_metas: existingMetas
@@ -1045,7 +1115,7 @@ function getAllToolDefinitions() {
1045
1115
  const isDryRun = args.dry_run === true;
1046
1116
  let saveStats = null;
1047
1117
  if (!isDryRun && result.results.length > 0) {
1048
- saveStats = plur.saveMetaEngrams(result.results);
1118
+ saveStats = await plur.saveMetaEngrams(result.results);
1049
1119
  }
1050
1120
  return {
1051
1121
  engrams_analyzed: result.engrams_analyzed,
@@ -1081,7 +1151,7 @@ function getAllToolDefinitions() {
1081
1151
  }
1082
1152
  },
1083
1153
  handler: async (args, plur) => {
1084
- const allEngrams = plur.list();
1154
+ const allEngrams = await plur.list();
1085
1155
  const metaEngrams = allEngrams.filter((e) => e.id.startsWith("META-"));
1086
1156
  const minConfidence = args.min_confidence ?? 0;
1087
1157
  const levelFilter = args.hierarchy_level;
@@ -1132,20 +1202,20 @@ function getAllToolDefinitions() {
1132
1202
  required: ["meta_engram_id", "test_domain", "llm_base_url", "llm_api_key"]
1133
1203
  },
1134
1204
  handler: async (args, plur) => {
1135
- const allEngrams = plur.list();
1205
+ const allEngrams = await plur.list();
1136
1206
  const meta = allEngrams.find((e) => e.id === args.meta_engram_id);
1137
1207
  if (!meta) {
1138
1208
  throw new Error(`Meta-engram not found: ${args.meta_engram_id}`);
1139
1209
  }
1140
1210
  const testDomain = args.test_domain;
1141
- const testEngrams = plur.list({ domain: testDomain });
1211
+ const testEngrams = await plur.list({ domain: testDomain });
1142
1212
  const llm = makeHttpLlm(
1143
1213
  args.llm_base_url,
1144
1214
  args.llm_api_key,
1145
1215
  args.llm_model
1146
1216
  );
1147
1217
  const result = await validateMetaEngram(meta, testEngrams, testDomain, llm);
1148
- plur.updateEngram(meta);
1218
+ await plur.updateEngram(meta);
1149
1219
  return {
1150
1220
  meta_engram_id: result.meta_engram_id,
1151
1221
  test_domain: result.test_domain,
@@ -1170,7 +1240,7 @@ function getAllToolDefinitions() {
1170
1240
  }
1171
1241
  },
1172
1242
  handler: async (args, plur) => {
1173
- const status = plur.status({
1243
+ const status = await plur.status({
1174
1244
  domain: args.domain,
1175
1245
  created_after: args.created_after
1176
1246
  });
@@ -1202,10 +1272,26 @@ function getAllToolDefinitions() {
1202
1272
  behind: minorVersionsBehind(versionCheck.current, versionCheck.latest)
1203
1273
  }
1204
1274
  } : {},
1205
- capabilities: mcpCanary.status()
1275
+ capabilities: await mcpCanary.status()
1206
1276
  };
1207
1277
  }
1208
1278
  },
1279
+ {
1280
+ name: "plur_receipt",
1281
+ 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.',
1282
+ annotations: { title: "Memory receipt", readOnlyHint: true, idempotentHint: true },
1283
+ inputSchema: {
1284
+ type: "object",
1285
+ properties: {
1286
+ days: { type: "number", description: "Restrict to the last N days (integer). Omit for all recorded history." }
1287
+ }
1288
+ },
1289
+ handler: async (args, plur) => {
1290
+ const days = typeof args.days === "number" && Number.isFinite(args.days) && args.days >= 1 ? Math.floor(args.days) : void 0;
1291
+ const receipt = await plur.receipt(days ? { days } : void 0);
1292
+ return { summary: receiptSummary(receipt), ...receipt };
1293
+ }
1294
+ },
1209
1295
  {
1210
1296
  name: "plur_doctor",
1211
1297
  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).',
@@ -1222,7 +1308,7 @@ function getAllToolDefinitions() {
1222
1308
  plur.resetEmbedder();
1223
1309
  plur.resetReranker();
1224
1310
  }
1225
- const status = plur.status();
1311
+ const status = await plur.status();
1226
1312
  const before = plur.embedderStatus();
1227
1313
  if (!before.disabled) {
1228
1314
  try {
@@ -1325,7 +1411,7 @@ function getAllToolDefinitions() {
1325
1411
  evalStatus = { result: run.result, stale: false };
1326
1412
  freshlyRun = !run.cached;
1327
1413
  } else {
1328
- evalStatus = plur.rerankerEvalStatus(rerankerName);
1414
+ evalStatus = await plur.rerankerEvalStatus(rerankerName);
1329
1415
  }
1330
1416
  if (!evalStatus) {
1331
1417
  checks.push({
@@ -1358,7 +1444,7 @@ function getAllToolDefinitions() {
1358
1444
  });
1359
1445
  }
1360
1446
  }
1361
- const canaryStatuses = mcpCanary.status();
1447
+ const canaryStatuses = await mcpCanary.status();
1362
1448
  for (const cs of canaryStatuses) {
1363
1449
  if (!cs.healthy) {
1364
1450
  checks.push({ check: `capability: ${cs.capability}`, ok: false, detail: cs.warning });
@@ -1420,16 +1506,17 @@ function getAllToolDefinitions() {
1420
1506
  const task = args.task;
1421
1507
  const tags = args.tags;
1422
1508
  _cleanExpiredSessions();
1423
- _activeSessionId = session_id;
1424
1509
  _sessionTelemetry.set(session_id, {
1425
1510
  pack_counts: {},
1426
1511
  injection_calls: 0,
1427
1512
  started_at: (/* @__PURE__ */ new Date()).toISOString()
1428
1513
  });
1429
1514
  let outbox_result;
1515
+ let outbox_error;
1430
1516
  try {
1431
1517
  outbox_result = await plur.flushOutbox();
1432
- } catch {
1518
+ } catch (err) {
1519
+ outbox_error = err.message;
1433
1520
  }
1434
1521
  const remote_scopes = plur.getWritableRemoteScopes().map((s) => {
1435
1522
  const md = plur.getScopeMetadata(s.scope);
@@ -1444,7 +1531,7 @@ function getAllToolDefinitions() {
1444
1531
  const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
1445
1532
  const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
1446
1533
  plur.setSessionScope(default_scope);
1447
- const status = plur.status();
1534
+ const status = await plur.status();
1448
1535
  const store_stats = {
1449
1536
  engram_count: status.engram_count,
1450
1537
  episode_count: status.episode_count,
@@ -1456,8 +1543,9 @@ function getAllToolDefinitions() {
1456
1543
  try {
1457
1544
  const result = await plur.injectHybrid(task, {
1458
1545
  scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1459
- session_id
1546
+ session_id,
1460
1547
  // stamped on the co_injection provenance event (#452)
1548
+ source: "session_start"
1461
1549
  });
1462
1550
  _recordInjectionTelemetry(session_id, result.injected_packs);
1463
1551
  if (result.count > 0) {
@@ -1468,9 +1556,10 @@ function getAllToolDefinitions() {
1468
1556
  engrams = { text: lines.join("\n"), count: result.count, injected_ids: result.injected_ids };
1469
1557
  }
1470
1558
  } catch {
1471
- const result = plur.inject(task, {
1559
+ const result = await plur.inject(task, {
1472
1560
  scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
1473
- session_id
1561
+ session_id,
1562
+ source: "session_start"
1474
1563
  });
1475
1564
  _recordInjectionTelemetry(session_id, result.injected_packs);
1476
1565
  if (result.count > 0) {
@@ -1512,7 +1601,7 @@ Auto-detected project scope: "${default_scope}" (from .plur.yaml in the current
1512
1601
  } else if (scope_source === "none") {
1513
1602
  guide += `
1514
1603
 
1515
- \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.`;
1604
+ \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.`;
1516
1605
  }
1517
1606
  if (remote_scopes.length > 0) {
1518
1607
  const safe = (x) => String(x ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
@@ -1527,9 +1616,10 @@ Auto-detected project scope: "${default_scope}" (from .plur.yaml in the current
1527
1616
 
1528
1617
  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}).` : `
1529
1618
 
1530
- 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.`;
1619
+ 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.`;
1531
1620
  try {
1532
1621
  const discoveries = await plur.discoverRemoteScopes({ timeoutMs: 3e3 });
1622
+ plur.persistScopeMetadata(discoveries);
1533
1623
  const failures = discoveries.filter((d) => !d.ok);
1534
1624
  if (failures.length > 0) {
1535
1625
  const authExpired = failures.some((f) => /\b40[13]\b/.test(f.error ?? ""));
@@ -1541,12 +1631,11 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
1541
1631
 
1542
1632
  \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.`;
1543
1633
  }
1544
- const unregistered = [...new Set(discoveries.filter((d) => d.ok).flatMap((d) => d.unregistered))];
1545
- if (unregistered.length > 0) {
1546
- const list = unregistered.map((s) => `"${safe(s)}"`).join(", ");
1634
+ const offerable = [...new Set(discoveries.filter((d) => d.ok).flatMap((d) => d.unregistered))].filter(isSharedScope);
1635
+ if (offerable.length > 0) {
1547
1636
  guide += `
1548
1637
 
1549
- \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.`;
1638
+ \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).`;
1550
1639
  }
1551
1640
  } catch {
1552
1641
  }
@@ -1585,6 +1674,10 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
1585
1674
  warnings: outbox_result.expired_warnings
1586
1675
  }
1587
1676
  } : {},
1677
+ ...outbox_error ? {
1678
+ outbox_error,
1679
+ outbox_warning: `The outbox flush failed \u2014 ${outbox_error}. Engrams routed to a remote store are still queued locally and were NOT pushed. They retry on the next session_start or plur_sync.`
1680
+ } : {},
1588
1681
  // Version staleness warning (issue #151)
1589
1682
  ...version_warning ? { version_warning, version: VERSION } : {}
1590
1683
  };
@@ -1650,7 +1743,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1650
1743
  `engram_suggestions[${i}] must be a string or {statement: string, type?: string}, got ${typeof s}`
1651
1744
  );
1652
1745
  }
1653
- plur.learn(statement, { type });
1746
+ await plur.learn(statement, { type });
1654
1747
  engrams_created++;
1655
1748
  }
1656
1749
  }
@@ -1666,7 +1759,6 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1666
1759
  } : void 0;
1667
1760
  if (session_id) {
1668
1761
  _sessionTelemetry.delete(session_id);
1669
- if (_activeSessionId === session_id) _activeSessionId = void 0;
1670
1762
  }
1671
1763
  try {
1672
1764
  const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
@@ -1681,7 +1773,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1681
1773
  }
1682
1774
  } catch {
1683
1775
  }
1684
- const status = plur.status();
1776
+ const status = await plur.status();
1685
1777
  return {
1686
1778
  engrams_created,
1687
1779
  episode_id: episode.id,
@@ -1743,7 +1835,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1743
1835
  inputSchema: { type: "object", properties: {} },
1744
1836
  handler: async (_args, plur) => {
1745
1837
  const stores = await plur.listStoresAsync();
1746
- const outboxCount = plur.outboxCount();
1838
+ const outboxCount = await plur.outboxCount();
1747
1839
  return {
1748
1840
  stores,
1749
1841
  count: stores.length,
@@ -1753,29 +1845,33 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1753
1845
  },
1754
1846
  {
1755
1847
  name: "plur_suggest_scope",
1756
- 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).',
1848
+ 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.',
1757
1849
  annotations: { title: "Suggest scope", readOnlyHint: true, idempotentHint: true },
1758
1850
  inputSchema: {
1759
1851
  type: "object",
1760
1852
  properties: {
1761
1853
  statement: { type: "string", description: "The engram statement to route" },
1762
1854
  domain: { type: "string", description: 'Optional dotted namespace for the engram (e.g. "plur.core.security") \u2014 strongest routing signal' },
1763
- tags: { type: "array", items: { type: "string" }, description: "Optional tags on the engram" }
1855
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags on the engram" },
1856
+ 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." }
1764
1857
  },
1765
1858
  required: ["statement"]
1766
1859
  },
1767
1860
  handler: async (args, plur) => {
1768
- const candidates = plur.suggestScope({
1861
+ const raw = args.min_confidence;
1862
+ const explicit = typeof raw === "number" && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : void 0;
1863
+ const minConfidence = explicit ?? plur.getScopeRoutingConfig().min_confidence ?? SUGGEST_DISPLAY_MIN_CONFIDENCE;
1864
+ const candidates = await plur.suggestScope({
1769
1865
  statement: args.statement,
1770
1866
  domain: args.domain,
1771
1867
  tags: args.tags
1772
- });
1773
- return { candidates, count: candidates.length };
1868
+ }, { minConfidence });
1869
+ return { candidates, count: candidates.length, min_confidence: minConfidence };
1774
1870
  }
1775
1871
  },
1776
1872
  {
1777
1873
  name: "plur_scopes_discover",
1778
- 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.",
1874
+ 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.",
1779
1875
  annotations: { title: "Discover scopes", readOnlyHint: false, idempotentHint: true },
1780
1876
  inputSchema: {
1781
1877
  type: "object",
@@ -1831,7 +1927,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1831
1927
  const promoted = [];
1832
1928
  const errors = [];
1833
1929
  for (const id of targetIds) {
1834
- const engram = plur.getById(id);
1930
+ const engram = await plur.getById(id);
1835
1931
  if (!engram) {
1836
1932
  errors.push({ id, error: "Not found" });
1837
1933
  continue;
@@ -1848,7 +1944,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1848
1944
  engram.activation.retrieval_strength = 0.7;
1849
1945
  engram.activation.storage_strength = 1;
1850
1946
  engram.activation.last_accessed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1851
- plur.updateEngram(engram);
1947
+ await plur.updateEngram(engram);
1852
1948
  promoted.push({ id, statement: engram.statement });
1853
1949
  }
1854
1950
  return { promoted, errors, success: errors.length === 0 };
@@ -1893,12 +1989,12 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1893
1989
  if (args.action === "resolve") {
1894
1990
  const winner = args.winner;
1895
1991
  if (!winner) throw new Error('action:"resolve" requires winner (the engram id to keep)');
1896
- const { record, retired_id } = plur.resolveTension(id, winner);
1992
+ const { record, retired_id } = await plur.resolveTension(id, winner);
1897
1993
  return { record, retired: retired_id, message: `Tension ${id} resolved: ${winner} wins, ${retired_id} retired.` };
1898
1994
  }
1899
1995
  throw new Error(`Unknown action: ${args.action}. Use confirm, dismiss, or resolve.`);
1900
1996
  }
1901
- const engrams = plur.list({
1997
+ const engrams = await plur.list({
1902
1998
  scope: args.scope,
1903
1999
  domain: args.domain
1904
2000
  });
@@ -1922,7 +2018,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1922
2018
  temporal_discount: args.temporal_discount ?? tensionsConfig.temporal_discount,
1923
2019
  ...persist ? { exclude_pairs: new Set(plur.suppressedTensionPairKeys()) } : {}
1924
2020
  });
1925
- const persisted = persist && result.tensions.length > 0 ? plur.recordTensions(result.tensions) : void 0;
2021
+ const persisted = persist && result.tensions.length > 0 ? await plur.recordTensions(result.tensions) : void 0;
1926
2022
  return {
1927
2023
  pairs_checked: result.pairs_checked,
1928
2024
  count: result.new_tensions,
@@ -1975,7 +2071,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1975
2071
  annotations: { title: "Purge Tensions", destructiveHint: true, idempotentHint: true },
1976
2072
  inputSchema: { type: "object", properties: {} },
1977
2073
  handler: async (_args, plur) => {
1978
- const result = plur.purgeTensions();
2074
+ const result = await plur.purgeTensions();
1979
2075
  return {
1980
2076
  purged_conflict_refs: result.purged_count,
1981
2077
  engrams_modified: result.engrams_modified,
@@ -1998,7 +2094,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
1998
2094
  required: ["episode_id"]
1999
2095
  },
2000
2096
  handler: async (args, plur) => {
2001
- const engram = plur.episodeToEngram(args.episode_id, {
2097
+ const engram = await plur.episodeToEngram(args.episode_id, {
2002
2098
  scope: args.scope,
2003
2099
  domain: args.domain,
2004
2100
  tags: args.tags
@@ -2035,7 +2131,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2035
2131
  };
2036
2132
  }
2037
2133
  const { listHistoryMonths, readHistory } = await import("@plur-ai/core");
2038
- const status = plur.status();
2134
+ const status = await plur.status();
2039
2135
  const months = listHistoryMonths(status.storage_root);
2040
2136
  const allEvents = [];
2041
2137
  for (const month of months.reverse()) {
@@ -2108,7 +2204,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2108
2204
  },
2109
2205
  handler: async (args, plur) => {
2110
2206
  const name = args.name;
2111
- let engrams = plur.list({
2207
+ let engrams = await plur.list({
2112
2208
  domain: args.filter_domain,
2113
2209
  scope: args.filter_scope
2114
2210
  });
@@ -2122,9 +2218,9 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2122
2218
  if (filterType) {
2123
2219
  engrams = engrams.filter((e) => e.type === filterType);
2124
2220
  }
2125
- const { homedir: homedir3 } = await import("os");
2126
- const { join: join3 } = await import("path");
2127
- const outputDir = args.output_dir || join3(homedir3(), "plur-packs", name);
2221
+ const { homedir: homedir2 } = await import("os");
2222
+ const { join: join2 } = await import("path");
2223
+ const outputDir = args.output_dir || join2(homedir2(), "plur-packs", name);
2128
2224
  const result = plur.exportPack(engrams, outputDir, {
2129
2225
  name,
2130
2226
  version: "1.0.0",
@@ -2189,7 +2285,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2189
2285
  }
2190
2286
  },
2191
2287
  handler: async (args, plur) => {
2192
- const status = plur.status();
2288
+ const status = await plur.status();
2193
2289
  const storagePath = status.storage_root;
2194
2290
  if (!args.force_regenerate) {
2195
2291
  const cached = getProfileForInjection(storagePath);
@@ -2202,7 +2298,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2202
2298
  }
2203
2299
  const model = args.llm_model ?? selectModelForOperation("profile", status.config?.llm);
2204
2300
  const llm = makeHttpLlm(args.llm_base_url, args.llm_api_key, model);
2205
- const engrams = plur.list({ scope: args.scope });
2301
+ const engrams = await plur.list({ scope: args.scope });
2206
2302
  const profile = await generateProfile(engrams, llm, storagePath, status.config?.profile?.cache_ttl_hours ?? 24);
2207
2303
  return { profile, source: "generated", engram_count: engrams.length, model };
2208
2304
  }
@@ -2210,351 +2306,11 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2210
2306
  ];
2211
2307
  }
2212
2308
 
2213
- // src/server.ts
2214
- function serverPidPath(baseDir) {
2215
- return join2(baseDir ?? join2(homedir2(), ".plur"), "server.pid");
2216
- }
2217
- function readEnterpriseToken(baseDir) {
2218
- const configPath = join2(baseDir ?? join2(homedir2(), ".plur"), "config.json");
2219
- if (!existsSync2(configPath)) return void 0;
2220
- try {
2221
- const cfg = JSON.parse(readFileSync(configPath, "utf8"));
2222
- const ent = cfg?.enterprise;
2223
- if (!ent || typeof ent.url !== "string" || typeof ent.token !== "string") return void 0;
2224
- return { url: ent.url, token: ent.token, username: ent.username };
2225
- } catch {
2226
- return void 0;
2227
- }
2228
- }
2229
- var _pendingReload = false;
2230
- function isPendingReload() {
2231
- return _pendingReload;
2232
- }
2233
- function clearPendingReload() {
2234
- _pendingReload = false;
2235
- }
2236
- var INSTRUCTIONS = `PLUR is your persistent memory. Corrections, preferences, and conventions persist across sessions as engrams.
2237
-
2238
- 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.
2239
-
2240
- SESSION LIFECYCLE:
2241
- - 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.
2242
- - Without hooks: call plur_session_start at the start, plur_session_end at the end.
2243
-
2244
- DURING the session:
2245
- - When user corrects you ("no, use X not Y") \u2192 call plur_learn immediately
2246
- - When user states a preference ("always X", "never Y") \u2192 call plur_learn immediately
2247
- - When you discover a codebase convention or pattern \u2192 call plur_learn
2248
-
2249
- SCOPE SELECTION (set scope PER engram, by content \u2014 not once per session):
2250
- A single session produces engrams that belong in different stores. Choose the
2251
- "scope" on EACH plur_learn call from what the engram is about:
2252
- - Team / engineering / architecture / shared-convention knowledge \u2192 the matching
2253
- team store scope (e.g. "group:<org>/<team>"). plur_session_start lists the
2254
- scopes this install can write to \u2014 use them.
2255
- - Personal preferences, your workflow, or details specific to one project \u2192 the
2256
- default/local scope (or "project:<name>").
2257
- - "global" is for genuinely cross-project facts only (language gotchas, tool
2258
- quirks). Do NOT let team-relevant knowledge fall back to "global" by omitting
2259
- scope \u2014 with a team store configured it never reaches the team store. If no
2260
- scope fits, prefer the project/local default over global.
2261
-
2262
- OPTIONAL but improves quality:
2263
- - Call plur_feedback to rate which injected engrams helped (positive/negative)
2264
- - Call plur_recall_hybrid before answering factual questions \u2014 the answer may be in memory
2265
-
2266
- Do not ask permission to use these tools \u2014 they are your memory system.
2267
-
2268
- Setup: If this is a fresh install, suggest the user run: npx @plur-ai/mcp init
2269
- This installs hooks for automatic injection + session management. One-time global setup.`;
2270
- var GUIDE_RESOURCE = `# PLUR \u2014 Agent Guide
2271
-
2272
- ## What is PLUR?
2273
-
2274
- 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.
2275
-
2276
- ## Quick Start
2277
-
2278
- 1. \`plur_session_start\` \u2014 start a session, inject relevant context
2279
- 2. \`plur_learn\` \u2014 store a new learning
2280
- 3. \`plur_feedback\` \u2014 rate injected engrams
2281
- 4. \`plur_session_end\` \u2014 capture summary and new learnings
2282
-
2283
- ## When to Call Each Tool
2284
-
2285
- | Trigger | Tool |
2286
- |---------|------|
2287
- | Session starts | \`plur_session_start\` with task description |
2288
- | User corrects you | \`plur_learn\` with the correction |
2289
- | User states preference ("always X", "never Y") | \`plur_learn\` with scope and type |
2290
- | You used a recalled engram successfully | \`plur_feedback\` with "positive" |
2291
- | A recalled engram was wrong or irrelevant | \`plur_feedback\` with "negative" |
2292
- | User says "forget X" or a memory is outdated | \`plur_forget\` |
2293
- | You need to check what's stored | \`plur_status\` or \`plur_packs_list\` |
2294
- | End of session | \`plur_session_end\` with summary and suggestions |
2295
-
2296
- ## Tool Categories
2297
-
2298
- ### Session Management
2299
- - **plur_session_start** \u2014 start a session, inject relevant context
2300
- - **plur_session_end** \u2014 end a session, capture summary and new learnings
2301
-
2302
- ### Core Memory
2303
- - **plur_learn** \u2014 store a correction, preference, or convention
2304
- - **plur_recall** \u2014 BM25 keyword search
2305
- - **plur_recall_hybrid** \u2014 BM25 + embeddings (recommended default)
2306
- - **plur_feedback** \u2014 rate an engram (trains relevance)
2307
- - **plur_forget** \u2014 retire an outdated engram
2308
- - **plur_promote** \u2014 activate a candidate engram
2309
-
2310
- ### Context Injection
2311
- - **plur_inject** \u2014 select engrams for a task (BM25)
2312
- - **plur_inject_hybrid** \u2014 select engrams for a task (BM25 + embeddings, recommended)
2313
-
2314
- ### Episodic Timeline
2315
- - **plur_capture** \u2014 record what happened in a session
2316
- - **plur_timeline** \u2014 query past episodes
2317
-
2318
- ### Knowledge Management
2319
- - **plur_ingest** \u2014 extract engrams from text content
2320
- - **plur_packs_install** \u2014 install curated engram packs
2321
- - **plur_packs_list** \u2014 list installed packs
2322
- - **plur_packs_export** \u2014 export engrams as a shareable pack
2323
-
2324
- ### Multi-Store
2325
- - **plur_stores_add** \u2014 register an additional engram store
2326
- - **plur_stores_list** \u2014 list all configured stores
2327
-
2328
- **Note:** Multi-store is currently config-only. Recall and inject search the primary store. Cross-store search coming in a future release.
2329
-
2330
- ### Sync & Status
2331
- - **plur_sync** \u2014 sync engrams across devices via git
2332
- - **plur_sync_status** \u2014 check sync state
2333
- - **plur_status** \u2014 system health
2334
-
2335
- ## Scoping
2336
-
2337
- Use \`scope\` to namespace engrams per project:
2338
- - \`scope: "global"\` \u2014 applies everywhere (default)
2339
- - \`scope: "project:my-app"\` \u2014 applies only to my-app
2340
- - Scoped recall automatically includes global engrams
2341
-
2342
- ## Storage
2343
-
2344
- \`\`\`
2345
- ~/.plur/
2346
- \u251C\u2500\u2500 engrams.yaml # learned knowledge
2347
- \u251C\u2500\u2500 episodes.yaml # session timeline
2348
- \u2514\u2500\u2500 config.yaml # settings
2349
- \`\`\`
2350
-
2351
- Override with \`PLUR_PATH\` environment variable.
2352
- `;
2353
- async function createServer(plur, options) {
2354
- const instance = plur ?? new Plur2();
2355
- const tools = getToolDefinitions(options?.profile ?? "full");
2356
- checkForUpdate("@plur-ai/mcp", VERSION, (r) => {
2357
- if (r.updateAvailable) {
2358
- console.error(`[plur] Update available: ${r.current} \u2192 ${r.latest}. Run: npx @plur-ai/mcp@latest`);
2359
- }
2360
- });
2361
- const server = new Server(
2362
- { name: "plur-mcp", version: VERSION },
2363
- {
2364
- capabilities: {
2365
- tools: {},
2366
- resources: {},
2367
- prompts: {},
2368
- logging: {}
2369
- },
2370
- instructions: INSTRUCTIONS
2371
- }
2372
- );
2373
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
2374
- tools: tools.map((t) => ({
2375
- name: t.name,
2376
- description: t.description,
2377
- inputSchema: t.inputSchema,
2378
- ...t.annotations && { annotations: t.annotations }
2379
- }))
2380
- }));
2381
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2382
- const tool = tools.find((t) => t.name === request.params.name);
2383
- if (!tool) {
2384
- return {
2385
- content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${request.params.name}`, success: false }) }],
2386
- isError: true
2387
- };
2388
- }
2389
- mcpCanary.tick();
2390
- try {
2391
- let args = request.params.arguments ?? {};
2392
- const validated = validateToolArgs(tool, args);
2393
- if (!validated.ok) {
2394
- return {
2395
- content: [{ type: "text", text: JSON.stringify(validated.errorPayload) }],
2396
- isError: true
2397
- };
2398
- }
2399
- args = validated.data;
2400
- const result = await tool.handler(args, instance);
2401
- let payload = result;
2402
- let resultIsError = false;
2403
- if (result && typeof result === "object" && result._isError === true) {
2404
- resultIsError = true;
2405
- const { _isError, ...rest } = result;
2406
- payload = rest;
2407
- }
2408
- return {
2409
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
2410
- ...resultIsError ? { isError: true } : {}
2411
- };
2412
- } catch (err) {
2413
- const message = err?.message ?? String(err);
2414
- server.sendLoggingMessage({ level: "error", data: `Tool ${request.params.name} failed: ${message}` });
2415
- return {
2416
- content: [{ type: "text", text: JSON.stringify({ error: message, success: false }) }],
2417
- isError: true
2418
- };
2419
- }
2420
- });
2421
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({
2422
- resources: [
2423
- {
2424
- uri: "plur://guide",
2425
- name: "PLUR Agent Guide",
2426
- description: "Complete reference for all PLUR tools, when to use them, scoping, and storage",
2427
- mimeType: "text/markdown"
2428
- },
2429
- {
2430
- uri: "plur://status",
2431
- name: "PLUR Status",
2432
- description: "Live system health \u2014 engram count, episode count, pack count, storage path",
2433
- mimeType: "application/json"
2434
- }
2435
- ]
2436
- }));
2437
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2438
- const uri = request.params.uri;
2439
- if (uri === "plur://guide") {
2440
- const cursorNote = options?.profile === "cursor" ? `
2441
-
2442
- ## Cursor tool profile
2443
-
2444
- 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: {...} }\`.` : "";
2445
- return {
2446
- contents: [{
2447
- uri: "plur://guide",
2448
- mimeType: "text/markdown",
2449
- text: GUIDE_RESOURCE + cursorNote
2450
- }]
2451
- };
2452
- }
2453
- if (uri === "plur://status") {
2454
- const status = instance.status();
2455
- return {
2456
- contents: [{
2457
- uri: "plur://status",
2458
- mimeType: "application/json",
2459
- text: JSON.stringify({
2460
- engram_count: status.engram_count,
2461
- episode_count: status.episode_count,
2462
- pack_count: status.pack_count,
2463
- storage_root: status.storage_root,
2464
- version: VERSION
2465
- }, null, 2)
2466
- }]
2467
- };
2468
- }
2469
- throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${uri}`);
2470
- });
2471
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({
2472
- prompts: [
2473
- {
2474
- name: "plur-getting-started",
2475
- description: "Step-by-step guide to set up and start using PLUR memory"
2476
- },
2477
- {
2478
- name: "plur-session-start",
2479
- description: "Load relevant context for a task \u2014 call at the start of each session",
2480
- arguments: [
2481
- { name: "task", description: "Brief description of the task or goal", required: true },
2482
- { name: "scope", description: "Project scope (e.g. project:my-app)", required: false }
2483
- ]
2484
- }
2485
- ]
2486
- }));
2487
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2488
- const name = request.params.name;
2489
- if (name === "plur-getting-started") {
2490
- const status = instance.status();
2491
- return {
2492
- description: "Get started with PLUR memory",
2493
- messages: [{
2494
- role: "user",
2495
- content: {
2496
- type: "text",
2497
- text: `I just set up PLUR. Here's my current status:
2498
-
2499
- - Engrams stored: ${status.engram_count}
2500
- - Episodes recorded: ${status.episode_count}
2501
- - Packs installed: ${status.pack_count}
2502
- - Storage: ${status.storage_root}
2503
-
2504
- ${status.engram_count === 0 ? `I have no memories yet. Help me get started by:
2505
- 1. Teaching me a coding preference or convention (I'll use plur_learn)
2506
- 2. Then recalling it to verify it works (I'll use plur_recall_hybrid)
2507
- 3. Rating the recall quality (I'll use plur_feedback)` : `I have ${status.engram_count} engrams stored. Try asking me something related to your project \u2014 I'll check my memory first.`}`
2508
- }
2509
- }]
2510
- };
2511
- }
2512
- if (name === "plur-session-start") {
2513
- const task = request.params.arguments?.task ?? "general work";
2514
- const scope = request.params.arguments?.scope;
2515
- return {
2516
- description: "Load relevant context for this session",
2517
- messages: [{
2518
- role: "user",
2519
- content: {
2520
- type: "text",
2521
- text: `Starting a new session. Task: ${task}${scope ? ` (scope: ${scope})` : ""}
2522
-
2523
- Please:
2524
- 1. Call plur_recall_hybrid with query "${task}"${scope ? ` and scope "${scope}"` : ""} to load relevant memories
2525
- 2. Review the recalled engrams and apply any relevant conventions or preferences
2526
- 3. If any recalled engrams are helpful, call plur_feedback with "positive"
2527
- 4. If any are irrelevant, call plur_feedback with "negative"`
2528
- }
2529
- }]
2530
- };
2531
- }
2532
- throw new McpError(ErrorCode.InvalidRequest, `Unknown prompt: ${name}`);
2533
- });
2534
- return server;
2535
- }
2536
- async function runStdio() {
2537
- const profile = process.env.PLUR_TOOL_PROFILE === "cursor" ? "cursor" : "full";
2538
- const server = await createServer(void 0, { profile });
2539
- registerFlushOnExit({});
2540
- try {
2541
- writeFileSync(serverPidPath(), String(process.pid));
2542
- } catch {
2543
- }
2544
- if (process.platform !== "win32") {
2545
- process.on("SIGUSR1", () => {
2546
- _pendingReload = true;
2547
- });
2548
- }
2549
- const transport = new StdioServerTransport();
2550
- await server.connect(transport);
2551
- }
2552
2309
  export {
2553
- INSTRUCTIONS,
2554
- clearPendingReload,
2555
- createServer,
2556
- isPendingReload,
2557
- readEnterpriseToken,
2558
- runStdio,
2559
- serverPidPath
2310
+ registerFlushOnExit,
2311
+ VERSION,
2312
+ validateToolArgs,
2313
+ mcpCanary,
2314
+ CURSOR_CORE_TOOL_NAMES,
2315
+ getToolDefinitions
2560
2316
  };