@plur-ai/mcp 0.20.1 → 0.21.1

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,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-445S5QNU.js";
3
+ } from "./chunk-O2NJFMNP.js";
4
4
 
5
5
  // src/telemetry.ts
6
6
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
@@ -15,9 +15,29 @@ function recordTelemetry(event) {
15
15
 
16
16
  // src/tools.ts
17
17
  import { existsSync, unlinkSync } from "fs";
18
- import { join } from "path";
18
+ import { join, dirname, resolve } from "path";
19
19
  import { homedir } from "os";
20
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES, bareEngramId, summariseProvenance, renderProvenanceSummary } from "@plur-ai/core";
20
+ import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, NO_SESSION, findProjectConfigPath, readProjectConfigFromPath, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES, summariseProvenance, formatLayer3, renderProvenanceSummary, describeNeedsAction, summarizeOutbox, folderMapProblem, tokenEnvUnsetDetail, tokenEnvUnsetFix } from "@plur-ai/core";
21
+
22
+ // src/folder-map-advice.ts
23
+ import { folderRepairCommand } from "@plur-ai/core";
24
+ function folderMapAdvice(problem, root) {
25
+ const command = problem.fixable ? folderRepairCommand(root) : null;
26
+ if (command) {
27
+ const summary = problem.repair_summary;
28
+ return {
29
+ command,
30
+ ...summary ? { summary } : {},
31
+ text: `PLUR can repair this${summary ? `; the repair changes ${summary}` : ""}. Show the user what is wrong and what the repair changes, and ask whether to repair the file (a backup is saved first; they can see the full change by running plur folders repair in a terminal). Only after the user agrees, run: ${command}`
32
+ };
33
+ }
34
+ if (problem.fixable) return { text: "The user can repair it by running plur folders repair in a terminal (it shows the change and asks first)." };
35
+ return {
36
+ text: problem.line !== void 0 ? `plur folders repair cannot fix this automatically: the user has to fix line ${problem.line} of that file by hand (plur folders repair then checks it).` : "plur folders repair cannot fix this automatically: the user has to fix or remove that file by hand."
37
+ };
38
+ }
39
+
40
+ // src/tools.ts
21
41
  import { z } from "zod";
22
42
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
23
43
  return async (prompt) => {
@@ -79,8 +99,11 @@ var recallHandler = async (args, plur) => {
79
99
  // MCP recall remote budget (#776)
80
100
  // #243: session default scope (incl. mid-session plur_session_scope
81
101
  // changes) establishes the remote dialing org context when no explicit
82
- // scope filter is passed.
83
- session: _resolveInjectionSession(args)
102
+ // scope filter is passed. Same rule as writes (E7, formal R2): not
103
+ // exactly one open session and no id → NO_SESSION, never the process
104
+ // slot the last-started session owns. #1566: with no session default
105
+ // of its own, the workspace's scope — the one resolver writes use.
106
+ session: await _readSession(args, plur)
84
107
  });
85
108
  const response2 = {
86
109
  results: results.map((e) => {
@@ -90,7 +113,11 @@ var recallHandler = async (args, plur) => {
90
113
  const measuredUnder = raw.measured_under;
91
114
  const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
92
115
  return {
93
- id: raw._originalId ?? bareEngramId(e.id),
116
+ // The id plur_learn returned for this engram (F3): a team row keeps
117
+ // its store prefix (`ENG-<PREFIX>-…`), so it never shares an id with
118
+ // a local engram minted the same day, and forget/feedback/pin route
119
+ // it to its store. A local engram's id has no prefix.
120
+ id: e.id,
94
121
  statement: e.statement + annotation + measuredAnnotation,
95
122
  type: e.type,
96
123
  scope: e.scope,
@@ -124,8 +151,9 @@ var recallHandler = async (args, plur) => {
124
151
  // MCP recall remote budget (#776)
125
152
  // #243: session default scope (incl. mid-session plur_session_scope
126
153
  // changes) establishes the remote dialing org context when no explicit
127
- // scope filter is passed.
128
- session: _resolveInjectionSession(args)
154
+ // scope filter is passed. Same rule as writes (E7, formal R2), and the
155
+ // same workspace scope when the session has no default (#1566).
156
+ session: await _readSession(args, plur)
129
157
  });
130
158
  recordTelemetry("recall");
131
159
  const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
@@ -154,7 +182,8 @@ var recallHandler = async (args, plur) => {
154
182
  const measuredUnder = raw.measured_under;
155
183
  const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
156
184
  const base = {
157
- id: raw._originalId ?? bareEngramId(e.id),
185
+ id: e.id,
186
+ // same id plur_learn returned — see the keyword branch (F3)
158
187
  statement: e.statement + annotation + measuredAnnotation,
159
188
  type: e.type,
160
189
  scope: e.scope,
@@ -216,10 +245,12 @@ function jsonSchemaPropToZod(prop) {
216
245
  }
217
246
  const items = prop.items;
218
247
  const itemVariants = items?.anyOf ?? items?.oneOf;
219
- const itemsAcceptString = items?.type === "string" || Array.isArray(itemVariants) && itemVariants.some((v) => v?.type === "string");
220
- if (itemsAcceptString) {
248
+ if (items?.type === "string") {
221
249
  return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
222
250
  }
251
+ if (Array.isArray(itemVariants) && itemVariants.some((v) => v?.type === "string")) {
252
+ return trimmed.length === 0 ? [] : [trimmed];
253
+ }
223
254
  return val;
224
255
  }, z.array(itemSchema));
225
256
  }
@@ -253,9 +284,9 @@ function validateToolArgs(tool, rawArgs) {
253
284
  const arrayShapedDrop = partialDrop && missingArrayParams.length > 0;
254
285
  let dropHint = "";
255
286
  if (wholePayloadDrop) {
256
- dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.' : "");
287
+ dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays. (A list of free-text statements such as engram_suggestions is not comma-split: send it as a JSON string; a bare string is one item.)' : "");
257
288
  } else if (arrayShapedDrop) {
258
- dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.`;
289
+ dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays. (A list of free-text statements such as engram_suggestions is not comma-split: send it as a JSON string; a bare string is one item.)`;
259
290
  }
260
291
  const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
261
292
  const disposition = wholePayloadDrop ? "The request reached the server but its arguments did not \u2014 do not abandon the call, and do not rewrite the payload: it was never evaluated. Retry the IDENTICAL call; it usually succeeds. If you issued several tool calls in one message, send them one per message \u2014 batching is the strongest correlate of this drop (#772)." : "The call reached the server \u2014 this is a malformed-arguments error, not a transport failure. Fix the field(s) named above and retry; do not abandon the call.";
@@ -362,16 +393,23 @@ mcpCanary.expect({
362
393
  });
363
394
  var _sessionTelemetry = /* @__PURE__ */ new Map();
364
395
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
396
+ var _pendingScopeEvictions = /* @__PURE__ */ new Set();
365
397
  function _cleanExpiredSessions(plur) {
366
398
  const cutoff = Date.now() - SESSION_TTL_MS;
367
399
  for (const [id, state] of _sessionTelemetry) {
368
400
  if (new Date(state.started_at).getTime() < cutoff) {
369
401
  _sessionTelemetry.delete(id);
402
+ _pendingScopeEvictions.add(id);
403
+ }
404
+ }
405
+ if (plur) {
406
+ for (const id of _pendingScopeEvictions) {
370
407
  try {
371
- plur?.clearSessionScope({ session: id });
408
+ plur.clearSessionScope({ session: id });
372
409
  } catch {
373
410
  }
374
411
  }
412
+ _pendingScopeEvictions.clear();
375
413
  }
376
414
  }
377
415
  function _implicitSessionId() {
@@ -379,11 +417,165 @@ function _implicitSessionId() {
379
417
  if (_sessionTelemetry.size !== 1) return void 0;
380
418
  return _sessionTelemetry.keys().next().value;
381
419
  }
420
+ function learnDecision(engram) {
421
+ return (engram.write_count ?? 1) > 1 ? { decision: "NOOP", existing_id: engram.id } : { decision: "ADD" };
422
+ }
423
+ var _warnedUntrustedConfigs = /* @__PURE__ */ new Set();
424
+ function _estimatePinnedCost(statement, ctx) {
425
+ const id = "ENG-0000-0000-000";
426
+ const text = String(statement ?? "");
427
+ const commitment = typeof ctx.commitment === "string" ? ctx.commitment : void 0;
428
+ let rendered = 0;
429
+ try {
430
+ rendered = formatLayer3({ id, statement: text, domain: ctx.domain, rationale: ctx.rationale, commitment, confidence_score: 0 }).length + 1;
431
+ } catch {
432
+ }
433
+ const fieldSum = id.length + 4 + text.length + (ctx.rationale ? 15 + ctx.rationale.length : 0) + (ctx.domain ? ctx.domain.length + 10 : 0) + (commitment ? commitment.length + 14 : 0) + 23;
434
+ return Math.ceil(Math.max(rendered, fieldSum) / 4);
435
+ }
436
+ function _shellWord(s, platform = process.platform) {
437
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/.test(s)) return null;
438
+ if (/^[A-Za-z0-9_@+=:,./~-]+$/.test(s)) return s;
439
+ if (platform === "win32") {
440
+ if (/[$`%!"\u201c\u201d\u201e]/.test(s) || s.endsWith("\\")) return null;
441
+ return `"${s}"`;
442
+ }
443
+ return `'${s.replace(/'/g, `'\\''`)}'`;
444
+ }
445
+ function trustCommand(dir, storageRoot, platform = process.platform) {
446
+ const target = dir === null ? "<dir>" : _shellWord(dir, platform);
447
+ if (target === null) return null;
448
+ if (!storageRoot || resolve(storageRoot) === resolve(join(homedir(), ".plur"))) return `plur trust ${target}`;
449
+ const store = _shellWord(resolve(storageRoot), platform);
450
+ return store === null ? null : `plur --path ${store} trust ${target}`;
451
+ }
452
+ function folderMapStatus(root) {
453
+ let p;
454
+ try {
455
+ p = folderMapProblem(root);
456
+ } catch {
457
+ return {};
458
+ }
459
+ if (!p) return {};
460
+ const advice = folderMapAdvice(p, root);
461
+ return {
462
+ folder_map: {
463
+ ok: false,
464
+ file: p.file,
465
+ problem: p.problem,
466
+ ...p.line !== void 0 ? { line: p.line } : {},
467
+ ...p.column !== void 0 ? { column: p.column } : {},
468
+ fixable: p.fixable,
469
+ ...advice.command ? { repair_command: advice.command } : {},
470
+ ...advice.summary ? { repair_summary: advice.summary } : {},
471
+ advice: `Memory tools are paused until it is fixed. ${advice.text}`
472
+ }
473
+ };
474
+ }
475
+ function folderOnCommand(entry, storageRoot, platform = process.platform) {
476
+ if (_UNSAFE_PATH_CHARS.test(entry)) return null;
477
+ const target = _shellWord(entry, platform);
478
+ if (target === null) return null;
479
+ if (!storageRoot || resolve(storageRoot) === resolve(join(homedir(), ".plur"))) return `plur folders set ${target} --on`;
480
+ const store = _shellWord(resolve(storageRoot), platform);
481
+ return store === null ? null : `plur --path ${store} folders set ${target} --on`;
482
+ }
483
+ function redactStoreErrors(errors) {
484
+ return Object.fromEntries(Object.entries(errors).map(([k, v]) => [k, String(v).split("\n", 1)[0].slice(0, 300)]));
485
+ }
486
+ var UNTRUSTED_SCOPE_GRAMMAR = /^(?:global|[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._@/:-]{0,199})$/;
487
+ var UNTRUSTED_DOMAIN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
488
+ var _UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/;
489
+ function _escapedText(s) {
490
+ return JSON.stringify(s.length > 1024 ? s.slice(0, 1024) + "\u2026" : s).replace(/[\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
491
+ }
492
+ var FOLDER_SCOPE = /* @__PURE__ */ Symbol("plur.folderScope");
493
+ function _folderContext(args) {
494
+ const c = args[FOLDER_SCOPE];
495
+ return c && typeof c === "object" && typeof c.resolve === "function" ? c : null;
496
+ }
497
+ function readTrustedProjectConfig(trust) {
498
+ const configPath = findProjectConfigPath();
499
+ const raw = readProjectConfigFromPath(configPath);
500
+ if (!raw.scope && !raw.domain) return {};
501
+ const configDir = configPath ? dirname(configPath) : null;
502
+ let trusted = false;
503
+ try {
504
+ trusted = configDir !== null && trust.isDirectoryTrusted(configDir);
505
+ } catch {
506
+ trusted = false;
507
+ }
508
+ if (trusted) return { scope: raw.scope, domain: raw.domain };
509
+ const declared = [
510
+ raw.scope ? UNTRUSTED_SCOPE_GRAMMAR.test(raw.scope) ? `scope ${_escapedText(raw.scope)}` : "an invalid scope" : null,
511
+ raw.domain ? UNTRUSTED_DOMAIN_GRAMMAR.test(raw.domain) ? `domain ${_escapedText(raw.domain)}` : "an invalid domain" : null
512
+ ].filter(Boolean).join(" / ");
513
+ const cmd = configDir !== null && _UNSAFE_PATH_CHARS.test(configDir) ? null : trustCommand(configDir, trust.storageRoot);
514
+ const warning = `${configPath ? _escapedText(configPath) : ".plur.yaml"} declares ${declared}, but ${configDir ? _escapedText(configDir) : "its directory"} is not a trusted directory \u2014 ignoring it and using the local default scope instead. If this project is yours, ` + (cmd !== null ? `run: ${cmd}` : "run `plur trust` for that directory from a terminal.");
515
+ if (configPath && !_warnedUntrustedConfigs.has(configPath)) {
516
+ _warnedUntrustedConfigs.add(configPath);
517
+ try {
518
+ process.stderr.write(`[plur] ${warning}
519
+ `);
520
+ } catch {
521
+ }
522
+ }
523
+ return { warning };
524
+ }
525
+ function describeRefusedRoute(scope) {
526
+ return isSharedScope(scope) ? { kind: "shared", what: `the shared scope "${scope}"`, rule: "unscoped writes are never auto-routed into a shared store" } : {
527
+ kind: "remote-personal",
528
+ what: `"${scope}", a personal scope on a remote store that is not verified as your own namespace (its /me identity is unknown or belongs to another user)`,
529
+ rule: "unscoped writes are never auto-routed into a remote personal namespace that is not verifiably yours"
530
+ };
531
+ }
382
532
  function _resolveInjectionSession(args) {
383
533
  const explicit = args.session_id;
384
534
  if (typeof explicit === "string" && explicit.length > 0) return explicit;
385
535
  return _implicitSessionId();
386
536
  }
537
+ var NO_SESSION_SLOT_WARNING = "No session is open, so this is the process-default slot. An id-less plur_learn / plur_inject / plur_recall uses NO session default unless exactly one session is open, so this slot governs neither writes nor the recall dialing context. Call plur_session_start (then plur_session_scope with its session_id) to scope a session.";
538
+ function _resolveWriteSession(args) {
539
+ return _resolveInjectionSession(args) ?? NO_SESSION;
540
+ }
541
+ var FOLDER_SCOPE_SESSION_PREFIX = "\0plur:folder-scope:";
542
+ function _knownSession(id, plur, viaServer) {
543
+ if (id.startsWith("\0")) return false;
544
+ if (_sessionTelemetry.has(id)) return true;
545
+ if (viaServer) return false;
546
+ try {
547
+ return plur.trackedSessionScopes().includes(id);
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+ async function _writeSession(args, plur, base, read = false) {
553
+ const explicit = typeof args.session_id === "string" && args.session_id.length > 0 ? args.session_id : void 0;
554
+ const chosen = base !== void 0 ? base : explicit ?? _implicitSessionId();
555
+ const ctx = _folderContext(args);
556
+ if (!ctx) return chosen ?? NO_SESSION;
557
+ const session = chosen !== void 0 && _knownSession(chosen, plur, true) ? chosen : NO_SESSION;
558
+ let ws = null;
559
+ try {
560
+ ws = read && ctx.admitted ? ctx.admitted() : await ctx.resolve();
561
+ } catch {
562
+ ws = null;
563
+ }
564
+ if (session !== NO_SESSION) {
565
+ const record = _sessionTelemetry.get(session);
566
+ const own = plur.getSessionScope({ session });
567
+ if (record?.scope_adjusted) return session;
568
+ if (record && own != null && ws !== null && record.workspace_key === ws.key && record.workspace_scope === ws.scope) return session;
569
+ }
570
+ const scope = ws?.scope ?? null;
571
+ if (scope === null) return NO_SESSION;
572
+ const key = FOLDER_SCOPE_SESSION_PREFIX + scope;
573
+ plur.setSessionScope(scope, { session: key });
574
+ return key;
575
+ }
576
+ function _readSession(args, plur) {
577
+ return _writeSession(args, plur, void 0, true);
578
+ }
387
579
  function _resolveScopeSession(args) {
388
580
  const explicit = args.session_id;
389
581
  if (typeof explicit === "string" && explicit.length > 0) {
@@ -415,7 +607,9 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
415
607
  "plur_receipt",
416
608
  "plur_doctor",
417
609
  "plur_packs_uninstall",
418
- "plur_tensions_purge"
610
+ "plur_tensions_purge",
611
+ "plur_tensions",
612
+ "plur_validate_meta"
419
613
  ]);
420
614
  function summarizeToolDescription(description) {
421
615
  const line = description.split("\n", 1)[0].trim();
@@ -493,13 +687,16 @@ function buildAdminDispatchTool(all) {
493
687
  const innerArgs = args.args ?? {};
494
688
  const validated = validateToolArgs(target, innerArgs);
495
689
  if (!validated.ok) {
496
- return { ...validated.errorPayload, error: `${action}: ${validated.errorPayload.error}` };
690
+ const inner = String(validated.errorPayload.error);
691
+ return { ...validated.errorPayload, error: inner.startsWith(`${action}:`) ? inner : `${action}: ${inner}` };
497
692
  }
498
693
  try {
499
- return await target.handler(validated.data, plur);
694
+ const carried = args[FOLDER_SCOPE];
695
+ const data = carried !== void 0 ? { ...validated.data, [FOLDER_SCOPE]: carried } : validated.data;
696
+ return await target.handler(data, plur);
500
697
  } catch (err) {
501
698
  const message = err?.message ?? String(err);
502
- throw new Error(`${action}: ${message}`);
699
+ throw new Error(message.startsWith(`${action}:`) ? message : `${action}: ${message}`);
503
700
  }
504
701
  }
505
702
  };
@@ -567,7 +764,7 @@ function getAllToolDefinitions() {
567
764
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
568
765
  valid_until: { type: "string", description: 'ISO date (YYYY-MM-DD) the knowledge expires \u2014 inject/recall skip the engram after this date. Set this for any time-bound fact (offers, deadlines, temporary endpoints). When omitted, an explicit expiry phrase in the statement ("valid until 31 May 2026") is auto-parsed and echoed back (#347)' },
569
766
  supersedes: { type: "array", items: { type: "string" }, description: "Engram IDs this statement intentionally replaces (#240). Writes relations.supersedes on the new engram and the reverse superseded_by edge on each local target. Supersedes-linked pairs are skipped by tension scans \u2014 an intentional update is not a contradiction. Use when updating a standing fact (new version, changed rule) rather than contradicting it." },
570
- session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open; pass it when several are (#243)." },
767
+ session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open. With none or several open and no session_id, NO session default applies: the write takes the unscoped path (#243, E7)." },
571
768
  measured_under: {
572
769
  type: "object",
573
770
  description: "Measurement context for numeric or benchmark-derived claims (#869). Records the conditions under which the asserted value was measured \u2014 model, source_type, hardware, dataset, date. When present, the tension scanner does not treat two measurements from the same store taken under different configurations as a contradiction (the skipped pair is reported in the scan result). Omit for non-numeric engrams.",
@@ -630,7 +827,7 @@ function getAllToolDefinitions() {
630
827
  visibility: {
631
828
  type: "string",
632
829
  enum: ["private", "public", "template"],
633
- description: 'Whether this memory may leave this machine. Defaults to "private", which means it is EXCLUDED from every exported pack. Set "public" only when the user has said this is shareable with others \u2014 it is their decision, not yours. Without this an agent cannot mark anything shareable at all, so every memory it writes is private forever and any pack built from them is empty.'
830
+ description: 'Who this memory is shared with beyond its scope. Defaults to "private": EXCLUDED from every exported pack and from shared git sync. The default does NOT keep a team-scope write on this machine \u2014 with visibility omitted, a write whose scope is a team store (e.g. group:acme/eng) still goes to that team store. Only an EXPLICIT "private" keeps such a write local (stored here with a warning, never sent to the team store). Set "public" only when the user has said this is shareable with others \u2014 it is their decision, not yours. Without "public" nothing an agent writes can appear in a pack.'
634
831
  }
635
832
  },
636
833
  required: ["statement"]
@@ -647,7 +844,7 @@ function getAllToolDefinitions() {
647
844
  // hierarchy segment as a FULL term hit, double the weight of a
648
845
  // statement word, so a missing domain forfeits the strongest
649
846
  // retrieval signal an author has. Explicit argument always wins.
650
- domain: args.domain ?? readProjectConfig().domain ?? void 0,
847
+ domain: args.domain ?? readTrustedProjectConfig(plur).domain ?? void 0,
651
848
  source: args.source,
652
849
  tags: args.tags,
653
850
  rationale: args.rationale,
@@ -671,13 +868,15 @@ function getAllToolDefinitions() {
671
868
  // #243: resolve which session's default scope governs this write —
672
869
  // explicit session_id first, else the lone open session. Never
673
870
  // persisted on the engram (LearnContext.session selects a scope, it
674
- // is not part of one).
675
- session: _resolveInjectionSession(args),
871
+ // is not part of one). The workspace's answer where the session
872
+ // gives none, or its roots changed (#1562, #1563 review round 2).
873
+ session: await _writeSession(args, plur),
676
874
  llm
677
875
  };
678
876
  const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
679
- const scopeHint = (engramScope, wasRouted) => {
877
+ const scopeHint = (engramScope, wasRouted, delivery) => {
680
878
  if (explicitScope || wasRouted || isSharedScope(engramScope)) return {};
879
+ if (delivery !== "local") return {};
681
880
  let remote = [];
682
881
  try {
683
882
  remote = plur.getWritableRemoteScopes();
@@ -685,6 +884,7 @@ function getAllToolDefinitions() {
685
884
  return {};
686
885
  }
687
886
  if (remote.length === 0) return {};
887
+ if (remote.some((s) => s.scope === engramScope)) return {};
688
888
  const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
689
889
  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.` };
690
890
  };
@@ -725,12 +925,14 @@ function getAllToolDefinitions() {
725
925
  }
726
926
  try {
727
927
  const engram = await plur.learnRouted(statement, context);
928
+ const delivered = plur.deliveryOf(engram, context?.scope);
728
929
  const isOutbox = !!engram.structured_data?._outbox;
729
930
  const demoted = engram.structured_data?._demoted;
730
931
  const routed = engram.structured_data?._routed;
932
+ const routeRefused = engram.structured_data?._routeRefused;
731
933
  mcpCanary.signal("learn_activity");
732
934
  recordTelemetry("learn");
733
- const dedup = isOutbox ? void 0 : await plur.nearDuplicates(statement, context, engram.id);
935
+ const dedup = isOutbox ? void 0 : await plur.nearDuplicates(statement, context, plur.readIdFor(engram));
734
936
  const redraft = (() => {
735
937
  const ids = args.supersedes;
736
938
  if (!ids?.length) return void 0;
@@ -759,7 +961,18 @@ function getAllToolDefinitions() {
759
961
  pinned: engram.pinned === true,
760
962
  // See the note on recall results: same fact, not same record.
761
963
  content_hash: engram.content_hash,
762
- decision: "ADD",
964
+ // An absorbed duplicate (content-hash or cross-scope recurrence)
965
+ // hands back the EXISTING engram with write_count bumped; reporting
966
+ // it as 'ADD' told the caller a new memory exists when none was
967
+ // written. Same vocabulary as plur_learn_batch (formal Adapters #1).
968
+ ...learnDecision(engram),
969
+ // #1264: always present. The warning sits BEFORE the others so a
970
+ // more specific `warning` below (outbox, demotion, refusal) still
971
+ // wins that key; `delivery_warning` keeps this one either way.
972
+ delivery: delivered.delivery,
973
+ // 0.21.1: why it was queued (rejected token vs unreachable server).
974
+ ...delivered.reason ? { delivery_reason: delivered.reason, delivery_reason_code: delivered.reason_code } : {},
975
+ ...delivered.warning ? { delivery_warning: delivered.warning, warning: delivered.warning } : {},
763
976
  ...dedup?.near_duplicates?.length ? { dedup } : {},
764
977
  ...redraft ? { redraft } : {},
765
978
  ...(() => {
@@ -767,14 +980,24 @@ function getAllToolDefinitions() {
767
980
  return c ? { composition: c } : {};
768
981
  })(),
769
982
  ...temporalEcho(engram),
770
- ...scopeHint(engram.scope, !!routed),
983
+ ...scopeHint(engram.scope, !!routed, delivered.delivery),
771
984
  ...domainHint(!!routed),
772
- ...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
985
+ ...isOutbox ? { outbox: true, warning: delivered.reason ?? "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
773
986
  ...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.` } : {},
774
- ...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.` } : {}
987
+ ...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.` } : {},
988
+ // #1115: a shared scope matched but was NOT adopted. Said plainly,
989
+ // as a `warning`, because the old `info` string for the opposite
990
+ // outcome proved easy to miss in a long session — and this one
991
+ // changes what the caller should do next, rather than merely
992
+ // reporting where the write went.
993
+ ...routeRefused ? (() => {
994
+ const why = describeRefusedRoute(routeRefused.scope);
995
+ return { route_refused: { scope: routeRefused.scope, confidence: routeRefused.confidence, reason: routeRefused.reason, kind: why.kind }, warning: `No scope was provided. This content matched ${why.what} (confidence ${routeRefused.confidence}), but ${why.rule} \u2014 it was stored at "${engram.scope}" instead. If it belongs there, pass scope: "${routeRefused.scope}" explicitly, or move it with plur_rescope.` };
996
+ })() : {}
775
997
  };
776
998
  } catch (err) {
777
999
  const engram = await plur.learn(statement, context);
1000
+ const delivered = plur.deliveryOf(engram, context?.scope);
778
1001
  const isOutbox = !!engram.structured_data?._outbox;
779
1002
  const routedFallback = engram.structured_data?._routed;
780
1003
  mcpCanary.signal("learn_activity");
@@ -788,19 +1011,26 @@ function getAllToolDefinitions() {
788
1011
  statement: engram.statement,
789
1012
  scope: engram.scope,
790
1013
  type: engram.type,
791
- decision: "ADD",
1014
+ ...learnDecision(engram),
1015
+ delivery: delivered.delivery,
1016
+ ...delivered.reason ? { delivery_reason: delivered.reason, delivery_reason_code: delivered.reason_code } : {},
1017
+ ...delivered.warning ? { delivery_warning: delivered.warning } : {},
792
1018
  ...temporalEcho(engram),
793
- ...scopeHint(engram.scope, !!routedFallback),
1019
+ ...scopeHint(engram.scope, !!routedFallback, delivered.delivery),
794
1020
  ...domainHint(!!routedFallback),
795
1021
  ...isOutbox ? { outbox: true } : {},
796
- warning: `Remote write failed (${err.message}); engram queued for retry.`
1022
+ // The routed write can fail for reasons that have nothing to do
1023
+ // with a remote (a local lock, a store error), and learn() only
1024
+ // queues when the scope is remote-backed. Say what actually
1025
+ // happened (formal Adapters #1).
1026
+ warning: isOutbox ? `Remote write failed (${err.message}); engram queued for retry.` : `Routed write failed (${err.message}); stored through the local learn() fallback at "${engram.scope}".`
797
1027
  };
798
1028
  }
799
1029
  }
800
1030
  },
801
1031
  {
802
1032
  name: "plur_learn_batch",
803
- description: 'Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost. Note: unlike plur_learn, batch items take the LOCAL learn path \u2014 remote-scope auto-routing (learnRouted) is not applied per item, so for shared/remote-store writes prefer plur_learn or pass an explicit local scope. See plur-ai/plur#281.',
1033
+ description: 'Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost. Each item is written through the same routed path as plur_learn (learnRouted, #930), resolves the same session default scope (pass `session_id` when several sessions are open) and the same .plur.yaml domain default, and a `pinned` item is refused with pinned_quota_exceeded when the pinned set has no room \u2014 reported in `failures`, the rest of the batch still written. Items take no `visibility`: each gets the default ("private" \u2014 excluded from packs and shared git sync), and an item whose scope is a team store still goes to that team store; to keep a team-scope memory local, use plur_learn with an explicit visibility: "private". See plur-ai/plur#281.',
804
1034
  annotations: { title: "Learn (batch)", destructiveHint: false, idempotentHint: false },
805
1035
  inputSchema: {
806
1036
  type: "object",
@@ -837,7 +1067,8 @@ function getAllToolDefinitions() {
837
1067
  required: ["statement"]
838
1068
  }
839
1069
  },
840
- max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." }
1070
+ max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." },
1071
+ session_id: { type: "string", description: "Session these writes belong to (from plur_session_start). Resolves the session default scope for items without an explicit scope, exactly as plur_learn does. Optional when one session is open. With none or several open and no session_id, NO session default applies: the write takes the unscoped path (#243, E7)." }
841
1072
  },
842
1073
  required: ["engrams"]
843
1074
  },
@@ -847,12 +1078,15 @@ function getAllToolDefinitions() {
847
1078
  if (raw.length === 0) {
848
1079
  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." };
849
1080
  }
1081
+ const batchSession = await _writeSession(args, plur);
1082
+ const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
850
1083
  const items = raw.map((e) => ({
851
1084
  statement: sanitizeStatement(e.statement),
852
1085
  context: {
853
1086
  type: e.type,
854
1087
  scope: e.scope,
855
- domain: e.domain,
1088
+ domain: e.domain ?? projectDomain,
1089
+ session: batchSession,
856
1090
  source: e.source,
857
1091
  tags: e.tags,
858
1092
  rationale: e.rationale,
@@ -864,11 +1098,44 @@ function getAllToolDefinitions() {
864
1098
  }
865
1099
  }));
866
1100
  const maxLlmCalls = typeof args.max_llm_calls === "number" ? args.max_llm_calls : void 0;
867
- const { results, stats, failures } = await plur.learnBatch(
868
- items,
1101
+ const gateFailures = [];
1102
+ let admitted = items.map((_, i) => i);
1103
+ if (items.some((it) => it.context.pinned === true)) {
1104
+ const q = await plur.pinnedQuota();
1105
+ let free = q.free;
1106
+ admitted = [];
1107
+ items.forEach((it, i) => {
1108
+ if (it.context.pinned !== true) {
1109
+ admitted.push(i);
1110
+ return;
1111
+ }
1112
+ if (free <= 0) {
1113
+ const claimed = q.free - free;
1114
+ gateFailures.push({
1115
+ index: i,
1116
+ statement: String(it.statement ?? "").slice(0, 80),
1117
+ error: `pinned_quota_exceeded: the pinned set has no room (quota ${q.quota}, used ${q.used}${claimed > 0 ? `, plus ~${claimed} claimed by earlier pinned items in this batch` : ""}); this item was NOT stored \u2014 learn it unpinned or unpin something first (plur_pin {list:true}).`
1118
+ });
1119
+ return;
1120
+ }
1121
+ admitted.push(i);
1122
+ free -= _estimatePinnedCost(it.statement, it.context);
1123
+ });
1124
+ }
1125
+ const batchOut = admitted.length === 0 ? { results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [] } : await plur.learnBatch(
1126
+ admitted.map((i) => items[i]),
869
1127
  llm,
870
1128
  maxLlmCalls !== void 0 ? { maxLlmCalls } : void 0
871
1129
  );
1130
+ const results = batchOut.results.map((r) => ({
1131
+ ...r,
1132
+ input_index: r.input_index !== void 0 ? admitted[r.input_index] : void 0
1133
+ }));
1134
+ const failures = [
1135
+ ...batchOut.failures.map((f) => ({ ...f, index: admitted[f.index] ?? f.index })),
1136
+ ...gateFailures
1137
+ ].sort((a, b) => a.index - b.index);
1138
+ const stats = { ...batchOut.stats, failed: (batchOut.stats.failed ?? 0) + gateFailures.length };
872
1139
  mcpCanary.signal("learn_activity");
873
1140
  recordTelemetry("learn");
874
1141
  const ids = raw.map(() => null);
@@ -894,13 +1161,31 @@ function getAllToolDefinitions() {
894
1161
  } catch {
895
1162
  }
896
1163
  }
1164
+ const refusedScopes = [...new Set(results.map((r) => r.engram.structured_data?._routeRefused?.scope).filter((sc) => typeof sc === "string"))];
1165
+ const refusedCount = results.filter((r) => r.engram.structured_data?._routeRefused !== void 0).length;
1166
+ const warnings = [];
1167
+ if (failures.length > 0) {
1168
+ warnings.push(`${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.`);
1169
+ }
1170
+ if (refusedCount > 0) {
1171
+ warnings.push(
1172
+ `${refusedCount} of ${raw.length} engram(s) had no scope and matched ${refusedScopes.map((sc) => describeRefusedRoute(sc).what).join("; ")} \u2014 unscoped writes are never auto-routed into a shared store or into a remote personal namespace that is not verifiably yours, so they were stored at the local default instead. Pass an explicit scope on those items if they belong there, or move them with plur_rescope.`
1173
+ );
1174
+ }
897
1175
  return {
898
1176
  ids,
899
1177
  results: results.map((r) => {
900
1178
  const isOutbox = !!r.engram.structured_data?._outbox;
1179
+ const routed = r.engram.structured_data?._routed;
1180
+ const routeRefused = r.engram.structured_data?._routeRefused;
1181
+ const requested = r.input_index !== void 0 ? raw[r.input_index]?.scope : void 0;
1182
+ const delivered = plur.deliveryOf(r.engram, typeof requested === "string" ? requested : void 0);
901
1183
  return {
902
1184
  input_index: r.input_index,
903
1185
  id: isOutbox ? r.engram.id : plur.readIdFor(r.engram),
1186
+ delivery: delivered.delivery,
1187
+ ...delivered.reason ? { delivery_reason: delivered.reason, delivery_reason_code: delivered.reason_code } : {},
1188
+ ...delivered.warning ? { delivery_warning: delivered.warning } : {},
904
1189
  statement: r.engram.statement,
905
1190
  scope: r.engram.scope,
906
1191
  type: r.engram.type,
@@ -909,18 +1194,21 @@ function getAllToolDefinitions() {
909
1194
  // #856 audit: `dedup` was computed and then dropped here, so the
910
1195
  // reporting it exists for reached no caller — "anything below the
911
1196
  // bar is still reported" was not observable anywhere.
912
- ...r.dedup ? { dedup: r.dedup } : {}
1197
+ ...r.dedup ? { dedup: r.dedup } : {},
1198
+ ...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason } } : {},
1199
+ ...routeRefused ? { route_refused: { scope: routeRefused.scope, confidence: routeRefused.confidence, reason: routeRefused.reason } } : {}
913
1200
  };
914
1201
  }),
915
1202
  stats,
916
1203
  ...batchDomainHint,
917
- ...failures.length > 0 ? { failures, warning: `${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.` } : {}
1204
+ ...failures.length > 0 ? { failures } : {},
1205
+ ...warnings.length > 0 ? { warning: warnings.join(" ") } : {}
918
1206
  };
919
1207
  }
920
1208
  },
921
1209
  {
922
1210
  name: "plur_recall",
923
- description: 'Search engrams by topic. Default mode is hybrid (BM25 + local embeddings via RRF) \u2014 set mode:"keyword" for BM25-only. Local search plus, when a configured enterprise store is part of the current project/work, one live timeout-bounded recall per remote host merged in (a `remote_stores` block + warning appears when a host is degraded; no host configured or implicated = fully local). Note: a project-scope filter also returns personal-family engrams (local, global, user:*, agent:*); an explicit scope=global recall returns ALL personal-family engrams \u2014 wider than scope=global INJECT, which is targeted to the global namespace only.',
1211
+ description: 'Search engrams by topic. Default mode is hybrid (BM25 + local embeddings via RRF) \u2014 set mode:"keyword" for BM25-only. Local search plus, when a configured enterprise store is part of the current project/work \u2014 or when the scope (or the default scope this session registered itself) is a personal user: scope and a remote store is configured with that same scope (exact match, case-insensitive; that rule adds only the matching store, though a store set to dial: always or a trusted project remote can still add others) \u2014 one live timeout-bounded recall per remote host merged in (a `remote_stores` block + warning appears when a host is degraded; no host configured or implicated = fully local). Note: a project-scope filter also returns personal-family engrams (local, global, user:*, agent:*); an explicit scope=global recall returns ALL personal-family engrams \u2014 wider than scope=global INJECT, which is targeted to the global namespace only.',
924
1212
  annotations: { title: "Recall", readOnlyHint: true, idempotentHint: true },
925
1213
  inputSchema: {
926
1214
  type: "object",
@@ -939,7 +1227,7 @@ function getAllToolDefinitions() {
939
1227
  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" } } },
940
1228
  caller_session_id: { type: "string", description: 'Session ID of calling agent for budget enforcement. Hybrid mode only \u2014 ignored when mode:"keyword".' },
941
1229
  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".' },
942
- session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243)." }
1230
+ session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243); with none or several open and no session_id, no session default applies." }
943
1231
  },
944
1232
  required: ["query"]
945
1233
  },
@@ -947,7 +1235,7 @@ function getAllToolDefinitions() {
947
1235
  },
948
1236
  {
949
1237
  name: "plur_recall_hybrid",
950
- description: "[Deprecated since 0.16 \u2014 use plur_recall (mode defaults to hybrid). Alias kept for backwards compatibility; removal earliest 0.18.] Hybrid search \u2014 BM25 + local embeddings merged via Reciprocal Rank Fusion, plus the live enterprise-store recall leg when one is configured and project-relevant.",
1238
+ description: "[Deprecated since 0.16 \u2014 use plur_recall (mode defaults to hybrid). Alias kept for backwards compatibility; removal earliest 0.18.] Hybrid search \u2014 BM25 + local embeddings merged via Reciprocal Rank Fusion, plus the live enterprise-store recall leg when one is configured and project-relevant, or when the scope is a personal user: scope matching the own scope of a configured remote store (exact, case-insensitive; adds only that store, while dial: always and a trusted project remote still apply).",
951
1239
  annotations: { title: "Recall (hybrid) [deprecated alias]", readOnlyHint: true, idempotentHint: true },
952
1240
  inputSchema: {
953
1241
  type: "object",
@@ -959,7 +1247,7 @@ function getAllToolDefinitions() {
959
1247
  budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
960
1248
  caller_session_id: { type: "string", description: "Session ID of calling agent for budget enforcement" },
961
1249
  include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" },
962
- session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243)." }
1250
+ session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243). Optional when one session is open; with none or several open and no session_id, no session default applies." }
963
1251
  },
964
1252
  required: ["query"]
965
1253
  },
@@ -981,12 +1269,12 @@ function getAllToolDefinitions() {
981
1269
  task: { type: "string", description: "The task description to inject context for" },
982
1270
  budget: { type: "number", description: "Token budget for injection (default 2000)" },
983
1271
  scope: { type: "string", description: "Scope filter for engram selection" },
984
- 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." }
1272
+ session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open. With none or several open and no session_id, no session default applies (E7) and the injection is not attributed to any session." }
985
1273
  },
986
1274
  required: ["task"]
987
1275
  },
988
1276
  handler: async (args, plur) => {
989
- const session_id = _resolveInjectionSession(args);
1277
+ const session_id = _resolveWriteSession(args);
990
1278
  const result = await plur.inject(args.task, {
991
1279
  budget: args.budget,
992
1280
  scope: args.scope,
@@ -1019,17 +1307,21 @@ function getAllToolDefinitions() {
1019
1307
  task: { type: "string", description: "The task description to inject context for" },
1020
1308
  budget: { type: "number", description: "Token budget for injection (default 2000)" },
1021
1309
  scope: { type: "string", description: "Scope filter for engram selection" },
1022
- 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." }
1310
+ session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open. With none or several open and no session_id, no session default applies (E7) and the injection is not attributed to any session." }
1023
1311
  },
1024
1312
  required: ["task"]
1025
1313
  },
1026
1314
  handler: async (args, plur) => {
1027
- const session_id = _resolveInjectionSession(args);
1315
+ const session_id = _resolveWriteSession(args);
1028
1316
  const result = await plur.injectHybrid(args.task, {
1029
1317
  budget: args.budget,
1030
1318
  scope: args.scope,
1031
1319
  source: "inject",
1032
- session_id
1320
+ session_id,
1321
+ // #1566: the dialing context follows the same rule as writes — the
1322
+ // workspace's scope when the session has no default of its own.
1323
+ // session_id above stays the caller's for attribution.
1324
+ dial_session: await _readSession(args, plur)
1033
1325
  });
1034
1326
  _recordInjectionTelemetry(session_id, result.injected_packs);
1035
1327
  const response = {
@@ -1098,8 +1390,8 @@ function getAllToolDefinitions() {
1098
1390
  return { mode: "batch", results, summary };
1099
1391
  }
1100
1392
  try {
1101
- await plur.feedback(args.id, args.signal, args.scope);
1102
- return { success: true, id: args.id, signal: args.signal };
1393
+ const { warnings } = await plur.feedback(args.id, args.signal, args.scope);
1394
+ return { success: true, id: args.id, signal: args.signal, ...warnings.length > 0 ? { warnings } : {} };
1103
1395
  } catch (err) {
1104
1396
  if (err.message?.includes("readonly store")) {
1105
1397
  return { success: false, id: args.id, signal: args.signal, note: "Engram is in a readonly store. Feedback noted for this session but not persisted." };
@@ -1117,7 +1409,8 @@ function getAllToolDefinitions() {
1117
1409
  properties: {
1118
1410
  id: { type: "string", description: "Engram ID to pin or unpin" },
1119
1411
  pinned: { type: "boolean", description: "Target value (default true)" },
1120
- list: { type: "boolean", description: "If true, just return the current set of pinned engrams (no mutation)" }
1412
+ list: { type: "boolean", description: "If true, just return the current set of pinned engrams (no mutation)" },
1413
+ scope: { type: "string", description: `Which store holds it. Ids are minted per store, so one bare id can name a local engram and an unrelated remote one; such an id is refused. Pass "primary" for the local engram, or a remote store's scope (or the namespaced ENG-XXX-\u2026 id from recall) for the remote one.` }
1121
1414
  }
1122
1415
  },
1123
1416
  handler: async (args, plur) => {
@@ -1134,7 +1427,7 @@ function getAllToolDefinitions() {
1134
1427
  if (!args.id) throw new Error("Provide id (or list:true to list pinned)");
1135
1428
  const target = args.pinned ?? true;
1136
1429
  if (target === true) {
1137
- const q = await plur.pinnedQuota(args.id);
1430
+ const q = await plur.pinnedQuota(args.id, args.scope ? { scope: args.scope } : void 0);
1138
1431
  if (q.candidate && !q.candidate.fits) {
1139
1432
  const deficit = q.candidate.would_be - q.quota;
1140
1433
  const covering = [];
@@ -1159,7 +1452,7 @@ function getAllToolDefinitions() {
1159
1452
  };
1160
1453
  }
1161
1454
  }
1162
- const updated = await plur.setPinnedAsync(args.id, target);
1455
+ const updated = await plur.setPinnedAsync(args.id, target, args.scope ? { scope: args.scope } : void 0);
1163
1456
  if (!updated) throw new Error(`Engram not found: ${args.id}`);
1164
1457
  return {
1165
1458
  id: updated.id,
@@ -1190,18 +1483,18 @@ function getAllToolDefinitions() {
1190
1483
  const engram = scope ? void 0 : await plur.getById(args.id);
1191
1484
  if (engram) {
1192
1485
  if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
1193
- await plur.forget(args.id, args.reason, { force: true });
1194
- return { success: true, retired: { id: engram.id, statement: engram.statement } };
1486
+ const { warnings: warnings2 } = await plur.forget(args.id, args.reason, { force: true });
1487
+ return { success: true, retired: { id: engram.id, statement: engram.statement }, ...warnings2.length > 0 ? { warnings: warnings2 } : {} };
1195
1488
  }
1196
- await plur.forget(args.id, args.reason, { force: true, ...scope ? { scope } : {} });
1197
- return { success: true, retired: { id: args.id, ...scope ? { scope } : {} } };
1489
+ const { warnings } = await plur.forget(args.id, args.reason, { force: true, ...scope ? { scope } : {} });
1490
+ return { success: true, retired: { id: args.id, ...scope ? { scope } : {} }, ...warnings.length > 0 ? { warnings } : {} };
1198
1491
  }
1199
1492
  if (args.search) {
1200
1493
  const matches = await plur.recall(args.search, { limit: 100, remote: false });
1201
1494
  if (matches.length === 0) return { success: false, error: `No active engrams matching "${args.search}"` };
1202
1495
  if (matches.length === 1) {
1203
- await plur.forget(matches[0].id, args.reason, { force: true });
1204
- return { success: true, retired: { id: matches[0].id, statement: matches[0].statement } };
1496
+ const { warnings } = await plur.forget(matches[0].id, args.reason, { force: true });
1497
+ return { success: true, retired: { id: matches[0].id, statement: matches[0].statement }, ...warnings.length > 0 ? { warnings } : {} };
1205
1498
  }
1206
1499
  return {
1207
1500
  success: false,
@@ -1404,6 +1697,13 @@ function getAllToolDefinitions() {
1404
1697
  // field has, so a caller reading only that cannot distinguish a
1405
1698
  // clean pack from one whose baseline was destroyed.
1406
1699
  integrity_status: p.integrity_status,
1700
+ // 'carried-from-v1' when the v2 baseline was carried from a v1 row
1701
+ // without re-verification (§5.5): `ok` then means "unchanged since
1702
+ // the migration", not "matches what was installed".
1703
+ ...p.baseline ? { baseline: p.baseline } : {},
1704
+ // true when the pack's only registry row is a legacy row another
1705
+ // same-name pack could own; integrity_status is then 'unverified'.
1706
+ ...p.registry_ambiguous ? { registry_ambiguous: true } : {},
1407
1707
  installed_at: p.installed_at,
1408
1708
  source: p.source
1409
1709
  })),
@@ -1489,7 +1789,7 @@ function getAllToolDefinitions() {
1489
1789
  },
1490
1790
  {
1491
1791
  name: "plur_outbox",
1492
- description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable. Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
1792
+ description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable, plus any other queued remote operation core lists (e.g. a retirement still to be applied on the remote). Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
1493
1793
  annotations: { title: "Outbox", readOnlyHint: false, idempotentHint: false },
1494
1794
  inputSchema: {
1495
1795
  type: "object",
@@ -1500,11 +1800,21 @@ function getAllToolDefinitions() {
1500
1800
  handler: async (args, plur) => {
1501
1801
  const before = await plur.listOutbox();
1502
1802
  if (args.flush !== true) {
1503
- return { pending: before.length, entries: before };
1803
+ const summary = summarizeOutbox(before);
1804
+ return {
1805
+ pending: before.length,
1806
+ retrying: summary.retrying,
1807
+ needs_action: summary.needs_action,
1808
+ ...summary.needs_action > 0 ? { needs_action_scopes: summary.scopes } : {},
1809
+ entries: before
1810
+ };
1504
1811
  }
1505
- const result = await plur.flushOutbox();
1812
+ const result = await plur.flushOutbox({ force: true });
1506
1813
  return {
1507
- pending: await plur.outboxCount(),
1814
+ // Counted from the same list the entries come from (formal R2): a
1815
+ // separate counter can miss an entry kind the list shows (e.g. a
1816
+ // queued remote retirement), and report 0 while one is stuck.
1817
+ pending: (await plur.listOutbox()).length,
1508
1818
  flushed: result.flushed,
1509
1819
  failed: result.failed,
1510
1820
  ...result.expired_warnings.length > 0 ? { expired_warnings: result.expired_warnings } : {},
@@ -1632,8 +1942,13 @@ function getAllToolDefinitions() {
1632
1942
  },
1633
1943
  {
1634
1944
  name: "plur_validate_meta",
1635
- description: "Test a meta-engram template against engrams from a new domain \u2014 updates confidence and domain_coverage",
1636
- annotations: { title: "Validate meta-engram", destructiveHint: false, idempotentHint: false },
1945
+ description: "Test a meta-engram template against engrams from a new domain \u2014 updates confidence and domain_coverage. A meta-engram that fails validation in a third domain is demoted (top \u2192 mop) or, below top level, RETIRED.",
1946
+ // Destructive (owner decision I_tensions_resolve, formal R2): the third
1947
+ // failed validation retires a non-top meta-engram (core
1948
+ // meta/validation.ts) and the handler persists it. A removal needs an
1949
+ // explicit, gated act, so plur_admin refuses this tool and it is a direct
1950
+ // tool in every profile (CURSOR_CORE_TOOL_NAMES).
1951
+ annotations: { title: "Validate meta-engram", destructiveHint: true, idempotentHint: false },
1637
1952
  inputSchema: {
1638
1953
  type: "object",
1639
1954
  properties: {
@@ -1658,9 +1973,15 @@ function getAllToolDefinitions() {
1658
1973
  args.llm_api_key,
1659
1974
  args.llm_model
1660
1975
  );
1976
+ const wasRetired = meta.status === "retired";
1661
1977
  const result = await validateMetaEngram(meta, testEngrams, testDomain, llm);
1978
+ const retiredNow = !wasRetired && meta.status === "retired";
1662
1979
  await plur.updateEngram(meta);
1663
1980
  return {
1981
+ ...retiredNow ? {
1982
+ retired: true,
1983
+ note: `Meta-engram ${result.meta_engram_id} was retired: its prediction failed in a third domain. It no longer injects; its history records the retirement.`
1984
+ } : {},
1664
1985
  meta_engram_id: result.meta_engram_id,
1665
1986
  test_domain: result.test_domain,
1666
1987
  prediction_held: result.prediction_held,
@@ -1704,6 +2025,9 @@ function getAllToolDefinitions() {
1704
2025
  tension_count: status.tension_count,
1705
2026
  versioned_engram_count: status.versioned_engram_count ?? 0,
1706
2027
  outbox_count: status.outbox_count ?? 0,
2028
+ // #1299: queued writes a retry cannot deliver, per scope.
2029
+ outbox_needs_action: status.outbox_needs_action ?? 0,
2030
+ ...status.outbox_attention ? { outbox_attention: status.outbox_attention } : {},
1707
2031
  // Injection-provenance event/label counts (#452) — #202's volume gate.
1708
2032
  history_events: status.history_events ?? {
1709
2033
  co_injection: 0,
@@ -1716,7 +2040,7 @@ function getAllToolDefinitions() {
1716
2040
  // Artifacts that could not be read (audit 2026-08-03, finding 14).
1717
2041
  // Core reports these; this hand-built response dropped them, so an
1718
2042
  // agent asking for status saw a healthy-looking `pack_count: 0`.
1719
- ...status.store_errors ? { store_errors: status.store_errors } : {},
2043
+ ...status.store_errors ? { store_errors: redactStoreErrors(status.store_errors) } : {},
1720
2044
  // Spreading-activation drop counters — absent when both are zero.
1721
2045
  ...status.spread_drops ? { spread_drops: status.spread_drops } : {},
1722
2046
  // Version check (issue #151)
@@ -1727,7 +2051,11 @@ function getAllToolDefinitions() {
1727
2051
  behind: minorVersionsBehind(versionCheck.current, versionCheck.latest)
1728
2052
  }
1729
2053
  } : {},
1730
- capabilities: await mcpCanary.status()
2054
+ capabilities: await mcpCanary.status(),
2055
+ // #1526: a broken folders.yaml pauses every memory tool. Status is
2056
+ // where an agent looks, so it names the line, the problem in plain
2057
+ // words, and the repair (run only after the user agrees).
2058
+ ...folderMapStatus(plur.storageRoot)
1731
2059
  };
1732
2060
  }
1733
2061
  },
@@ -2010,6 +2338,9 @@ function getAllToolDefinitions() {
2010
2338
  detail: soon ? `Reachable, but token expires in ${h.tokenExpiresInDays}d \u2014 reauth soon` : `Reachable, auth valid${expiresNote}`
2011
2339
  });
2012
2340
  if (soon) remediation.push(`Remote ${h.url}: token expires in ${h.tokenExpiresInDays}d \u2014 mint a new token (<host>/me/api-keys), update ~/.plur/config.yaml, restart.`);
2341
+ } else if (h.tokenEnvUnset) {
2342
+ checks.push({ check: `remote store: ${h.url}`, ok: false, detail: tokenEnvUnsetDetail(h.tokenEnvUnset, h.scopes?.[0] ?? h.url) });
2343
+ remediation.push(tokenEnvUnsetFix(h.url, h.tokenEnvUnset));
2013
2344
  } else if (h.status === "auth_expired") {
2014
2345
  checks.push({ check: `remote store: ${h.url}`, ok: false, detail: `AUTH FAILED${expiresNote} \u2014 team-scoped writes are queuing to the outbox, not syncing. (${h.reason ?? ""})` });
2015
2346
  remediation.push(`Remote ${h.url}: re-authenticate \u2014 open <host>/auth/github (or <host>/me/api-keys) in a browser, paste the token into ~/.plur/config.yaml, then restart Claude/MCP so it reloads. Queued engrams flush on next session_start.`);
@@ -2055,9 +2386,18 @@ function getAllToolDefinitions() {
2055
2386
  } catch {
2056
2387
  }
2057
2388
  const tool_surface = describeToolSurface();
2389
+ const folderMap = folderMapStatus(plur.storageRoot);
2390
+ if (folderMap.folder_map) {
2391
+ const fm = folderMap.folder_map;
2392
+ checks.push({ check: "folder map", ok: false, detail: `${fm.file} ${fm.problem}` });
2393
+ remediation.push(`Folder map: ${fm.file} ${fm.problem}. ${fm.advice}`);
2394
+ } else {
2395
+ checks.push({ check: "folder map", ok: true, detail: "folders.yaml is readable (or absent: no decisions yet)" });
2396
+ }
2058
2397
  return {
2059
2398
  ok: checks.every((c) => c.ok),
2060
2399
  checks,
2400
+ ...folderMap,
2061
2401
  embedder: {
2062
2402
  before_probe: before,
2063
2403
  after_probe: after
@@ -2071,7 +2411,11 @@ function getAllToolDefinitions() {
2071
2411
  {
2072
2412
  name: "plur_session_start",
2073
2413
  description: "Start a session \u2014 inject relevant engrams for your task. Call at the beginning of every session.",
2074
- annotations: { title: "Session Start", readOnlyHint: true, idempotentHint: false },
2414
+ // Not read-only (formal R2, mcp-integrations#6): start registers the
2415
+ // session's scope, flushes the remote-write outbox (pushes to remote
2416
+ // stores) and writes telemetry. It only replays writes already asked for,
2417
+ // so it is not destructive.
2418
+ annotations: { title: "Session Start", readOnlyHint: false, destructiveHint: false, idempotentHint: false },
2075
2419
  inputSchema: {
2076
2420
  type: "object",
2077
2421
  properties: {
@@ -2101,6 +2445,12 @@ function getAllToolDefinitions() {
2101
2445
  } catch (err) {
2102
2446
  outbox_error = err.message;
2103
2447
  }
2448
+ let outbox_needs;
2449
+ try {
2450
+ const summary = await plur.outboxSummary();
2451
+ if (summary.needs_action > 0) outbox_needs = summary;
2452
+ } catch {
2453
+ }
2104
2454
  const remote_scopes = plur.getWritableRemoteScopes().map((s) => {
2105
2455
  const md = plur.getScopeMetadata(s.scope);
2106
2456
  return {
@@ -2109,18 +2459,32 @@ function getAllToolDefinitions() {
2109
2459
  ...md?.covers && md.covers.length > 0 ? { covers: md.covers } : {}
2110
2460
  };
2111
2461
  });
2112
- const projectConfig = readProjectConfig();
2462
+ const projectConfig = readTrustedProjectConfig(plur);
2113
2463
  const explicit_default_scope = args.default_scope ?? null;
2114
- const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
2115
- const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
2464
+ const folderCtx = _folderContext(args);
2465
+ let workspace = null;
2466
+ if (folderCtx) {
2467
+ try {
2468
+ workspace = await folderCtx.resolve();
2469
+ } catch {
2470
+ workspace = null;
2471
+ }
2472
+ }
2473
+ const folder_scope = folderCtx ? workspace?.scope ?? null : null;
2474
+ const default_scope = explicit_default_scope ?? (folderCtx ? folder_scope : projectConfig.scope ?? null) ?? null;
2475
+ const scope_source = explicit_default_scope ? "caller" : folder_scope && folder_scope !== projectConfig.scope ? "folder-map" : default_scope ? "project-config" : "none";
2116
2476
  const default_domain = projectConfig.domain ?? null;
2117
- plur.setSessionScope(default_scope);
2477
+ if (!folderCtx) plur.setSessionScope(default_scope);
2118
2478
  plur.setSessionScope(default_scope, { session: session_id });
2119
2479
  {
2120
2480
  const t = _sessionTelemetry.get(session_id);
2121
2481
  if (t) {
2122
2482
  t.default_scope = default_scope;
2123
2483
  t.default_scope_source = scope_source;
2484
+ if (workspace) {
2485
+ t.workspace_key = workspace.key;
2486
+ t.workspace_scope = workspace.scope;
2487
+ }
2124
2488
  }
2125
2489
  }
2126
2490
  const status = await plur.status().catch(() => null);
@@ -2129,7 +2493,7 @@ function getAllToolDefinitions() {
2129
2493
  episode_count: status?.episode_count ?? 0,
2130
2494
  pack_count: status?.pack_count ?? 0
2131
2495
  };
2132
- const store_errors = status?.store_errors;
2496
+ const store_errors = status?.store_errors ? redactStoreErrors(status.store_errors) : void 0;
2133
2497
  await plur.warmRemoteCaches().catch(() => {
2134
2498
  });
2135
2499
  let engrams = null;
@@ -2201,7 +2565,16 @@ ${guide}`;
2201
2565
  version_warning = `Update available: PLUR v${versionCheck.current} \u2192 v${versionCheck.latest}. Run: npm i -g @plur-ai/cli@latest && plur init (configs pin versions)`;
2202
2566
  }
2203
2567
  }
2204
- if (scope_source === "project-config") {
2568
+ if (projectConfig.warning) {
2569
+ guide = `\u26A0\uFE0F ${projectConfig.warning}
2570
+
2571
+ ${guide}`;
2572
+ }
2573
+ if (scope_source === "folder-map") {
2574
+ guide += `
2575
+
2576
+ This folder's scope: "${default_scope}" (from your folder map, plur folders). plur_learn calls without an explicit scope will be tagged with this scope. Pass scope: "global" only for genuinely cross-project knowledge.`;
2577
+ } else if (scope_source === "project-config") {
2205
2578
  guide += `
2206
2579
 
2207
2580
  Auto-detected project scope: "${default_scope}" (from .plur.yaml in the current project). plur_learn calls without an explicit scope will be tagged with this scope, keeping this project's knowledge separate from your other projects. Pass scope: "global" only for genuinely cross-project knowledge (general coding conventions, language gotchas, tool quirks).`;
@@ -2261,6 +2634,11 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
2261
2634
  } catch {
2262
2635
  }
2263
2636
  }
2637
+ if (outbox_needs) {
2638
+ guide += `
2639
+
2640
+ \u26A0\uFE0F OUTBOX: ${outbox_needs.needs_action} queued team write(s) cannot be delivered by retrying. ` + describeNeedsAction(outbox_needs).join(" ") + " Tell the user; nothing is dropped automatically. `plur outbox` lists them.";
2641
+ }
2264
2642
  const session_tool_profile = activeToolProfile();
2265
2643
  if (session_tool_profile !== "full") {
2266
2644
  guide += `
@@ -2277,6 +2655,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2277
2655
  ...remote_scopes.length > 0 ? { remote_scopes } : {},
2278
2656
  ...default_scope ? { default_scope, scope_source } : {},
2279
2657
  ...default_domain ? { default_domain, domain_source: "project-config" } : {},
2658
+ ...projectConfig.warning ? { project_config_warning: projectConfig.warning } : {},
2280
2659
  // Ask LLM to check back — MCP can't push, but we can request a follow-up
2281
2660
  follow_up: store_stats.engram_count === 0 ? "This is a fresh store with 0 engrams. After your first exchange with the user, review what you learned and call plur_learn for any corrections, preferences, or patterns. Build the memory from this session." : void 0,
2282
2661
  // On fresh install, suggest hook setup for reliable injection
@@ -2293,6 +2672,10 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2293
2672
  outbox_error,
2294
2673
  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.`
2295
2674
  } : {},
2675
+ // #1299: writes a retry cannot deliver — count, scope, reason, next step.
2676
+ ...outbox_needs ? {
2677
+ outbox_needs_action: { count: outbox_needs.needs_action, scopes: outbox_needs.scopes }
2678
+ } : {},
2296
2679
  // Version staleness warning (issue #151)
2297
2680
  ...version_warning ? { version_warning, version: VERSION } : {}
2298
2681
  };
@@ -2302,7 +2685,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2302
2685
  },
2303
2686
  {
2304
2687
  name: "plur_session_scope",
2305
- description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials; op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
2688
+ description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials (it needs an open session: with none open it refuses, since no id-less call reads a session-less slot); op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
2306
2689
  annotations: { title: "Session scope", destructiveHint: false, idempotentHint: true },
2307
2690
  inputSchema: {
2308
2691
  type: "object",
@@ -2335,6 +2718,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2335
2718
  const reason = args.reason;
2336
2719
  const { session, ambiguous, open } = _resolveScopeSession(args);
2337
2720
  const record = session ? _sessionTelemetry.get(session) : void 0;
2721
+ const noSessionSlot = session === void 0 && open === 0;
2338
2722
  const remote_scopes = plur.getWritableRemoteScopes();
2339
2723
  const withCommon = (body) => ({
2340
2724
  op,
@@ -2350,7 +2734,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2350
2734
  source,
2351
2735
  ...ambiguous ? {
2352
2736
  warning: `${open} sessions are open \u2014 this is the process-default slot, not a specific session's scope. Pass session_id (from plur_session_start) to inspect one.`
2353
- } : {},
2737
+ } : noSessionSlot && scope != null ? { warning: NO_SESSION_SLOT_WARNING } : {},
2354
2738
  guide: scope == null ? "No session default scope is set: unscoped plur_learn writes auto-route on a confident covers match or land at the unscoped default. Explicit per-call scope always wins." : `Unscoped plur_learn calls this session default to "${scope}"; recall dialing follows the same org context. Explicit per-call scope always wins.`
2355
2739
  });
2356
2740
  }
@@ -2360,6 +2744,11 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2360
2744
  );
2361
2745
  }
2362
2746
  if (op === "set") {
2747
+ if (noSessionSlot) {
2748
+ throw new Error(
2749
+ "plur_session_scope: no session is open, so there is no session scope to set \u2014 an id-less plur_learn / plur_inject / plur_recall uses no session default unless exactly one session is open. Call plur_session_start first (then pass its session_id here), or pass scope explicitly on each plur_learn."
2750
+ );
2751
+ }
2363
2752
  const scope = args.scope;
2364
2753
  if (typeof scope !== "string" || scope.trim().length === 0) {
2365
2754
  throw new Error('plur_session_scope: op:"set" requires a non-empty string "scope" (use op:"clear" to revert to the session-start default)');
@@ -2370,7 +2759,8 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2370
2759
  const { previous: previous2, next: next2 } = plur.adjustSessionScope(scope, { session, reason, trigger: "set" });
2371
2760
  if (record) record.scope_adjusted = true;
2372
2761
  const remoteEntry = remote_scopes.find((s) => s.scope === scope);
2373
- const warning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
2762
+ const sharedWarning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
2763
+ const warning = sharedWarning;
2374
2764
  return withCommon({
2375
2765
  previous_scope: previous2,
2376
2766
  new_scope: next2,
@@ -2378,7 +2768,8 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2378
2768
  ...warning ? { warning } : {}
2379
2769
  });
2380
2770
  }
2381
- const restored = record !== void 0 ? record.default_scope ?? null : readProjectConfig().scope ?? null;
2771
+ const clearCtx = _folderContext(args);
2772
+ const restored = record !== void 0 ? record.default_scope ?? null : clearCtx ? null : readTrustedProjectConfig(plur).scope ?? null;
2382
2773
  const restored_source = record !== void 0 ? record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : restored != null ? "project-config" : "none";
2383
2774
  const { previous, next } = plur.adjustSessionScope(restored, { session, reason, trigger: "clear" });
2384
2775
  if (record) record.scope_adjusted = false;
@@ -2424,7 +2815,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2424
2815
  }
2425
2816
  ]
2426
2817
  },
2427
- description: 'Learnings from this session. Preferred shape is {statement: "...", type?: "..."}; bare strings are also accepted and treated as the statement. Review the conversation for corrections, preferences, patterns, and technical facts before calling.'
2818
+ description: 'Learnings from this session. Preferred shape is {statement: "...", type?: "..."}; bare strings are also accepted and treated as the statement. If the whole parameter arrives as one plain string it is ONE suggestion (never split on commas); send several as a JSON array. Review the conversation for corrections, preferences, patterns, and technical facts before calling.'
2428
2819
  }
2429
2820
  },
2430
2821
  required: ["summary", "engram_suggestions"]
@@ -2457,13 +2848,19 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2457
2848
  session_id,
2458
2849
  channel: "mcp"
2459
2850
  });
2851
+ const endSession = _resolveInjectionSession(args);
2852
+ const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
2460
2853
  let engrams_created = 0;
2461
2854
  const engrams_failed = [];
2462
2855
  for (let i = 0; i < items.length; i++) {
2463
2856
  const { statement, type } = items[i];
2464
2857
  try {
2465
- await plur.learn(statement, {
2858
+ await plur.learnRouted(sanitizeStatement(statement), {
2466
2859
  type,
2860
+ // E7: no resolvable session → no session default; through the
2861
+ // server, the workspace's answer (#1563 review round 2).
2862
+ session: await _writeSession(args, plur, endSession),
2863
+ domain: projectDomain,
2467
2864
  // Link the engram back to the session that produced it (#960).
2468
2865
  session_episode_id: episode.id,
2469
2866
  // An end-of-session summary is the model's reading of what
@@ -2475,20 +2872,23 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2475
2872
  engrams_failed.push({ index: i, statement: statement.slice(0, 80), error: err.message });
2476
2873
  }
2477
2874
  }
2478
- const telemetry = session_id ? _sessionTelemetry.get(session_id) : void 0;
2875
+ const telemetry = endSession ? _sessionTelemetry.get(endSession) : void 0;
2479
2876
  const injection_summary = telemetry && telemetry.injection_calls > 0 ? {
2480
2877
  pack_counts: { ...telemetry.pack_counts },
2481
2878
  total_injections: telemetry.injection_calls,
2482
2879
  session_duration_ms: Date.now() - new Date(telemetry.started_at).getTime()
2483
2880
  } : void 0;
2484
- if (session_id) {
2485
- _sessionTelemetry.delete(session_id);
2486
- plur.clearSessionScope({ session: session_id });
2881
+ if (endSession) {
2882
+ _sessionTelemetry.delete(endSession);
2883
+ plur.clearSessionScope({ session: endSession });
2487
2884
  }
2488
2885
  try {
2489
- const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
2886
+ const plurDir = process.env.PLUR_PATH || join(homedir(), ".plur");
2490
2887
  const sessionsDir = join(plurDir, "sessions");
2491
- const keys = [session_id, process.env.CLAUDE_SESSION_ID, String(process.ppid)].filter(Boolean).map((k) => k.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64));
2888
+ const keys = [session_id, process.env.CLAUDE_SESSION_ID, String(process.ppid)].filter(Boolean).flatMap((k) => [
2889
+ (k.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown").slice(0, 64),
2890
+ k.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64)
2891
+ ]).filter(Boolean);
2492
2892
  for (const key of keys) {
2493
2893
  const cp = join(sessionsDir, `${key}.checkpoint.json`);
2494
2894
  if (existsSync(cp)) {
@@ -2595,12 +2995,24 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2595
2995
  const raw = args.min_confidence;
2596
2996
  const explicit = typeof raw === "number" && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : void 0;
2597
2997
  const minConfidence = explicit ?? plur.getScopeRoutingConfig().min_confidence ?? SUGGEST_DISPLAY_MIN_CONFIDENCE;
2598
- const candidates = await plur.suggestScope({
2998
+ const signals = {
2599
2999
  statement: args.statement,
2600
3000
  domain: args.domain,
2601
3001
  tags: args.tags
2602
- }, { minConfidence });
2603
- return { candidates, count: candidates.length, min_confidence: minConfidence };
3002
+ };
3003
+ const candidates = await plur.suggestScope(signals, { minConfidence });
3004
+ const decision = plur.previewAutoRoute(signals);
3005
+ const would_route = decision.action === "route" && decision.scope ? { scope: decision.scope, note: "An unscoped write of these signals would be auto-routed here." } : decision.action === "refuse-shared" && decision.refusedShared ? (() => {
3006
+ const why = describeRefusedRoute(decision.refusedShared.scope);
3007
+ return {
3008
+ scope: null,
3009
+ // Field name kept for compatibility; `refused_kind` says which kind it is.
3010
+ refused_shared: decision.refusedShared.scope,
3011
+ refused_kind: why.kind,
3012
+ note: `The best match is ${why.what}, and ${why.rule}. An unscoped write would land at the local default instead. Pass that scope explicitly if the engram belongs there.`
3013
+ };
3014
+ })() : { scope: null, note: "An unscoped write of these signals would land at the local default \u2014 nothing matched confidently enough to route." };
3015
+ return { candidates, count: candidates.length, min_confidence: minConfidence, would_route };
2604
3016
  }
2605
3017
  },
2606
3018
  {
@@ -2691,6 +3103,14 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2691
3103
  {
2692
3104
  name: "plur_rescope",
2693
3105
  description: "Move existing engram(s) to a different scope (#676) \u2014 e.g. promote a personal/local engram into a team scope so it reaches the shared store. Bypasses the content-hash dedup that makes a plur_learn re-emit a silent no-op: rescope matches by id and moves the engram. Remote targets (a configured writable store scope): a copy is pushed via the routed write path (the server assigns the id, provenance is kept in the copy's source field) and the local original is soft-retired with a superseded_by link \u2014 set keep_local:true to keep it active. Local targets (local, global, project:*): the scope is rewritten in place, preserving id and activation. The target must be local/global/project:* or a scope with a configured writable store \u2014 anything else fails early (typo protection). Content is re-scanned for secrets/sensitive material before any shared/remote target and a hit blocks the move. Batch via ids; dry_run:true previews every decision without mutating anything. NOT candidate activation \u2014 that is plur_promote.",
3106
+ // NOT destructive (owner decision I_tensions_resolve, formal R2): a
3107
+ // rescope never removes content. A local target rewrites the scope in
3108
+ // place (same id, same activation); a remote target retires the local
3109
+ // original only after the copy was pushed, and links it to that copy
3110
+ // with `superseded_by`. A copy always remains, so this is a move, not a
3111
+ // removal — it stays dispatchable through plur_admin
3112
+ // (rescope-tool.test.ts). Contrast plur_tensions resolve, which retires
3113
+ // the loser with no copy and is therefore destructive.
2694
3114
  annotations: { title: "Rescope", destructiveHint: false, idempotentHint: true },
2695
3115
  inputSchema: {
2696
3116
  type: "object",
@@ -2720,7 +3140,14 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2720
3140
  {
2721
3141
  name: "plur_tensions",
2722
3142
  description: 'Tension lifecycle (#181). Default: list persisted tension records (unresolved first). scan:true runs an LLM contradiction scan, persists NEW detections as records, and skips already-recorded pairs. Lifecycle actions: action:"confirm" (real conflict), action:"dismiss" (false positive \u2014 pair suppressed from future scans), action:"resolve" + winner:<engram_id> (loser engram retired). Scan requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args.',
2723
- annotations: { title: "Tensions", readOnlyHint: false, idempotentHint: true },
3143
+ // Not idempotent (formal R2, mcp-integrations#6): scan persists each NEW
3144
+ // detection, and an LLM judge can find new pairs on a repeat call.
3145
+ // Destructive (owner decision I_tensions_resolve, formal R2): action
3146
+ // "resolve" retires the losing engram with no copy left — exactly
3147
+ // plur_forget's effect. A removal needs an explicit, gated act, so
3148
+ // plur_admin refuses this tool and it is a direct tool in every profile
3149
+ // (CURSOR_CORE_TOOL_NAMES), where the client sees this annotation.
3150
+ annotations: { title: "Tensions", readOnlyHint: false, destructiveHint: true, idempotentHint: false },
2724
3151
  inputSchema: {
2725
3152
  type: "object",
2726
3153
  properties: {
@@ -2866,6 +3293,9 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2866
3293
  handler: async (args, plur) => {
2867
3294
  const engram = await plur.episodeToEngram(args.episode_id, {
2868
3295
  scope: args.scope,
3296
+ // The same default as every other new write (#1563 review round 3,
3297
+ // N1): never the process-wide slot.
3298
+ session: await _writeSession(args, plur),
2869
3299
  domain: args.domain,
2870
3300
  tags: args.tags
2871
3301
  });
@@ -3096,9 +3526,12 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
3096
3526
  }
3097
3527
 
3098
3528
  export {
3529
+ folderMapAdvice,
3099
3530
  registerFlushOnExit,
3100
3531
  validateToolArgs,
3101
3532
  mcpCanary,
3533
+ folderOnCommand,
3534
+ FOLDER_SCOPE,
3102
3535
  CURSOR_CORE_TOOL_NAMES,
3103
3536
  resolveToolProfile,
3104
3537
  setActiveToolProfile,