@plur-ai/mcp 0.21.0 → 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.
package/README.md CHANGED
@@ -69,6 +69,37 @@ Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_le
69
69
 
70
70
  A `plur_*` name missing from `tools/list` means it moved behind the gateway, not that the server is down. `plur_admin { action: "help" }` returns every action with a one-line description and its argument schema; `plur_doctor` reports the same inventory as `tool_surface`.
71
71
 
72
+ ## Folders where PLUR is off
73
+
74
+ Your folder map (`~/.plur/folders.yaml`, changed by `plur folders set` from a terminal) can turn PLUR off for a folder. The editor hooks already go silent there; the MCP server's memory tools do too.
75
+
76
+ **What is gated.** 33 tools — every tool that reads or writes engrams or episodes or returns their text: `plur_learn`, `plur_learn_batch`, `plur_recall`, `plur_recall_hybrid`, `plur_inject`, `plur_inject_hybrid`, `plur_session_start`, `plur_session_end`, `plur_capture`, `plur_timeline`, `plur_feedback`, `plur_pin`, `plur_forget`, `plur_ingest`, `plur_promote`, `plur_rescope`, `plur_episode_to_engram`, `plur_report_failure`, `plur_extract_meta`, `plur_meta_engrams`, `plur_validate_meta`, `plur_tensions`, `plur_tensions_purge`, `plur_similarity_search`, `plur_history`, `plur_provenance`, `plur_profile`, `plur_receipt`, `plur_packs_install`, `plur_packs_uninstall`, `plur_packs_export`, `plur_sync`, `plur_outbox` — called directly or through `plur_admin`. In an `off` folder they read and write no store, local or remote (no outbox row either), and return a normal, non-error answer:
77
+
78
+ ```json
79
+ { "success": true, "plur": "off", "folder": "/work/secret", "message": "PLUR is off for this folder … plur folders set /work/secret --on" }
80
+ ```
81
+
82
+ The message names every folder-map entry that turns the folder off (a parent folder or a glob can), with the command for each.
83
+
84
+ **What is not gated.** The admin and diagnostic tools keep working: `plur_status`, `plur_doctor`, `plur_stores_list`, `plur_stores_add`, `plur_sync_status`, `plur_packs_list`, `plur_packs_discover`, `plur_packs_preview`, `plur_scopes_discover`, `plur_suggest_scope`, `plur_session_scope`. Some of them still read stores — status, doctor and stores_list load them to count or probe them, and doctor runs a recall probe. What they can return: counts, health and configuration; A store that cannot be parsed is reported by the error's first line only (what went wrong, line and column) — in `plur_status` and in the error any tool returns — never by the file's lines. One exception: `plur_packs_preview` previews any pack directory the agent names, including an installed pack under your PLUR home, and then returns that pack's statements. Server startup is not gated yet: starting the server in an `off` folder can still register a `.plur/` store found there ([#1523](https://github.com/plur-ai/plur/issues/1523)).
85
+
86
+ **Which folder.** The editor's workspace: each root the client lists over MCP `roots/list`, plus the folder the server was started in. If any of them is `off`, the memory tools are off. The folder map is read on every call, so a change takes effect on the next one. Calls made while the roots are being fetched wait for them. If fetching them fails or times out, or a root does not resolve to a folder on this machine (`file://otherhost/…`, an encoded slash), that call does nothing (it answers `"reason": "workspace-unknown"`, not an error) and the next call asks again; it never falls back to the start folder alone. If the client's roots keep failing, memory stays off until they work. The roots answer is cached, and shared between calls made at the same time, only when the client declares `roots.listChanged`; otherwise every call sends its own roots request. A client that declares no roots capability at all, and was started outside the workspace, cannot be checked against it.
87
+
88
+ **A broken folder map fails safe.** If `folders.yaml` exists but cannot be read or parsed — including a dangling symlink, a symlink loop, an empty or comments-only file, and an unknown top-level key such as a misspelled `folder:` — the gated tools do nothing and answer with `"reason": "folder-map-unreadable"`, naming the file, the line and column, and the problem in plain words — quoting at most the key on that line, never a value (#1526). A misspelled `plur:` or `path:` key (`plru:`, `pth:`) counts as broken too. When `plur folders repair` can fix it, the answer carries `repair_summary` (what the repair changes, to show the user first) and `repair_command` (`plur folders repair --yes`, with `--path` for a non-default store) and tells the agent to run it only after the user agrees; otherwise it says the line must be fixed by hand. A repair adds no `on` to the map; folders with their own trusted `.plur.yaml` or project MCP config are on again, as before the map broke. `plur_status` and `plur_doctor` report the same problem as `folder_map` (and `plur_doctor` fails its `folder map` check). No `folders.yaml` at all (nothing at that path) means no decisions yet: every folder is undecided and gets the question below. A broken map offers no folder-question command and issues no nonce (only the repair command, when the repair can fix it). On first read, an old `trust.yaml` is imported into a new `folders.yaml` (a one-time core migration); otherwise the server never writes the map.
89
+
90
+ **An undecided folder gets the folder question** ([#1525](https://github.com/plur-ai/plur/issues/1525)). In a folder you have not decided about (folder map `ask`: no entry and no project marker, or a repo `.plur.yaml` asking for settings you have not trusted), the gated tools read and write no memory and answer, without an error, with the same question the editor hooks ask:
91
+
92
+ ```json
93
+ { "success": true, "plur": "ask", "folder": "/work/app", "question": "[PLUR Memory — no decision for this folder yet …] …",
94
+ "answers": [
95
+ { "label": "Yes, with the team scope group:acme/eng", "command": "plur folders set /work/app --scope group:acme/eng --nonce … --session mcp-…" },
96
+ { "label": "Yes, without a team scope", "command": "plur folders set /work/app --on --nonce … --session mcp-…" },
97
+ { "label": "Not now", "command": "plur folders set /work/app --not-now --nonce … --session mcp-…" },
98
+ { "label": "Never here", "command": "plur folders set /work/app --off --nonce … --session mcp-…" } ] }
99
+ ```
100
+
101
+ The agent asks you and runs the command for your answer (or you run it in a terminal). Each command carries its own single-use nonce, bound to that folder, that answer and this MCP session: a command from another session, or one that drops `--session`, is refused. Every memory call returns the same question, with the same nonces, until you answer; the next call after an answer follows it. **Yes** turns memory on, and the answer's team scope becomes this session's default write scope: an unscoped `plur_learn` or `plur_learn_batch` goes there, with or without `plur_session_start` (which also uses the folder map's scope for its default when you pass none). An unscoped `plur_recall` (either mode), `plur_recall_hybrid` or `plur_inject_hybrid` searches that team's store too, again with or without `plur_session_start`. An explicit scope still wins. With several workspace folders, a folder scope is used only when every folder (none left out, each through its real path) has the same one; the home folder, a folder above it or `/` as a workspace folder means no folder scope. A `plur_session_start` default holds only while the workspace folders, and the scope they resolve to, are the ones the session started with. **Never here** turns it off. **Not now** writes nothing to the folder map: memory stays off for the rest of this MCP session, without the question; the folder stays undecided, so the next session asks again. The server never writes the map itself. The session's unanswered nonces are deleted as soon as the session closes (stdin ends, or SIGTERM / SIGINT); the server then exits once the tool calls already running have answered (after stdin ends, however long they take; on a signal, within 2 s); a server killed outright leaves its nonce file, which the next server removes once the nonces have expired (24 h). Precedence across the workspace folders is off, then ask, then on. `off` is checked on every workspace folder, the server's start folder included. The question is never asked about your home folder, a filesystem root or a folder above home, whether it is a root the client sends or the start folder: a yes or a never-here there would cover every folder under it, so memory there runs as before. Otherwise it is asked about the client's roots when it sends any — the start folder is wherever the client happened to launch the server — and, when it sends none (or only such folders), about the start folder. "Not now" stops the question, not the other answers: the yes and never-here commands of the same question keep working for that session, as with the editor hooks.
102
+
72
103
  ## Sync across machines
73
104
 
74
105
  Your agent can sync memory to any git remote:
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "0.21.0";
2
+ var VERSION = "0.21.1";
3
3
 
4
4
  export {
5
5
  VERSION
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-WA3JKTZ7.js";
3
+ } from "./chunk-O2NJFMNP.js";
4
4
 
5
5
  // src/telemetry.ts
6
6
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
@@ -17,7 +17,27 @@ function recordTelemetry(event) {
17
17
  import { existsSync, unlinkSync } from "fs";
18
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, NO_SESSION, findProjectConfigPath, readProjectConfigFromPath, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES, bareEngramId, summariseProvenance, formatLayer3, renderProvenanceSummary, describeNeedsAction, summarizeOutbox } 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) => {
@@ -81,8 +101,9 @@ var recallHandler = async (args, plur) => {
81
101
  // changes) establishes the remote dialing org context when no explicit
82
102
  // scope filter is passed. Same rule as writes (E7, formal R2): not
83
103
  // exactly one open session and no id → NO_SESSION, never the process
84
- // slot the last-started session owns.
85
- session: _resolveWriteSession(args)
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)
86
107
  });
87
108
  const response2 = {
88
109
  results: results.map((e) => {
@@ -92,7 +113,11 @@ var recallHandler = async (args, plur) => {
92
113
  const measuredUnder = raw.measured_under;
93
114
  const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
94
115
  return {
95
- 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,
96
121
  statement: e.statement + annotation + measuredAnnotation,
97
122
  type: e.type,
98
123
  scope: e.scope,
@@ -126,8 +151,9 @@ var recallHandler = async (args, plur) => {
126
151
  // MCP recall remote budget (#776)
127
152
  // #243: session default scope (incl. mid-session plur_session_scope
128
153
  // changes) establishes the remote dialing org context when no explicit
129
- // scope filter is passed. Same rule as writes (E7, formal R2).
130
- session: _resolveWriteSession(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)
131
157
  });
132
158
  recordTelemetry("recall");
133
159
  const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
@@ -156,7 +182,8 @@ var recallHandler = async (args, plur) => {
156
182
  const measuredUnder = raw.measured_under;
157
183
  const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
158
184
  const base = {
159
- id: raw._originalId ?? bareEngramId(e.id),
185
+ id: e.id,
186
+ // same id plur_learn returned — see the keyword branch (F3)
160
187
  statement: e.statement + annotation + measuredAnnotation,
161
188
  type: e.type,
162
189
  scope: e.scope,
@@ -422,12 +449,51 @@ function trustCommand(dir, storageRoot, platform = process.platform) {
422
449
  const store = _shellWord(resolve(storageRoot), platform);
423
450
  return store === null ? null : `plur --path ${store} trust ${target}`;
424
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
+ }
425
486
  var UNTRUSTED_SCOPE_GRAMMAR = /^(?:global|[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._@/:-]{0,199})$/;
426
487
  var UNTRUSTED_DOMAIN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
427
488
  var _UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/;
428
489
  function _escapedText(s) {
429
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")}`);
430
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
+ }
431
497
  function readTrustedProjectConfig(trust) {
432
498
  const configPath = findProjectConfigPath();
433
499
  const raw = readProjectConfigFromPath(configPath);
@@ -472,6 +538,44 @@ var NO_SESSION_SLOT_WARNING = "No session is open, so this is the process-defaul
472
538
  function _resolveWriteSession(args) {
473
539
  return _resolveInjectionSession(args) ?? NO_SESSION;
474
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
+ }
475
579
  function _resolveScopeSession(args) {
476
580
  const explicit = args.session_id;
477
581
  if (typeof explicit === "string" && explicit.length > 0) {
@@ -587,7 +691,9 @@ function buildAdminDispatchTool(all) {
587
691
  return { ...validated.errorPayload, error: inner.startsWith(`${action}:`) ? inner : `${action}: ${inner}` };
588
692
  }
589
693
  try {
590
- 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);
591
697
  } catch (err) {
592
698
  const message = err?.message ?? String(err);
593
699
  throw new Error(message.startsWith(`${action}:`) ? message : `${action}: ${message}`);
@@ -762,13 +868,15 @@ function getAllToolDefinitions() {
762
868
  // #243: resolve which session's default scope governs this write —
763
869
  // explicit session_id first, else the lone open session. Never
764
870
  // persisted on the engram (LearnContext.session selects a scope, it
765
- // is not part of one).
766
- session: _resolveWriteSession(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),
767
874
  llm
768
875
  };
769
876
  const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
770
- const scopeHint = (engramScope, wasRouted) => {
877
+ const scopeHint = (engramScope, wasRouted, delivery) => {
771
878
  if (explicitScope || wasRouted || isSharedScope(engramScope)) return {};
879
+ if (delivery !== "local") return {};
772
880
  let remote = [];
773
881
  try {
774
882
  remote = plur.getWritableRemoteScopes();
@@ -776,6 +884,7 @@ function getAllToolDefinitions() {
776
884
  return {};
777
885
  }
778
886
  if (remote.length === 0) return {};
887
+ if (remote.some((s) => s.scope === engramScope)) return {};
779
888
  const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
780
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.` };
781
890
  };
@@ -823,7 +932,7 @@ function getAllToolDefinitions() {
823
932
  const routeRefused = engram.structured_data?._routeRefused;
824
933
  mcpCanary.signal("learn_activity");
825
934
  recordTelemetry("learn");
826
- 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));
827
936
  const redraft = (() => {
828
937
  const ids = args.supersedes;
829
938
  if (!ids?.length) return void 0;
@@ -861,6 +970,8 @@ function getAllToolDefinitions() {
861
970
  // more specific `warning` below (outbox, demotion, refusal) still
862
971
  // wins that key; `delivery_warning` keeps this one either way.
863
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 } : {},
864
975
  ...delivered.warning ? { delivery_warning: delivered.warning, warning: delivered.warning } : {},
865
976
  ...dedup?.near_duplicates?.length ? { dedup } : {},
866
977
  ...redraft ? { redraft } : {},
@@ -869,9 +980,9 @@ function getAllToolDefinitions() {
869
980
  return c ? { composition: c } : {};
870
981
  })(),
871
982
  ...temporalEcho(engram),
872
- ...scopeHint(engram.scope, !!routed),
983
+ ...scopeHint(engram.scope, !!routed, delivered.delivery),
873
984
  ...domainHint(!!routed),
874
- ...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." } : {},
875
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.` } : {},
876
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.` } : {},
877
988
  // #1115: a shared scope matched but was NOT adopted. Said plainly,
@@ -902,9 +1013,10 @@ function getAllToolDefinitions() {
902
1013
  type: engram.type,
903
1014
  ...learnDecision(engram),
904
1015
  delivery: delivered.delivery,
1016
+ ...delivered.reason ? { delivery_reason: delivered.reason, delivery_reason_code: delivered.reason_code } : {},
905
1017
  ...delivered.warning ? { delivery_warning: delivered.warning } : {},
906
1018
  ...temporalEcho(engram),
907
- ...scopeHint(engram.scope, !!routedFallback),
1019
+ ...scopeHint(engram.scope, !!routedFallback, delivered.delivery),
908
1020
  ...domainHint(!!routedFallback),
909
1021
  ...isOutbox ? { outbox: true } : {},
910
1022
  // The routed write can fail for reasons that have nothing to do
@@ -966,7 +1078,7 @@ function getAllToolDefinitions() {
966
1078
  if (raw.length === 0) {
967
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." };
968
1080
  }
969
- const batchSession = _resolveWriteSession(args);
1081
+ const batchSession = await _writeSession(args, plur);
970
1082
  const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
971
1083
  const items = raw.map((e) => ({
972
1084
  statement: sanitizeStatement(e.statement),
@@ -1066,9 +1178,14 @@ function getAllToolDefinitions() {
1066
1178
  const isOutbox = !!r.engram.structured_data?._outbox;
1067
1179
  const routed = r.engram.structured_data?._routed;
1068
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);
1069
1183
  return {
1070
1184
  input_index: r.input_index,
1071
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 } : {},
1072
1189
  statement: r.engram.statement,
1073
1190
  scope: r.engram.scope,
1074
1191
  type: r.engram.type,
@@ -1091,7 +1208,7 @@ function getAllToolDefinitions() {
1091
1208
  },
1092
1209
  {
1093
1210
  name: "plur_recall",
1094
- 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.',
1095
1212
  annotations: { title: "Recall", readOnlyHint: true, idempotentHint: true },
1096
1213
  inputSchema: {
1097
1214
  type: "object",
@@ -1118,7 +1235,7 @@ function getAllToolDefinitions() {
1118
1235
  },
1119
1236
  {
1120
1237
  name: "plur_recall_hybrid",
1121
- 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).",
1122
1239
  annotations: { title: "Recall (hybrid) [deprecated alias]", readOnlyHint: true, idempotentHint: true },
1123
1240
  inputSchema: {
1124
1241
  type: "object",
@@ -1200,7 +1317,11 @@ function getAllToolDefinitions() {
1200
1317
  budget: args.budget,
1201
1318
  scope: args.scope,
1202
1319
  source: "inject",
1203
- 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)
1204
1325
  });
1205
1326
  _recordInjectionTelemetry(session_id, result.injected_packs);
1206
1327
  const response = {
@@ -1269,8 +1390,8 @@ function getAllToolDefinitions() {
1269
1390
  return { mode: "batch", results, summary };
1270
1391
  }
1271
1392
  try {
1272
- await plur.feedback(args.id, args.signal, args.scope);
1273
- 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 } : {} };
1274
1395
  } catch (err) {
1275
1396
  if (err.message?.includes("readonly store")) {
1276
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." };
@@ -1288,7 +1409,8 @@ function getAllToolDefinitions() {
1288
1409
  properties: {
1289
1410
  id: { type: "string", description: "Engram ID to pin or unpin" },
1290
1411
  pinned: { type: "boolean", description: "Target value (default true)" },
1291
- 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.` }
1292
1414
  }
1293
1415
  },
1294
1416
  handler: async (args, plur) => {
@@ -1305,7 +1427,7 @@ function getAllToolDefinitions() {
1305
1427
  if (!args.id) throw new Error("Provide id (or list:true to list pinned)");
1306
1428
  const target = args.pinned ?? true;
1307
1429
  if (target === true) {
1308
- const q = await plur.pinnedQuota(args.id);
1430
+ const q = await plur.pinnedQuota(args.id, args.scope ? { scope: args.scope } : void 0);
1309
1431
  if (q.candidate && !q.candidate.fits) {
1310
1432
  const deficit = q.candidate.would_be - q.quota;
1311
1433
  const covering = [];
@@ -1330,7 +1452,7 @@ function getAllToolDefinitions() {
1330
1452
  };
1331
1453
  }
1332
1454
  }
1333
- const updated = await plur.setPinnedAsync(args.id, target);
1455
+ const updated = await plur.setPinnedAsync(args.id, target, args.scope ? { scope: args.scope } : void 0);
1334
1456
  if (!updated) throw new Error(`Engram not found: ${args.id}`);
1335
1457
  return {
1336
1458
  id: updated.id,
@@ -1361,18 +1483,18 @@ function getAllToolDefinitions() {
1361
1483
  const engram = scope ? void 0 : await plur.getById(args.id);
1362
1484
  if (engram) {
1363
1485
  if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
1364
- await plur.forget(args.id, args.reason, { force: true });
1365
- 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 } : {} };
1366
1488
  }
1367
- await plur.forget(args.id, args.reason, { force: true, ...scope ? { scope } : {} });
1368
- 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 } : {} };
1369
1491
  }
1370
1492
  if (args.search) {
1371
1493
  const matches = await plur.recall(args.search, { limit: 100, remote: false });
1372
1494
  if (matches.length === 0) return { success: false, error: `No active engrams matching "${args.search}"` };
1373
1495
  if (matches.length === 1) {
1374
- await plur.forget(matches[0].id, args.reason, { force: true });
1375
- 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 } : {} };
1376
1498
  }
1377
1499
  return {
1378
1500
  success: false,
@@ -1918,7 +2040,7 @@ function getAllToolDefinitions() {
1918
2040
  // Artifacts that could not be read (audit 2026-08-03, finding 14).
1919
2041
  // Core reports these; this hand-built response dropped them, so an
1920
2042
  // agent asking for status saw a healthy-looking `pack_count: 0`.
1921
- ...status.store_errors ? { store_errors: status.store_errors } : {},
2043
+ ...status.store_errors ? { store_errors: redactStoreErrors(status.store_errors) } : {},
1922
2044
  // Spreading-activation drop counters — absent when both are zero.
1923
2045
  ...status.spread_drops ? { spread_drops: status.spread_drops } : {},
1924
2046
  // Version check (issue #151)
@@ -1929,7 +2051,11 @@ function getAllToolDefinitions() {
1929
2051
  behind: minorVersionsBehind(versionCheck.current, versionCheck.latest)
1930
2052
  }
1931
2053
  } : {},
1932
- 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)
1933
2059
  };
1934
2060
  }
1935
2061
  },
@@ -2212,6 +2338,9 @@ function getAllToolDefinitions() {
2212
2338
  detail: soon ? `Reachable, but token expires in ${h.tokenExpiresInDays}d \u2014 reauth soon` : `Reachable, auth valid${expiresNote}`
2213
2339
  });
2214
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));
2215
2344
  } else if (h.status === "auth_expired") {
2216
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 ?? ""})` });
2217
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.`);
@@ -2257,9 +2386,18 @@ function getAllToolDefinitions() {
2257
2386
  } catch {
2258
2387
  }
2259
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
+ }
2260
2397
  return {
2261
2398
  ok: checks.every((c) => c.ok),
2262
2399
  checks,
2400
+ ...folderMap,
2263
2401
  embedder: {
2264
2402
  before_probe: before,
2265
2403
  after_probe: after
@@ -2323,16 +2461,30 @@ function getAllToolDefinitions() {
2323
2461
  });
2324
2462
  const projectConfig = readTrustedProjectConfig(plur);
2325
2463
  const explicit_default_scope = args.default_scope ?? null;
2326
- const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
2327
- 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";
2328
2476
  const default_domain = projectConfig.domain ?? null;
2329
- plur.setSessionScope(default_scope);
2477
+ if (!folderCtx) plur.setSessionScope(default_scope);
2330
2478
  plur.setSessionScope(default_scope, { session: session_id });
2331
2479
  {
2332
2480
  const t = _sessionTelemetry.get(session_id);
2333
2481
  if (t) {
2334
2482
  t.default_scope = default_scope;
2335
2483
  t.default_scope_source = scope_source;
2484
+ if (workspace) {
2485
+ t.workspace_key = workspace.key;
2486
+ t.workspace_scope = workspace.scope;
2487
+ }
2336
2488
  }
2337
2489
  }
2338
2490
  const status = await plur.status().catch(() => null);
@@ -2341,7 +2493,7 @@ function getAllToolDefinitions() {
2341
2493
  episode_count: status?.episode_count ?? 0,
2342
2494
  pack_count: status?.pack_count ?? 0
2343
2495
  };
2344
- const store_errors = status?.store_errors;
2496
+ const store_errors = status?.store_errors ? redactStoreErrors(status.store_errors) : void 0;
2345
2497
  await plur.warmRemoteCaches().catch(() => {
2346
2498
  });
2347
2499
  let engrams = null;
@@ -2418,7 +2570,11 @@ ${guide}`;
2418
2570
 
2419
2571
  ${guide}`;
2420
2572
  }
2421
- if (scope_source === "project-config") {
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") {
2422
2578
  guide += `
2423
2579
 
2424
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).`;
@@ -2612,7 +2768,8 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2612
2768
  ...warning ? { warning } : {}
2613
2769
  });
2614
2770
  }
2615
- const restored = record !== void 0 ? record.default_scope ?? null : readTrustedProjectConfig(plur).scope ?? null;
2771
+ const clearCtx = _folderContext(args);
2772
+ const restored = record !== void 0 ? record.default_scope ?? null : clearCtx ? null : readTrustedProjectConfig(plur).scope ?? null;
2616
2773
  const restored_source = record !== void 0 ? record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : restored != null ? "project-config" : "none";
2617
2774
  const { previous, next } = plur.adjustSessionScope(restored, { session, reason, trigger: "clear" });
2618
2775
  if (record) record.scope_adjusted = false;
@@ -2700,8 +2857,9 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2700
2857
  try {
2701
2858
  await plur.learnRouted(sanitizeStatement(statement), {
2702
2859
  type,
2703
- // E7: no resolvable session → no session default (NO_SESSION).
2704
- session: endSession ?? NO_SESSION,
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),
2705
2863
  domain: projectDomain,
2706
2864
  // Link the engram back to the session that produced it (#960).
2707
2865
  session_episode_id: episode.id,
@@ -3135,6 +3293,9 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
3135
3293
  handler: async (args, plur) => {
3136
3294
  const engram = await plur.episodeToEngram(args.episode_id, {
3137
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),
3138
3299
  domain: args.domain,
3139
3300
  tags: args.tags
3140
3301
  });
@@ -3365,9 +3526,12 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
3365
3526
  }
3366
3527
 
3367
3528
  export {
3529
+ folderMapAdvice,
3368
3530
  registerFlushOnExit,
3369
3531
  validateToolArgs,
3370
3532
  mcpCanary,
3533
+ folderOnCommand,
3534
+ FOLDER_SCOPE,
3371
3535
  CURSOR_CORE_TOOL_NAMES,
3372
3536
  resolveToolProfile,
3373
3537
  setActiveToolProfile,
package/dist/index.d.ts CHANGED
@@ -48,6 +48,13 @@ interface HookEntry {
48
48
  async?: boolean;
49
49
  }>;
50
50
  }
51
+ /**
52
+ * Write the PLUR section into CLAUDE.md with core's `upsertInstructionSection`
53
+ * — the same logic as `plur init`: only a section PLUR shipped is replaced,
54
+ * one the user wrote or edited is kept and the new one added beside it, and a
55
+ * timestamped backup precedes any change to an existing file.
56
+ */
57
+ declare function installClaudeMd(claudeMdPath?: string): Promise<string>;
51
58
  /**
52
59
  * Merge PLUR hooks into Claude Code settings (#1279). No I/O apart from
53
60
  * reading plur-hook.meta.json for the exec-form check.
@@ -72,4 +79,4 @@ declare function applyPlurHooks(settings: Settings, hooksMap: Record<string, Hoo
72
79
  status: 'installed' | 'healed' | 'already';
73
80
  };
74
81
 
75
- export { type HookEntry, type Settings, applyPlurHooks, buildPlurHooks, compareSemver, extractManifestVersion };
82
+ export { type HookEntry, type Settings, applyPlurHooks, buildPlurHooks, compareSemver, extractManifestVersion, installClaudeMd };