@plur-ai/mcp 0.19.4 → 0.20.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
@@ -63,7 +63,7 @@ By default (lean profile), your agent gets 12 tools. Everything else is reachabl
63
63
  | `plur_tensions_purge` | Clear stale/resolved tensions |
64
64
  | `plur_admin` | Dispatch to any other tool: `{ action: "plur_packs_install", args: {...} }` |
65
65
 
66
- Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_learn_batch`, `plur_ingest`, `plur_sync`, `plur_packs_install`, `plur_packs_list`, `plur_capture`, `plur_timeline`, and more) are all reachable via `plur_admin`. Set `PLUR_TOOL_PROFILE=full` to expose all 43 tools directly.
66
+ Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_learn_batch`, `plur_ingest`, `plur_sync`, `plur_packs_install`, `plur_packs_list`, `plur_capture`, `plur_timeline`, `plur_provenance`, and more) are all reachable via `plur_admin`. Set `PLUR_TOOL_PROFILE=full` to expose all 44 tools directly.
67
67
 
68
68
  A `plur_*` name missing from `tools/list` means it moved behind the gateway, not that the server is down. `plur_admin { action: "help" }` returns every action with a one-line description and its argument schema; `plur_doctor` reports the same inventory as `tool_surface`.
69
69
 
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var VERSION = "0.20.1";
3
+
4
+ export {
5
+ VERSION
6
+ };
@@ -1,3 +1,7 @@
1
+ import {
2
+ VERSION
3
+ } from "./chunk-445S5QNU.js";
4
+
1
5
  // src/telemetry.ts
2
6
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
3
7
  function recordTelemetry(event) {
@@ -9,14 +13,11 @@ function recordTelemetry(event) {
9
13
  }
10
14
  }
11
15
 
12
- // src/version.ts
13
- var VERSION = "0.19.4";
14
-
15
16
  // src/tools.ts
16
17
  import { existsSync, unlinkSync } from "fs";
17
18
  import { join } from "path";
18
19
  import { homedir } from "os";
19
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES } from "@plur-ai/core";
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
21
  import { z } from "zod";
21
22
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
22
23
  return async (prompt) => {
@@ -89,7 +90,7 @@ var recallHandler = async (args, plur) => {
89
90
  const measuredUnder = raw.measured_under;
90
91
  const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
91
92
  return {
92
- id: e.id,
93
+ id: raw._originalId ?? bareEngramId(e.id),
93
94
  statement: e.statement + annotation + measuredAnnotation,
94
95
  type: e.type,
95
96
  scope: e.scope,
@@ -153,7 +154,7 @@ var recallHandler = async (args, plur) => {
153
154
  const measuredUnder = raw.measured_under;
154
155
  const measuredAnnotation = measuredUnder ? " [measured under: " + Object.entries(measuredUnder).filter(([, v]) => v != null).map(([k, v]) => `${k}=${v}`).join(", ") + "]" : "";
155
156
  const base = {
156
- id: e.id,
157
+ id: raw._originalId ?? bareEngramId(e.id),
157
158
  statement: e.statement + annotation + measuredAnnotation,
158
159
  type: e.type,
159
160
  scope: e.scope,
@@ -299,7 +300,11 @@ var PLUR_GUIDE = `## PLUR Quick Start
299
300
  5. Call **plur_session_end** before the conversation ends \u2014 suggest new engrams
300
301
 
301
302
  ### Core Tools
302
- - **plur_learn** \u2014 record corrections, preferences, patterns (CALL THIS OFTEN)
303
+ - **plur_learn** \u2014 one assertion per call, small enough to act on at a glance. The
304
+ mechanism goes in \`rationale\`, the evidence in \`source\`. Call it often, and do not
305
+ force one: a bad engram costs injection budget forever, a missed one costs a re-ask.
306
+ For anything beyond a one-line correction, use the \`plur-create-engrams\` skill \u2014
307
+ it is the authoring contract, not a style preference.
303
308
  - **plur_recall** \u2014 search engrams by topic (default: hybrid BM25 + embeddings; use mode:"keyword" for BM25-only)
304
309
  - **plur_forget** \u2014 retire an outdated engram`;
305
310
  function getLlmFunction() {
@@ -318,6 +323,32 @@ function sanitizeStatement(raw) {
318
323
  }
319
324
  return raw.slice(0, cut).trimEnd();
320
325
  }
326
+ function composeHints(statement, rationale, source) {
327
+ const hints = [];
328
+ const chars = statement.length;
329
+ if (chars <= 400) return void 0;
330
+ if (/\b(on|proven|observed|stated|decided|confirmed)\s+20\d\d-\d\d-\d\d/i.test(statement)) {
331
+ hints.push("carries a dated observation \u2014 that is a citation, move it to `source`");
332
+ }
333
+ const engRefs = statement.match(/\b(ENG|ABS|META)-[A-Za-z0-9-]+/g);
334
+ if (engRefs && engRefs.length >= 2) {
335
+ hints.push(`names ${engRefs.length} other engrams \u2014 use relations.supersedes, or cite them in \`rationale\``);
336
+ }
337
+ if (/\b(because|since|the reason is|which is why)\b/i.test(statement) && !rationale) {
338
+ hints.push("argues its own case inline while `rationale` is empty \u2014 move the mechanism there");
339
+ }
340
+ if (/\b(and also|additionally|separately|furthermore)\b/i.test(statement)) {
341
+ hints.push('contains "and also" \u2014 that is a second engram, split it');
342
+ }
343
+ if (!rationale) {
344
+ hints.push("`rationale` is empty on a long statement: state the mechanism that makes this true, and therefore when it stops being true");
345
+ }
346
+ if (!source) {
347
+ hints.push("`source` is empty: where did this come from");
348
+ }
349
+ if (!hints.length) return void 0;
350
+ return { chars, misplaced: hints };
351
+ }
321
352
  var mcpCanary = new CapabilityCanary({ threshold: 10 });
322
353
  mcpCanary.expect({
323
354
  id: "session_start_hook",
@@ -514,12 +545,12 @@ function getAllToolDefinitions() {
514
545
  return [
515
546
  {
516
547
  name: "plur_learn",
517
- description: "Create an engram \u2014 record a reusable learning, preference, or correction. A write is never suppressed by similarity: exact content-hash duplicates NOOP, and anything merely SIMILAR is written and reported back in `dedup.near_duplicates` (closest existing engrams and their cosine scores) so you can supersede or merge deliberately. High similarity is a reason to look, not a decision \u2014 cosine cannot tell a duplicate from a correction of it. Multi-agent note: in an orchestration that spawns subagents, have the PARENT session own plur_learn writes \u2014 spawned subagents should return their findings as text for the parent to persist, rather than each calling plur_learn (tool availability is not guaranteed in every subagent context). See plur-ai/plur#281.",
548
+ description: "Create an engram \u2014 record a reusable learning, preference, or correction. A write is never suppressed by similarity: exact content-hash duplicates NOOP, and anything merely SIMILAR is written and reported back in `dedup.near_duplicates` (closest existing engrams, their cosine scores, and a preview of each neighbour's own statement \u2014 read them before moving on; that is what they are for) so you can supersede or merge deliberately. High similarity is a reason to look, not a decision \u2014 cosine cannot tell a duplicate from a correction of it. Multi-agent note: in an orchestration that spawns subagents, have the PARENT session own plur_learn writes \u2014 spawned subagents should return their findings as text for the parent to persist, rather than each calling plur_learn (tool availability is not guaranteed in every subagent context). See plur-ai/plur#281.",
518
549
  annotations: { title: "Learn", destructiveHint: false, idempotentHint: false },
519
550
  inputSchema: {
520
551
  type: "object",
521
552
  properties: {
522
- statement: { type: "string", description: "The knowledge assertion to store" },
553
+ statement: { type: "string", description: 'ONE assertion, written so someone who was not there can act on it. Route the rest to the field whose job it is: the mechanism that makes it true goes in `rationale`, where it came from in `source`, when it applies in `tags`/`domain`. An "and also" means a second engram. Good: "Never name a client unless the user names them first, say the customer." Typical 100-300 chars; past ~600 you are carrying another field content. Length is a symptom, not the rule.' },
523
554
  type: {
524
555
  type: "string",
525
556
  enum: ["behavioral", "terminological", "procedural", "architectural"],
@@ -528,10 +559,10 @@ function getAllToolDefinitions() {
528
559
  scope: { type: "string", description: "Namespace, e.g. global, project:myapp" },
529
560
  domain: { type: "string", description: "Domain tag, e.g. software.deployment" },
530
561
  tags: { type: "array", items: { type: "string" }, description: "Searchable keyword tags \u2014 contribute to BM25/embedding recall, so concrete keywords pay off" },
531
- rationale: { type: "string", description: "Why this knowledge matters \u2014 also enters the search corpus, helps recall by intent not just statement" },
562
+ rationale: { type: "string", description: 'The mechanism that makes the statement true, and therefore the condition under which it would STOP being true. One sentence. "Because the user said so on <date>" is a citation, not a mechanism: that belongs in `source`. This text is indexed, so a real mechanism also carries concrete nouns a future query can match. NOTE: constraints render without rationale (plur-ai/plur#1144), so a mechanism a prohibition needs the model to weigh must stay in the statement.' },
532
563
  source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
533
564
  pinned: { type: "boolean", description: "Always-load flag. If true, this engram bypasses the keyword-relevance gate at injection time. Use sparingly: meta-rules, safety conventions, core operating principles only." },
534
- commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked", "draft"], description: "How firmly the user has committed to this belief (default: leaning). `draft` marks the engram as pending human approval \u2014 core stores and recalls it normally; enforcement is left to deployments with a review queue." },
565
+ commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked", "draft"], description: "How firmly the user has committed to this belief (default: leaning). `draft` marks the engram as pending human approval: core stores and RECALLS it normally but NEVER injects it (#1141), so an unapproved rule cannot shape agent behaviour. Retrieval stays open because reviewing something requires reading it." },
535
566
  locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
536
567
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
537
568
  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)' },
@@ -539,7 +570,7 @@ function getAllToolDefinitions() {
539
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)." },
540
571
  measured_under: {
541
572
  type: "object",
542
- 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, differing-condition measurements are stored as refinements rather than tensions. Omit for non-numeric engrams.",
573
+ 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.",
543
574
  properties: {
544
575
  model: { type: "string", description: 'Model or system variant (e.g. "claude-opus-4", "gpt-4o")' },
545
576
  source_type: { type: "string", description: 'Source environment type (e.g. "local-git", "gitlab", "bench", "production")' },
@@ -547,6 +578,59 @@ function getAllToolDefinitions() {
547
578
  dataset: { type: "string", description: 'Dataset or workload identifier (e.g. "LongMemEval-S", "plur-bench-2026-Q2")' },
548
579
  date: { type: "string", description: "ISO date (YYYY-MM-DD) the measurement was taken" }
549
580
  }
581
+ },
582
+ attribution: {
583
+ type: "object",
584
+ description: 'Who is answerable for this memory (#961). Every sub-field optional; OMIT rather than guess \u2014 a memory with no agent is honest, one with an invented agent is worse than none. Set asserted_by to "unidentified" when nobody is identified, rather than leaving it out: absence cannot be told apart from a memory written before this existed.',
585
+ properties: {
586
+ asserted_by: { type: "string", description: 'Who or what asserted it. Any address: a local name, a Decentralized Identifier, or "unidentified".' },
587
+ runtime: {
588
+ type: "object",
589
+ description: "The software writing this. Usually known, so usually worth setting.",
590
+ properties: { name: { type: "string" }, version: { type: "string" } }
591
+ },
592
+ model: {
593
+ type: "object",
594
+ description: "The model behind the statement, if one was involved. Prompt TEXT is never stored, only a hash.",
595
+ properties: {
596
+ name: { type: "string" },
597
+ prompt_id: { type: "string" },
598
+ prompt_version: { type: "string" },
599
+ prompt_sha256: { type: "string" }
600
+ }
601
+ },
602
+ tool: {
603
+ type: "object",
604
+ description: "An extractor or importer, with its version.",
605
+ properties: { name: { type: "string" }, version: { type: "string" } }
606
+ },
607
+ on_behalf_of: { type: "string", description: "The party the runtime acted for." }
608
+ }
609
+ },
610
+ claim_class: {
611
+ type: "string",
612
+ enum: ["observed", "documented", "structural", "asserted", "inferred", "revised"],
613
+ description: 'What KIND of claim this is (#963), and the most useful single field for anyone later deciding how much to trust it. Use "asserted" when a PERSON stated it outright, "inferred" when YOU worked it out, "documented" when you took it from prose someone wrote, "observed" for a record of something that happened, "structural" when read off the shape of an artifact, "revised" for a rewrite. Omit only when it genuinely cannot be determined.'
614
+ },
615
+ license: {
616
+ type: "string",
617
+ description: 'Which licence governs reuse of this memory, as an SPDX-style identifier such as "cc-by-4.0" or "apache-2.0". Set it only when the user has actually said which licence applies \u2014 do NOT guess one. Left unset, a default applies that nobody chose, and a provenance record reports it as unchosen rather than presenting it as a decision.'
618
+ },
619
+ // Deliberately exposed to the LLM, reversing #139, which kept
620
+ // `visibility` off this schema because `public` is what gates pack
621
+ // export and shared git sync. Without it an agent cannot mark
622
+ // anything shareable, so every pack built from agent-written
623
+ // memories was empty (#970) — the walkthrough and the agent
624
+ // conversation demo both depend on it. The reason #139 was cautious
625
+ // still holds, and is answered elsewhere: every path `public` opens
626
+ // (pack export, shared sync, remote push, rescope, explicit update,
627
+ // outbox flush) scans the FULL engram — statement plus every other
628
+ // field, `attribution` and `license` included — before content
629
+ // leaves the machine. See `engramContentFields` in core.
630
+ visibility: {
631
+ type: "string",
632
+ 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.'
550
634
  }
551
635
  },
552
636
  required: ["statement"]
@@ -556,17 +640,34 @@ function getAllToolDefinitions() {
556
640
  const context = {
557
641
  type: args.type,
558
642
  scope: args.scope,
559
- domain: args.domain,
643
+ // .plur.yaml `domain:` as the default (#1148). The key was parsed by
644
+ // project-config and consumed nowhere, so setting it was a silent
645
+ // no-op — the same shape as injection.pinned_ratio before #1142.
646
+ // Domain is not decorative: scoreEngram counts every matching
647
+ // hierarchy segment as a FULL term hit, double the weight of a
648
+ // statement word, so a missing domain forfeits the strongest
649
+ // retrieval signal an author has. Explicit argument always wins.
650
+ domain: args.domain ?? readProjectConfig().domain ?? void 0,
560
651
  source: args.source,
561
652
  tags: args.tags,
562
653
  rationale: args.rationale,
563
654
  commitment: args.commitment,
564
655
  locked_reason: args.locked_reason,
656
+ // Quota-gated below, before the write — `plur_pin` was the only
657
+ // guarded entry point, and writing a NEW pinned engram is the other
658
+ // normal way to create a pin (#1138 review).
565
659
  pinned: args.pinned,
566
660
  valid_from: args.valid_from,
567
661
  valid_until: args.valid_until,
568
662
  supersedes: args.supersedes,
569
663
  measured_under: args.measured_under,
664
+ // Who is answerable, and what kind of claim this is (#961, #963).
665
+ // Passed through untouched: we never invent an agent, and we never
666
+ // guess a claim class the caller did not state.
667
+ attribution: args.attribution,
668
+ claim_class: args.claim_class,
669
+ license: args.license,
670
+ visibility: args.visibility,
570
671
  // #243: resolve which session's default scope governs this write —
571
672
  // explicit session_id first, else the lone open session. Never
572
673
  // persisted on the engram (LearnContext.session selects a scope, it
@@ -608,6 +709,20 @@ function getAllToolDefinitions() {
608
709
  };
609
710
  };
610
711
  const statement = sanitizeStatement(args.statement);
712
+ if (context.pinned === true) {
713
+ const q = await plur.pinnedQuota();
714
+ if (q.free <= 0) {
715
+ return {
716
+ success: false,
717
+ error: "pinned_quota_exceeded",
718
+ quota: q.quota,
719
+ used: q.used,
720
+ free: q.free,
721
+ pinned_count: q.count,
722
+ note: "The pinned set has no room left, so this engram cannot be pinned \u2014 a pin that does not fit is dropped at injection time, which is the silent failure the quota exists to prevent. Learn it unpinned (drop `pinned`), or unpin something first with plur_pin {list:true} to see the set and its costs, or raise `injection_budget` / `injection.pinned_ratio` in ~/.plur/config.yaml. The statement was NOT stored \u2014 re-send it once you have decided."
723
+ };
724
+ }
725
+ }
611
726
  try {
612
727
  const engram = await plur.learnRouted(statement, context);
613
728
  const isOutbox = !!engram.structured_data?._outbox;
@@ -616,6 +731,20 @@ function getAllToolDefinitions() {
616
731
  mcpCanary.signal("learn_activity");
617
732
  recordTelemetry("learn");
618
733
  const dedup = isOutbox ? void 0 : await plur.nearDuplicates(statement, context, engram.id);
734
+ const redraft = (() => {
735
+ const ids = args.supersedes;
736
+ if (!ids?.length) return void 0;
737
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
738
+ const sameDay = ids.filter((id) => {
739
+ const m = /(\d{4})-(\d{2})-?(\d{2})/.exec(id);
740
+ return m ? `${m[1]}-${m[2]}-${m[3]}` === today : false;
741
+ });
742
+ if (!sameDay.length) return void 0;
743
+ return {
744
+ superseded_today: sameDay,
745
+ note: "You are replacing an engram minted today \u2014 that is a redraft, not a correction, and it leaves a chain of near-identical records behind. Think the assertion through once and write it once. If the earlier one was simply wrong, retire it with plur_forget instead of stacking another supersede."
746
+ };
747
+ })();
619
748
  return {
620
749
  // #914: report the id in the form plur_recall hands back, so a
621
750
  // caller that records what it just learned and passes it to
@@ -632,6 +761,11 @@ function getAllToolDefinitions() {
632
761
  content_hash: engram.content_hash,
633
762
  decision: "ADD",
634
763
  ...dedup?.near_duplicates?.length ? { dedup } : {},
764
+ ...redraft ? { redraft } : {},
765
+ ...(() => {
766
+ const c = composeHints(statement, context?.rationale, context?.source);
767
+ return c ? { composition: c } : {};
768
+ })(),
635
769
  ...temporalEcho(engram),
636
770
  ...scopeHint(engram.scope, !!routed),
637
771
  ...domainHint(!!routed),
@@ -646,7 +780,11 @@ function getAllToolDefinitions() {
646
780
  mcpCanary.signal("learn_activity");
647
781
  recordTelemetry("learn");
648
782
  return {
649
- id: engram.id,
783
+ // Mirror the happy-path fix (#914): report the namespaced form so a
784
+ // caller holding this id can pass it to plur_forget / plur_feedback
785
+ // without hitting the collision the id form mismatch causes.
786
+ // Outbox engrams stay local-form (same rule as line 1149).
787
+ id: isOutbox ? engram.id : plur.readIdFor(engram),
650
788
  statement: engram.statement,
651
789
  scope: engram.scope,
652
790
  type: engram.type,
@@ -673,7 +811,7 @@ function getAllToolDefinitions() {
673
811
  items: {
674
812
  type: "object",
675
813
  properties: {
676
- statement: { type: "string", description: "The knowledge assertion to store" },
814
+ statement: { type: "string", description: 'ONE assertion, written so someone who was not there can act on it. Route the rest to the field whose job it is: the mechanism that makes it true goes in `rationale`, where it came from in `source`, when it applies in `tags`/`domain`. An "and also" means a second engram. Good: "Never name a client unless the user names them first, say the customer." Typical 100-300 chars; past ~600 you are carrying another field content. Length is a symptom, not the rule.' },
677
815
  type: { type: "string", enum: ["behavioral", "terminological", "procedural", "architectural"], description: "Category of the engram" },
678
816
  scope: { type: "string", description: "Namespace, e.g. global, project:myapp" },
679
817
  domain: { type: "string", description: "Domain tag, e.g. software.deployment" },
@@ -681,7 +819,7 @@ function getAllToolDefinitions() {
681
819
  rationale: { type: "string", description: "Why this knowledge matters \u2014 also enters the search corpus" },
682
820
  source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
683
821
  pinned: { type: "boolean", description: "Always-load flag. Use sparingly: meta-rules, safety conventions, core principles." },
684
- commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked", "draft"], description: "How firmly the user has committed (default: leaning). `draft` marks the engram as pending human approval \u2014 core stores and recalls it normally; enforcement is left to deployments with a review queue." },
822
+ commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked", "draft"], description: "How firmly the user has committed (default: leaning). `draft` marks the engram as pending human approval: core stores and RECALLS it normally but NEVER injects it (#1141), so an unapproved rule cannot shape agent behaviour. Retrieval stays open because reviewing something requires reading it." },
685
823
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid" },
686
824
  valid_until: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge expires" },
687
825
  measured_under: {
@@ -735,7 +873,10 @@ function getAllToolDefinitions() {
735
873
  recordTelemetry("learn");
736
874
  const ids = raw.map(() => null);
737
875
  for (const r of results) {
738
- if (r.input_index !== void 0) ids[r.input_index] = r.engram.id;
876
+ if (r.input_index !== void 0) {
877
+ const isOutbox = !!r.engram.structured_data?._outbox;
878
+ ids[r.input_index] = isOutbox ? r.engram.id : plur.readIdFor(r.engram);
879
+ }
739
880
  }
740
881
  let batchDomainHint = {};
741
882
  const routedInputs = /* @__PURE__ */ new Set();
@@ -755,19 +896,22 @@ function getAllToolDefinitions() {
755
896
  }
756
897
  return {
757
898
  ids,
758
- results: results.map((r) => ({
759
- input_index: r.input_index,
760
- id: r.engram.id,
761
- statement: r.engram.statement,
762
- scope: r.engram.scope,
763
- type: r.engram.type,
764
- decision: r.decision,
765
- ...r.existing_id ? { existing_id: r.existing_id } : {},
766
- // #856 audit: `dedup` was computed and then dropped here, so the
767
- // reporting it exists for reached no caller — "anything below the
768
- // bar is still reported" was not observable anywhere.
769
- ...r.dedup ? { dedup: r.dedup } : {}
770
- })),
899
+ results: results.map((r) => {
900
+ const isOutbox = !!r.engram.structured_data?._outbox;
901
+ return {
902
+ input_index: r.input_index,
903
+ id: isOutbox ? r.engram.id : plur.readIdFor(r.engram),
904
+ statement: r.engram.statement,
905
+ scope: r.engram.scope,
906
+ type: r.engram.type,
907
+ decision: r.decision,
908
+ ...r.existing_id ? { existing_id: r.existing_id } : {},
909
+ // #856 audit: `dedup` was computed and then dropped here, so the
910
+ // reporting it exists for reached no caller — "anything below the
911
+ // bar is still reported" was not observable anywhere.
912
+ ...r.dedup ? { dedup: r.dedup } : {}
913
+ };
914
+ }),
771
915
  stats,
772
916
  ...batchDomainHint,
773
917
  ...failures.length > 0 ? { failures, warning: `${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.` } : {}
@@ -857,7 +1001,11 @@ function getAllToolDefinitions() {
857
1001
  tokens_used: result.tokens_used,
858
1002
  injected_ids: result.injected_ids,
859
1003
  // #181: unresolved-tension warnings — flag contradicted context
860
- ...result.warnings ? { warnings: result.warnings } : {}
1004
+ ...result.warnings ? { warnings: result.warnings } : {},
1005
+ // #1142: pinned engrams that did not fit. `pinned: true` reads as a
1006
+ // promise; it is priority-subject-to-capacity, and a caller must be
1007
+ // able to see what it did not get.
1008
+ ...result.omitted_pinned?.length ? { omitted_pinned: result.omitted_pinned } : {}
861
1009
  };
862
1010
  }
863
1011
  },
@@ -892,7 +1040,11 @@ function getAllToolDefinitions() {
892
1040
  injected_ids: result.injected_ids,
893
1041
  mode: "hybrid",
894
1042
  // #181: unresolved-tension warnings — flag contradicted context
895
- ...result.warnings ? { warnings: result.warnings } : {}
1043
+ ...result.warnings ? { warnings: result.warnings } : {},
1044
+ // #1142: pinned engrams that did not fit. `pinned: true` reads as a
1045
+ // promise; it is priority-subject-to-capacity, and a caller must be
1046
+ // able to see what it did not get.
1047
+ ...result.omitted_pinned?.length ? { omitted_pinned: result.omitted_pinned } : {}
896
1048
  };
897
1049
  attachRemoteStoreDegradation(response, plur);
898
1050
  return response;
@@ -958,7 +1110,7 @@ function getAllToolDefinitions() {
958
1110
  },
959
1111
  {
960
1112
  name: "plur_pin",
961
- description: "Toggle the always-load (pinned) flag on an engram. Pinned engrams bypass the keyword-relevance gate at injection time and are eligible for loading on every session, regardless of overlap with the user task. Use sparingly \u2014 meta-rules, safety conventions, core operating principles. Pass {id, pinned:true} to pin or {id, pinned:false} to unpin. List current pinned with {list:true}.",
1113
+ description: `Toggle the always-load (pinned) flag on an engram. Pinned engrams bypass the keyword-relevance gate and load on every session regardless of overlap with the task. Use sparingly \u2014 meta-rules, safety conventions, core operating principles; a fact you need only sometimes should be recalled, not pinned. The pinned set has a QUOTA (injection_budget \xD7 injection.pinned_ratio): "always-load" only means anything if the set fits, so a pin that would exceed it is REFUSED with the current usage and unpin suggestions rather than silently dropping something already pinned. Resolve it by unpinning something or raising the limit \u2014 the choice is the user's, so surface it rather than picking one. Pass {id, pinned:true} to pin, {id, pinned:false} to unpin, {list:true} to list the set with its quota usage.`,
962
1114
  annotations: { title: "Pin", destructiveHint: false, idempotentHint: true },
963
1115
  inputSchema: {
964
1116
  type: "object",
@@ -971,13 +1123,42 @@ function getAllToolDefinitions() {
971
1123
  handler: async (args, plur) => {
972
1124
  if (args.list === true) {
973
1125
  const pinned = await plur.listPinned();
1126
+ const q = await plur.pinnedQuota();
974
1127
  return {
975
1128
  count: pinned.length,
1129
+ quota: { tokens: q.quota, used: q.used, free: q.free, over: q.over },
1130
+ ...q.over ? { warning: `Pinned engrams use ${q.used} tokens against a ${q.quota}-token quota. The overflow is dropped at injection time, so some pinned engrams are NOT being loaded. Unpin some, or raise injection_budget / injection.pinned_ratio.` } : {},
976
1131
  pinned: pinned.map((e) => ({ id: e.id, statement: e.statement, scope: e.scope, domain: e.domain }))
977
1132
  };
978
1133
  }
979
1134
  if (!args.id) throw new Error("Provide id (or list:true to list pinned)");
980
1135
  const target = args.pinned ?? true;
1136
+ if (target === true) {
1137
+ const q = await plur.pinnedQuota(args.id);
1138
+ if (q.candidate && !q.candidate.fits) {
1139
+ const deficit = q.candidate.would_be - q.quota;
1140
+ const covering = [];
1141
+ let freed = 0;
1142
+ for (const e of q.entries) {
1143
+ if (freed >= deficit) break;
1144
+ covering.push(e);
1145
+ freed += e.cost;
1146
+ }
1147
+ const suggestions = covering.slice(0, 5).map((e) => ({ id: e.id, frees: e.cost, net_feedback: e.net_feedback, last_accessed: e.last_accessed, statement: e.statement.slice(0, 100) }));
1148
+ return {
1149
+ success: false,
1150
+ error: "pinned_quota_exceeded",
1151
+ quota: q.quota,
1152
+ used: q.used,
1153
+ this_engram_cost: q.candidate.cost,
1154
+ would_be: q.candidate.would_be,
1155
+ over_by: deficit,
1156
+ unpin_candidates: suggestions,
1157
+ unpins_needed: covering.length,
1158
+ note: `Pinned engrams are always-load, so the set cannot exceed its share of the injection budget \u2014 over-committing means silently dropping something already pinned. ` + (covering.length > suggestions.length ? `At least ${covering.length} unpins are needed to fit this one; the ${suggestions.length} largest are listed. ` : `Unpin ${covering.length === 1 ? "the suggestion" : "the suggestions"} below to fit this one. `) + "Or raise `injection_budget` / `injection.pinned_ratio` in ~/.plur/config.yaml. Candidates are ordered by what they free, NOT by importance: feedback covers ~4% of engrams so it cannot rank them, and a ranking that looks authoritative would put a thumb on a decision only the user can make."
1159
+ };
1160
+ }
1161
+ }
981
1162
  const updated = await plur.setPinnedAsync(args.id, target);
982
1163
  if (!updated) throw new Error(`Engram not found: ${args.id}`);
983
1164
  return {
@@ -996,6 +1177,10 @@ function getAllToolDefinitions() {
996
1177
  properties: {
997
1178
  id: { type: "string", description: "Exact engram ID to retire" },
998
1179
  search: { type: "string", description: "Search term to find engram to retire" },
1180
+ reason: {
1181
+ type: "string",
1182
+ description: "Why this is being retired. Recorded in the history log and on the engram (#959). Say what changed, not just that something did."
1183
+ },
999
1184
  scope: { type: "string", description: 'Which store holds it (#831). Ids are minted per store, so one id can name several unrelated engrams. Pass "primary" to stay on disk \u2014 the local primary store and any local secondary stores, never a remote \u2014 or a remote scope (e.g. "group:plur/plur-ai/engineering") to target that server. A scope matching no configured store is rejected, not guessed at. Omit it and an id resolving in two places is refused.' }
1000
1185
  }
1001
1186
  },
@@ -1005,17 +1190,17 @@ function getAllToolDefinitions() {
1005
1190
  const engram = scope ? void 0 : await plur.getById(args.id);
1006
1191
  if (engram) {
1007
1192
  if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
1008
- await plur.forget(args.id, void 0, { force: true });
1193
+ await plur.forget(args.id, args.reason, { force: true });
1009
1194
  return { success: true, retired: { id: engram.id, statement: engram.statement } };
1010
1195
  }
1011
- await plur.forget(args.id, void 0, { force: true, ...scope ? { scope } : {} });
1196
+ await plur.forget(args.id, args.reason, { force: true, ...scope ? { scope } : {} });
1012
1197
  return { success: true, retired: { id: args.id, ...scope ? { scope } : {} } };
1013
1198
  }
1014
1199
  if (args.search) {
1015
1200
  const matches = await plur.recall(args.search, { limit: 100, remote: false });
1016
1201
  if (matches.length === 0) return { success: false, error: `No active engrams matching "${args.search}"` };
1017
1202
  if (matches.length === 1) {
1018
- await plur.forget(matches[0].id, void 0, { force: true });
1203
+ await plur.forget(matches[0].id, args.reason, { force: true });
1019
1204
  return { success: true, retired: { id: matches[0].id, statement: matches[0].statement } };
1020
1205
  }
1021
1206
  return {
@@ -1162,6 +1347,20 @@ function getAllToolDefinitions() {
1162
1347
  conflicts: result.conflicts,
1163
1348
  security: result.security,
1164
1349
  registry: result.registry,
1350
+ // ENGRAM-STANDARD-v1 §5.6.5: what was neutralized, by field, and the
1351
+ // integrity verdict with "shipped none" distinct from "matched". Both
1352
+ // were computed and then dropped at this surface, so an agent
1353
+ // installing a pack could not tell the user either.
1354
+ neutralized: result.neutralized,
1355
+ integrity_check: result.integrity_check,
1356
+ // The four provenance counts §5.6.5 requires a consumer to report:
1357
+ // records found against engrams shipped, how many were unreadable,
1358
+ // how many name an engram the pack does not contain, and how many
1359
+ // engrams have no record. Computed by the preview install already
1360
+ // runs, and dropped at this surface until now — so an agent
1361
+ // installing a pack could not tell the user any of it. Absent when
1362
+ // the pack shipped no provenance directory at all.
1363
+ provenance: result.provenance,
1165
1364
  success: true
1166
1365
  };
1167
1366
  }
@@ -1518,6 +1717,8 @@ function getAllToolDefinitions() {
1518
1717
  // Core reports these; this hand-built response dropped them, so an
1519
1718
  // agent asking for status saw a healthy-looking `pack_count: 0`.
1520
1719
  ...status.store_errors ? { store_errors: status.store_errors } : {},
1720
+ // Spreading-activation drop counters — absent when both are zero.
1721
+ ...status.spread_drops ? { spread_drops: status.spread_drops } : {},
1521
1722
  // Version check (issue #151)
1522
1723
  ...versionCheck?.updateAvailable && versionCheck.latest ? {
1523
1724
  update_available: {
@@ -1530,9 +1731,99 @@ function getAllToolDefinitions() {
1530
1731
  };
1531
1732
  }
1532
1733
  },
1734
+ {
1735
+ name: "plur_provenance",
1736
+ description: 'Where a memory came from: who asserted it, whether a person stated it or a model worked it out, when, what it came from, and whether you may reuse it. Read-only. Accepts an engram id or a search term \u2014 nobody remembers ids. IMPORTANT when relaying to the user: report the `not_recorded` list as prominently as the rest. A memory written before provenance was captured genuinely cannot say who asserted it, and presenting the record as complete would make it look more authoritative than it is. Nothing here is guessed. Prefer relaying `summary`; ask for format "record" only when a machine-readable document is actually needed. NOT plur_receipt: this describes the origin of ONE memory; plur_receipt counts how the whole store is being used.',
1737
+ annotations: { title: "Where a memory came from", readOnlyHint: true, idempotentHint: true },
1738
+ inputSchema: {
1739
+ type: "object",
1740
+ properties: {
1741
+ id: { type: "string", description: "Exact engram id, e.g. ENG-2026-08-21-086" },
1742
+ search: { type: "string", description: "Find the engram by what it says, if you do not know its id" },
1743
+ format: {
1744
+ type: "string",
1745
+ enum: ["summary", "record"],
1746
+ description: "summary (default) is prose a person can read. record is the JSON-LD document, for machines."
1747
+ }
1748
+ // No `save` here. This tool is annotated read-only and idempotent,
1749
+ // and a host may run it without asking on that basis; a flag that
1750
+ // writes files would make the annotation a lie. Writing a record is
1751
+ // `plur provenance --write` on the command line, or
1752
+ // `plur.writeProvenance()`.
1753
+ }
1754
+ },
1755
+ handler: async (args, plur) => {
1756
+ let id = args.id;
1757
+ let matchedStatement;
1758
+ let matchCount = 0;
1759
+ const ellipsise = (t, n) => {
1760
+ const chars = Array.from(t);
1761
+ return chars.length > n ? `${chars.slice(0, n).join("")}\u2026` : t;
1762
+ };
1763
+ let alternatives = [];
1764
+ if (!id && typeof args.search === "string" && args.search.length) {
1765
+ const LIST = 3;
1766
+ const matches = await plur.recall(args.search, { limit: 25, remote: false });
1767
+ if (!matches.length) {
1768
+ return {
1769
+ found: false,
1770
+ message: `Nothing matched "${args.search}". Try different words, or pass an exact id.`
1771
+ };
1772
+ }
1773
+ id = matches[0].id;
1774
+ matchedStatement = matches[0].statement;
1775
+ matchCount = matches.length;
1776
+ alternatives = matches.slice(1, LIST).map((m) => ({ id: m.id, statement: ellipsise(m.statement, 80) }));
1777
+ }
1778
+ if (!id) {
1779
+ return { found: false, message: "Pass either an engram id or a search term." };
1780
+ }
1781
+ const record = await plur.provenanceFor(id, { mode: "portable" });
1782
+ if (!record) {
1783
+ return { found: false, message: `No engram with id ${id}.` };
1784
+ }
1785
+ if (args.format !== void 0 && args.format !== "summary" && args.format !== "record") {
1786
+ return {
1787
+ found: false,
1788
+ message: `Unknown format "${String(args.format)}". Use "summary" for prose or "record" for the JSON-LD document.`
1789
+ };
1790
+ }
1791
+ const summary = summariseProvenance(record);
1792
+ if (args.format === "record") {
1793
+ return {
1794
+ found: true,
1795
+ engram_id: id,
1796
+ record,
1797
+ // The same answers the summary gives. Asking for the document used
1798
+ // to mean losing every reuse verdict, so the same question got an
1799
+ // answer through one surface and silence through the other.
1800
+ ...summary.fields,
1801
+ not_recorded: summary.missing,
1802
+ complete: summary.complete
1803
+ };
1804
+ }
1805
+ return {
1806
+ found: true,
1807
+ engram_id: id,
1808
+ ...matchedStatement ? { matched: matchedStatement.slice(0, 200) } : {},
1809
+ summary: renderProvenanceSummary(summary),
1810
+ // Structured values, NOT a line-split of the prose above. `facts`
1811
+ // used to be exactly that: the same text a second time, which an
1812
+ // agent pays for twice and cannot parse either copy of.
1813
+ ...summary.fields,
1814
+ not_recorded: summary.missing,
1815
+ complete: summary.complete,
1816
+ ...matchCount > 1 ? {
1817
+ note: `${matchCount} engrams matched "${String(args.search)}"; this is the closest.` + (matchCount - 1 > alternatives.length ? ` Showing ${alternatives.length} of the other ${matchCount - 1}.` : ""),
1818
+ match_count: matchCount,
1819
+ other_matches: alternatives
1820
+ } : {}
1821
+ };
1822
+ }
1823
+ },
1533
1824
  {
1534
1825
  name: "plur_receipt",
1535
- description: 'Counted report of what your memory retrieved for you: engrams stored, how many were retrieved and how often, which are most relied on, and how much of the store is dormant. Local and read-only; every figure is directly counted, never estimated. IMPORTANT when relaying to the user: `activation_rate` is COVERAGE over the logging window (\u2248 how much of the store was surfaced), NOT a quality or effectiveness score \u2014 it is naturally low and FALLS as more engrams are added, so never present it as "memory is N% effective". A `summary` line is included; prefer relaying that.',
1826
+ description: 'Counted report of what your memory retrieved for you: engrams stored, how many were retrieved and how often, which are most relied on, and how much of the store is dormant. Local and read-only; every figure is directly counted, never estimated. IMPORTANT when relaying to the user: `activation_rate` is COVERAGE over the logging window (\u2248 how much of the store was surfaced), NOT a quality or effectiveness score \u2014 it is naturally low and FALLS as more engrams are added, so never present it as "memory is N% effective". A `summary` line is included; prefer relaying that. NOT plur_provenance: this counts usage across the whole store; plur_provenance says where a single memory came from.',
1536
1827
  annotations: { title: "Memory receipt", readOnlyHint: true, idempotentHint: true },
1537
1828
  inputSchema: {
1538
1829
  type: "object",
@@ -1822,6 +2113,7 @@ function getAllToolDefinitions() {
1822
2113
  const explicit_default_scope = args.default_scope ?? null;
1823
2114
  const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
1824
2115
  const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
2116
+ const default_domain = projectConfig.domain ?? null;
1825
2117
  plur.setSessionScope(default_scope);
1826
2118
  plur.setSessionScope(default_scope, { session: session_id });
1827
2119
  {
@@ -1853,9 +2145,15 @@ function getAllToolDefinitions() {
1853
2145
  _recordInjectionTelemetry(session_id, result.injected_packs);
1854
2146
  if (result.count > 0) {
1855
2147
  const lines = [];
1856
- if (result.directives) lines.push("## DIRECTIVES\n", result.directives);
1857
- if (result.constraints) lines.push("\n## CONSTRAINTS\n", result.constraints);
2148
+ if (result.constraints) lines.push("## CONSTRAINTS\n", result.constraints);
2149
+ if (result.directives) lines.push("\n## DIRECTIVES\n", result.directives);
1858
2150
  if (result.consider) lines.push("\n## ALSO CONSIDER\n", result.consider);
2151
+ if (result.omitted_pinned?.length) {
2152
+ lines.push(
2153
+ "\n## PINNED, NOT LOADED\n",
2154
+ `${result.omitted_pinned.length} pinned engram(s) did not fit this injection: ${result.omitted_pinned.map((o) => o.id).join(", ")}. Treat them as unread, not as absent \u2014 recall one explicitly if the task touches it.`
2155
+ );
2156
+ }
1859
2157
  engrams = { text: lines.join("\n"), count: result.count, injected_ids: result.injected_ids };
1860
2158
  }
1861
2159
  } catch {
@@ -1867,9 +2165,15 @@ function getAllToolDefinitions() {
1867
2165
  _recordInjectionTelemetry(session_id, result.injected_packs);
1868
2166
  if (result.count > 0) {
1869
2167
  const lines = [];
1870
- if (result.directives) lines.push("## DIRECTIVES\n", result.directives);
1871
- if (result.constraints) lines.push("\n## CONSTRAINTS\n", result.constraints);
2168
+ if (result.constraints) lines.push("## CONSTRAINTS\n", result.constraints);
2169
+ if (result.directives) lines.push("\n## DIRECTIVES\n", result.directives);
1872
2170
  if (result.consider) lines.push("\n## ALSO CONSIDER\n", result.consider);
2171
+ if (result.omitted_pinned?.length) {
2172
+ lines.push(
2173
+ "\n## PINNED, NOT LOADED\n",
2174
+ `${result.omitted_pinned.length} pinned engram(s) did not fit this injection: ${result.omitted_pinned.map((o) => o.id).join(", ")}. Treat them as unread, not as absent \u2014 recall one explicitly if the task touches it.`
2175
+ );
2176
+ }
1873
2177
  engrams = { text: lines.join("\n"), count: result.count, injected_ids: result.injected_ids };
1874
2178
  }
1875
2179
  }
@@ -1972,6 +2276,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
1972
2276
  // Remote scope routing info (#229)
1973
2277
  ...remote_scopes.length > 0 ? { remote_scopes } : {},
1974
2278
  ...default_scope ? { default_scope, scope_source } : {},
2279
+ ...default_domain ? { default_domain, domain_source: "project-config" } : {},
1975
2280
  // Ask LLM to check back — MCP can't push, but we can request a follow-up
1976
2281
  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,
1977
2282
  // On fresh install, suggest hook setup for reliable injection
@@ -2128,7 +2433,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2128
2433
  const summary = args.summary;
2129
2434
  const session_id = args.session_id;
2130
2435
  const suggestions = args.engram_suggestions;
2131
- let engrams_created = 0;
2436
+ const items = [];
2132
2437
  if (Array.isArray(suggestions) && suggestions.length) {
2133
2438
  for (let i = 0; i < suggestions.length; i++) {
2134
2439
  const s = suggestions[i];
@@ -2145,14 +2450,31 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2145
2450
  `engram_suggestions[${i}] must be a string or {statement: string, type?: string}, got ${typeof s}`
2146
2451
  );
2147
2452
  }
2148
- await plur.learn(statement, { type });
2149
- engrams_created++;
2453
+ items.push({ statement, type });
2150
2454
  }
2151
2455
  }
2152
2456
  const episode = plur.capture(summary, {
2153
2457
  session_id,
2154
2458
  channel: "mcp"
2155
2459
  });
2460
+ let engrams_created = 0;
2461
+ const engrams_failed = [];
2462
+ for (let i = 0; i < items.length; i++) {
2463
+ const { statement, type } = items[i];
2464
+ try {
2465
+ await plur.learn(statement, {
2466
+ type,
2467
+ // Link the engram back to the session that produced it (#960).
2468
+ session_episode_id: episode.id,
2469
+ // An end-of-session summary is the model's reading of what
2470
+ // happened, not something the user stated outright (#963).
2471
+ claim_class: "inferred"
2472
+ });
2473
+ engrams_created++;
2474
+ } catch (err) {
2475
+ engrams_failed.push({ index: i, statement: statement.slice(0, 80), error: err.message });
2476
+ }
2477
+ }
2156
2478
  const telemetry = session_id ? _sessionTelemetry.get(session_id) : void 0;
2157
2479
  const injection_summary = telemetry && telemetry.injection_calls > 0 ? {
2158
2480
  pack_counts: { ...telemetry.pack_counts },
@@ -2179,10 +2501,11 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2179
2501
  const status = await plur.status();
2180
2502
  return {
2181
2503
  engrams_created,
2504
+ ...engrams_failed.length ? { engrams_failed } : {},
2182
2505
  episode_id: episode.id,
2183
2506
  total_engrams: status.engram_count,
2184
2507
  ...injection_summary ? { injection_summary } : {},
2185
- hint: engrams_created === 0 ? "No engrams captured this session. If any corrections, preferences, or patterns came up, consider calling plur_learn before ending." : void 0
2508
+ hint: engrams_failed.length ? `${engrams_failed.length} suggestion(s) could not be stored \u2014 see engrams_failed. The rest were.` : engrams_created === 0 ? "No engrams captured this session. If any corrections, preferences, or patterns came up, consider calling plur_learn before ending." : void 0
2186
2509
  };
2187
2510
  }
2188
2511
  },
@@ -2459,12 +2782,15 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2459
2782
  batch_size: args.batch_size,
2460
2783
  temporal_domains: tensionsConfig.temporal_domains,
2461
2784
  snapshot_pairs: tensionsConfig.snapshot_pairs,
2785
+ measured_under_pairs: tensionsConfig.measured_under_pairs,
2462
2786
  temporal_discount: args.temporal_discount ?? tensionsConfig.temporal_discount,
2463
2787
  ...persist ? { exclude_pairs: new Set(plur.suppressedTensionPairKeys()) } : {}
2464
2788
  });
2465
2789
  const persisted = persist && result.tensions.length > 0 ? await plur.recordTensions(result.tensions) : void 0;
2466
2790
  return {
2467
2791
  pairs_checked: result.pairs_checked,
2792
+ // #869 review: policy skips are reported, never silent.
2793
+ skipped: result.skipped,
2468
2794
  count: result.new_tensions,
2469
2795
  ...persisted ? { persisted_new: persisted.new_count } : {},
2470
2796
  tensions: result.tensions.map((t, i) => ({
@@ -2642,7 +2968,11 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2642
2968
  filter_tags: { type: "array", items: { type: "string" }, description: "Filter by tags" },
2643
2969
  filter_type: { type: "string", enum: ["behavioral", "procedural", "architectural", "terminological"], description: "Filter by engram type" },
2644
2970
  output_dir: { type: "string", description: "Output directory (default: ~/plur-packs/<name>)" },
2645
- creator: { type: "string", description: "Creator name" }
2971
+ creator: { type: "string", description: "Creator name" },
2972
+ license: {
2973
+ type: "string",
2974
+ description: 'Licence for the pack as a collection, e.g. "cc-by-4.0", "apache-2.0", "cc0-1.0", or "unlicensed" to grant nothing. REQUIRED unless the user has set provenance.default_license in their config \u2014 export fails without one. Ask the user which licence applies; do NOT guess. A pack goes to strangers, and leaving this blank does not leave it blank: a share-alike default fills in that nobody agreed to.'
2975
+ }
2646
2976
  },
2647
2977
  required: ["name"]
2648
2978
  },
@@ -2665,12 +2995,27 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2665
2995
  const { homedir: homedir2 } = await import("os");
2666
2996
  const { join: join2 } = await import("path");
2667
2997
  const outputDir = args.output_dir || join2(homedir2(), "plur-packs", name);
2668
- const result = plur.exportPack(engrams, outputDir, {
2669
- name,
2670
- version: "1.0.0",
2671
- description: args.description,
2672
- creator: args.creator || void 0
2673
- });
2998
+ let result;
2999
+ try {
3000
+ result = plur.exportPack(engrams, outputDir, {
3001
+ name,
3002
+ version: "1.0.0",
3003
+ description: args.description,
3004
+ creator: args.creator || void 0,
3005
+ license: args.license || void 0
3006
+ });
3007
+ } catch (err) {
3008
+ const message = err.message;
3009
+ if (/needs a licence/.test(message)) {
3010
+ return {
3011
+ exported: false,
3012
+ error: "This pack has no licence, and export will not choose one.",
3013
+ next_step: 'Ask the user which licence applies to this pack (for example cc-by-4.0, apache-2.0, cc0-1.0, or "unlicensed" to grant nothing) and call plur_packs_export again with `license` set. The user can also set provenance.default_license in their config to answer once.',
3014
+ name
3015
+ };
3016
+ }
3017
+ throw err;
3018
+ }
2674
3019
  return {
2675
3020
  path: result.path,
2676
3021
  engram_count: result.engram_count,
@@ -2752,7 +3097,6 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2752
3097
 
2753
3098
  export {
2754
3099
  registerFlushOnExit,
2755
- VERSION,
2756
3100
  validateToolArgs,
2757
3101
  mcpCanary,
2758
3102
  CURSOR_CORE_TOOL_NAMES,
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ VERSION
4
+ } from "./chunk-445S5QNU.js";
2
5
 
3
6
  // src/index.ts
4
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "fs";
5
8
  import { join } from "path";
6
9
  import { fileURLToPath } from "url";
7
10
  import { homedir, platform } from "os";
8
- var VERSION = "0.19.4";
9
11
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
10
12
 
11
13
  Usage:
@@ -295,7 +297,7 @@ async function runInit() {
295
297
  async function runPacks() {
296
298
  const plurPath = process.env.PLUR_PATH ?? join(homedir(), ".plur");
297
299
  const { Plur } = await import("@plur-ai/core");
298
- const { packsCommand } = await import("./packs-cli-YQTKUTWY.js");
300
+ const { packsCommand } = await import("./packs-cli-XXZTSBHW.js");
299
301
  const plur = new Plur({ path: plurPath });
300
302
  const result = await packsCommand(process.argv.slice(3), plur);
301
303
  if (result.stdout) process.stdout.write(result.stdout);
@@ -321,7 +323,7 @@ if (arg === "packs") {
321
323
  process.exit(0);
322
324
  }
323
325
  if (arg === "serve" || arg === void 0) {
324
- const { runStdio } = await import("./server-IJLE4NG4.js");
326
+ const { runStdio } = await import("./server-LODRVEQB.js");
325
327
  runStdio().catch((err) => {
326
328
  console.error("Failed to start PLUR MCP server:", err);
327
329
  process.exit(1);
@@ -7,8 +7,14 @@ async function packsCommand(args, plur) {
7
7
  if (!arg) return fail("Usage: plur-mcp packs install <path>\n");
8
8
  try {
9
9
  const result = await plur.installPack(arg);
10
- return ok(`Installed pack '${result.name}' (${result.installed} engrams)
11
- `);
10
+ let out = `Installed pack '${result.name}' (${result.installed} engrams)
11
+ `;
12
+ const n = result.neutralized;
13
+ if (n?.pinned_stripped) out += ` neutralized: pinned removed from ${n.pinned_stripped} engram(s)
14
+ `;
15
+ if (n?.locked_downgraded) out += ` neutralized: commitment: locked downgraded to decided on ${n.locked_downgraded} engram(s)
16
+ `;
17
+ return ok(out);
12
18
  } catch (err) {
13
19
  return fail(`Error: ${err.message}
14
20
  `);
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  CURSOR_CORE_TOOL_NAMES,
3
- VERSION,
4
3
  getToolDefinitions,
5
4
  mcpCanary,
6
5
  registerFlushOnExit,
7
6
  resolveToolProfile,
8
7
  setActiveToolProfile,
9
8
  validateToolArgs
10
- } from "./chunk-6HBPG6IR.js";
9
+ } from "./chunk-SWQDW42Z.js";
10
+ import {
11
+ VERSION
12
+ } from "./chunk-445S5QNU.js";
11
13
 
12
14
  // src/server.ts
13
15
  import { Server, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
@@ -2,7 +2,8 @@ import {
2
2
  CURSOR_CORE_TOOL_NAMES,
3
3
  getToolDefinitions,
4
4
  validateToolArgs
5
- } from "./chunk-6HBPG6IR.js";
5
+ } from "./chunk-SWQDW42Z.js";
6
+ import "./chunk-445S5QNU.js";
6
7
 
7
8
  // src/tools-export.ts
8
9
  function getToolSchemas(profile) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plur-ai/mcp",
3
3
  "mcpName": "io.github.plur-ai/plur",
4
- "version": "0.19.4",
4
+ "version": "0.20.1",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "plur-mcp": "dist/index.js"
@@ -16,7 +16,7 @@
16
16
  "@modelcontextprotocol/client": "2.0.0-beta.4",
17
17
  "@modelcontextprotocol/core": "2.0.0-beta.4",
18
18
  "zod": "^3.23.0",
19
- "@plur-ai/core": "0.19.4"
19
+ "@plur-ai/core": "0.20.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^25.5.0"
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "scripts": {
53
53
  "build": "tsup",
54
- "test": "vitest run"
54
+ "test": "vitest run",
55
+ "pretest": "npm run build"
55
56
  }
56
57
  }
@@ -11,11 +11,14 @@ x-datacore:
11
11
  match_terms: [memory, learn, remember, session, feedback, engram, forget, correction, preference, recall, verification, safety, plur]
12
12
  domain: plur.best-practices
13
13
  engram_count: 12
14
- # Note: every engram in this pack carries `pinned: true` at the engram
15
- # level, which is the actual "always-inject" mechanism (introduced in
16
- # PLUR 0.9.4). Pack-level injection_policy: pinned is not a recognized
17
- # value in 0.9.4 — keep it on_match here so loaders that respect it
18
- # behave predictably; the per-engram pinned flags do the work.
14
+ # The engrams below still carry `pinned: true`, but INSTALL STRIPS IT.
15
+ # `sanitizePackEngrams` removes `pinned` and downgrades `commitment: locked`
16
+ # from every pack, this one included, because a pack is an archive from a
17
+ # stranger and its author does not get to decide what is always in front of
18
+ # the recipient's model. The flags are left in place so the intent is
19
+ # legible and so the behaviour returns if the host ever grows a way to
20
+ # grant it deliberately. `injection_policy: on_match` is what actually
21
+ # governs this pack. See plur-ai/plur#1019.
19
22
  ---
20
23
 
21
24
  # Effective Memory
@@ -24,7 +27,9 @@ Your agent has memory. These habits make it actually useful.
24
27
 
25
28
  Without them, memory is a growing pile of assertions nobody retrieves. With them, memory compounds — each session builds on the last, corrections stick, and the agent gets measurably better over time.
26
29
 
27
- This pack is **pinned** in PLUR 0.9.4+. Engrams here bypass keyword gating and are always eligible for injection at session start. They cover the meta-rules every agent needs regardless of domain: how to capture corrections, when to recall before answering, what "verified" means, how to stay safe with destructive actions, and why never to type a weekday from memory.
30
+ These engrams cover the meta-rules every agent needs regardless of domain: how to capture corrections, when to recall before answering, what "verified" means, how to stay safe with destructive actions, and why never to type a weekday from memory.
31
+
32
+ They were written to be **pinned** — always eligible for injection, bypassing keyword gating. That is no longer what happens. Install strips `pinned` from every pack, this one included, so these engrams are matched on keywords like any other (`injection_policy: on_match`, with the `match_terms` above). The stripping is right — a pack author should not be able to occupy a recipient's context unconditionally — but it means this pack's meta-rules only surface when a session's wording happens to touch them.
28
33
 
29
34
  ## Install
30
35
 
@@ -46,9 +51,13 @@ npx @plur-ai/cli@0.9.4 packs install effective-memory
46
51
  - **Discipline** — read before edit; don't ask "want to continue?" mid-task.
47
52
  - **Time** — never type a day-of-week from memory.
48
53
 
49
- ## Why pinned
54
+ ## Why these were written pinned, and what happens instead
55
+
56
+ Pinned engrams bypass the keyword-relevance gate in `scoreEngram` and the per-pack and per-domain caps in `fillTokenBudget`. Cross-cutting meta-rules are the case that justifies it: "call `plur_learn` when corrected" is relevant to every session and keyword-matches almost none of them.
57
+
58
+ **Install strips the flag**, so that is not the behaviour you get. `sanitizePackEngrams` removes `pinned` and downgrades `commitment: locked` from every installed pack, and it is right to — a pack is an archive from a stranger, and letting its author decide what is permanently in front of your model is a privilege no producer should take by shipping a YAML field.
50
59
 
51
- Pinned engrams (introduced in PLUR 0.9.4) bypass the keyword-relevance gate in `scoreEngram` and per-pack/per-domain caps in `fillTokenBudget`. They are always eligible for injection regardless of how the user's query keywords overlap with the engram statement. Use this for cross-cutting meta-rules only; pinning everything defeats the purpose.
60
+ The consequence is that this pack's rules are keyword-gated, which is a weaker guarantee than they were designed for. Whether a host should be able to grant always-inject to a pack it trusts deliberately — as opposed to a pack claiming it — is an open question, tracked in plur-ai/plur#1019.
52
61
 
53
62
  ## Versioning
54
63