@plur-ai/mcp 0.16.1 → 0.17.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
CHANGED
|
@@ -42,7 +42,7 @@ Next session starts → relevant ones injected → agent remembers
|
|
|
42
42
|
You rate the result → engram strengthens → quality improves
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Knowledge is stored as **engrams** — small assertions that strengthen with use and decay when irrelevant. Search
|
|
45
|
+
Knowledge is stored as **engrams** — small assertions that strengthen with use and decay when irrelevant. Search runs locally (BM25 + embeddings); with a PLUR Enterprise store configured, recall also makes one live, timeout-bounded call per relevant remote host and merges the team's engrams in — and tells you, per host, when that leg is degraded instead of failing silently. Without a remote store it is fully local, costs nothing, and works offline. [Benchmark methodology →](https://plur.ai/benchmark.html)
|
|
46
46
|
|
|
47
47
|
## Tools
|
|
48
48
|
|
|
@@ -63,7 +63,9 @@ By default (lean profile), your agent gets 12 tools. Everything else is reachabl
|
|
|
63
63
|
| `plur_tensions_purge` | Clear stale/resolved tensions |
|
|
64
64
|
| `plur_admin` | Dispatch to any other tool: `{ action: "plur_packs_install", args: {...} }` |
|
|
65
65
|
|
|
66
|
-
Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_learn_batch`, `plur_ingest`, `plur_sync`, `plur_packs_install`, `plur_packs_list`, `plur_capture`, `plur_timeline`, and more) are all reachable via `plur_admin`. Set `PLUR_TOOL_PROFILE=full` to expose all
|
|
66
|
+
Less commonly needed tools (`plur_recall_hybrid`, `plur_inject_hybrid`, `plur_learn_batch`, `plur_ingest`, `plur_sync`, `plur_packs_install`, `plur_packs_list`, `plur_capture`, `plur_timeline`, and more) are all reachable via `plur_admin`. Set `PLUR_TOOL_PROFILE=full` to expose all 42 tools directly.
|
|
67
|
+
|
|
68
|
+
A `plur_*` name missing from `tools/list` means it moved behind the gateway, not that the server is down. `plur_admin { action: "help" }` returns every action with a one-line description and its argument schema; `plur_doctor` reports the same inventory as `tool_surface`.
|
|
67
69
|
|
|
68
70
|
## Sync across machines
|
|
69
71
|
|
|
@@ -10,13 +10,13 @@ function recordTelemetry(event) {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
// src/version.ts
|
|
13
|
-
var VERSION = "0.
|
|
13
|
+
var VERSION = "0.17.0";
|
|
14
14
|
|
|
15
15
|
// src/tools.ts
|
|
16
16
|
import { existsSync, unlinkSync } from "fs";
|
|
17
17
|
import { join } from "path";
|
|
18
18
|
import { homedir } from "os";
|
|
19
|
-
import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE } from "@plur-ai/core";
|
|
19
|
+
import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation } from "@plur-ai/core";
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
|
|
22
22
|
return async (prompt) => {
|
|
@@ -39,15 +39,38 @@ function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
|
|
|
39
39
|
return data.choices?.[0]?.message?.content ?? "";
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
function attachRemoteStoreDegradation(response, plur) {
|
|
43
|
+
let status;
|
|
44
|
+
try {
|
|
45
|
+
status = plur.remoteStoreStatus();
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const degraded = status.filter((s) => s.status !== "ok" || (s.dropped_scopes?.length ?? 0) > 0);
|
|
50
|
+
if (degraded.length === 0) return;
|
|
51
|
+
response.remote_stores = degraded.map((s) => ({
|
|
52
|
+
host: s.host,
|
|
53
|
+
status: s.status,
|
|
54
|
+
...s.dropped_scopes && s.dropped_scopes.length > 0 ? { dropped_scopes: s.dropped_scopes } : {}
|
|
55
|
+
}));
|
|
56
|
+
const line = `Remote store degradation \u2014 ${degraded.map((s) => mcpRemoteWarningLine(s)).join(" ")}`;
|
|
57
|
+
response.warning = typeof response.warning === "string" && response.warning.length > 0 ? `${response.warning} ${line}` : line;
|
|
58
|
+
}
|
|
42
59
|
var recallHandler = async (args, plur) => {
|
|
43
60
|
const mode = args.mode ?? "hybrid";
|
|
44
61
|
if (mode === "keyword") {
|
|
45
62
|
const results = await plur.recall(args.query, {
|
|
46
63
|
scope: args.scope,
|
|
47
64
|
domain: args.domain,
|
|
48
|
-
limit: args.limit
|
|
65
|
+
limit: args.limit,
|
|
66
|
+
remote_timeout_ms: 2e3,
|
|
67
|
+
// MCP recall remote budget (#776)
|
|
68
|
+
// #243: session default scope (incl. mid-session plur_session_scope
|
|
69
|
+
// changes) establishes the remote dialing org context when no explicit
|
|
70
|
+
// scope filter is passed.
|
|
71
|
+
session: _resolveInjectionSession(args)
|
|
49
72
|
});
|
|
50
|
-
|
|
73
|
+
const response2 = {
|
|
51
74
|
results: results.map((e) => {
|
|
52
75
|
const supersededBy = e.relations?.superseded_by;
|
|
53
76
|
const annotation = supersededBy?.length ? ` [superseded by ${supersededBy.join(", ")}]` : "";
|
|
@@ -63,6 +86,8 @@ var recallHandler = async (args, plur) => {
|
|
|
63
86
|
count: results.length,
|
|
64
87
|
mode: "keyword"
|
|
65
88
|
};
|
|
89
|
+
attachRemoteStoreDegradation(response2, plur);
|
|
90
|
+
return response2;
|
|
66
91
|
}
|
|
67
92
|
const budget = args.budget;
|
|
68
93
|
const cap = budget?.max_results ?? args.limit ?? 20;
|
|
@@ -70,7 +95,13 @@ var recallHandler = async (args, plur) => {
|
|
|
70
95
|
const meta = await plur.recallHybridWithMeta(args.query, {
|
|
71
96
|
scope: args.scope,
|
|
72
97
|
domain: args.domain,
|
|
73
|
-
limit: fetchLimit
|
|
98
|
+
limit: fetchLimit,
|
|
99
|
+
remote_timeout_ms: 2e3,
|
|
100
|
+
// MCP recall remote budget (#776)
|
|
101
|
+
// #243: session default scope (incl. mid-session plur_session_scope
|
|
102
|
+
// changes) establishes the remote dialing org context when no explicit
|
|
103
|
+
// scope filter is passed.
|
|
104
|
+
session: _resolveInjectionSession(args)
|
|
74
105
|
});
|
|
75
106
|
recordTelemetry("recall");
|
|
76
107
|
const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
|
|
@@ -125,6 +156,7 @@ var recallHandler = async (args, plur) => {
|
|
|
125
156
|
response.reranker_warning = `PLUR_RERANKER is set but the reranker did not engage \u2014 results are RRF-only (fusion order, no cross-encoder rerank).${corruptNote} Last error: ${rr.lastError}. Run plur_doctor for diagnosis.`;
|
|
126
157
|
}
|
|
127
158
|
}
|
|
159
|
+
attachRemoteStoreDegradation(response, plur);
|
|
128
160
|
return response;
|
|
129
161
|
};
|
|
130
162
|
var RECALL_HYBRID_DEPRECATION = "plur_recall_hybrid is deprecated since 0.16 \u2014 use plur_recall (mode defaults to hybrid). This alias will be removed in 0.18.";
|
|
@@ -184,17 +216,26 @@ function validateToolArgs(tool, rawArgs) {
|
|
|
184
216
|
const receivedFields = Object.keys(rawArgs);
|
|
185
217
|
const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
|
|
186
218
|
const hasArrayParam = Object.values(schema.properties ?? {}).some((p) => p?.type === "array");
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
219
|
+
const missingFields = parsed.error.issues.filter((i) => i.code === "invalid_type" && i.received === "undefined").map((i) => String(i.path[0] ?? "")).filter((k) => k.length > 0);
|
|
220
|
+
const missingArrayParams = missingFields.filter((k) => schema.properties?.[k]?.type === "array");
|
|
221
|
+
const wholePayloadDrop = receivedFields.length === 0;
|
|
222
|
+
const partialDrop = !wholePayloadDrop && missingArrayParams.length > 0;
|
|
223
|
+
let dropHint = "";
|
|
224
|
+
if (wholePayloadDrop) {
|
|
225
|
+
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.' : "");
|
|
226
|
+
} else if (partialDrop) {
|
|
227
|
+
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.`;
|
|
228
|
+
}
|
|
191
229
|
const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
|
|
230
|
+
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.";
|
|
192
231
|
return {
|
|
193
232
|
ok: false,
|
|
194
233
|
errorPayload: {
|
|
195
|
-
error: `Invalid arguments: ${details}. ${receivedNote}
|
|
234
|
+
error: `Invalid arguments: ${details}. ${receivedNote} ${disposition}${dropHint}`,
|
|
196
235
|
success: false,
|
|
197
236
|
received_fields: receivedFields,
|
|
237
|
+
missing_fields: missingFields,
|
|
238
|
+
...wholePayloadDrop ? { drop: "whole_payload" } : partialDrop ? { drop: "partial" } : {},
|
|
198
239
|
_isError: true
|
|
199
240
|
}
|
|
200
241
|
};
|
|
@@ -259,10 +300,16 @@ mcpCanary.expect({
|
|
|
259
300
|
});
|
|
260
301
|
var _sessionTelemetry = /* @__PURE__ */ new Map();
|
|
261
302
|
var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
|
|
262
|
-
function _cleanExpiredSessions() {
|
|
303
|
+
function _cleanExpiredSessions(plur) {
|
|
263
304
|
const cutoff = Date.now() - SESSION_TTL_MS;
|
|
264
305
|
for (const [id, state] of _sessionTelemetry) {
|
|
265
|
-
if (new Date(state.started_at).getTime() < cutoff)
|
|
306
|
+
if (new Date(state.started_at).getTime() < cutoff) {
|
|
307
|
+
_sessionTelemetry.delete(id);
|
|
308
|
+
try {
|
|
309
|
+
plur?.clearSessionScope({ session: id });
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
}
|
|
266
313
|
}
|
|
267
314
|
}
|
|
268
315
|
function _implicitSessionId() {
|
|
@@ -275,6 +322,16 @@ function _resolveInjectionSession(args) {
|
|
|
275
322
|
if (typeof explicit === "string" && explicit.length > 0) return explicit;
|
|
276
323
|
return _implicitSessionId();
|
|
277
324
|
}
|
|
325
|
+
function _resolveScopeSession(args) {
|
|
326
|
+
const explicit = args.session_id;
|
|
327
|
+
if (typeof explicit === "string" && explicit.length > 0) {
|
|
328
|
+
return { session: explicit, ambiguous: false, open: _sessionTelemetry.size };
|
|
329
|
+
}
|
|
330
|
+
_cleanExpiredSessions();
|
|
331
|
+
const open = _sessionTelemetry.size;
|
|
332
|
+
if (open === 1) return { session: _sessionTelemetry.keys().next().value, ambiguous: false, open };
|
|
333
|
+
return { session: void 0, ambiguous: open > 1, open };
|
|
334
|
+
}
|
|
278
335
|
function _recordInjectionTelemetry(session_id, injected_packs) {
|
|
279
336
|
if (!session_id) return;
|
|
280
337
|
const state = _sessionTelemetry.get(session_id);
|
|
@@ -298,12 +355,39 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
|
298
355
|
"plur_packs_uninstall",
|
|
299
356
|
"plur_tensions_purge"
|
|
300
357
|
]);
|
|
358
|
+
function summarizeToolDescription(description) {
|
|
359
|
+
const line = description.split("\n", 1)[0].trim();
|
|
360
|
+
if (line.length <= 200) return line;
|
|
361
|
+
const cut = line.lastIndexOf(". ", 200);
|
|
362
|
+
return cut > 40 ? line.slice(0, cut + 1) : `${line.slice(0, 199)}\u2026`;
|
|
363
|
+
}
|
|
364
|
+
function describeActionInventory(actions) {
|
|
365
|
+
const families = /* @__PURE__ */ new Map();
|
|
366
|
+
for (const name of actions) {
|
|
367
|
+
const family = name.replace(/^plur_/, "").split("_", 1)[0];
|
|
368
|
+
families.set(family, [...families.get(family) ?? [], name]);
|
|
369
|
+
}
|
|
370
|
+
const sorted = [...families.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
371
|
+
const grouped = [];
|
|
372
|
+
const misc = [];
|
|
373
|
+
for (const [family, members] of sorted) {
|
|
374
|
+
if (members.length > 1) grouped.push(`${family}: ${members.join(", ")}`);
|
|
375
|
+
else misc.push(members[0]);
|
|
376
|
+
}
|
|
377
|
+
if (misc.length > 0) grouped.push(`other: ${misc.join(", ")}`);
|
|
378
|
+
const full = `Actions, grouped \u2014 ${grouped.join(" \xB7 ")}.`;
|
|
379
|
+
if (full.length <= 1500) return full;
|
|
380
|
+
const counted = sorted.map(([family, members]) => `${family} (${members.length})`).join(", ");
|
|
381
|
+
return `${actions.length} actions in groups ${counted} \u2014 the full list is in { action: "help" }.`;
|
|
382
|
+
}
|
|
301
383
|
function buildAdminDispatchTool(all) {
|
|
302
384
|
const byName = new Map(all.map((t) => [t.name, t]));
|
|
303
385
|
const adminActions = all.map((t) => t.name).filter((n) => !CURSOR_CORE_TOOL_NAMES.has(n)).sort();
|
|
386
|
+
const exampleAction = adminActions.includes("plur_recall_hybrid") ? "plur_recall_hybrid" : adminActions[0];
|
|
387
|
+
const exampleArgs = exampleAction === "plur_recall_hybrid" ? '{ query: "deploy checklist" }' : "{}";
|
|
304
388
|
return {
|
|
305
389
|
name: "plur_admin",
|
|
306
|
-
description: `
|
|
390
|
+
description: `Gateway to the ${adminActions.length} PLUR operations that are not top-level tools under the current profile (collapsed into one dispatch tool so Cursor's ~40-tool-per-workspace limit is not exhausted by PLUR alone). A plur_* name missing from tools/list means it moved HERE \u2014 not that the MCP is unavailable. Calling convention: { action: "<tool name>", args: { ...that tool's normal arguments } } \u2014 same arguments, same validation, same result as a direct call. Example: { action: "${exampleAction}", args: ${exampleArgs} }. Send { action: "help" } for every action's one-line description and argument schema. ${describeActionInventory(adminActions)}`,
|
|
307
391
|
annotations: { title: "Admin dispatch", readOnlyHint: false },
|
|
308
392
|
inputSchema: {
|
|
309
393
|
type: "object",
|
|
@@ -315,16 +399,27 @@ function buildAdminDispatchTool(all) {
|
|
|
315
399
|
// CallToolRequestSchema handler's Zod validation would reject
|
|
316
400
|
// unknown actions before this handler's `if (!target)` branch ever
|
|
317
401
|
// ran.
|
|
318
|
-
action: { type: "string", description:
|
|
402
|
+
action: { type: "string", description: 'Which underlying plur_* tool to invoke, or "help" to list every action with its description and argument schema' },
|
|
319
403
|
args: { type: "object", description: "Arguments for the chosen action, matching that tool's normal input schema", additionalProperties: true }
|
|
320
404
|
},
|
|
321
405
|
required: ["action"]
|
|
322
406
|
},
|
|
323
407
|
handler: async (args, plur) => {
|
|
324
408
|
const action = args.action;
|
|
409
|
+
if (action === "help") {
|
|
410
|
+
return {
|
|
411
|
+
calling_convention: 'plur_admin { action: "<tool name>", args: { ... } } \u2014 same arguments, same validation, same result as calling that tool directly. A tools/list miss on one of these names means it is consolidated here, NOT that the MCP is unavailable.',
|
|
412
|
+
actions: adminActions.map((name) => {
|
|
413
|
+
const t = byName.get(name);
|
|
414
|
+
return { action: name, description: summarizeToolDescription(t.description), args_schema: t.inputSchema };
|
|
415
|
+
}),
|
|
416
|
+
standalone_tools: all.map((t) => t.name).filter((n) => CURSOR_CORE_TOOL_NAMES.has(n)).sort(),
|
|
417
|
+
standalone_note: "standalone_tools are exposed as top-level tools in every profile \u2014 call them directly, never through plur_admin (destructive ones are refused here so their risk annotations stay visible to your client)."
|
|
418
|
+
};
|
|
419
|
+
}
|
|
325
420
|
const target = byName.get(action);
|
|
326
421
|
if (!target) {
|
|
327
|
-
return { error: `Unknown action "${action}". Valid actions: ${adminActions.join(", ")}`, success: false, _isError: true };
|
|
422
|
+
return { error: `Unknown action "${action}". Valid actions: help, ${adminActions.join(", ")}`, success: false, _isError: true };
|
|
328
423
|
}
|
|
329
424
|
if (target.annotations?.destructiveHint === true) {
|
|
330
425
|
return {
|
|
@@ -347,6 +442,29 @@ function buildAdminDispatchTool(all) {
|
|
|
347
442
|
}
|
|
348
443
|
};
|
|
349
444
|
}
|
|
445
|
+
function resolveToolProfile(env = process.env) {
|
|
446
|
+
const v = env.PLUR_TOOL_PROFILE;
|
|
447
|
+
return v === "full" ? "full" : v === "cursor" ? "cursor" : "lean";
|
|
448
|
+
}
|
|
449
|
+
var activeProfile = null;
|
|
450
|
+
function setActiveToolProfile(profile) {
|
|
451
|
+
activeProfile = profile;
|
|
452
|
+
}
|
|
453
|
+
function activeToolProfile() {
|
|
454
|
+
return activeProfile ?? resolveToolProfile();
|
|
455
|
+
}
|
|
456
|
+
function describeToolSurface(profile = activeToolProfile()) {
|
|
457
|
+
const all = getAllToolDefinitions();
|
|
458
|
+
const exposed = getToolDefinitions(profile);
|
|
459
|
+
const standalone = exposed.map((t) => t.name).sort();
|
|
460
|
+
const admin_actions = profile === "full" ? [] : all.map((t) => t.name).filter((n) => !CURSOR_CORE_TOOL_NAMES.has(n)).sort();
|
|
461
|
+
return {
|
|
462
|
+
profile,
|
|
463
|
+
standalone,
|
|
464
|
+
admin_actions,
|
|
465
|
+
note: admin_actions.length === 0 ? "All tools are exposed directly under this profile." : `Only the ${standalone.length} tools in "standalone" are callable by name. Everything in "admin_actions" is reachable as plur_admin { action: "<name>", args: {...} } \u2014 same arguments, same validation, same result. A name-lookup miss on one of those means it moved, NOT that the MCP is unavailable. Call plur_admin { action: "help" } for each action's description and argument schema. Set PLUR_TOOL_PROFILE=full to expose all tools directly.`
|
|
466
|
+
};
|
|
467
|
+
}
|
|
350
468
|
function getToolDefinitions(profile = "lean") {
|
|
351
469
|
const all = getAllToolDefinitions();
|
|
352
470
|
if (profile === "full") return all;
|
|
@@ -386,7 +504,8 @@ function getAllToolDefinitions() {
|
|
|
386
504
|
locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
|
|
387
505
|
valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
|
|
388
506
|
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)' },
|
|
389
|
-
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." }
|
|
507
|
+
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." },
|
|
508
|
+
session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open; pass it when several are (#243)." }
|
|
390
509
|
},
|
|
391
510
|
required: ["statement"]
|
|
392
511
|
},
|
|
@@ -405,6 +524,11 @@ function getAllToolDefinitions() {
|
|
|
405
524
|
valid_from: args.valid_from,
|
|
406
525
|
valid_until: args.valid_until,
|
|
407
526
|
supersedes: args.supersedes,
|
|
527
|
+
// #243: resolve which session's default scope governs this write —
|
|
528
|
+
// explicit session_id first, else the lone open session. Never
|
|
529
|
+
// persisted on the engram (LearnContext.session selects a scope, it
|
|
530
|
+
// is not part of one).
|
|
531
|
+
session: _resolveInjectionSession(args),
|
|
408
532
|
llm
|
|
409
533
|
};
|
|
410
534
|
const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
|
|
@@ -578,7 +702,7 @@ function getAllToolDefinitions() {
|
|
|
578
702
|
},
|
|
579
703
|
{
|
|
580
704
|
name: "plur_recall",
|
|
581
|
-
description: 'Search engrams by topic. Default mode is hybrid (BM25 + local embeddings via RRF) \u2014 set mode:"keyword" for BM25-only.
|
|
705
|
+
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.',
|
|
582
706
|
annotations: { title: "Recall", readOnlyHint: true, idempotentHint: true },
|
|
583
707
|
inputSchema: {
|
|
584
708
|
type: "object",
|
|
@@ -590,7 +714,8 @@ function getAllToolDefinitions() {
|
|
|
590
714
|
limit: { type: "number", description: "Max results to return (default 20)" },
|
|
591
715
|
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" }, ttl_seconds: { type: "number" } } },
|
|
592
716
|
caller_session_id: { type: "string", description: 'Session ID of calling agent for budget enforcement. Hybrid mode only \u2014 ignored when mode:"keyword".' },
|
|
593
|
-
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".' }
|
|
717
|
+
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".' },
|
|
718
|
+
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)." }
|
|
594
719
|
},
|
|
595
720
|
required: ["query"]
|
|
596
721
|
},
|
|
@@ -598,7 +723,7 @@ function getAllToolDefinitions() {
|
|
|
598
723
|
},
|
|
599
724
|
{
|
|
600
725
|
name: "plur_recall_hybrid",
|
|
601
|
-
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
|
|
726
|
+
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.",
|
|
602
727
|
annotations: { title: "Recall (hybrid) [deprecated alias]", readOnlyHint: true, idempotentHint: true },
|
|
603
728
|
inputSchema: {
|
|
604
729
|
type: "object",
|
|
@@ -609,7 +734,8 @@ function getAllToolDefinitions() {
|
|
|
609
734
|
limit: { type: "number", description: "Max results to return (default 20)" },
|
|
610
735
|
budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" }, ttl_seconds: { type: "number" } } },
|
|
611
736
|
caller_session_id: { type: "string", description: "Session ID of calling agent for budget enforcement" },
|
|
612
|
-
include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" }
|
|
737
|
+
include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" },
|
|
738
|
+
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)." }
|
|
613
739
|
},
|
|
614
740
|
required: ["query"]
|
|
615
741
|
},
|
|
@@ -678,7 +804,7 @@ function getAllToolDefinitions() {
|
|
|
678
804
|
session_id
|
|
679
805
|
});
|
|
680
806
|
_recordInjectionTelemetry(session_id, result.injected_packs);
|
|
681
|
-
|
|
807
|
+
const response = {
|
|
682
808
|
directives: result.directives,
|
|
683
809
|
consider: result.consider,
|
|
684
810
|
count: result.count,
|
|
@@ -688,6 +814,8 @@ function getAllToolDefinitions() {
|
|
|
688
814
|
// #181: unresolved-tension warnings — flag contradicted context
|
|
689
815
|
...result.warnings ? { warnings: result.warnings } : {}
|
|
690
816
|
};
|
|
817
|
+
attachRemoteStoreDegradation(response, plur);
|
|
818
|
+
return response;
|
|
691
819
|
}
|
|
692
820
|
},
|
|
693
821
|
{
|
|
@@ -790,17 +918,17 @@ function getAllToolDefinitions() {
|
|
|
790
918
|
const engram = await plur.getById(args.id);
|
|
791
919
|
if (engram) {
|
|
792
920
|
if (engram.status === "retired") return { success: false, error: `Already retired: ${args.id}` };
|
|
793
|
-
await plur.forget(args.id);
|
|
921
|
+
await plur.forget(args.id, void 0, { force: true });
|
|
794
922
|
return { success: true, retired: { id: engram.id, statement: engram.statement } };
|
|
795
923
|
}
|
|
796
|
-
await plur.forget(args.id);
|
|
924
|
+
await plur.forget(args.id, void 0, { force: true });
|
|
797
925
|
return { success: true, retired: { id: args.id } };
|
|
798
926
|
}
|
|
799
927
|
if (args.search) {
|
|
800
|
-
const matches = await plur.recall(args.search, { limit: 100 });
|
|
928
|
+
const matches = await plur.recall(args.search, { limit: 100, remote: false });
|
|
801
929
|
if (matches.length === 0) return { success: false, error: `No active engrams matching "${args.search}"` };
|
|
802
930
|
if (matches.length === 1) {
|
|
803
|
-
await plur.forget(matches[0].id);
|
|
931
|
+
await plur.forget(matches[0].id, void 0, { force: true });
|
|
804
932
|
return { success: true, retired: { id: matches[0].id, statement: matches[0].statement } };
|
|
805
933
|
}
|
|
806
934
|
return {
|
|
@@ -985,6 +1113,11 @@ function getAllToolDefinitions() {
|
|
|
985
1113
|
engram_count: p.engram_count,
|
|
986
1114
|
integrity: p.integrity,
|
|
987
1115
|
integrity_ok: p.integrity_ok,
|
|
1116
|
+
// 'ok' | 'modified' | 'unverified' (#805, F11). `integrity_ok`
|
|
1117
|
+
// collapses "cannot be checked" into the same `undefined` an absent
|
|
1118
|
+
// field has, so a caller reading only that cannot distinguish a
|
|
1119
|
+
// clean pack from one whose baseline was destroyed.
|
|
1120
|
+
integrity_status: p.integrity_status,
|
|
988
1121
|
installed_at: p.installed_at,
|
|
989
1122
|
source: p.source
|
|
990
1123
|
})),
|
|
@@ -1030,7 +1163,7 @@ function getAllToolDefinitions() {
|
|
|
1030
1163
|
remote_type: {
|
|
1031
1164
|
type: "string",
|
|
1032
1165
|
enum: ["personal", "shared"],
|
|
1033
|
-
description: "What the sync remote is for (#640). personal (default): mirror everything non-local, private included \u2014 a solo user's own backup. shared: push ONLY shared-scope, non-private engrams \u2014 personal-family and private engrams never reach the remote. Persist the choice in config.yaml as sync.remote_type instead of passing it per call."
|
|
1166
|
+
description: "What the sync remote is for (#640). personal (default): mirror everything non-local, private included \u2014 a solo user's own backup. shared: push ONLY shared-scope, non-private engrams \u2014 personal-family and private engrams never reach the remote, and episode/candidate/tension records derived from non-pushed engrams are stripped too (#686). Persist the choice in config.yaml as sync.remote_type instead of passing it per call."
|
|
1034
1167
|
}
|
|
1035
1168
|
}
|
|
1036
1169
|
},
|
|
@@ -1245,8 +1378,13 @@ function getAllToolDefinitions() {
|
|
|
1245
1378
|
created_after: args.created_after
|
|
1246
1379
|
});
|
|
1247
1380
|
const versionCheck = getCachedUpdateCheck("@plur-ai/mcp");
|
|
1381
|
+
const tool_profile = activeToolProfile();
|
|
1248
1382
|
return {
|
|
1249
1383
|
version: VERSION,
|
|
1384
|
+
tool_profile,
|
|
1385
|
+
...tool_profile !== "full" ? {
|
|
1386
|
+
tool_surface_note: `Tool profile "${tool_profile}": most plur_* operations are not top-level tools \u2014 call them as plur_admin { action: "<name>", args: {...} }; send { action: "help" } for the list. A name-lookup miss means a tool moved there, not that the MCP is down.`
|
|
1387
|
+
} : {},
|
|
1250
1388
|
engram_count: status.engram_count,
|
|
1251
1389
|
episode_count: status.episode_count,
|
|
1252
1390
|
pack_count: status.pack_count,
|
|
@@ -1264,6 +1402,10 @@ function getAllToolDefinitions() {
|
|
|
1264
1402
|
},
|
|
1265
1403
|
// Last background index/reembed failure (#272) — absent when healthy.
|
|
1266
1404
|
...status.index_error ? { index_error: status.index_error } : {},
|
|
1405
|
+
// Artifacts that could not be read (audit 2026-08-03, finding 14).
|
|
1406
|
+
// Core reports these; this hand-built response dropped them, so an
|
|
1407
|
+
// agent asking for status saw a healthy-looking `pack_count: 0`.
|
|
1408
|
+
...status.store_errors ? { store_errors: status.store_errors } : {},
|
|
1267
1409
|
// Version check (issue #151)
|
|
1268
1410
|
...versionCheck?.updateAvailable && versionCheck.latest ? {
|
|
1269
1411
|
update_available: {
|
|
@@ -1473,6 +1615,28 @@ function getAllToolDefinitions() {
|
|
|
1473
1615
|
}
|
|
1474
1616
|
} catch {
|
|
1475
1617
|
}
|
|
1618
|
+
try {
|
|
1619
|
+
for (const s of plur.remoteStoreStatus()) {
|
|
1620
|
+
const fix = doctorRemoteRemediation(s);
|
|
1621
|
+
const degraded = s.status !== "ok" || (s.dropped_scopes?.length ?? 0) > 0;
|
|
1622
|
+
checks.push({
|
|
1623
|
+
check: `remote recall: ${s.host}`,
|
|
1624
|
+
ok: !degraded,
|
|
1625
|
+
detail: degraded ? `Last live recall: ${s.status}${s.dropped_scopes?.length ? ` (dropped scopes: ${s.dropped_scopes.join(", ")})` : ""} \u2014 recent recalls served local results only.` : `Last live recall ok (${s.count} row(s) in ${s.ms}ms)`
|
|
1626
|
+
});
|
|
1627
|
+
if (fix) remediation.push(fix);
|
|
1628
|
+
}
|
|
1629
|
+
for (const c of plur.remoteEndpointTokenConflicts()) {
|
|
1630
|
+
checks.push({
|
|
1631
|
+
check: `remote store tokens: ${c.url}`,
|
|
1632
|
+
ok: false,
|
|
1633
|
+
detail: `${c.tokens} distinct tokens configured for this endpoint \u2014 recall dials once per (url, token).`
|
|
1634
|
+
});
|
|
1635
|
+
remediation.push(`Remote ${c.url}: ${c.tokens} distinct tokens are configured across its store entries \u2014 consolidate to one token in ~/.plur/config.yaml so recall dials the host once.`);
|
|
1636
|
+
}
|
|
1637
|
+
} catch {
|
|
1638
|
+
}
|
|
1639
|
+
const tool_surface = describeToolSurface();
|
|
1476
1640
|
return {
|
|
1477
1641
|
ok: checks.every((c) => c.ok),
|
|
1478
1642
|
checks,
|
|
@@ -1481,6 +1645,7 @@ function getAllToolDefinitions() {
|
|
|
1481
1645
|
after_probe: after
|
|
1482
1646
|
},
|
|
1483
1647
|
capabilities: canaryStatuses,
|
|
1648
|
+
tool_surface,
|
|
1484
1649
|
remediation: remediation.length > 0 ? remediation : ["All checks passed \u2014 PLUR is healthy."]
|
|
1485
1650
|
};
|
|
1486
1651
|
}
|
|
@@ -1505,7 +1670,7 @@ function getAllToolDefinitions() {
|
|
|
1505
1670
|
const session_id = crypto.randomUUID();
|
|
1506
1671
|
const task = args.task;
|
|
1507
1672
|
const tags = args.tags;
|
|
1508
|
-
_cleanExpiredSessions();
|
|
1673
|
+
_cleanExpiredSessions(plur);
|
|
1509
1674
|
_sessionTelemetry.set(session_id, {
|
|
1510
1675
|
pack_counts: {},
|
|
1511
1676
|
injection_calls: 0,
|
|
@@ -1531,12 +1696,21 @@ function getAllToolDefinitions() {
|
|
|
1531
1696
|
const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
|
|
1532
1697
|
const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
|
|
1533
1698
|
plur.setSessionScope(default_scope);
|
|
1534
|
-
|
|
1699
|
+
plur.setSessionScope(default_scope, { session: session_id });
|
|
1700
|
+
{
|
|
1701
|
+
const t = _sessionTelemetry.get(session_id);
|
|
1702
|
+
if (t) {
|
|
1703
|
+
t.default_scope = default_scope;
|
|
1704
|
+
t.default_scope_source = scope_source;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
const status = await plur.status().catch(() => null);
|
|
1535
1708
|
const store_stats = {
|
|
1536
|
-
engram_count: status
|
|
1537
|
-
episode_count: status
|
|
1538
|
-
pack_count: status
|
|
1709
|
+
engram_count: status?.engram_count ?? 0,
|
|
1710
|
+
episode_count: status?.episode_count ?? 0,
|
|
1711
|
+
pack_count: status?.pack_count ?? 0
|
|
1539
1712
|
};
|
|
1713
|
+
const store_errors = status?.store_errors;
|
|
1540
1714
|
await plur.warmRemoteCaches().catch(() => {
|
|
1541
1715
|
});
|
|
1542
1716
|
let engrams = null;
|
|
@@ -1545,7 +1719,9 @@ function getAllToolDefinitions() {
|
|
|
1545
1719
|
scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
|
|
1546
1720
|
session_id,
|
|
1547
1721
|
// stamped on the co_injection provenance event (#452)
|
|
1548
|
-
source: "session_start"
|
|
1722
|
+
source: "session_start",
|
|
1723
|
+
remote_timeout_ms: 5e3
|
|
1724
|
+
// session_start warm budget (#776)
|
|
1549
1725
|
});
|
|
1550
1726
|
_recordInjectionTelemetry(session_id, result.injected_packs);
|
|
1551
1727
|
if (result.count > 0) {
|
|
@@ -1654,10 +1830,17 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
|
|
|
1654
1830
|
} catch {
|
|
1655
1831
|
}
|
|
1656
1832
|
}
|
|
1657
|
-
|
|
1833
|
+
const session_tool_profile = activeToolProfile();
|
|
1834
|
+
if (session_tool_profile !== "full") {
|
|
1835
|
+
guide += `
|
|
1836
|
+
|
|
1837
|
+
Tool profile "${session_tool_profile}": most plur_* tools are not exposed by name \u2014 call them via plur_admin { action: "<tool name>", args: {...} } (send { action: "help" } for the full action list).`;
|
|
1838
|
+
}
|
|
1839
|
+
const sessionResponse = {
|
|
1658
1840
|
session_id,
|
|
1659
1841
|
engrams: engrams ?? [],
|
|
1660
1842
|
store_stats,
|
|
1843
|
+
...store_errors ? { store_errors } : {},
|
|
1661
1844
|
guide,
|
|
1662
1845
|
// Remote scope routing info (#229)
|
|
1663
1846
|
...remote_scopes.length > 0 ? { remote_scopes } : {},
|
|
@@ -1681,6 +1864,98 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
|
|
|
1681
1864
|
// Version staleness warning (issue #151)
|
|
1682
1865
|
...version_warning ? { version_warning, version: VERSION } : {}
|
|
1683
1866
|
};
|
|
1867
|
+
attachRemoteStoreDegradation(sessionResponse, plur);
|
|
1868
|
+
return sessionResponse;
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
name: "plur_session_scope",
|
|
1873
|
+
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.`,
|
|
1874
|
+
annotations: { title: "Session scope", destructiveHint: false, idempotentHint: true },
|
|
1875
|
+
inputSchema: {
|
|
1876
|
+
type: "object",
|
|
1877
|
+
properties: {
|
|
1878
|
+
op: {
|
|
1879
|
+
type: "string",
|
|
1880
|
+
enum: ["set", "show", "clear"],
|
|
1881
|
+
description: "set: replace the session default write scope; show: report the effective scope and its derivation; clear: revert to the session-start default."
|
|
1882
|
+
},
|
|
1883
|
+
scope: {
|
|
1884
|
+
type: "string",
|
|
1885
|
+
description: 'New session default scope (op:"set" only), e.g. "project:myapp" or "group:org/team". Must match a configured store scope to route writes to a remote store \u2014 other strings stay local under that namespace.'
|
|
1886
|
+
},
|
|
1887
|
+
reason: {
|
|
1888
|
+
type: "string",
|
|
1889
|
+
description: "Optional one-line explanation of why the scope is changing \u2014 logged on the session_scope_changed event for retrospective debugging."
|
|
1890
|
+
},
|
|
1891
|
+
session_id: {
|
|
1892
|
+
type: "string",
|
|
1893
|
+
description: "Session to target (from plur_session_start). Optional when one session is open; REQUIRED when several are \u2014 a scope change must never decide another session's writes."
|
|
1894
|
+
}
|
|
1895
|
+
},
|
|
1896
|
+
required: ["op"]
|
|
1897
|
+
},
|
|
1898
|
+
handler: async (args, plur) => {
|
|
1899
|
+
const op = args.op;
|
|
1900
|
+
if (op !== "set" && op !== "show" && op !== "clear") {
|
|
1901
|
+
throw new Error(`plur_session_scope: op must be "set", "show" or "clear", got ${JSON.stringify(args.op)}`);
|
|
1902
|
+
}
|
|
1903
|
+
const reason = args.reason;
|
|
1904
|
+
const { session, ambiguous, open } = _resolveScopeSession(args);
|
|
1905
|
+
const record = session ? _sessionTelemetry.get(session) : void 0;
|
|
1906
|
+
const remote_scopes = plur.getWritableRemoteScopes();
|
|
1907
|
+
const withCommon = (body) => ({
|
|
1908
|
+
op,
|
|
1909
|
+
...body,
|
|
1910
|
+
...session ? { session_id: session } : {},
|
|
1911
|
+
...remote_scopes.length > 0 ? { remote_scopes } : {}
|
|
1912
|
+
});
|
|
1913
|
+
if (op === "show") {
|
|
1914
|
+
const scope = plur.getSessionScope({ session });
|
|
1915
|
+
const source = record ? record.scope_adjusted ? "session-adjusted" : record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : scope == null ? "none" : "process-default";
|
|
1916
|
+
return withCommon({
|
|
1917
|
+
scope,
|
|
1918
|
+
source,
|
|
1919
|
+
...ambiguous ? {
|
|
1920
|
+
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.`
|
|
1921
|
+
} : {},
|
|
1922
|
+
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.`
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
if (ambiguous) {
|
|
1926
|
+
throw new Error(
|
|
1927
|
+
`plur_session_scope: ${open} sessions are open on this server \u2014 pass session_id (from plur_session_start) so the scope change targets the right session and cannot bleed into another one.`
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
if (op === "set") {
|
|
1931
|
+
const scope = args.scope;
|
|
1932
|
+
if (typeof scope !== "string" || scope.trim().length === 0) {
|
|
1933
|
+
throw new Error('plur_session_scope: op:"set" requires a non-empty string "scope" (use op:"clear" to revert to the session-start default)');
|
|
1934
|
+
}
|
|
1935
|
+
if (!/^\S+$/.test(scope) || scope.length > 200) {
|
|
1936
|
+
throw new Error(`plur_session_scope: invalid scope ${JSON.stringify(scope)} \u2014 a scope is a single token without whitespace (e.g. "project:myapp", "group:org/team"), max 200 chars`);
|
|
1937
|
+
}
|
|
1938
|
+
const { previous: previous2, next: next2 } = plur.adjustSessionScope(scope, { session, reason, trigger: "set" });
|
|
1939
|
+
if (record) record.scope_adjusted = true;
|
|
1940
|
+
const remoteEntry = remote_scopes.find((s) => s.scope === scope);
|
|
1941
|
+
const warning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
|
|
1942
|
+
return withCommon({
|
|
1943
|
+
previous_scope: previous2,
|
|
1944
|
+
new_scope: next2,
|
|
1945
|
+
...reason ? { reason } : {},
|
|
1946
|
+
...warning ? { warning } : {}
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
const restored = record !== void 0 ? record.default_scope ?? null : readProjectConfig().scope ?? null;
|
|
1950
|
+
const restored_source = record !== void 0 ? record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : restored != null ? "project-config" : "none";
|
|
1951
|
+
const { previous, next } = plur.adjustSessionScope(restored, { session, reason, trigger: "clear" });
|
|
1952
|
+
if (record) record.scope_adjusted = false;
|
|
1953
|
+
return withCommon({
|
|
1954
|
+
previous_scope: previous,
|
|
1955
|
+
new_scope: next,
|
|
1956
|
+
restored_source,
|
|
1957
|
+
...reason ? { reason } : {}
|
|
1958
|
+
});
|
|
1684
1959
|
}
|
|
1685
1960
|
},
|
|
1686
1961
|
{
|
|
@@ -1759,6 +2034,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1759
2034
|
} : void 0;
|
|
1760
2035
|
if (session_id) {
|
|
1761
2036
|
_sessionTelemetry.delete(session_id);
|
|
2037
|
+
plur.clearSessionScope({ session: session_id });
|
|
1762
2038
|
}
|
|
1763
2039
|
try {
|
|
1764
2040
|
const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
|
|
@@ -1824,7 +2100,15 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1824
2100
|
requested_scope: requestedScope,
|
|
1825
2101
|
note: `This path is already registered under scope "${result.scope}". A local store is keyed by its path, so the requested scope "${requestedScope}" was NOT added. Use a separate store file for a different scope, or remove the existing entry first.`
|
|
1826
2102
|
} : {},
|
|
1827
|
-
kind: url ? "remote" : "filesystem"
|
|
2103
|
+
kind: url ? "remote" : "filesystem",
|
|
2104
|
+
// Filesystem stores are read sources — plur_learn writes to the
|
|
2105
|
+
// primary engrams.yaml and carries the scope label there (#766).
|
|
2106
|
+
// Pre-populate the file via plur_packs_export or direct YAML edit
|
|
2107
|
+
// to inject team engrams; plur_learn with this scope will land them
|
|
2108
|
+
// in your primary store tagged with the scope.
|
|
2109
|
+
...path && !scopeDropped && !url ? {
|
|
2110
|
+
note: `Store initialized at ${path}. plur_learn calls with scope "${result.scope}" are tagged with that scope but stored in your primary engrams.yaml. To share engrams across machines via this file, populate it via plur_packs_export or direct YAML and commit it to your repo.`
|
|
2111
|
+
} : {}
|
|
1828
2112
|
};
|
|
1829
2113
|
}
|
|
1830
2114
|
},
|
|
@@ -1912,7 +2196,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1912
2196
|
},
|
|
1913
2197
|
{
|
|
1914
2198
|
name: "plur_promote",
|
|
1915
|
-
description: "Activate candidate engrams so they appear in injection results",
|
|
2199
|
+
description: "Activate candidate engrams so they appear in injection results. Status change only \u2014 it does NOT move an engram to another scope; to promote an engram into a team/shared scope use plur_rescope (#676).",
|
|
1916
2200
|
annotations: { title: "Promote", destructiveHint: false, idempotentHint: true },
|
|
1917
2201
|
inputSchema: {
|
|
1918
2202
|
type: "object",
|
|
@@ -1944,12 +2228,45 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1944
2228
|
engram.activation.retrieval_strength = 0.7;
|
|
1945
2229
|
engram.activation.storage_strength = 1;
|
|
1946
2230
|
engram.activation.last_accessed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1947
|
-
await plur.updateEngram(engram);
|
|
2231
|
+
const written = await plur.updateEngram(engram);
|
|
2232
|
+
if (!written) {
|
|
2233
|
+
errors.push({ id, error: "Not persisted \u2014 the engram may have been removed or its store is read-only" });
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
1948
2236
|
promoted.push({ id, statement: engram.statement });
|
|
1949
2237
|
}
|
|
1950
2238
|
return { promoted, errors, success: errors.length === 0 };
|
|
1951
2239
|
}
|
|
1952
2240
|
},
|
|
2241
|
+
{
|
|
2242
|
+
name: "plur_rescope",
|
|
2243
|
+
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.",
|
|
2244
|
+
annotations: { title: "Rescope", destructiveHint: false, idempotentHint: true },
|
|
2245
|
+
inputSchema: {
|
|
2246
|
+
type: "object",
|
|
2247
|
+
properties: {
|
|
2248
|
+
id: { type: "string", description: "Single engram ID to move" },
|
|
2249
|
+
ids: { type: "array", items: { type: "string" }, description: "Multiple engram IDs to move to the same target scope (batch)" },
|
|
2250
|
+
target_scope: { type: "string", description: "Destination scope: local, global, project:<name>, or a scope with a configured writable store (e.g. group:org/team)" },
|
|
2251
|
+
keep_local: { type: "boolean", description: "Remote targets only: keep the local original active after the push (default false \u2014 it is retired with a superseded_by link so it stops injecting)" },
|
|
2252
|
+
dry_run: { type: "boolean", description: "Preview the per-engram outcome without mutating anything, local or remote" }
|
|
2253
|
+
},
|
|
2254
|
+
required: ["target_scope"]
|
|
2255
|
+
},
|
|
2256
|
+
handler: async (args, plur) => {
|
|
2257
|
+
const targetIds = args.ids ?? (args.id ? [args.id] : []);
|
|
2258
|
+
if (targetIds.length === 0) throw new Error("Provide id or ids");
|
|
2259
|
+
const { results, success } = await plur.rescope(targetIds, args.target_scope, {
|
|
2260
|
+
keep_local: args.keep_local,
|
|
2261
|
+
dry_run: args.dry_run
|
|
2262
|
+
});
|
|
2263
|
+
return {
|
|
2264
|
+
results,
|
|
2265
|
+
success,
|
|
2266
|
+
...args.dry_run === true ? { dry_run: true, note: "Dry run \u2014 nothing was changed." } : {}
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
},
|
|
1953
2270
|
{
|
|
1954
2271
|
name: "plur_tensions",
|
|
1955
2272
|
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.',
|
|
@@ -2312,5 +2629,7 @@ export {
|
|
|
2312
2629
|
validateToolArgs,
|
|
2313
2630
|
mcpCanary,
|
|
2314
2631
|
CURSOR_CORE_TOOL_NAMES,
|
|
2632
|
+
resolveToolProfile,
|
|
2633
|
+
setActiveToolProfile,
|
|
2315
2634
|
getToolDefinitions
|
|
2316
2635
|
};
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSy
|
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { homedir, platform } from "os";
|
|
8
|
-
var VERSION = "0.
|
|
8
|
+
var VERSION = "0.17.0";
|
|
9
9
|
var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
@@ -351,7 +351,7 @@ if (arg === "packs") {
|
|
|
351
351
|
process.exit(0);
|
|
352
352
|
}
|
|
353
353
|
if (arg === "serve" || arg === void 0) {
|
|
354
|
-
const { runStdio } = await import("./server-
|
|
354
|
+
const { runStdio } = await import("./server-GLVXYLB5.js");
|
|
355
355
|
runStdio().catch((err) => {
|
|
356
356
|
console.error("Failed to start PLUR MCP server:", err);
|
|
357
357
|
process.exit(1);
|
|
@@ -4,24 +4,51 @@ import {
|
|
|
4
4
|
getToolDefinitions,
|
|
5
5
|
mcpCanary,
|
|
6
6
|
registerFlushOnExit,
|
|
7
|
+
resolveToolProfile,
|
|
8
|
+
setActiveToolProfile,
|
|
7
9
|
validateToolArgs
|
|
8
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-JRKCEXLE.js";
|
|
9
11
|
|
|
10
12
|
// src/server.ts
|
|
11
13
|
import { Server, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
|
|
12
14
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
-
import { join } from "path";
|
|
15
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
16
|
+
import { join as join2 } from "path";
|
|
15
17
|
import { homedir } from "os";
|
|
16
|
-
import { Plur, checkForUpdate } from "@plur-ai/core";
|
|
18
|
+
import { Plur, checkForUpdate, VERSION_CHECK_SUCCESS_TTL_MS } from "@plur-ai/core";
|
|
19
|
+
|
|
20
|
+
// src/drop-log.ts
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
|
22
|
+
import { dirname, join } from "path";
|
|
23
|
+
import { atomicWrite, withLock } from "@plur-ai/core";
|
|
24
|
+
var PAYLOAD_DROP_LOG_MAX_ENTRIES = 100;
|
|
25
|
+
function payloadDropLogPath(storageRoot) {
|
|
26
|
+
return join(storageRoot, "logs", "payload-drops.jsonl");
|
|
27
|
+
}
|
|
28
|
+
function recordPayloadDrop(storageRoot, record) {
|
|
29
|
+
try {
|
|
30
|
+
const path = payloadDropLogPath(storageRoot);
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
withLock(path, () => {
|
|
33
|
+
appendFileSync(path, JSON.stringify(record) + "\n");
|
|
34
|
+
const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.length > 0);
|
|
35
|
+
if (lines.length > PAYLOAD_DROP_LOG_MAX_ENTRIES) {
|
|
36
|
+
atomicWrite(path, lines.slice(-PAYLOAD_DROP_LOG_MAX_ENTRIES).join("\n") + "\n", { durable: false });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/server.ts
|
|
17
44
|
function serverPidPath(baseDir) {
|
|
18
|
-
return
|
|
45
|
+
return join2(baseDir ?? join2(homedir(), ".plur"), "server.pid");
|
|
19
46
|
}
|
|
20
47
|
function readEnterpriseToken(baseDir) {
|
|
21
|
-
const configPath =
|
|
22
|
-
if (!
|
|
48
|
+
const configPath = join2(baseDir ?? join2(homedir(), ".plur"), "config.json");
|
|
49
|
+
if (!existsSync2(configPath)) return void 0;
|
|
23
50
|
try {
|
|
24
|
-
const cfg = JSON.parse(
|
|
51
|
+
const cfg = JSON.parse(readFileSync2(configPath, "utf8"));
|
|
25
52
|
const ent = cfg?.enterprise;
|
|
26
53
|
if (!ent || typeof ent.url !== "string" || typeof ent.token !== "string") return void 0;
|
|
27
54
|
return { url: ent.url, token: ent.token, username: ent.username };
|
|
@@ -40,7 +67,7 @@ var INSTRUCTIONS = `PLUR is your persistent memory. Corrections, preferences, an
|
|
|
40
67
|
|
|
41
68
|
PLUR is a GLOBAL tool \u2014 one MCP server, one engram store (~/.plur/), available in every project. Multi-project scoping uses domain/scope fields on engrams, not separate installations.
|
|
42
69
|
|
|
43
|
-
TOOL PROFILE: by default only the core session tools are exposed directly (lean profile). Every other plur_* operation is reachable via plur_admin: { action: "<tool name>", args: {...} } \u2014 same arguments and validation as a direct call. PLUR_TOOL_PROFILE=full exposes everything directly.
|
|
70
|
+
TOOL PROFILE: by default only the core session tools are exposed directly (lean profile). Every other plur_* operation is reachable via plur_admin: { action: "<tool name>", args: {...} } \u2014 same arguments and validation as a direct call. A plur_* name missing from tools/list means it MOVED behind plur_admin, not that the MCP is down \u2014 never conclude the server is unavailable from a name-lookup miss. Discover the live surface with plur_admin { action: "help" } (every action with description + argument schema) or plur_doctor (tool_surface). PLUR_TOOL_PROFILE=full exposes everything directly.
|
|
44
71
|
|
|
45
72
|
SESSION LIFECYCLE:
|
|
46
73
|
- With hooks installed (plur init): engrams are injected automatically on first message. You do NOT need to call plur_session_start \u2014 it happens via hooks. Just call plur_session_end before the conversation ends.
|
|
@@ -76,7 +103,7 @@ var GUIDE_RESOURCE = `# PLUR \u2014 Agent Guide
|
|
|
76
103
|
|
|
77
104
|
## What is PLUR?
|
|
78
105
|
|
|
79
|
-
Persistent memory for AI agents. Corrections, preferences, and conventions are stored as **engrams** \u2014 small assertions that strengthen with use and decay when irrelevant (ACT-R model). Storage is plain YAML on disk. Search
|
|
106
|
+
Persistent memory for AI agents. Corrections, preferences, and conventions are stored as **engrams** \u2014 small assertions that strengthen with use and decay when irrelevant (ACT-R model). Storage is plain YAML on disk. Search runs locally (BM25 + embeddings); when an enterprise/remote store is configured AND relevant to the current project, recall additionally makes one live timeout-bounded call per remote host and merges the results \u2014 degradation is surfaced per host via a \`remote_stores\` block, never silent. With no remote store configured, search is fully local with zero API calls.
|
|
80
107
|
|
|
81
108
|
## Quick Start
|
|
82
109
|
|
|
@@ -110,7 +137,8 @@ Persistent memory for AI agents. Corrections, preferences, and conventions are s
|
|
|
110
137
|
- **plur_recall** \u2014 hybrid search by default (BM25 + embeddings); pass mode:"keyword" for BM25-only
|
|
111
138
|
- **plur_feedback** \u2014 rate an engram (trains relevance)
|
|
112
139
|
- **plur_forget** \u2014 retire an outdated engram
|
|
113
|
-
- **plur_promote** \u2014 activate a candidate engram
|
|
140
|
+
- **plur_promote** \u2014 activate a candidate engram (status only \u2014 never changes scope)
|
|
141
|
+
- **plur_rescope** \u2014 move an engram to another scope (e.g. promote a local engram into a team store)
|
|
114
142
|
|
|
115
143
|
### Context Injection
|
|
116
144
|
- **plur_inject** \u2014 select engrams for a task (BM25)
|
|
@@ -156,14 +184,24 @@ Use \`scope\` to namespace engrams per project:
|
|
|
156
184
|
|
|
157
185
|
Override with \`PLUR_PATH\` environment variable.
|
|
158
186
|
`;
|
|
187
|
+
var versionRecheckTimer;
|
|
159
188
|
async function createServer(plur, options) {
|
|
160
189
|
const instance = plur ?? new Plur();
|
|
161
|
-
const
|
|
162
|
-
|
|
190
|
+
const profile = options?.profile ?? "lean";
|
|
191
|
+
setActiveToolProfile(profile);
|
|
192
|
+
const tools = getToolDefinitions(profile);
|
|
193
|
+
const announceUpdate = (r) => {
|
|
163
194
|
if (r.updateAvailable) {
|
|
164
195
|
console.error(`[plur] Update available: ${r.current} \u2192 ${r.latest}. Run: npx @plur-ai/mcp@latest`);
|
|
165
196
|
}
|
|
166
|
-
}
|
|
197
|
+
};
|
|
198
|
+
checkForUpdate("@plur-ai/mcp", VERSION, announceUpdate);
|
|
199
|
+
if (!versionRecheckTimer) {
|
|
200
|
+
versionRecheckTimer = setInterval(() => {
|
|
201
|
+
checkForUpdate("@plur-ai/mcp", VERSION, announceUpdate);
|
|
202
|
+
}, VERSION_CHECK_SUCCESS_TTL_MS);
|
|
203
|
+
versionRecheckTimer.unref?.();
|
|
204
|
+
}
|
|
167
205
|
const server = new Server(
|
|
168
206
|
{ name: "plur-mcp", version: VERSION },
|
|
169
207
|
{
|
|
@@ -205,9 +243,31 @@ async function createServer(plur, options) {
|
|
|
205
243
|
}
|
|
206
244
|
mcpCanary.tick();
|
|
207
245
|
try {
|
|
208
|
-
|
|
246
|
+
const rawArguments = request.params.arguments;
|
|
247
|
+
let args = rawArguments ?? {};
|
|
209
248
|
const validated = validateToolArgs(tool, args);
|
|
210
249
|
if (!validated.ok) {
|
|
250
|
+
const drop = validated.errorPayload.drop;
|
|
251
|
+
if (drop) {
|
|
252
|
+
recordPayloadDrop(instance.storageRoot, {
|
|
253
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
254
|
+
tool: request.params.name,
|
|
255
|
+
arguments_wire: drop === "whole_payload" ? rawArguments === void 0 ? "absent" : "empty_object" : "partial",
|
|
256
|
+
params_keys: Object.keys(request.params ?? {}),
|
|
257
|
+
received_fields: validated.errorPayload.received_fields,
|
|
258
|
+
missing_fields: validated.errorPayload.missing_fields,
|
|
259
|
+
request_id: request.id,
|
|
260
|
+
server_version: VERSION
|
|
261
|
+
});
|
|
262
|
+
try {
|
|
263
|
+
void Promise.resolve(server.sendLoggingMessage({
|
|
264
|
+
level: "warning",
|
|
265
|
+
data: `Payload drop (#772) on ${request.params.name}: arguments ${rawArguments === void 0 ? "key absent from frame" : `arrived with fields [${validated.errorPayload.received_fields.join(", ")}]`}. Recorded in ${payloadDropLogPath(instance.storageRoot)}.`
|
|
266
|
+
})).catch(() => {
|
|
267
|
+
});
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
211
271
|
return {
|
|
212
272
|
content: [{ type: "text", text: JSON.stringify(validated.errorPayload) }],
|
|
213
273
|
isError: true
|
|
@@ -351,8 +411,7 @@ Please:
|
|
|
351
411
|
return server;
|
|
352
412
|
}
|
|
353
413
|
async function runStdio() {
|
|
354
|
-
const
|
|
355
|
-
const profile = envProfile === "full" ? "full" : envProfile === "cursor" ? "cursor" : "lean";
|
|
414
|
+
const profile = resolveToolProfile();
|
|
356
415
|
const server = await createServer(void 0, { profile });
|
|
357
416
|
registerFlushOnExit({});
|
|
358
417
|
try {
|
|
@@ -368,6 +427,7 @@ async function runStdio() {
|
|
|
368
427
|
await server.connect(transport);
|
|
369
428
|
}
|
|
370
429
|
export {
|
|
430
|
+
GUIDE_RESOURCE,
|
|
371
431
|
INSTRUCTIONS,
|
|
372
432
|
clearPendingReload,
|
|
373
433
|
createServer,
|
package/dist/tools-export.d.ts
CHANGED
package/dist/tools-export.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plur-ai/mcp",
|
|
3
3
|
"mcpName": "io.github.plur-ai/plur",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.17.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"plur-mcp": "dist/index.js"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@modelcontextprotocol/client": "2.0.0-beta.4",
|
|
17
17
|
"@modelcontextprotocol/core": "2.0.0-beta.4",
|
|
18
18
|
"zod": "^3.23.0",
|
|
19
|
-
"@plur-ai/core": "0.
|
|
19
|
+
"@plur-ai/core": "0.17.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^25.5.0"
|