@plur-ai/mcp 0.10.0 → 0.11.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/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.11.0";
|
|
9
9
|
var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
@@ -279,7 +279,7 @@ if (arg === "init") {
|
|
|
279
279
|
process.exit(0);
|
|
280
280
|
}
|
|
281
281
|
if (arg === "serve" || arg === void 0) {
|
|
282
|
-
const { runStdio } = await import("./server-
|
|
282
|
+
const { runStdio } = await import("./server-M7GBKD4M.js");
|
|
283
283
|
runStdio().catch((err) => {
|
|
284
284
|
console.error("Failed to start PLUR MCP server:", err);
|
|
285
285
|
process.exit(1);
|
|
@@ -17,7 +17,7 @@ import { Plur as Plur2, checkForUpdate } from "@plur-ai/core";
|
|
|
17
17
|
import { existsSync, unlinkSync } from "fs";
|
|
18
18
|
import { join } from "path";
|
|
19
19
|
import { homedir } from "os";
|
|
20
|
-
import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope } from "@plur-ai/core";
|
|
20
|
+
import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName } from "@plur-ai/core";
|
|
21
21
|
|
|
22
22
|
// src/telemetry.ts
|
|
23
23
|
import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
|
|
@@ -31,7 +31,7 @@ function recordTelemetry(event) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// src/version.ts
|
|
34
|
-
var VERSION = "0.
|
|
34
|
+
var VERSION = "0.11.0";
|
|
35
35
|
|
|
36
36
|
// src/tools.ts
|
|
37
37
|
function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
|
|
@@ -133,7 +133,10 @@ function getToolDefinitions() {
|
|
|
133
133
|
source: { type: "string", description: "Origin of this knowledge (URL, conversation ref, etc.)" },
|
|
134
134
|
pinned: { type: "boolean", description: "Always-load flag. If true, this engram bypasses the keyword-relevance gate at injection time. Use sparingly: meta-rules, safety conventions, core operating principles only." },
|
|
135
135
|
commitment: { type: "string", enum: ["exploring", "leaning", "decided", "locked"], description: "How firmly the user has committed to this belief (default: leaning)" },
|
|
136
|
-
locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" }
|
|
136
|
+
locked_reason: { type: "string", description: "Why this engram is locked (only meaningful when commitment=locked)" },
|
|
137
|
+
valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
|
|
138
|
+
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)' },
|
|
139
|
+
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." }
|
|
137
140
|
},
|
|
138
141
|
required: ["statement"]
|
|
139
142
|
},
|
|
@@ -149,6 +152,9 @@ function getToolDefinitions() {
|
|
|
149
152
|
commitment: args.commitment,
|
|
150
153
|
locked_reason: args.locked_reason,
|
|
151
154
|
pinned: args.pinned,
|
|
155
|
+
valid_from: args.valid_from,
|
|
156
|
+
valid_until: args.valid_until,
|
|
157
|
+
supersedes: args.supersedes,
|
|
152
158
|
llm
|
|
153
159
|
};
|
|
154
160
|
const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
|
|
@@ -164,6 +170,14 @@ function getToolDefinitions() {
|
|
|
164
170
|
const scopes = remote.map((s) => `"${s.scope}"`).join(", ");
|
|
165
171
|
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.` };
|
|
166
172
|
};
|
|
173
|
+
const temporalEcho = (engram) => {
|
|
174
|
+
const extracted = engram.structured_data?._expiry_extracted;
|
|
175
|
+
return {
|
|
176
|
+
...engram.temporal?.valid_from ? { valid_from: engram.temporal.valid_from } : {},
|
|
177
|
+
...engram.temporal?.valid_until ? { valid_until: engram.temporal.valid_until } : {},
|
|
178
|
+
...extracted ? { expiry_note: `Parsed expiry phrase "${extracted.phrase}" from the statement \u2192 temporal.valid_until=${extracted.valid_until}. The engram stops injecting/recalling after that date. If this is wrong, re-learn with an explicit valid_until.` } : {}
|
|
179
|
+
};
|
|
180
|
+
};
|
|
167
181
|
const statement = sanitizeStatement(args.statement);
|
|
168
182
|
try {
|
|
169
183
|
const engram = await plur.learnRouted(statement, context);
|
|
@@ -179,6 +193,7 @@ function getToolDefinitions() {
|
|
|
179
193
|
type: engram.type,
|
|
180
194
|
pinned: engram.pinned === true,
|
|
181
195
|
decision: "ADD",
|
|
196
|
+
...temporalEcho(engram),
|
|
182
197
|
...scopeHint(engram.scope, !!routed),
|
|
183
198
|
...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
|
|
184
199
|
...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.` } : {},
|
|
@@ -196,6 +211,7 @@ function getToolDefinitions() {
|
|
|
196
211
|
scope: engram.scope,
|
|
197
212
|
type: engram.type,
|
|
198
213
|
decision: "ADD",
|
|
214
|
+
...temporalEcho(engram),
|
|
199
215
|
...scopeHint(engram.scope, !!routedFallback),
|
|
200
216
|
...isOutbox ? { outbox: true } : {},
|
|
201
217
|
warning: `Remote write failed (${err.message}); engram queued for retry.`
|
|
@@ -310,6 +326,14 @@ function getToolDefinitions() {
|
|
|
310
326
|
if (meta.mode === "hybrid-degraded") {
|
|
311
327
|
response.warning = `Embedding layer unavailable \u2014 results are BM25-only. Run plur_doctor for diagnosis. Last error: ${meta.embedderError ?? "unknown"}`;
|
|
312
328
|
}
|
|
329
|
+
if (resolveRerankerName() !== "off") {
|
|
330
|
+
response.reranked = meta.reranked ?? 0;
|
|
331
|
+
const rr = plur.rerankerStatus();
|
|
332
|
+
if (boundedResults.length > 0 && (meta.reranked ?? 0) === 0 && rr.lastError) {
|
|
333
|
+
const corruptNote = rr.lastErrorKind === "corrupt-cache" ? " The model cache looks corrupt (truncated download) \u2014 purge and re-download, see plur_doctor." : "";
|
|
334
|
+
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.`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
313
337
|
return response;
|
|
314
338
|
}
|
|
315
339
|
},
|
|
@@ -336,7 +360,9 @@ function getToolDefinitions() {
|
|
|
336
360
|
consider: result.consider,
|
|
337
361
|
count: result.count,
|
|
338
362
|
tokens_used: result.tokens_used,
|
|
339
|
-
injected_ids: result.injected_ids
|
|
363
|
+
injected_ids: result.injected_ids,
|
|
364
|
+
// #181: unresolved-tension warnings — flag contradicted context
|
|
365
|
+
...result.warnings ? { warnings: result.warnings } : {}
|
|
340
366
|
};
|
|
341
367
|
}
|
|
342
368
|
},
|
|
@@ -364,7 +390,9 @@ function getToolDefinitions() {
|
|
|
364
390
|
count: result.count,
|
|
365
391
|
tokens_used: result.tokens_used,
|
|
366
392
|
injected_ids: result.injected_ids,
|
|
367
|
-
mode: "hybrid"
|
|
393
|
+
mode: "hybrid",
|
|
394
|
+
// #181: unresolved-tension warnings — flag contradicted context
|
|
395
|
+
...result.warnings ? { warnings: result.warnings } : {}
|
|
368
396
|
};
|
|
369
397
|
}
|
|
370
398
|
},
|
|
@@ -709,6 +737,8 @@ function getToolDefinitions() {
|
|
|
709
737
|
},
|
|
710
738
|
handler: async (args, plur) => {
|
|
711
739
|
const result = plur.sync(args.remote, { full: args.full === true });
|
|
740
|
+
await plur.waitForIndex();
|
|
741
|
+
const indexError = plur.lastIndexError();
|
|
712
742
|
let outbox_result;
|
|
713
743
|
try {
|
|
714
744
|
outbox_result = await plur.flushOutbox();
|
|
@@ -716,6 +746,10 @@ function getToolDefinitions() {
|
|
|
716
746
|
}
|
|
717
747
|
return {
|
|
718
748
|
...result,
|
|
749
|
+
...indexError ? {
|
|
750
|
+
index_error: indexError,
|
|
751
|
+
warning: `Index ${indexError.op} failed \u2014 ${indexError.message}. YAML is still the source of truth; run plur_sync with full=true to rebuild the index.`
|
|
752
|
+
} : {},
|
|
719
753
|
...outbox_result && (outbox_result.flushed > 0 || outbox_result.failed > 0) ? {
|
|
720
754
|
outbox: {
|
|
721
755
|
flushed: outbox_result.flushed,
|
|
@@ -907,6 +941,15 @@ function getToolDefinitions() {
|
|
|
907
941
|
tension_count: status.tension_count,
|
|
908
942
|
versioned_engram_count: status.versioned_engram_count ?? 0,
|
|
909
943
|
outbox_count: status.outbox_count ?? 0,
|
|
944
|
+
// Injection-provenance event/label counts (#452) — #202's volume gate.
|
|
945
|
+
history_events: status.history_events ?? {
|
|
946
|
+
co_injection: 0,
|
|
947
|
+
injection_outcome: 0,
|
|
948
|
+
outcome_positive: 0,
|
|
949
|
+
outcome_negative: 0
|
|
950
|
+
},
|
|
951
|
+
// Last background index/reembed failure (#272) — absent when healthy.
|
|
952
|
+
...status.index_error ? { index_error: status.index_error } : {},
|
|
910
953
|
// Version check (issue #151)
|
|
911
954
|
...versionCheck?.updateAvailable && versionCheck.latest ? {
|
|
912
955
|
update_available: {
|
|
@@ -926,12 +969,14 @@ function getToolDefinitions() {
|
|
|
926
969
|
inputSchema: {
|
|
927
970
|
type: "object",
|
|
928
971
|
properties: {
|
|
929
|
-
retry: { type: "boolean", description: "If true, reset cached embedder failure state and retry the model load before reporting" }
|
|
972
|
+
retry: { type: "boolean", description: "If true, reset cached embedder failure state and retry the model load before reporting" },
|
|
973
|
+
rerank_eval: { type: "boolean", description: "If true and a reranker is configured (PLUR_RERANKER), run the per-store self-eval gate (#451): probes synthesized from this store's own engrams compare rerank-on vs RRF-only ordering. Verdict is cached in the store and advisory \u2014 it never auto-disables reranking. Costs one cross-encoder pass per probe (~20 probes)." }
|
|
930
974
|
}
|
|
931
975
|
},
|
|
932
976
|
handler: async (args, plur) => {
|
|
933
977
|
if (args.retry === true) {
|
|
934
978
|
plur.resetEmbedder();
|
|
979
|
+
plur.resetReranker();
|
|
935
980
|
}
|
|
936
981
|
const status = plur.status();
|
|
937
982
|
const before = plur.embedderStatus();
|
|
@@ -984,6 +1029,74 @@ function getToolDefinitions() {
|
|
|
984
1029
|
" \u2022 Or opt out: set PLUR_DISABLE_EMBEDDINGS=1, or write `embeddings: { enabled: false }` to ~/.plur/config.yaml \u2014 hybrid search will run BM25-only"
|
|
985
1030
|
);
|
|
986
1031
|
}
|
|
1032
|
+
const rerankerName = resolveRerankerName();
|
|
1033
|
+
if (rerankerName !== "off") {
|
|
1034
|
+
const adapter = getReranker(rerankerName);
|
|
1035
|
+
let rerankerOk = false;
|
|
1036
|
+
let rerankerDetail;
|
|
1037
|
+
const probeStart = Date.now();
|
|
1038
|
+
try {
|
|
1039
|
+
const scores = await adapter.scoreBatch("plur doctor probe", ["probe document"]);
|
|
1040
|
+
rerankerOk = scores.length === 1 && Number.isFinite(scores[0]);
|
|
1041
|
+
const latencyNote = rerankerName === "bge-reranker-v2-m3" ? "seconds-scale per recall on CPU is expected \u2014 #220" : "ms-scale per recall on CPU is expected \u2014 #451";
|
|
1042
|
+
rerankerDetail = rerankerOk ? `${rerankerName} loaded and scoring (probe ${Date.now() - probeStart}ms; ${latencyNote})` : `Probe returned malformed scores (${JSON.stringify(scores)}) \u2014 recall silently falls back to RRF-only`;
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
const message = err.message;
|
|
1045
|
+
const kind = classifyRerankerFailure(message);
|
|
1046
|
+
if (kind === "corrupt-cache") {
|
|
1047
|
+
rerankerDetail = `Model cache looks corrupt (${message}) \u2014 recall silently falls back to RRF-only`;
|
|
1048
|
+
remediation.push(
|
|
1049
|
+
`Reranker model cache is corrupt \u2014 the classic symptom of a truncated download (#340). Delete ~/.cache/huggingface/hub/${hfCacheDirName(adapter.modelId)}/ and run plur_doctor with retry:true \u2014 the model will redownload via the classic (non-Xet) path.`
|
|
1050
|
+
);
|
|
1051
|
+
} else {
|
|
1052
|
+
rerankerDetail = `Failed to load: ${message} \u2014 recall silently falls back to RRF-only`;
|
|
1053
|
+
remediation.push(
|
|
1054
|
+
`Reranker "${rerankerName}" is unavailable while PLUR_RERANKER requests it \u2014 recall degrades to RRF order without it. Check connectivity to huggingface.co (first-run download), or unset PLUR_RERANKER to opt out deliberately.`
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
checks.push({ check: "reranker available", ok: rerankerOk, detail: rerankerDetail });
|
|
1059
|
+
try {
|
|
1060
|
+
let evalStatus;
|
|
1061
|
+
let freshlyRun = false;
|
|
1062
|
+
if (args.rerank_eval === true) {
|
|
1063
|
+
const run = await plur.rerankerSelfEval();
|
|
1064
|
+
evalStatus = { result: run.result, stale: false };
|
|
1065
|
+
freshlyRun = !run.cached;
|
|
1066
|
+
} else {
|
|
1067
|
+
evalStatus = plur.rerankerEvalStatus(rerankerName);
|
|
1068
|
+
}
|
|
1069
|
+
if (!evalStatus) {
|
|
1070
|
+
checks.push({
|
|
1071
|
+
check: "reranker per-store eval",
|
|
1072
|
+
ok: true,
|
|
1073
|
+
detail: "Not yet evaluated on this store \u2014 cross-encoders can be net-negative out-of-domain (#451). Run plur_doctor with rerank_eval:true for the advisory self-check before trusting reranked order."
|
|
1074
|
+
});
|
|
1075
|
+
} else {
|
|
1076
|
+
const r = evalStatus.result;
|
|
1077
|
+
const harmful = r.verdict === "harmful";
|
|
1078
|
+
const sign = r.delta_mrr >= 0 ? "+" : "";
|
|
1079
|
+
const provenance = freshlyRun ? "measured now" : `cached ${r.evaluated_at}`;
|
|
1080
|
+
const staleNote = evalStatus.stale ? " [STALE \u2014 store changed since; re-run with rerank_eval:true]" : "";
|
|
1081
|
+
checks.push({
|
|
1082
|
+
check: "reranker per-store eval",
|
|
1083
|
+
ok: !harmful,
|
|
1084
|
+
detail: `${r.verdict} on this store (${provenance}${staleNote}): \u0394MRR ${sign}${r.delta_mrr.toFixed(3)} vs RRF-only over ${r.scored_probes} probes (hit@1 ${(r.rrf_hit1 * 100).toFixed(0)}%\u2192${(r.rerank_hit1 * 100).toFixed(0)}%, ${r.promotions} promoted / ${r.demotions} demoted, ~${r.mean_rerank_ms.toFixed(0)}ms/probe)`
|
|
1085
|
+
});
|
|
1086
|
+
if (harmful) {
|
|
1087
|
+
remediation.push(
|
|
1088
|
+
`Per-store self-eval measured reranker "${rerankerName}" as net-negative on THIS store (\u0394MRR ${r.delta_mrr.toFixed(3)}; it demoted known-relevant engrams in ${r.demotions}/${r.scored_probes} probes). This gate is advisory \u2014 reranking remains enabled. Unset PLUR_RERANKER to opt out for this store, or re-run plur_doctor with rerank_eval:true after the store grows/changes.`
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
checks.push({
|
|
1094
|
+
check: "reranker per-store eval",
|
|
1095
|
+
ok: false,
|
|
1096
|
+
detail: `self-eval failed: ${err.message}`
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
987
1100
|
const canaryStatuses = mcpCanary.status();
|
|
988
1101
|
for (const cs of canaryStatuses) {
|
|
989
1102
|
if (!cs.healthy) {
|
|
@@ -1039,7 +1152,7 @@ function getToolDefinitions() {
|
|
|
1039
1152
|
required: ["task"]
|
|
1040
1153
|
},
|
|
1041
1154
|
handler: async (args, plur) => {
|
|
1042
|
-
mcpCanary.
|
|
1155
|
+
mcpCanary.reset();
|
|
1043
1156
|
mcpCanary.signal("session_start_hook");
|
|
1044
1157
|
const crypto = await import("crypto");
|
|
1045
1158
|
const session_id = crypto.randomUUID();
|
|
@@ -1074,7 +1187,9 @@ function getToolDefinitions() {
|
|
|
1074
1187
|
let engrams = null;
|
|
1075
1188
|
try {
|
|
1076
1189
|
const result = await plur.injectHybrid(task, {
|
|
1077
|
-
scope: tags?.length ? `tags:${tags.join(",")}` : void 0
|
|
1190
|
+
scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
|
|
1191
|
+
session_id
|
|
1192
|
+
// stamped on the co_injection provenance event (#452)
|
|
1078
1193
|
});
|
|
1079
1194
|
if (result.count > 0) {
|
|
1080
1195
|
const lines = [];
|
|
@@ -1085,7 +1200,8 @@ function getToolDefinitions() {
|
|
|
1085
1200
|
}
|
|
1086
1201
|
} catch {
|
|
1087
1202
|
const result = plur.inject(task, {
|
|
1088
|
-
scope: tags?.length ? `tags:${tags.join(",")}` : void 0
|
|
1203
|
+
scope: tags?.length ? `tags:${tags.join(",")}` : void 0,
|
|
1204
|
+
session_id
|
|
1089
1205
|
});
|
|
1090
1206
|
if (result.count > 0) {
|
|
1091
1207
|
const lines = [];
|
|
@@ -1443,22 +1559,48 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1443
1559
|
},
|
|
1444
1560
|
{
|
|
1445
1561
|
name: "plur_tensions",
|
|
1446
|
-
description:
|
|
1447
|
-
annotations: { title: "Tensions", readOnlyHint:
|
|
1562
|
+
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.',
|
|
1563
|
+
annotations: { title: "Tensions", readOnlyHint: false, idempotentHint: true },
|
|
1448
1564
|
inputSchema: {
|
|
1449
1565
|
type: "object",
|
|
1450
1566
|
properties: {
|
|
1451
1567
|
scope: { type: "string", description: "Filter by scope" },
|
|
1452
1568
|
domain: { type: "string", description: "Filter by domain prefix" },
|
|
1453
|
-
scan: { type: "boolean", description: "Run an active contradiction scan using an LLM judge. Requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args." },
|
|
1569
|
+
scan: { type: "boolean", description: "Run an active contradiction scan using an LLM judge. New detections are persisted as tension records; recorded pairs (any status) are skipped. Requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args." },
|
|
1570
|
+
persist: { type: "boolean", description: "Persist scan detections as tension records (default true). Set false for a dry-run scan that also ignores the recorded-pair suppress list." },
|
|
1571
|
+
action: { type: "string", enum: ["confirm", "dismiss", "resolve"], description: "Lifecycle action on a persisted tension record (requires id). confirm: mark real. dismiss: false positive, suppress the pair. resolve: pick winner (requires winner), the losing engram is retired." },
|
|
1572
|
+
id: { type: "string", description: "Tension record id (T-YYYY-MMDD-NNN) for action mode" },
|
|
1573
|
+
winner: { type: "string", description: 'Engram id that wins the tension (action:"resolve" only). The other engram is retired.' },
|
|
1574
|
+
status: { type: "string", enum: ["detected", "confirmed", "dismissed", "resolved", "all"], description: "List-mode status filter. Default: unresolved records (detected + confirmed)." },
|
|
1454
1575
|
llm_base_url: { type: "string", description: "OpenAI-compatible API base URL for scan mode (e.g. https://api.openai.com/v1)" },
|
|
1455
1576
|
llm_api_key: { type: "string", description: "API key for the LLM (scan mode)" },
|
|
1456
1577
|
llm_model: { type: "string", description: "Model name for scan mode (default: gpt-4o-mini)" },
|
|
1457
1578
|
min_confidence: { type: "number", description: "Minimum confidence threshold for scan mode (0\u20131, default: 0.7)" },
|
|
1458
|
-
max_pairs: { type: "number", description: "Maximum candidate pairs to evaluate in scan mode (default: 50)" }
|
|
1579
|
+
max_pairs: { type: "number", description: "Maximum candidate pairs to evaluate in scan mode (default: 50)" },
|
|
1580
|
+
batch_size: { type: "number", description: "Pairs judged per LLM call in scan mode (default: 5). Set to 1 for sequential single-pair judging." },
|
|
1581
|
+
temporal_discount: { type: "boolean", description: "Multiply judge confidence by a days-apart ladder (same day x1.0 ... 15+ days x0.3) in scan mode (#240). Overrides the config default (tensions.temporal_discount, off by default). The judge prompt already carries recorded dates; enable this only when date-aware judging alone leaves too many temporal-evolution false positives." }
|
|
1459
1582
|
}
|
|
1460
1583
|
},
|
|
1461
1584
|
handler: async (args, plur) => {
|
|
1585
|
+
if (args.action) {
|
|
1586
|
+
const id = args.id;
|
|
1587
|
+
if (!id) throw new Error(`action:"${args.action}" requires id (tension record id, e.g. T-2026-0703-001)`);
|
|
1588
|
+
if (args.action === "confirm") {
|
|
1589
|
+
const record = plur.confirmTension(id);
|
|
1590
|
+
return { record, message: `Tension ${id} confirmed as a real conflict. Resolve it with action:"resolve" + winner:<engram_id>.` };
|
|
1591
|
+
}
|
|
1592
|
+
if (args.action === "dismiss") {
|
|
1593
|
+
const record = plur.dismissTension(id);
|
|
1594
|
+
return { record, message: `Tension ${id} dismissed \u2014 the pair is suppressed from future scans.` };
|
|
1595
|
+
}
|
|
1596
|
+
if (args.action === "resolve") {
|
|
1597
|
+
const winner = args.winner;
|
|
1598
|
+
if (!winner) throw new Error('action:"resolve" requires winner (the engram id to keep)');
|
|
1599
|
+
const { record, retired_id } = plur.resolveTension(id, winner);
|
|
1600
|
+
return { record, retired: retired_id, message: `Tension ${id} resolved: ${winner} wins, ${retired_id} retired.` };
|
|
1601
|
+
}
|
|
1602
|
+
throw new Error(`Unknown action: ${args.action}. Use confirm, dismiss, or resolve.`);
|
|
1603
|
+
}
|
|
1462
1604
|
const engrams = plur.list({
|
|
1463
1605
|
scope: args.scope,
|
|
1464
1606
|
domain: args.domain
|
|
@@ -1472,22 +1614,37 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1472
1614
|
count: 0
|
|
1473
1615
|
};
|
|
1474
1616
|
}
|
|
1617
|
+
const tensionsConfig = plur.getTensionsConfig();
|
|
1618
|
+
const persist = args.persist !== false;
|
|
1475
1619
|
const result = await scanForTensions(engrams, llm, {
|
|
1476
1620
|
min_confidence: args.min_confidence,
|
|
1477
|
-
max_pairs: args.max_pairs
|
|
1621
|
+
max_pairs: args.max_pairs,
|
|
1622
|
+
batch_size: args.batch_size,
|
|
1623
|
+
temporal_domains: tensionsConfig.temporal_domains,
|
|
1624
|
+
snapshot_pairs: tensionsConfig.snapshot_pairs,
|
|
1625
|
+
temporal_discount: args.temporal_discount ?? tensionsConfig.temporal_discount,
|
|
1626
|
+
...persist ? { exclude_pairs: new Set(plur.suppressedTensionPairKeys()) } : {}
|
|
1478
1627
|
});
|
|
1628
|
+
const persisted = persist && result.tensions.length > 0 ? plur.recordTensions(result.tensions) : void 0;
|
|
1479
1629
|
return {
|
|
1480
1630
|
pairs_checked: result.pairs_checked,
|
|
1481
1631
|
count: result.new_tensions,
|
|
1482
|
-
|
|
1632
|
+
...persisted ? { persisted_new: persisted.new_count } : {},
|
|
1633
|
+
tensions: result.tensions.map((t, i) => ({
|
|
1634
|
+
...persisted ? { tension_id: persisted.records[i].id, category: persisted.records[i].category, status: persisted.records[i].status } : {},
|
|
1483
1635
|
engram_a: { id: t.id_a, statement: t.statement_a },
|
|
1484
1636
|
engram_b: { id: t.id_b, statement: t.statement_b },
|
|
1485
1637
|
confidence: t.confidence,
|
|
1486
|
-
reason: t.reason
|
|
1487
|
-
|
|
1638
|
+
reason: t.reason,
|
|
1639
|
+
...t.days_apart !== void 0 ? { days_apart: t.days_apart } : {},
|
|
1640
|
+
...t.raw_confidence !== void 0 ? { raw_confidence: t.raw_confidence } : {}
|
|
1641
|
+
})),
|
|
1642
|
+
...persisted && persisted.new_count > 0 ? { next_steps: 'Review each tension: action:"confirm" (real), action:"dismiss" (false positive), or action:"resolve" + winner:<engram_id> (retire the loser).' } : {}
|
|
1488
1643
|
};
|
|
1489
1644
|
}
|
|
1490
|
-
const
|
|
1645
|
+
const statusArg = args.status;
|
|
1646
|
+
const records = statusArg === "all" ? plur.listTensions() : plur.listTensions({ status: statusArg ? [statusArg] : ["detected", "confirmed"] });
|
|
1647
|
+
const legacy = [];
|
|
1491
1648
|
const seen = /* @__PURE__ */ new Set();
|
|
1492
1649
|
for (const engram of engrams) {
|
|
1493
1650
|
if (!engram.relations?.conflicts?.length) continue;
|
|
@@ -1497,16 +1654,22 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
|
|
|
1497
1654
|
seen.add(pairKey);
|
|
1498
1655
|
const other = engrams.find((e) => e.id === conflictId);
|
|
1499
1656
|
if (!other) continue;
|
|
1500
|
-
|
|
1657
|
+
legacy.push({
|
|
1501
1658
|
engram_a: { id: engram.id, statement: engram.statement, type: engram.type },
|
|
1502
1659
|
engram_b: { id: other.id, statement: other.statement, type: other.type },
|
|
1503
|
-
detected_at: engram.activation.last_accessed
|
|
1504
|
-
purge_hint: "These conflicts are from the legacy detection system. Run plur_tensions_purge to clear them, then use scan:true for active contradiction detection."
|
|
1660
|
+
detected_at: engram.activation.last_accessed
|
|
1505
1661
|
});
|
|
1506
1662
|
}
|
|
1507
1663
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1664
|
+
return {
|
|
1665
|
+
tensions: records,
|
|
1666
|
+
count: records.length,
|
|
1667
|
+
...legacy.length > 0 ? {
|
|
1668
|
+
legacy_conflicts: legacy,
|
|
1669
|
+
purge_hint: "legacy_conflicts are unvalidated relations.conflicts refs (importer heuristics or pre-#138 residue) \u2014 run scan:true to judge them, or plur_tensions_purge to clear them."
|
|
1670
|
+
} : {},
|
|
1671
|
+
...records.length === 0 && legacy.length === 0 ? { hint: "No persisted tensions. Run scan:true to detect contradictions." } : {}
|
|
1672
|
+
};
|
|
1510
1673
|
}
|
|
1511
1674
|
},
|
|
1512
1675
|
{
|
|
@@ -1899,7 +2062,22 @@ function jsonSchemaPropToZod(prop) {
|
|
|
1899
2062
|
if (prop.type === "boolean") return z.boolean();
|
|
1900
2063
|
if (prop.type === "array") {
|
|
1901
2064
|
const itemSchema = prop.items ? jsonSchemaPropToZod(prop.items) : z.unknown();
|
|
1902
|
-
return z.
|
|
2065
|
+
return z.preprocess((val) => {
|
|
2066
|
+
if (typeof val !== "string") return val;
|
|
2067
|
+
const trimmed = val.trim();
|
|
2068
|
+
if (trimmed.startsWith("[")) {
|
|
2069
|
+
try {
|
|
2070
|
+
const parsed = JSON.parse(trimmed);
|
|
2071
|
+
return Array.isArray(parsed) ? parsed : val;
|
|
2072
|
+
} catch {
|
|
2073
|
+
return val;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
if (prop.items?.type === "string") {
|
|
2077
|
+
return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2078
|
+
}
|
|
2079
|
+
return val;
|
|
2080
|
+
}, z.array(itemSchema));
|
|
1903
2081
|
}
|
|
1904
2082
|
if (prop.type === "object" && prop.properties) {
|
|
1905
2083
|
const shape = {};
|
|
@@ -1947,8 +2125,9 @@ async function createServer(plur) {
|
|
|
1947
2125
|
isError: true
|
|
1948
2126
|
};
|
|
1949
2127
|
}
|
|
2128
|
+
mcpCanary.tick();
|
|
1950
2129
|
try {
|
|
1951
|
-
|
|
2130
|
+
let args = request.params.arguments ?? {};
|
|
1952
2131
|
const schema = tool.inputSchema;
|
|
1953
2132
|
if (schema?.properties) {
|
|
1954
2133
|
const shape = {};
|
|
@@ -1961,15 +2140,18 @@ async function createServer(plur) {
|
|
|
1961
2140
|
const receivedFields = Object.keys(args);
|
|
1962
2141
|
const details = parsed.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`).join(", ");
|
|
1963
2142
|
const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
|
|
2143
|
+
const hasArrayParam = Object.values(schema.properties).some((p) => p?.type === "array");
|
|
2144
|
+
const arrayBugHint = receivedFields.length === 0 && hasArrayParam ? ' Known client-side bug (plur-ai/plur#297): some MCP clients drop the entire arguments payload when an array-typed parameter is included. 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.' : "";
|
|
1964
2145
|
return {
|
|
1965
2146
|
content: [{ type: "text", text: JSON.stringify({
|
|
1966
|
-
error: `Invalid arguments: ${details}. ${receivedNote} 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
|
|
2147
|
+
error: `Invalid arguments: ${details}. ${receivedNote} 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.` + arrayBugHint,
|
|
1967
2148
|
success: false,
|
|
1968
2149
|
received_fields: receivedFields
|
|
1969
2150
|
}) }],
|
|
1970
2151
|
isError: true
|
|
1971
2152
|
};
|
|
1972
2153
|
}
|
|
2154
|
+
args = parsed.data;
|
|
1973
2155
|
}
|
|
1974
2156
|
const result = await tool.handler(args, instance);
|
|
1975
2157
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plur-ai/mcp",
|
|
3
|
-
"
|
|
3
|
+
"mcpName": "io.github.plur-ai/plur",
|
|
4
|
+
"version": "0.11.0",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"bin": {
|
|
6
7
|
"plur-mcp": "dist/index.js"
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
"dependencies": {
|
|
14
15
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
15
16
|
"zod": "^3.23.0",
|
|
16
|
-
"@plur-ai/core": "0.
|
|
17
|
+
"@plur-ai/core": "0.11.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@types/node": "^25.5.0"
|