@plur-ai/mcp 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/{chunk-SWQDW42Z.js → chunk-UINIIUBY.js} +329 -60
- package/dist/{chunk-445S5QNU.js → chunk-WA3JKTZ7.js} +1 -1
- package/dist/index.d.ts +45 -1
- package/dist/index.js +201 -74
- package/dist/{server-LODRVEQB.js → server-EUMEOVXB.js} +2 -2
- package/dist/tools-export.js +2 -2
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VERSION
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-WA3JKTZ7.js";
|
|
4
4
|
|
|
5
5
|
// src/telemetry.ts
|
|
6
6
|
import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
|
|
@@ -15,9 +15,9 @@ function recordTelemetry(event) {
|
|
|
15
15
|
|
|
16
16
|
// src/tools.ts
|
|
17
17
|
import { existsSync, unlinkSync } from "fs";
|
|
18
|
-
import { join } from "path";
|
|
18
|
+
import { join, dirname, resolve } from "path";
|
|
19
19
|
import { homedir } from "os";
|
|
20
|
-
import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary,
|
|
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";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
|
|
23
23
|
return async (prompt) => {
|
|
@@ -79,8 +79,10 @@ var recallHandler = async (args, plur) => {
|
|
|
79
79
|
// MCP recall remote budget (#776)
|
|
80
80
|
// #243: session default scope (incl. mid-session plur_session_scope
|
|
81
81
|
// changes) establishes the remote dialing org context when no explicit
|
|
82
|
-
// scope filter is passed.
|
|
83
|
-
session
|
|
82
|
+
// scope filter is passed. Same rule as writes (E7, formal R2): not
|
|
83
|
+
// exactly one open session and no id → NO_SESSION, never the process
|
|
84
|
+
// slot the last-started session owns.
|
|
85
|
+
session: _resolveWriteSession(args)
|
|
84
86
|
});
|
|
85
87
|
const response2 = {
|
|
86
88
|
results: results.map((e) => {
|
|
@@ -124,8 +126,8 @@ var recallHandler = async (args, plur) => {
|
|
|
124
126
|
// MCP recall remote budget (#776)
|
|
125
127
|
// #243: session default scope (incl. mid-session plur_session_scope
|
|
126
128
|
// changes) establishes the remote dialing org context when no explicit
|
|
127
|
-
// scope filter is passed.
|
|
128
|
-
session:
|
|
129
|
+
// scope filter is passed. Same rule as writes (E7, formal R2).
|
|
130
|
+
session: _resolveWriteSession(args)
|
|
129
131
|
});
|
|
130
132
|
recordTelemetry("recall");
|
|
131
133
|
const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
|
|
@@ -216,10 +218,12 @@ function jsonSchemaPropToZod(prop) {
|
|
|
216
218
|
}
|
|
217
219
|
const items = prop.items;
|
|
218
220
|
const itemVariants = items?.anyOf ?? items?.oneOf;
|
|
219
|
-
|
|
220
|
-
if (itemsAcceptString) {
|
|
221
|
+
if (items?.type === "string") {
|
|
221
222
|
return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
222
223
|
}
|
|
224
|
+
if (Array.isArray(itemVariants) && itemVariants.some((v) => v?.type === "string")) {
|
|
225
|
+
return trimmed.length === 0 ? [] : [trimmed];
|
|
226
|
+
}
|
|
223
227
|
return val;
|
|
224
228
|
}, z.array(itemSchema));
|
|
225
229
|
}
|
|
@@ -253,9 +257,9 @@ function validateToolArgs(tool, rawArgs) {
|
|
|
253
257
|
const arrayShapedDrop = partialDrop && missingArrayParams.length > 0;
|
|
254
258
|
let dropHint = "";
|
|
255
259
|
if (wholePayloadDrop) {
|
|
256
|
-
dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.' : "");
|
|
260
|
+
dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays. (A list of free-text statements such as engram_suggestions is not comma-split: send it as a JSON string; a bare string is one item.)' : "");
|
|
257
261
|
} else if (arrayShapedDrop) {
|
|
258
|
-
dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays
|
|
262
|
+
dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays. (A list of free-text statements such as engram_suggestions is not comma-split: send it as a JSON string; a bare string is one item.)`;
|
|
259
263
|
}
|
|
260
264
|
const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
|
|
261
265
|
const disposition = wholePayloadDrop ? "The request reached the server but its arguments did not \u2014 do not abandon the call, and do not rewrite the payload: it was never evaluated. Retry the IDENTICAL call; it usually succeeds. If you issued several tool calls in one message, send them one per message \u2014 batching is the strongest correlate of this drop (#772)." : "The call reached the server \u2014 this is a malformed-arguments error, not a transport failure. Fix the field(s) named above and retry; do not abandon the call.";
|
|
@@ -362,16 +366,23 @@ mcpCanary.expect({
|
|
|
362
366
|
});
|
|
363
367
|
var _sessionTelemetry = /* @__PURE__ */ new Map();
|
|
364
368
|
var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
|
|
369
|
+
var _pendingScopeEvictions = /* @__PURE__ */ new Set();
|
|
365
370
|
function _cleanExpiredSessions(plur) {
|
|
366
371
|
const cutoff = Date.now() - SESSION_TTL_MS;
|
|
367
372
|
for (const [id, state] of _sessionTelemetry) {
|
|
368
373
|
if (new Date(state.started_at).getTime() < cutoff) {
|
|
369
374
|
_sessionTelemetry.delete(id);
|
|
375
|
+
_pendingScopeEvictions.add(id);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (plur) {
|
|
379
|
+
for (const id of _pendingScopeEvictions) {
|
|
370
380
|
try {
|
|
371
|
-
plur
|
|
381
|
+
plur.clearSessionScope({ session: id });
|
|
372
382
|
} catch {
|
|
373
383
|
}
|
|
374
384
|
}
|
|
385
|
+
_pendingScopeEvictions.clear();
|
|
375
386
|
}
|
|
376
387
|
}
|
|
377
388
|
function _implicitSessionId() {
|
|
@@ -379,11 +390,88 @@ function _implicitSessionId() {
|
|
|
379
390
|
if (_sessionTelemetry.size !== 1) return void 0;
|
|
380
391
|
return _sessionTelemetry.keys().next().value;
|
|
381
392
|
}
|
|
393
|
+
function learnDecision(engram) {
|
|
394
|
+
return (engram.write_count ?? 1) > 1 ? { decision: "NOOP", existing_id: engram.id } : { decision: "ADD" };
|
|
395
|
+
}
|
|
396
|
+
var _warnedUntrustedConfigs = /* @__PURE__ */ new Set();
|
|
397
|
+
function _estimatePinnedCost(statement, ctx) {
|
|
398
|
+
const id = "ENG-0000-0000-000";
|
|
399
|
+
const text = String(statement ?? "");
|
|
400
|
+
const commitment = typeof ctx.commitment === "string" ? ctx.commitment : void 0;
|
|
401
|
+
let rendered = 0;
|
|
402
|
+
try {
|
|
403
|
+
rendered = formatLayer3({ id, statement: text, domain: ctx.domain, rationale: ctx.rationale, commitment, confidence_score: 0 }).length + 1;
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
const fieldSum = id.length + 4 + text.length + (ctx.rationale ? 15 + ctx.rationale.length : 0) + (ctx.domain ? ctx.domain.length + 10 : 0) + (commitment ? commitment.length + 14 : 0) + 23;
|
|
407
|
+
return Math.ceil(Math.max(rendered, fieldSum) / 4);
|
|
408
|
+
}
|
|
409
|
+
function _shellWord(s, platform = process.platform) {
|
|
410
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/.test(s)) return null;
|
|
411
|
+
if (/^[A-Za-z0-9_@+=:,./~-]+$/.test(s)) return s;
|
|
412
|
+
if (platform === "win32") {
|
|
413
|
+
if (/[$`%!"\u201c\u201d\u201e]/.test(s) || s.endsWith("\\")) return null;
|
|
414
|
+
return `"${s}"`;
|
|
415
|
+
}
|
|
416
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
417
|
+
}
|
|
418
|
+
function trustCommand(dir, storageRoot, platform = process.platform) {
|
|
419
|
+
const target = dir === null ? "<dir>" : _shellWord(dir, platform);
|
|
420
|
+
if (target === null) return null;
|
|
421
|
+
if (!storageRoot || resolve(storageRoot) === resolve(join(homedir(), ".plur"))) return `plur trust ${target}`;
|
|
422
|
+
const store = _shellWord(resolve(storageRoot), platform);
|
|
423
|
+
return store === null ? null : `plur --path ${store} trust ${target}`;
|
|
424
|
+
}
|
|
425
|
+
var UNTRUSTED_SCOPE_GRAMMAR = /^(?:global|[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._@/:-]{0,199})$/;
|
|
426
|
+
var UNTRUSTED_DOMAIN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
|
|
427
|
+
var _UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/;
|
|
428
|
+
function _escapedText(s) {
|
|
429
|
+
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
|
+
}
|
|
431
|
+
function readTrustedProjectConfig(trust) {
|
|
432
|
+
const configPath = findProjectConfigPath();
|
|
433
|
+
const raw = readProjectConfigFromPath(configPath);
|
|
434
|
+
if (!raw.scope && !raw.domain) return {};
|
|
435
|
+
const configDir = configPath ? dirname(configPath) : null;
|
|
436
|
+
let trusted = false;
|
|
437
|
+
try {
|
|
438
|
+
trusted = configDir !== null && trust.isDirectoryTrusted(configDir);
|
|
439
|
+
} catch {
|
|
440
|
+
trusted = false;
|
|
441
|
+
}
|
|
442
|
+
if (trusted) return { scope: raw.scope, domain: raw.domain };
|
|
443
|
+
const declared = [
|
|
444
|
+
raw.scope ? UNTRUSTED_SCOPE_GRAMMAR.test(raw.scope) ? `scope ${_escapedText(raw.scope)}` : "an invalid scope" : null,
|
|
445
|
+
raw.domain ? UNTRUSTED_DOMAIN_GRAMMAR.test(raw.domain) ? `domain ${_escapedText(raw.domain)}` : "an invalid domain" : null
|
|
446
|
+
].filter(Boolean).join(" / ");
|
|
447
|
+
const cmd = configDir !== null && _UNSAFE_PATH_CHARS.test(configDir) ? null : trustCommand(configDir, trust.storageRoot);
|
|
448
|
+
const warning = `${configPath ? _escapedText(configPath) : ".plur.yaml"} declares ${declared}, but ${configDir ? _escapedText(configDir) : "its directory"} is not a trusted directory \u2014 ignoring it and using the local default scope instead. If this project is yours, ` + (cmd !== null ? `run: ${cmd}` : "run `plur trust` for that directory from a terminal.");
|
|
449
|
+
if (configPath && !_warnedUntrustedConfigs.has(configPath)) {
|
|
450
|
+
_warnedUntrustedConfigs.add(configPath);
|
|
451
|
+
try {
|
|
452
|
+
process.stderr.write(`[plur] ${warning}
|
|
453
|
+
`);
|
|
454
|
+
} catch {
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return { warning };
|
|
458
|
+
}
|
|
459
|
+
function describeRefusedRoute(scope) {
|
|
460
|
+
return isSharedScope(scope) ? { kind: "shared", what: `the shared scope "${scope}"`, rule: "unscoped writes are never auto-routed into a shared store" } : {
|
|
461
|
+
kind: "remote-personal",
|
|
462
|
+
what: `"${scope}", a personal scope on a remote store that is not verified as your own namespace (its /me identity is unknown or belongs to another user)`,
|
|
463
|
+
rule: "unscoped writes are never auto-routed into a remote personal namespace that is not verifiably yours"
|
|
464
|
+
};
|
|
465
|
+
}
|
|
382
466
|
function _resolveInjectionSession(args) {
|
|
383
467
|
const explicit = args.session_id;
|
|
384
468
|
if (typeof explicit === "string" && explicit.length > 0) return explicit;
|
|
385
469
|
return _implicitSessionId();
|
|
386
470
|
}
|
|
471
|
+
var NO_SESSION_SLOT_WARNING = "No session is open, so this is the process-default slot. An id-less plur_learn / plur_inject / plur_recall uses NO session default unless exactly one session is open, so this slot governs neither writes nor the recall dialing context. Call plur_session_start (then plur_session_scope with its session_id) to scope a session.";
|
|
472
|
+
function _resolveWriteSession(args) {
|
|
473
|
+
return _resolveInjectionSession(args) ?? NO_SESSION;
|
|
474
|
+
}
|
|
387
475
|
function _resolveScopeSession(args) {
|
|
388
476
|
const explicit = args.session_id;
|
|
389
477
|
if (typeof explicit === "string" && explicit.length > 0) {
|
|
@@ -415,7 +503,9 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
|
415
503
|
"plur_receipt",
|
|
416
504
|
"plur_doctor",
|
|
417
505
|
"plur_packs_uninstall",
|
|
418
|
-
"plur_tensions_purge"
|
|
506
|
+
"plur_tensions_purge",
|
|
507
|
+
"plur_tensions",
|
|
508
|
+
"plur_validate_meta"
|
|
419
509
|
]);
|
|
420
510
|
function summarizeToolDescription(description) {
|
|
421
511
|
const line = description.split("\n", 1)[0].trim();
|
|
@@ -493,13 +583,14 @@ function buildAdminDispatchTool(all) {
|
|
|
493
583
|
const innerArgs = args.args ?? {};
|
|
494
584
|
const validated = validateToolArgs(target, innerArgs);
|
|
495
585
|
if (!validated.ok) {
|
|
496
|
-
|
|
586
|
+
const inner = String(validated.errorPayload.error);
|
|
587
|
+
return { ...validated.errorPayload, error: inner.startsWith(`${action}:`) ? inner : `${action}: ${inner}` };
|
|
497
588
|
}
|
|
498
589
|
try {
|
|
499
590
|
return await target.handler(validated.data, plur);
|
|
500
591
|
} catch (err) {
|
|
501
592
|
const message = err?.message ?? String(err);
|
|
502
|
-
throw new Error(`${action}: ${message}`);
|
|
593
|
+
throw new Error(message.startsWith(`${action}:`) ? message : `${action}: ${message}`);
|
|
503
594
|
}
|
|
504
595
|
}
|
|
505
596
|
};
|
|
@@ -567,7 +658,7 @@ function getAllToolDefinitions() {
|
|
|
567
658
|
valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
|
|
568
659
|
valid_until: { type: "string", description: 'ISO date (YYYY-MM-DD) the knowledge expires \u2014 inject/recall skip the engram after this date. Set this for any time-bound fact (offers, deadlines, temporary endpoints). When omitted, an explicit expiry phrase in the statement ("valid until 31 May 2026") is auto-parsed and echoed back (#347)' },
|
|
569
660
|
supersedes: { type: "array", items: { type: "string" }, description: "Engram IDs this statement intentionally replaces (#240). Writes relations.supersedes on the new engram and the reverse superseded_by edge on each local target. Supersedes-linked pairs are skipped by tension scans \u2014 an intentional update is not a contradiction. Use when updating a standing fact (new version, changed rule) rather than contradicting it." },
|
|
570
|
-
session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open
|
|
661
|
+
session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open. With none or several open and no session_id, NO session default applies: the write takes the unscoped path (#243, E7)." },
|
|
571
662
|
measured_under: {
|
|
572
663
|
type: "object",
|
|
573
664
|
description: "Measurement context for numeric or benchmark-derived claims (#869). Records the conditions under which the asserted value was measured \u2014 model, source_type, hardware, dataset, date. When present, the tension scanner does not treat two measurements from the same store taken under different configurations as a contradiction (the skipped pair is reported in the scan result). Omit for non-numeric engrams.",
|
|
@@ -630,7 +721,7 @@ function getAllToolDefinitions() {
|
|
|
630
721
|
visibility: {
|
|
631
722
|
type: "string",
|
|
632
723
|
enum: ["private", "public", "template"],
|
|
633
|
-
description: '
|
|
724
|
+
description: 'Who this memory is shared with beyond its scope. Defaults to "private": EXCLUDED from every exported pack and from shared git sync. The default does NOT keep a team-scope write on this machine \u2014 with visibility omitted, a write whose scope is a team store (e.g. group:acme/eng) still goes to that team store. Only an EXPLICIT "private" keeps such a write local (stored here with a warning, never sent to the team store). Set "public" only when the user has said this is shareable with others \u2014 it is their decision, not yours. Without "public" nothing an agent writes can appear in a pack.'
|
|
634
725
|
}
|
|
635
726
|
},
|
|
636
727
|
required: ["statement"]
|
|
@@ -647,7 +738,7 @@ function getAllToolDefinitions() {
|
|
|
647
738
|
// hierarchy segment as a FULL term hit, double the weight of a
|
|
648
739
|
// statement word, so a missing domain forfeits the strongest
|
|
649
740
|
// retrieval signal an author has. Explicit argument always wins.
|
|
650
|
-
domain: args.domain ??
|
|
741
|
+
domain: args.domain ?? readTrustedProjectConfig(plur).domain ?? void 0,
|
|
651
742
|
source: args.source,
|
|
652
743
|
tags: args.tags,
|
|
653
744
|
rationale: args.rationale,
|
|
@@ -672,7 +763,7 @@ function getAllToolDefinitions() {
|
|
|
672
763
|
// explicit session_id first, else the lone open session. Never
|
|
673
764
|
// persisted on the engram (LearnContext.session selects a scope, it
|
|
674
765
|
// is not part of one).
|
|
675
|
-
session:
|
|
766
|
+
session: _resolveWriteSession(args),
|
|
676
767
|
llm
|
|
677
768
|
};
|
|
678
769
|
const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
|
|
@@ -725,9 +816,11 @@ function getAllToolDefinitions() {
|
|
|
725
816
|
}
|
|
726
817
|
try {
|
|
727
818
|
const engram = await plur.learnRouted(statement, context);
|
|
819
|
+
const delivered = plur.deliveryOf(engram, context?.scope);
|
|
728
820
|
const isOutbox = !!engram.structured_data?._outbox;
|
|
729
821
|
const demoted = engram.structured_data?._demoted;
|
|
730
822
|
const routed = engram.structured_data?._routed;
|
|
823
|
+
const routeRefused = engram.structured_data?._routeRefused;
|
|
731
824
|
mcpCanary.signal("learn_activity");
|
|
732
825
|
recordTelemetry("learn");
|
|
733
826
|
const dedup = isOutbox ? void 0 : await plur.nearDuplicates(statement, context, engram.id);
|
|
@@ -759,7 +852,16 @@ function getAllToolDefinitions() {
|
|
|
759
852
|
pinned: engram.pinned === true,
|
|
760
853
|
// See the note on recall results: same fact, not same record.
|
|
761
854
|
content_hash: engram.content_hash,
|
|
762
|
-
|
|
855
|
+
// An absorbed duplicate (content-hash or cross-scope recurrence)
|
|
856
|
+
// hands back the EXISTING engram with write_count bumped; reporting
|
|
857
|
+
// it as 'ADD' told the caller a new memory exists when none was
|
|
858
|
+
// written. Same vocabulary as plur_learn_batch (formal Adapters #1).
|
|
859
|
+
...learnDecision(engram),
|
|
860
|
+
// #1264: always present. The warning sits BEFORE the others so a
|
|
861
|
+
// more specific `warning` below (outbox, demotion, refusal) still
|
|
862
|
+
// wins that key; `delivery_warning` keeps this one either way.
|
|
863
|
+
delivery: delivered.delivery,
|
|
864
|
+
...delivered.warning ? { delivery_warning: delivered.warning, warning: delivered.warning } : {},
|
|
763
865
|
...dedup?.near_duplicates?.length ? { dedup } : {},
|
|
764
866
|
...redraft ? { redraft } : {},
|
|
765
867
|
...(() => {
|
|
@@ -771,10 +873,20 @@ function getAllToolDefinitions() {
|
|
|
771
873
|
...domainHint(!!routed),
|
|
772
874
|
...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
|
|
773
875
|
...demoted ? { demoted: true, requested_scope: demoted.from, warning: `Sensitive content (${demoted.patterns}) detected \u2014 stored at "${demoted.to}"/private instead of the requested shared scope "${demoted.from}". If this is a false positive, re-scope deliberately.` } : {},
|
|
774
|
-
...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason }, info: `No scope was provided; auto-routed to "${routed.scope}" (confidence ${routed.confidence}) because its content matched that scope's covers. Pass an explicit scope to override.` } : {}
|
|
876
|
+
...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
|
+
// #1115: a shared scope matched but was NOT adopted. Said plainly,
|
|
878
|
+
// as a `warning`, because the old `info` string for the opposite
|
|
879
|
+
// outcome proved easy to miss in a long session — and this one
|
|
880
|
+
// changes what the caller should do next, rather than merely
|
|
881
|
+
// reporting where the write went.
|
|
882
|
+
...routeRefused ? (() => {
|
|
883
|
+
const why = describeRefusedRoute(routeRefused.scope);
|
|
884
|
+
return { route_refused: { scope: routeRefused.scope, confidence: routeRefused.confidence, reason: routeRefused.reason, kind: why.kind }, warning: `No scope was provided. This content matched ${why.what} (confidence ${routeRefused.confidence}), but ${why.rule} \u2014 it was stored at "${engram.scope}" instead. If it belongs there, pass scope: "${routeRefused.scope}" explicitly, or move it with plur_rescope.` };
|
|
885
|
+
})() : {}
|
|
775
886
|
};
|
|
776
887
|
} catch (err) {
|
|
777
888
|
const engram = await plur.learn(statement, context);
|
|
889
|
+
const delivered = plur.deliveryOf(engram, context?.scope);
|
|
778
890
|
const isOutbox = !!engram.structured_data?._outbox;
|
|
779
891
|
const routedFallback = engram.structured_data?._routed;
|
|
780
892
|
mcpCanary.signal("learn_activity");
|
|
@@ -788,19 +900,25 @@ function getAllToolDefinitions() {
|
|
|
788
900
|
statement: engram.statement,
|
|
789
901
|
scope: engram.scope,
|
|
790
902
|
type: engram.type,
|
|
791
|
-
|
|
903
|
+
...learnDecision(engram),
|
|
904
|
+
delivery: delivered.delivery,
|
|
905
|
+
...delivered.warning ? { delivery_warning: delivered.warning } : {},
|
|
792
906
|
...temporalEcho(engram),
|
|
793
907
|
...scopeHint(engram.scope, !!routedFallback),
|
|
794
908
|
...domainHint(!!routedFallback),
|
|
795
909
|
...isOutbox ? { outbox: true } : {},
|
|
796
|
-
|
|
910
|
+
// The routed write can fail for reasons that have nothing to do
|
|
911
|
+
// with a remote (a local lock, a store error), and learn() only
|
|
912
|
+
// queues when the scope is remote-backed. Say what actually
|
|
913
|
+
// happened (formal Adapters #1).
|
|
914
|
+
warning: isOutbox ? `Remote write failed (${err.message}); engram queued for retry.` : `Routed write failed (${err.message}); stored through the local learn() fallback at "${engram.scope}".`
|
|
797
915
|
};
|
|
798
916
|
}
|
|
799
917
|
}
|
|
800
918
|
},
|
|
801
919
|
{
|
|
802
920
|
name: "plur_learn_batch",
|
|
803
|
-
description: 'Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost.
|
|
921
|
+
description: 'Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost. Each item is written through the same routed path as plur_learn (learnRouted, #930), resolves the same session default scope (pass `session_id` when several sessions are open) and the same .plur.yaml domain default, and a `pinned` item is refused with pinned_quota_exceeded when the pinned set has no room \u2014 reported in `failures`, the rest of the batch still written. Items take no `visibility`: each gets the default ("private" \u2014 excluded from packs and shared git sync), and an item whose scope is a team store still goes to that team store; to keep a team-scope memory local, use plur_learn with an explicit visibility: "private". See plur-ai/plur#281.',
|
|
804
922
|
annotations: { title: "Learn (batch)", destructiveHint: false, idempotentHint: false },
|
|
805
923
|
inputSchema: {
|
|
806
924
|
type: "object",
|
|
@@ -837,7 +955,8 @@ function getAllToolDefinitions() {
|
|
|
837
955
|
required: ["statement"]
|
|
838
956
|
}
|
|
839
957
|
},
|
|
840
|
-
max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." }
|
|
958
|
+
max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." },
|
|
959
|
+
session_id: { type: "string", description: "Session these writes belong to (from plur_session_start). Resolves the session default scope for items without an explicit scope, exactly as plur_learn does. Optional when one session is open. With none or several open and no session_id, NO session default applies: the write takes the unscoped path (#243, E7)." }
|
|
841
960
|
},
|
|
842
961
|
required: ["engrams"]
|
|
843
962
|
},
|
|
@@ -847,12 +966,15 @@ function getAllToolDefinitions() {
|
|
|
847
966
|
if (raw.length === 0) {
|
|
848
967
|
return { ids: [], results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [], warning: "No engrams provided \u2014 pass a non-empty `engrams` array." };
|
|
849
968
|
}
|
|
969
|
+
const batchSession = _resolveWriteSession(args);
|
|
970
|
+
const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
|
|
850
971
|
const items = raw.map((e) => ({
|
|
851
972
|
statement: sanitizeStatement(e.statement),
|
|
852
973
|
context: {
|
|
853
974
|
type: e.type,
|
|
854
975
|
scope: e.scope,
|
|
855
|
-
domain: e.domain,
|
|
976
|
+
domain: e.domain ?? projectDomain,
|
|
977
|
+
session: batchSession,
|
|
856
978
|
source: e.source,
|
|
857
979
|
tags: e.tags,
|
|
858
980
|
rationale: e.rationale,
|
|
@@ -864,11 +986,44 @@ function getAllToolDefinitions() {
|
|
|
864
986
|
}
|
|
865
987
|
}));
|
|
866
988
|
const maxLlmCalls = typeof args.max_llm_calls === "number" ? args.max_llm_calls : void 0;
|
|
867
|
-
const
|
|
868
|
-
|
|
989
|
+
const gateFailures = [];
|
|
990
|
+
let admitted = items.map((_, i) => i);
|
|
991
|
+
if (items.some((it) => it.context.pinned === true)) {
|
|
992
|
+
const q = await plur.pinnedQuota();
|
|
993
|
+
let free = q.free;
|
|
994
|
+
admitted = [];
|
|
995
|
+
items.forEach((it, i) => {
|
|
996
|
+
if (it.context.pinned !== true) {
|
|
997
|
+
admitted.push(i);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (free <= 0) {
|
|
1001
|
+
const claimed = q.free - free;
|
|
1002
|
+
gateFailures.push({
|
|
1003
|
+
index: i,
|
|
1004
|
+
statement: String(it.statement ?? "").slice(0, 80),
|
|
1005
|
+
error: `pinned_quota_exceeded: the pinned set has no room (quota ${q.quota}, used ${q.used}${claimed > 0 ? `, plus ~${claimed} claimed by earlier pinned items in this batch` : ""}); this item was NOT stored \u2014 learn it unpinned or unpin something first (plur_pin {list:true}).`
|
|
1006
|
+
});
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
admitted.push(i);
|
|
1010
|
+
free -= _estimatePinnedCost(it.statement, it.context);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
const batchOut = admitted.length === 0 ? { results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [] } : await plur.learnBatch(
|
|
1014
|
+
admitted.map((i) => items[i]),
|
|
869
1015
|
llm,
|
|
870
1016
|
maxLlmCalls !== void 0 ? { maxLlmCalls } : void 0
|
|
871
1017
|
);
|
|
1018
|
+
const results = batchOut.results.map((r) => ({
|
|
1019
|
+
...r,
|
|
1020
|
+
input_index: r.input_index !== void 0 ? admitted[r.input_index] : void 0
|
|
1021
|
+
}));
|
|
1022
|
+
const failures = [
|
|
1023
|
+
...batchOut.failures.map((f) => ({ ...f, index: admitted[f.index] ?? f.index })),
|
|
1024
|
+
...gateFailures
|
|
1025
|
+
].sort((a, b) => a.index - b.index);
|
|
1026
|
+
const stats = { ...batchOut.stats, failed: (batchOut.stats.failed ?? 0) + gateFailures.length };
|
|
872
1027
|
mcpCanary.signal("learn_activity");
|
|
873
1028
|
recordTelemetry("learn");
|
|
874
1029
|
const ids = raw.map(() => null);
|
|
@@ -894,10 +1049,23 @@ function getAllToolDefinitions() {
|
|
|
894
1049
|
} catch {
|
|
895
1050
|
}
|
|
896
1051
|
}
|
|
1052
|
+
const refusedScopes = [...new Set(results.map((r) => r.engram.structured_data?._routeRefused?.scope).filter((sc) => typeof sc === "string"))];
|
|
1053
|
+
const refusedCount = results.filter((r) => r.engram.structured_data?._routeRefused !== void 0).length;
|
|
1054
|
+
const warnings = [];
|
|
1055
|
+
if (failures.length > 0) {
|
|
1056
|
+
warnings.push(`${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.`);
|
|
1057
|
+
}
|
|
1058
|
+
if (refusedCount > 0) {
|
|
1059
|
+
warnings.push(
|
|
1060
|
+
`${refusedCount} of ${raw.length} engram(s) had no scope and matched ${refusedScopes.map((sc) => describeRefusedRoute(sc).what).join("; ")} \u2014 unscoped writes are never auto-routed into a shared store or into a remote personal namespace that is not verifiably yours, so they were stored at the local default instead. Pass an explicit scope on those items if they belong there, or move them with plur_rescope.`
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
897
1063
|
return {
|
|
898
1064
|
ids,
|
|
899
1065
|
results: results.map((r) => {
|
|
900
1066
|
const isOutbox = !!r.engram.structured_data?._outbox;
|
|
1067
|
+
const routed = r.engram.structured_data?._routed;
|
|
1068
|
+
const routeRefused = r.engram.structured_data?._routeRefused;
|
|
901
1069
|
return {
|
|
902
1070
|
input_index: r.input_index,
|
|
903
1071
|
id: isOutbox ? r.engram.id : plur.readIdFor(r.engram),
|
|
@@ -909,12 +1077,15 @@ function getAllToolDefinitions() {
|
|
|
909
1077
|
// #856 audit: `dedup` was computed and then dropped here, so the
|
|
910
1078
|
// reporting it exists for reached no caller — "anything below the
|
|
911
1079
|
// bar is still reported" was not observable anywhere.
|
|
912
|
-
...r.dedup ? { dedup: r.dedup } : {}
|
|
1080
|
+
...r.dedup ? { dedup: r.dedup } : {},
|
|
1081
|
+
...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason } } : {},
|
|
1082
|
+
...routeRefused ? { route_refused: { scope: routeRefused.scope, confidence: routeRefused.confidence, reason: routeRefused.reason } } : {}
|
|
913
1083
|
};
|
|
914
1084
|
}),
|
|
915
1085
|
stats,
|
|
916
1086
|
...batchDomainHint,
|
|
917
|
-
...failures.length > 0 ? { failures
|
|
1087
|
+
...failures.length > 0 ? { failures } : {},
|
|
1088
|
+
...warnings.length > 0 ? { warning: warnings.join(" ") } : {}
|
|
918
1089
|
};
|
|
919
1090
|
}
|
|
920
1091
|
},
|
|
@@ -939,7 +1110,7 @@ function getAllToolDefinitions() {
|
|
|
939
1110
|
budget: { type: "object", description: 'Budget constraints for sub-agents. Hybrid mode only \u2014 ignored when mode:"keyword".', properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
|
|
940
1111
|
caller_session_id: { type: "string", description: 'Session ID of calling agent for budget enforcement. Hybrid mode only \u2014 ignored when mode:"keyword".' },
|
|
941
1112
|
include_episodes: { type: "boolean", description: 'If true, include linked episode summaries for each engram (SP2 episodic anchoring). Hybrid mode only \u2014 ignored when mode:"keyword".' },
|
|
942
|
-
session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243)." }
|
|
1113
|
+
session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243); with none or several open and no session_id, no session default applies." }
|
|
943
1114
|
},
|
|
944
1115
|
required: ["query"]
|
|
945
1116
|
},
|
|
@@ -959,7 +1130,7 @@ function getAllToolDefinitions() {
|
|
|
959
1130
|
budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
|
|
960
1131
|
caller_session_id: { type: "string", description: "Session ID of calling agent for budget enforcement" },
|
|
961
1132
|
include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" },
|
|
962
|
-
session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243)." }
|
|
1133
|
+
session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243). Optional when one session is open; with none or several open and no session_id, no session default applies." }
|
|
963
1134
|
},
|
|
964
1135
|
required: ["query"]
|
|
965
1136
|
},
|
|
@@ -981,12 +1152,12 @@ function getAllToolDefinitions() {
|
|
|
981
1152
|
task: { type: "string", description: "The task description to inject context for" },
|
|
982
1153
|
budget: { type: "number", description: "Token budget for injection (default 2000)" },
|
|
983
1154
|
scope: { type: "string", description: "Scope filter for engram selection" },
|
|
984
|
-
session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open
|
|
1155
|
+
session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open. With none or several open and no session_id, no session default applies (E7) and the injection is not attributed to any session." }
|
|
985
1156
|
},
|
|
986
1157
|
required: ["task"]
|
|
987
1158
|
},
|
|
988
1159
|
handler: async (args, plur) => {
|
|
989
|
-
const session_id =
|
|
1160
|
+
const session_id = _resolveWriteSession(args);
|
|
990
1161
|
const result = await plur.inject(args.task, {
|
|
991
1162
|
budget: args.budget,
|
|
992
1163
|
scope: args.scope,
|
|
@@ -1019,12 +1190,12 @@ function getAllToolDefinitions() {
|
|
|
1019
1190
|
task: { type: "string", description: "The task description to inject context for" },
|
|
1020
1191
|
budget: { type: "number", description: "Token budget for injection (default 2000)" },
|
|
1021
1192
|
scope: { type: "string", description: "Scope filter for engram selection" },
|
|
1022
|
-
session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open
|
|
1193
|
+
session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open. With none or several open and no session_id, no session default applies (E7) and the injection is not attributed to any session." }
|
|
1023
1194
|
},
|
|
1024
1195
|
required: ["task"]
|
|
1025
1196
|
},
|
|
1026
1197
|
handler: async (args, plur) => {
|
|
1027
|
-
const session_id =
|
|
1198
|
+
const session_id = _resolveWriteSession(args);
|
|
1028
1199
|
const result = await plur.injectHybrid(args.task, {
|
|
1029
1200
|
budget: args.budget,
|
|
1030
1201
|
scope: args.scope,
|
|
@@ -1404,6 +1575,13 @@ function getAllToolDefinitions() {
|
|
|
1404
1575
|
// field has, so a caller reading only that cannot distinguish a
|
|
1405
1576
|
// clean pack from one whose baseline was destroyed.
|
|
1406
1577
|
integrity_status: p.integrity_status,
|
|
1578
|
+
// 'carried-from-v1' when the v2 baseline was carried from a v1 row
|
|
1579
|
+
// without re-verification (§5.5): `ok` then means "unchanged since
|
|
1580
|
+
// the migration", not "matches what was installed".
|
|
1581
|
+
...p.baseline ? { baseline: p.baseline } : {},
|
|
1582
|
+
// true when the pack's only registry row is a legacy row another
|
|
1583
|
+
// same-name pack could own; integrity_status is then 'unverified'.
|
|
1584
|
+
...p.registry_ambiguous ? { registry_ambiguous: true } : {},
|
|
1407
1585
|
installed_at: p.installed_at,
|
|
1408
1586
|
source: p.source
|
|
1409
1587
|
})),
|
|
@@ -1489,7 +1667,7 @@ function getAllToolDefinitions() {
|
|
|
1489
1667
|
},
|
|
1490
1668
|
{
|
|
1491
1669
|
name: "plur_outbox",
|
|
1492
|
-
description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable. Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
|
|
1670
|
+
description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable, plus any other queued remote operation core lists (e.g. a retirement still to be applied on the remote). Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
|
|
1493
1671
|
annotations: { title: "Outbox", readOnlyHint: false, idempotentHint: false },
|
|
1494
1672
|
inputSchema: {
|
|
1495
1673
|
type: "object",
|
|
@@ -1500,11 +1678,21 @@ function getAllToolDefinitions() {
|
|
|
1500
1678
|
handler: async (args, plur) => {
|
|
1501
1679
|
const before = await plur.listOutbox();
|
|
1502
1680
|
if (args.flush !== true) {
|
|
1503
|
-
|
|
1681
|
+
const summary = summarizeOutbox(before);
|
|
1682
|
+
return {
|
|
1683
|
+
pending: before.length,
|
|
1684
|
+
retrying: summary.retrying,
|
|
1685
|
+
needs_action: summary.needs_action,
|
|
1686
|
+
...summary.needs_action > 0 ? { needs_action_scopes: summary.scopes } : {},
|
|
1687
|
+
entries: before
|
|
1688
|
+
};
|
|
1504
1689
|
}
|
|
1505
|
-
const result = await plur.flushOutbox();
|
|
1690
|
+
const result = await plur.flushOutbox({ force: true });
|
|
1506
1691
|
return {
|
|
1507
|
-
|
|
1692
|
+
// Counted from the same list the entries come from (formal R2): a
|
|
1693
|
+
// separate counter can miss an entry kind the list shows (e.g. a
|
|
1694
|
+
// queued remote retirement), and report 0 while one is stuck.
|
|
1695
|
+
pending: (await plur.listOutbox()).length,
|
|
1508
1696
|
flushed: result.flushed,
|
|
1509
1697
|
failed: result.failed,
|
|
1510
1698
|
...result.expired_warnings.length > 0 ? { expired_warnings: result.expired_warnings } : {},
|
|
@@ -1632,8 +1820,13 @@ function getAllToolDefinitions() {
|
|
|
1632
1820
|
},
|
|
1633
1821
|
{
|
|
1634
1822
|
name: "plur_validate_meta",
|
|
1635
|
-
description: "Test a meta-engram template against engrams from a new domain \u2014 updates confidence and domain_coverage",
|
|
1636
|
-
|
|
1823
|
+
description: "Test a meta-engram template against engrams from a new domain \u2014 updates confidence and domain_coverage. A meta-engram that fails validation in a third domain is demoted (top \u2192 mop) or, below top level, RETIRED.",
|
|
1824
|
+
// Destructive (owner decision I_tensions_resolve, formal R2): the third
|
|
1825
|
+
// failed validation retires a non-top meta-engram (core
|
|
1826
|
+
// meta/validation.ts) and the handler persists it. A removal needs an
|
|
1827
|
+
// explicit, gated act, so plur_admin refuses this tool and it is a direct
|
|
1828
|
+
// tool in every profile (CURSOR_CORE_TOOL_NAMES).
|
|
1829
|
+
annotations: { title: "Validate meta-engram", destructiveHint: true, idempotentHint: false },
|
|
1637
1830
|
inputSchema: {
|
|
1638
1831
|
type: "object",
|
|
1639
1832
|
properties: {
|
|
@@ -1658,9 +1851,15 @@ function getAllToolDefinitions() {
|
|
|
1658
1851
|
args.llm_api_key,
|
|
1659
1852
|
args.llm_model
|
|
1660
1853
|
);
|
|
1854
|
+
const wasRetired = meta.status === "retired";
|
|
1661
1855
|
const result = await validateMetaEngram(meta, testEngrams, testDomain, llm);
|
|
1856
|
+
const retiredNow = !wasRetired && meta.status === "retired";
|
|
1662
1857
|
await plur.updateEngram(meta);
|
|
1663
1858
|
return {
|
|
1859
|
+
...retiredNow ? {
|
|
1860
|
+
retired: true,
|
|
1861
|
+
note: `Meta-engram ${result.meta_engram_id} was retired: its prediction failed in a third domain. It no longer injects; its history records the retirement.`
|
|
1862
|
+
} : {},
|
|
1664
1863
|
meta_engram_id: result.meta_engram_id,
|
|
1665
1864
|
test_domain: result.test_domain,
|
|
1666
1865
|
prediction_held: result.prediction_held,
|
|
@@ -1704,6 +1903,9 @@ function getAllToolDefinitions() {
|
|
|
1704
1903
|
tension_count: status.tension_count,
|
|
1705
1904
|
versioned_engram_count: status.versioned_engram_count ?? 0,
|
|
1706
1905
|
outbox_count: status.outbox_count ?? 0,
|
|
1906
|
+
// #1299: queued writes a retry cannot deliver, per scope.
|
|
1907
|
+
outbox_needs_action: status.outbox_needs_action ?? 0,
|
|
1908
|
+
...status.outbox_attention ? { outbox_attention: status.outbox_attention } : {},
|
|
1707
1909
|
// Injection-provenance event/label counts (#452) — #202's volume gate.
|
|
1708
1910
|
history_events: status.history_events ?? {
|
|
1709
1911
|
co_injection: 0,
|
|
@@ -2071,7 +2273,11 @@ function getAllToolDefinitions() {
|
|
|
2071
2273
|
{
|
|
2072
2274
|
name: "plur_session_start",
|
|
2073
2275
|
description: "Start a session \u2014 inject relevant engrams for your task. Call at the beginning of every session.",
|
|
2074
|
-
|
|
2276
|
+
// Not read-only (formal R2, mcp-integrations#6): start registers the
|
|
2277
|
+
// session's scope, flushes the remote-write outbox (pushes to remote
|
|
2278
|
+
// stores) and writes telemetry. It only replays writes already asked for,
|
|
2279
|
+
// so it is not destructive.
|
|
2280
|
+
annotations: { title: "Session Start", readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
2075
2281
|
inputSchema: {
|
|
2076
2282
|
type: "object",
|
|
2077
2283
|
properties: {
|
|
@@ -2101,6 +2307,12 @@ function getAllToolDefinitions() {
|
|
|
2101
2307
|
} catch (err) {
|
|
2102
2308
|
outbox_error = err.message;
|
|
2103
2309
|
}
|
|
2310
|
+
let outbox_needs;
|
|
2311
|
+
try {
|
|
2312
|
+
const summary = await plur.outboxSummary();
|
|
2313
|
+
if (summary.needs_action > 0) outbox_needs = summary;
|
|
2314
|
+
} catch {
|
|
2315
|
+
}
|
|
2104
2316
|
const remote_scopes = plur.getWritableRemoteScopes().map((s) => {
|
|
2105
2317
|
const md = plur.getScopeMetadata(s.scope);
|
|
2106
2318
|
return {
|
|
@@ -2109,7 +2321,7 @@ function getAllToolDefinitions() {
|
|
|
2109
2321
|
...md?.covers && md.covers.length > 0 ? { covers: md.covers } : {}
|
|
2110
2322
|
};
|
|
2111
2323
|
});
|
|
2112
|
-
const projectConfig =
|
|
2324
|
+
const projectConfig = readTrustedProjectConfig(plur);
|
|
2113
2325
|
const explicit_default_scope = args.default_scope ?? null;
|
|
2114
2326
|
const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
|
|
2115
2327
|
const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
|
|
@@ -2201,6 +2413,11 @@ ${guide}`;
|
|
|
2201
2413
|
version_warning = `Update available: PLUR v${versionCheck.current} \u2192 v${versionCheck.latest}. Run: npm i -g @plur-ai/cli@latest && plur init (configs pin versions)`;
|
|
2202
2414
|
}
|
|
2203
2415
|
}
|
|
2416
|
+
if (projectConfig.warning) {
|
|
2417
|
+
guide = `\u26A0\uFE0F ${projectConfig.warning}
|
|
2418
|
+
|
|
2419
|
+
${guide}`;
|
|
2420
|
+
}
|
|
2204
2421
|
if (scope_source === "project-config") {
|
|
2205
2422
|
guide += `
|
|
2206
2423
|
|
|
@@ -2261,6 +2478,11 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
|
|
|
2261
2478
|
} catch {
|
|
2262
2479
|
}
|
|
2263
2480
|
}
|
|
2481
|
+
if (outbox_needs) {
|
|
2482
|
+
guide += `
|
|
2483
|
+
|
|
2484
|
+
\u26A0\uFE0F OUTBOX: ${outbox_needs.needs_action} queued team write(s) cannot be delivered by retrying. ` + describeNeedsAction(outbox_needs).join(" ") + " Tell the user; nothing is dropped automatically. `plur outbox` lists them.";
|
|
2485
|
+
}
|
|
2264
2486
|
const session_tool_profile = activeToolProfile();
|
|
2265
2487
|
if (session_tool_profile !== "full") {
|
|
2266
2488
|
guide += `
|
|
@@ -2277,6 +2499,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2277
2499
|
...remote_scopes.length > 0 ? { remote_scopes } : {},
|
|
2278
2500
|
...default_scope ? { default_scope, scope_source } : {},
|
|
2279
2501
|
...default_domain ? { default_domain, domain_source: "project-config" } : {},
|
|
2502
|
+
...projectConfig.warning ? { project_config_warning: projectConfig.warning } : {},
|
|
2280
2503
|
// Ask LLM to check back — MCP can't push, but we can request a follow-up
|
|
2281
2504
|
follow_up: store_stats.engram_count === 0 ? "This is a fresh store with 0 engrams. After your first exchange with the user, review what you learned and call plur_learn for any corrections, preferences, or patterns. Build the memory from this session." : void 0,
|
|
2282
2505
|
// On fresh install, suggest hook setup for reliable injection
|
|
@@ -2293,6 +2516,10 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2293
2516
|
outbox_error,
|
|
2294
2517
|
outbox_warning: `The outbox flush failed \u2014 ${outbox_error}. Engrams routed to a remote store are still queued locally and were NOT pushed. They retry on the next session_start or plur_sync.`
|
|
2295
2518
|
} : {},
|
|
2519
|
+
// #1299: writes a retry cannot deliver — count, scope, reason, next step.
|
|
2520
|
+
...outbox_needs ? {
|
|
2521
|
+
outbox_needs_action: { count: outbox_needs.needs_action, scopes: outbox_needs.scopes }
|
|
2522
|
+
} : {},
|
|
2296
2523
|
// Version staleness warning (issue #151)
|
|
2297
2524
|
...version_warning ? { version_warning, version: VERSION } : {}
|
|
2298
2525
|
};
|
|
@@ -2302,7 +2529,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2302
2529
|
},
|
|
2303
2530
|
{
|
|
2304
2531
|
name: "plur_session_scope",
|
|
2305
|
-
description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials; op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
|
|
2532
|
+
description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials (it needs an open session: with none open it refuses, since no id-less call reads a session-less slot); op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
|
|
2306
2533
|
annotations: { title: "Session scope", destructiveHint: false, idempotentHint: true },
|
|
2307
2534
|
inputSchema: {
|
|
2308
2535
|
type: "object",
|
|
@@ -2335,6 +2562,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2335
2562
|
const reason = args.reason;
|
|
2336
2563
|
const { session, ambiguous, open } = _resolveScopeSession(args);
|
|
2337
2564
|
const record = session ? _sessionTelemetry.get(session) : void 0;
|
|
2565
|
+
const noSessionSlot = session === void 0 && open === 0;
|
|
2338
2566
|
const remote_scopes = plur.getWritableRemoteScopes();
|
|
2339
2567
|
const withCommon = (body) => ({
|
|
2340
2568
|
op,
|
|
@@ -2350,7 +2578,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2350
2578
|
source,
|
|
2351
2579
|
...ambiguous ? {
|
|
2352
2580
|
warning: `${open} sessions are open \u2014 this is the process-default slot, not a specific session's scope. Pass session_id (from plur_session_start) to inspect one.`
|
|
2353
|
-
} : {},
|
|
2581
|
+
} : noSessionSlot && scope != null ? { warning: NO_SESSION_SLOT_WARNING } : {},
|
|
2354
2582
|
guide: scope == null ? "No session default scope is set: unscoped plur_learn writes auto-route on a confident covers match or land at the unscoped default. Explicit per-call scope always wins." : `Unscoped plur_learn calls this session default to "${scope}"; recall dialing follows the same org context. Explicit per-call scope always wins.`
|
|
2355
2583
|
});
|
|
2356
2584
|
}
|
|
@@ -2360,6 +2588,11 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2360
2588
|
);
|
|
2361
2589
|
}
|
|
2362
2590
|
if (op === "set") {
|
|
2591
|
+
if (noSessionSlot) {
|
|
2592
|
+
throw new Error(
|
|
2593
|
+
"plur_session_scope: no session is open, so there is no session scope to set \u2014 an id-less plur_learn / plur_inject / plur_recall uses no session default unless exactly one session is open. Call plur_session_start first (then pass its session_id here), or pass scope explicitly on each plur_learn."
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2363
2596
|
const scope = args.scope;
|
|
2364
2597
|
if (typeof scope !== "string" || scope.trim().length === 0) {
|
|
2365
2598
|
throw new Error('plur_session_scope: op:"set" requires a non-empty string "scope" (use op:"clear" to revert to the session-start default)');
|
|
@@ -2370,7 +2603,8 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2370
2603
|
const { previous: previous2, next: next2 } = plur.adjustSessionScope(scope, { session, reason, trigger: "set" });
|
|
2371
2604
|
if (record) record.scope_adjusted = true;
|
|
2372
2605
|
const remoteEntry = remote_scopes.find((s) => s.scope === scope);
|
|
2373
|
-
const
|
|
2606
|
+
const sharedWarning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
|
|
2607
|
+
const warning = sharedWarning;
|
|
2374
2608
|
return withCommon({
|
|
2375
2609
|
previous_scope: previous2,
|
|
2376
2610
|
new_scope: next2,
|
|
@@ -2378,7 +2612,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
|
|
|
2378
2612
|
...warning ? { warning } : {}
|
|
2379
2613
|
});
|
|
2380
2614
|
}
|
|
2381
|
-
const restored = record !== void 0 ? record.default_scope ?? null :
|
|
2615
|
+
const restored = record !== void 0 ? record.default_scope ?? null : readTrustedProjectConfig(plur).scope ?? null;
|
|
2382
2616
|
const restored_source = record !== void 0 ? record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : restored != null ? "project-config" : "none";
|
|
2383
2617
|
const { previous, next } = plur.adjustSessionScope(restored, { session, reason, trigger: "clear" });
|
|
2384
2618
|
if (record) record.scope_adjusted = false;
|
|
@@ -2424,7 +2658,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
2424
2658
|
}
|
|
2425
2659
|
]
|
|
2426
2660
|
},
|
|
2427
|
-
description: 'Learnings from this session. Preferred shape is {statement: "...", type?: "..."}; bare strings are also accepted and treated as the statement. Review the conversation for corrections, preferences, patterns, and technical facts before calling.'
|
|
2661
|
+
description: 'Learnings from this session. Preferred shape is {statement: "...", type?: "..."}; bare strings are also accepted and treated as the statement. If the whole parameter arrives as one plain string it is ONE suggestion (never split on commas); send several as a JSON array. Review the conversation for corrections, preferences, patterns, and technical facts before calling.'
|
|
2428
2662
|
}
|
|
2429
2663
|
},
|
|
2430
2664
|
required: ["summary", "engram_suggestions"]
|
|
@@ -2457,13 +2691,18 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
2457
2691
|
session_id,
|
|
2458
2692
|
channel: "mcp"
|
|
2459
2693
|
});
|
|
2694
|
+
const endSession = _resolveInjectionSession(args);
|
|
2695
|
+
const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
|
|
2460
2696
|
let engrams_created = 0;
|
|
2461
2697
|
const engrams_failed = [];
|
|
2462
2698
|
for (let i = 0; i < items.length; i++) {
|
|
2463
2699
|
const { statement, type } = items[i];
|
|
2464
2700
|
try {
|
|
2465
|
-
await plur.
|
|
2701
|
+
await plur.learnRouted(sanitizeStatement(statement), {
|
|
2466
2702
|
type,
|
|
2703
|
+
// E7: no resolvable session → no session default (NO_SESSION).
|
|
2704
|
+
session: endSession ?? NO_SESSION,
|
|
2705
|
+
domain: projectDomain,
|
|
2467
2706
|
// Link the engram back to the session that produced it (#960).
|
|
2468
2707
|
session_episode_id: episode.id,
|
|
2469
2708
|
// An end-of-session summary is the model's reading of what
|
|
@@ -2475,20 +2714,23 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
2475
2714
|
engrams_failed.push({ index: i, statement: statement.slice(0, 80), error: err.message });
|
|
2476
2715
|
}
|
|
2477
2716
|
}
|
|
2478
|
-
const telemetry =
|
|
2717
|
+
const telemetry = endSession ? _sessionTelemetry.get(endSession) : void 0;
|
|
2479
2718
|
const injection_summary = telemetry && telemetry.injection_calls > 0 ? {
|
|
2480
2719
|
pack_counts: { ...telemetry.pack_counts },
|
|
2481
2720
|
total_injections: telemetry.injection_calls,
|
|
2482
2721
|
session_duration_ms: Date.now() - new Date(telemetry.started_at).getTime()
|
|
2483
2722
|
} : void 0;
|
|
2484
|
-
if (
|
|
2485
|
-
_sessionTelemetry.delete(
|
|
2486
|
-
plur.clearSessionScope({ session:
|
|
2723
|
+
if (endSession) {
|
|
2724
|
+
_sessionTelemetry.delete(endSession);
|
|
2725
|
+
plur.clearSessionScope({ session: endSession });
|
|
2487
2726
|
}
|
|
2488
2727
|
try {
|
|
2489
|
-
const plurDir = process.env.PLUR_PATH
|
|
2728
|
+
const plurDir = process.env.PLUR_PATH || join(homedir(), ".plur");
|
|
2490
2729
|
const sessionsDir = join(plurDir, "sessions");
|
|
2491
|
-
const keys = [session_id, process.env.CLAUDE_SESSION_ID, String(process.ppid)].filter(Boolean).
|
|
2730
|
+
const keys = [session_id, process.env.CLAUDE_SESSION_ID, String(process.ppid)].filter(Boolean).flatMap((k) => [
|
|
2731
|
+
(k.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown").slice(0, 64),
|
|
2732
|
+
k.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64)
|
|
2733
|
+
]).filter(Boolean);
|
|
2492
2734
|
for (const key of keys) {
|
|
2493
2735
|
const cp = join(sessionsDir, `${key}.checkpoint.json`);
|
|
2494
2736
|
if (existsSync(cp)) {
|
|
@@ -2595,12 +2837,24 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
2595
2837
|
const raw = args.min_confidence;
|
|
2596
2838
|
const explicit = typeof raw === "number" && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : void 0;
|
|
2597
2839
|
const minConfidence = explicit ?? plur.getScopeRoutingConfig().min_confidence ?? SUGGEST_DISPLAY_MIN_CONFIDENCE;
|
|
2598
|
-
const
|
|
2840
|
+
const signals = {
|
|
2599
2841
|
statement: args.statement,
|
|
2600
2842
|
domain: args.domain,
|
|
2601
2843
|
tags: args.tags
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2844
|
+
};
|
|
2845
|
+
const candidates = await plur.suggestScope(signals, { minConfidence });
|
|
2846
|
+
const decision = plur.previewAutoRoute(signals);
|
|
2847
|
+
const would_route = decision.action === "route" && decision.scope ? { scope: decision.scope, note: "An unscoped write of these signals would be auto-routed here." } : decision.action === "refuse-shared" && decision.refusedShared ? (() => {
|
|
2848
|
+
const why = describeRefusedRoute(decision.refusedShared.scope);
|
|
2849
|
+
return {
|
|
2850
|
+
scope: null,
|
|
2851
|
+
// Field name kept for compatibility; `refused_kind` says which kind it is.
|
|
2852
|
+
refused_shared: decision.refusedShared.scope,
|
|
2853
|
+
refused_kind: why.kind,
|
|
2854
|
+
note: `The best match is ${why.what}, and ${why.rule}. An unscoped write would land at the local default instead. Pass that scope explicitly if the engram belongs there.`
|
|
2855
|
+
};
|
|
2856
|
+
})() : { scope: null, note: "An unscoped write of these signals would land at the local default \u2014 nothing matched confidently enough to route." };
|
|
2857
|
+
return { candidates, count: candidates.length, min_confidence: minConfidence, would_route };
|
|
2604
2858
|
}
|
|
2605
2859
|
},
|
|
2606
2860
|
{
|
|
@@ -2691,6 +2945,14 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
2691
2945
|
{
|
|
2692
2946
|
name: "plur_rescope",
|
|
2693
2947
|
description: "Move existing engram(s) to a different scope (#676) \u2014 e.g. promote a personal/local engram into a team scope so it reaches the shared store. Bypasses the content-hash dedup that makes a plur_learn re-emit a silent no-op: rescope matches by id and moves the engram. Remote targets (a configured writable store scope): a copy is pushed via the routed write path (the server assigns the id, provenance is kept in the copy's source field) and the local original is soft-retired with a superseded_by link \u2014 set keep_local:true to keep it active. Local targets (local, global, project:*): the scope is rewritten in place, preserving id and activation. The target must be local/global/project:* or a scope with a configured writable store \u2014 anything else fails early (typo protection). Content is re-scanned for secrets/sensitive material before any shared/remote target and a hit blocks the move. Batch via ids; dry_run:true previews every decision without mutating anything. NOT candidate activation \u2014 that is plur_promote.",
|
|
2948
|
+
// NOT destructive (owner decision I_tensions_resolve, formal R2): a
|
|
2949
|
+
// rescope never removes content. A local target rewrites the scope in
|
|
2950
|
+
// place (same id, same activation); a remote target retires the local
|
|
2951
|
+
// original only after the copy was pushed, and links it to that copy
|
|
2952
|
+
// with `superseded_by`. A copy always remains, so this is a move, not a
|
|
2953
|
+
// removal — it stays dispatchable through plur_admin
|
|
2954
|
+
// (rescope-tool.test.ts). Contrast plur_tensions resolve, which retires
|
|
2955
|
+
// the loser with no copy and is therefore destructive.
|
|
2694
2956
|
annotations: { title: "Rescope", destructiveHint: false, idempotentHint: true },
|
|
2695
2957
|
inputSchema: {
|
|
2696
2958
|
type: "object",
|
|
@@ -2720,7 +2982,14 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
2720
2982
|
{
|
|
2721
2983
|
name: "plur_tensions",
|
|
2722
2984
|
description: 'Tension lifecycle (#181). Default: list persisted tension records (unresolved first). scan:true runs an LLM contradiction scan, persists NEW detections as records, and skips already-recorded pairs. Lifecycle actions: action:"confirm" (real conflict), action:"dismiss" (false positive \u2014 pair suppressed from future scans), action:"resolve" + winner:<engram_id> (loser engram retired). Scan requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args.',
|
|
2723
|
-
|
|
2985
|
+
// Not idempotent (formal R2, mcp-integrations#6): scan persists each NEW
|
|
2986
|
+
// detection, and an LLM judge can find new pairs on a repeat call.
|
|
2987
|
+
// Destructive (owner decision I_tensions_resolve, formal R2): action
|
|
2988
|
+
// "resolve" retires the losing engram with no copy left — exactly
|
|
2989
|
+
// plur_forget's effect. A removal needs an explicit, gated act, so
|
|
2990
|
+
// plur_admin refuses this tool and it is a direct tool in every profile
|
|
2991
|
+
// (CURSOR_CORE_TOOL_NAMES), where the client sees this annotation.
|
|
2992
|
+
annotations: { title: "Tensions", readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
2724
2993
|
inputSchema: {
|
|
2725
2994
|
type: "object",
|
|
2726
2995
|
properties: {
|