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