prism-mcp-server 20.9.2 → 20.10.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 +14 -1
- package/dist/connect.js +3 -3
- package/dist/server.js +1 -1
- package/dist/tools/ledgerHandlers.js +130 -13
- package/dist/tools/scopedSkillTriggers.js +228 -0
- package/dist/tools/sessionMemoryDefinitions.js +2 -2
- package/dist/tools/skillRouting.js +12 -3
- package/dist/tools/skillScopeHandlers.js +39 -3
- package/dist/utils/skillBudget.js +18 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ or by re-enabling after each run.
|
|
|
116
116
|
<details>
|
|
117
117
|
<summary>Release history (optional)</summary>
|
|
118
118
|
|
|
119
|
-
## What's New in v20.9.0
|
|
119
|
+
## What's New in v20.9.0 – v20.9.3
|
|
120
120
|
|
|
121
121
|
- **Your skills follow your account.** `skill_save` stores a skill at the
|
|
122
122
|
scope you choose: this machine only (`local`, works offline and signed out),
|
|
@@ -128,6 +128,19 @@ or by re-enabling after each run.
|
|
|
128
128
|
them any time, losslessly. Deleting a scoped skill archives its final
|
|
129
129
|
content locally first, so nothing is ever silently unrecoverable.
|
|
130
130
|
|
|
131
|
+
- **Delivery that queues instead of failing.** Concurrent sessions no longer
|
|
132
|
+
starve skill sync on the local config store (WAL + busy-timeout) — a failure
|
|
133
|
+
that previously reported only "partial" where nobody could see it.
|
|
134
|
+
- **Withheld rules still bind.** When the context budget can't inline a
|
|
135
|
+
skill's text, the manifest of withheld names now states that those skills
|
|
136
|
+
still govern the work and names every way to load them before completion
|
|
137
|
+
claims.
|
|
138
|
+
- **The budget the floor never spent.** A long-standing accounting bug meant
|
|
139
|
+
no unprotected skill ever inlined at any normal context level — the
|
|
140
|
+
always-inlined protected floor was debiting the budget meant for everything
|
|
141
|
+
else. Task-matched skills (like the completion-evidence checklist) now
|
|
142
|
+
actually arrive.
|
|
143
|
+
|
|
131
144
|
## What's New in v20.8.2
|
|
132
145
|
|
|
133
146
|
- **Skill delivery now admits failure instead of hiding it.** A filesystem
|
package/dist/connect.js
CHANGED
|
@@ -45,7 +45,7 @@ const CODEX_STARTUP_BODY = [
|
|
|
45
45
|
"after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery to load that",
|
|
46
46
|
"exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection as",
|
|
47
47
|
"a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
|
|
48
|
-
"`Prism startup failure` and stop. Reuse the `conversation_id` returned
|
|
48
|
+
"`Prism startup failure` and stop. Reuse the `conversation_id` returned on the `<prism_session />` line for every",
|
|
49
49
|
"session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This hook-free",
|
|
50
50
|
"block is managed by `prism connect`; do not edit it manually.",
|
|
51
51
|
"",
|
|
@@ -579,7 +579,7 @@ function serializeClaudeStartupBlock(newline) {
|
|
|
579
579
|
"deferred, use native tool discovery/ToolSearch to load that",
|
|
580
580
|
"exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection",
|
|
581
581
|
"as a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
|
|
582
|
-
"`Prism startup failure` and stop. Reuse the `conversation_id` returned
|
|
582
|
+
"`Prism startup failure` and stop. Reuse the `conversation_id` returned on the `<prism_session />` line for every",
|
|
583
583
|
"session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This block is",
|
|
584
584
|
"managed by `prism connect`; do not edit it manually.",
|
|
585
585
|
"",
|
|
@@ -660,7 +660,7 @@ function serializeGeminiStartupBlock(newline) {
|
|
|
660
660
|
"to load that exact tool, then invoke it.",
|
|
661
661
|
"Do not use shell commands, file reads, subagents, or unrelated tool inspection as a substitute. Do not call",
|
|
662
662
|
"`session_load_context`. If discovery or invocation fails, report `Prism startup failure` and stop. Reuse the",
|
|
663
|
-
"`conversation_id` returned
|
|
663
|
+
"`conversation_id` returned on the `<prism_session />` line for session_save_ledger, session_save_handoff, and",
|
|
664
664
|
"session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
|
|
665
665
|
"",
|
|
666
666
|
...LOCAL_FIRST_POLICY_LINES,
|
package/dist/server.js
CHANGED
|
@@ -336,7 +336,7 @@ export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI A
|
|
|
336
336
|
`Do not substitute session_load_context while session_bootstrap is available; use session_load_context ` +
|
|
337
337
|
`only for an explicit project reload or as an older-server fallback. ` +
|
|
338
338
|
`Use session_save_ledger to log completed work and session_save_handoff to preserve state for the next session. ` +
|
|
339
|
-
`Reuse the conversation_id
|
|
339
|
+
`Reuse the conversation_id from session_bootstrap's <prism_session /> line for those saves and for ` +
|
|
340
340
|
`session_detect_drift, the 60-minute goal-alignment drift check. Do not add the id to the visible greeting.\n\n` +
|
|
341
341
|
`${LOCAL_FIRST_POLICY_TEXT}\n\n` +
|
|
342
342
|
`${EVIDENCE_WORKFLOW_POLICY_TEXT}\n\n` +
|
|
@@ -143,6 +143,15 @@ function stripSkillFrontmatter(raw) {
|
|
|
143
143
|
return text; // unterminated frontmatter — inline as-is
|
|
144
144
|
return text.slice(text.indexOf("\n", end + 1) + 1).trim();
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Room reserved for the trailing <prism_session /> facts line so it never
|
|
148
|
+
* competes with the skill block. The allocator below already subtracts
|
|
149
|
+
* systemReadyBlock.length — skills are protected and PROJECTS absorb budget
|
|
150
|
+
* pressure — so the facts line is reserved the same way rather than capping
|
|
151
|
+
* the assembled text, which truncates from the TAIL and would eat the very
|
|
152
|
+
* skill block this whole fix exists to deliver.
|
|
153
|
+
*/
|
|
154
|
+
const SESSION_FACTS_RESERVE = 256;
|
|
146
155
|
const NATIVE_STARTUP_MAX_CHARS = {
|
|
147
156
|
quick: 4_000,
|
|
148
157
|
standard: 8_000,
|
|
@@ -1425,7 +1434,13 @@ export async function sessionLoadContextHandler(args, options = {}) {
|
|
|
1425
1434
|
// here; without it a stale cached table would never be detected on
|
|
1426
1435
|
// this path, since there is no portal response to compare against.
|
|
1427
1436
|
const manifestVersion = Number(await getSetting("skill_manifest:routing_version", ""));
|
|
1428
|
-
|
|
1437
|
+
// Account/team skills can never appear in the PUBLIC routing table —
|
|
1438
|
+
// listing a private skill's name and trigger words in a world-readable
|
|
1439
|
+
// file is the leak this feature exists to avoid — so they declare
|
|
1440
|
+
// `prompt_triggers` in their own frontmatter and are matched here, on
|
|
1441
|
+
// device, from bodies already cached for injection.
|
|
1442
|
+
const scoped = await collectSkillTriggersOnThisMachine();
|
|
1443
|
+
const matched = (await resolvePromptSkillNames(prompt, Number.isFinite(manifestVersion) && manifestVersion > 0 ? manifestVersion : undefined, scoped?.triggers)).filter((name) => entitledSkillNames.has(name) || scoped?.localNames.has(name));
|
|
1429
1444
|
if (matched.length > 0) {
|
|
1430
1445
|
const shown = matched.slice(0, MAX_SYMPTOM_SKILLS);
|
|
1431
1446
|
const overflow = matched.length - shown.length;
|
|
@@ -1789,6 +1804,108 @@ export async function seedAndRecallDemoMemory(conversationId) {
|
|
|
1789
1804
|
return null;
|
|
1790
1805
|
}
|
|
1791
1806
|
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Machine-readable session facts, carried INSIDE the text block.
|
|
1809
|
+
*
|
|
1810
|
+
* Why not `structuredContent`: a tool result may carry both a text block and
|
|
1811
|
+
* `structuredContent`, and a host is free to surface only the latter — Claude
|
|
1812
|
+
* Code does exactly that, so from 2026-07-22 (when structuredContent was added
|
|
1813
|
+
* here) until this fix, every Claude Code session received these 129 bytes of
|
|
1814
|
+
* JSON and NONE of the ~7KB startup text: no memory context, no protected
|
|
1815
|
+
* skill floor, no symptom-triggered skills. Measured three ways: a text-only
|
|
1816
|
+
* tool (session_health_check) arrived intact, this one and skill_manage did
|
|
1817
|
+
* not, and Claude Code v2.1.212 delivered the full text before the change and
|
|
1818
|
+
* 129 bytes after it — same host version, so the regression was ours.
|
|
1819
|
+
*
|
|
1820
|
+
* MCP pairs `structuredContent` with an `outputSchema`; Prism declares none,
|
|
1821
|
+
* so emitting it was out of spec and a host preferring it is reasonable. The
|
|
1822
|
+
* facts now ride in the text, which every host renders.
|
|
1823
|
+
*/
|
|
1824
|
+
export function buildSessionFactsLine(facts) {
|
|
1825
|
+
// Attribute-safe, NOT markdown-escaped: escapeNativeMarkdown backslashes
|
|
1826
|
+
// hyphens, which corrupts every UUID it touches (`conv-abc` → `conv\-abc`)
|
|
1827
|
+
// and would make the id unusable by the caller it exists for. Strip only
|
|
1828
|
+
// what can break out of the attribute or the line.
|
|
1829
|
+
const safe = (value) => value.replace(/[\u0000-\u001f\u007f"<>]+/g, " ").trim();
|
|
1830
|
+
const attrs = Object.entries(facts)
|
|
1831
|
+
.map(([key, value]) => `${key}="${safe(String(Array.isArray(value) ? value.join(",") : value))}"`)
|
|
1832
|
+
.join(" ");
|
|
1833
|
+
return `<prism_session ${attrs} />`;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Prompt triggers declared by skills on this machine.
|
|
1837
|
+
*
|
|
1838
|
+
* Two sources, because a skill can reach a machine two ways:
|
|
1839
|
+
* - the `skill:<name>` settings cache, which backs body injection for every
|
|
1840
|
+
* DELIVERED skill (platform, account, team); and
|
|
1841
|
+
* - local skill directories, for skills saved with scope "local", which are
|
|
1842
|
+
* written straight to disk and never enter that cache.
|
|
1843
|
+
*
|
|
1844
|
+
* Missing the second source made `prompt_triggers` silently do nothing for
|
|
1845
|
+
* local skills — the same "configured and inert" experience the feature exists
|
|
1846
|
+
* to remove. Local skill NAMES are returned too: the caller filters matches by
|
|
1847
|
+
* entitlement, and a local skill is not in the entitlement manifest, so without
|
|
1848
|
+
* this it would match and then be discarded. A file the user wrote on their own
|
|
1849
|
+
* machine needs no entitlement.
|
|
1850
|
+
*
|
|
1851
|
+
* Failures are swallowed — a broken trigger degrades to "the public table
|
|
1852
|
+
* only", never takes down startup.
|
|
1853
|
+
*/
|
|
1854
|
+
async function collectSkillTriggersOnThisMachine() {
|
|
1855
|
+
try {
|
|
1856
|
+
const { collectScopedTriggers, collectLocalSkillTriggers } = await import("./scopedSkillTriggers.js");
|
|
1857
|
+
const merged = {};
|
|
1858
|
+
const localNames = new Set();
|
|
1859
|
+
const errors = [];
|
|
1860
|
+
const settings = await getAllSettings();
|
|
1861
|
+
const bodies = [];
|
|
1862
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
1863
|
+
if (key.startsWith("skill:") && value)
|
|
1864
|
+
bodies.push([key.slice("skill:".length), value]);
|
|
1865
|
+
}
|
|
1866
|
+
if (bodies.length > 0) {
|
|
1867
|
+
const delivered = collectScopedTriggers(bodies);
|
|
1868
|
+
for (const [pattern, names] of Object.entries(delivered.triggers)) {
|
|
1869
|
+
(merged[pattern] ||= []).push(...names);
|
|
1870
|
+
}
|
|
1871
|
+
errors.push(...delivered.errors);
|
|
1872
|
+
}
|
|
1873
|
+
// Isolated: a disk problem (or a host without the local root) must not cost
|
|
1874
|
+
// us the DELIVERED skills' triggers, which are already in hand.
|
|
1875
|
+
try {
|
|
1876
|
+
const { readdir, stat, readFile } = await import("node:fs/promises");
|
|
1877
|
+
const { join } = await import("node:path");
|
|
1878
|
+
// The skills root is overridable per caller and per home; skillManifestSync
|
|
1879
|
+
// owns it. Scanning the canonical root alone is sufficient because
|
|
1880
|
+
// skill_save writes every local skill there before mirroring.
|
|
1881
|
+
const { resolveCanonicalSkillsDir } = await import("../skillManifestSync.js");
|
|
1882
|
+
const local = await collectLocalSkillTriggers([resolveCanonicalSkillsDir()], {
|
|
1883
|
+
readdir: (path) => readdir(path),
|
|
1884
|
+
stat: async (path) => ({ size: (await stat(path)).size }),
|
|
1885
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
1886
|
+
}, join);
|
|
1887
|
+
for (const [pattern, names] of Object.entries(local.triggers)) {
|
|
1888
|
+
(merged[pattern] ||= []).push(...names);
|
|
1889
|
+
}
|
|
1890
|
+
for (const name of local.names)
|
|
1891
|
+
localNames.add(name);
|
|
1892
|
+
errors.push(...local.errors);
|
|
1893
|
+
}
|
|
1894
|
+
catch (error) {
|
|
1895
|
+
debugLog(`[skill-triggers] local scan skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
1896
|
+
}
|
|
1897
|
+
for (const error of errors) {
|
|
1898
|
+
// Loud, not silent: a rejected trigger looks exactly like the defect this
|
|
1899
|
+
// feature fixes — a skill that is installed and never fires.
|
|
1900
|
+
debugLog(`[skill-triggers] ignored trigger in "${error.skill}": ${error.reason}`);
|
|
1901
|
+
}
|
|
1902
|
+
return Object.keys(merged).length > 0 ? { triggers: merged, localNames } : undefined;
|
|
1903
|
+
}
|
|
1904
|
+
catch (error) {
|
|
1905
|
+
debugLog(`[skill-triggers] collection failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1906
|
+
return undefined;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1792
1909
|
export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
1793
1910
|
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
1794
1911
|
throw new Error("Invalid arguments for session_bootstrap");
|
|
@@ -1877,10 +1994,9 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
1877
1994
|
return {
|
|
1878
1995
|
content: [{
|
|
1879
1996
|
type: "text",
|
|
1880
|
-
text: capNativeStartupText(firstRunText, depth),
|
|
1997
|
+
text: capNativeStartupText(firstRunText, depth, undefined, `\n\n${buildSessionFactsLine({ conversation_id: conversationId, projects: "", depth, first_run: true })}`),
|
|
1881
1998
|
}],
|
|
1882
1999
|
isError: false,
|
|
1883
|
-
structuredContent: { conversation_id: conversationId, projects: [], depth, first_run: true },
|
|
1884
2000
|
};
|
|
1885
2001
|
}
|
|
1886
2002
|
const unconfiguredState = (depth === "quick" ? "" : `\n- 📝 **Last Session Summary:** Not loaded`) +
|
|
@@ -1891,10 +2007,9 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
1891
2007
|
return {
|
|
1892
2008
|
content: [{
|
|
1893
2009
|
type: "text",
|
|
1894
|
-
text: capNativeStartupText(noProjectsText, depth),
|
|
2010
|
+
text: capNativeStartupText(noProjectsText, depth, undefined, `\n\n${buildSessionFactsLine({ conversation_id: conversationId, projects: "", depth })}`),
|
|
1895
2011
|
}],
|
|
1896
2012
|
isError: false,
|
|
1897
|
-
structuredContent: { conversation_id: conversationId, projects: [], depth },
|
|
1898
2013
|
};
|
|
1899
2014
|
}
|
|
1900
2015
|
const startupMaxChars = NATIVE_STARTUP_MAX_CHARS[depth];
|
|
@@ -1909,7 +2024,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
1909
2024
|
const omissionLength = omittedProjectsText ? omittedProjectsText.length + 2 : 0;
|
|
1910
2025
|
const separatorsLength = Math.max(0, renderedProjectCount - 1) * 2;
|
|
1911
2026
|
perProjectMaxChars = Math.floor((startupMaxChars - startupHeader.length - systemReadyBlock.length - LOCAL_STARTUP_FALLBACK_NOTICE.length -
|
|
1912
|
-
6 - omissionLength - separatorsLength) /
|
|
2027
|
+
SESSION_FACTS_RESERVE - 6 - omissionLength - separatorsLength) /
|
|
1913
2028
|
renderedProjectCount);
|
|
1914
2029
|
if (perProjectMaxChars >= 512 || renderedProjectCount === 1)
|
|
1915
2030
|
break;
|
|
@@ -1970,15 +2085,17 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
1970
2085
|
type: "text",
|
|
1971
2086
|
text: `${startupHeader}\n\n${fallbackNotice}${startupContext.loaded.join("\n\n")}` +
|
|
1972
2087
|
(omittedProjectsText ? `\n\n${omittedProjectsText}` : "") +
|
|
1973
|
-
`\n\n${systemReadyBlock}
|
|
2088
|
+
`\n\n${systemReadyBlock}` +
|
|
2089
|
+
// Appended, not capped: SESSION_FACTS_RESERVE already bought this room
|
|
2090
|
+
// out of the PROJECT budget, so nothing here can displace the skills.
|
|
2091
|
+
`\n\n${buildSessionFactsLine({
|
|
2092
|
+
conversation_id: conversationId,
|
|
2093
|
+
projects: compactWithOmissionCount(renderedProjects.join(","), 120),
|
|
2094
|
+
depth,
|
|
2095
|
+
context_source: usedLocalFallback ? "local-last-good" : activeStorageBackend,
|
|
2096
|
+
})}`,
|
|
1974
2097
|
}],
|
|
1975
2098
|
isError: startupContext.hadError,
|
|
1976
|
-
structuredContent: {
|
|
1977
|
-
conversation_id: conversationId,
|
|
1978
|
-
projects: renderedProjects,
|
|
1979
|
-
depth,
|
|
1980
|
-
context_source: usedLocalFallback ? "local-last-good" : activeStorageBackend,
|
|
1981
|
-
},
|
|
1982
2099
|
};
|
|
1983
2100
|
}
|
|
1984
2101
|
export async function memoryHistoryHandler(args) {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt triggers declared by a skill, in its own frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* Prompt-keyword routing matches against the PUBLIC, unauthenticated routing
|
|
6
|
+
* table (`/_internal/skills-routing.json`). A private account or team skill can
|
|
7
|
+
* never be listed there — the name and its trigger words would be world
|
|
8
|
+
* readable — so scoped skills were delivered to disk and then never surfaced by
|
|
9
|
+
* any prompt. They were installed and inert.
|
|
10
|
+
*
|
|
11
|
+
* The fix keeps the trigger exactly as private as the body that declares it:
|
|
12
|
+
*
|
|
13
|
+
* ---
|
|
14
|
+
* name: my-skill
|
|
15
|
+
* description: …
|
|
16
|
+
* prompt_triggers:
|
|
17
|
+
* - "\\binvoice\\b.{0,20}\\bsubmit\\b"
|
|
18
|
+
* - "quarterly close"
|
|
19
|
+
* ---
|
|
20
|
+
*
|
|
21
|
+
* The body already reaches this machine through the AUTHENTICATED manifest and
|
|
22
|
+
* is cached as `skill:<name>`, so the triggers ride along with it. Nothing new
|
|
23
|
+
* is transmitted, no schema changes, and matching stays on-device — the user's
|
|
24
|
+
* prompt still never leaves the machine.
|
|
25
|
+
*
|
|
26
|
+
* SAFETY: these patterns come from user-authored content and are compiled on the
|
|
27
|
+
* startup path, where a pathological regex would hang every session for everyone
|
|
28
|
+
* the skill is shared with. Limits below are deliberately strict, and anything
|
|
29
|
+
* rejected is reported rather than silently dropped — a trigger that never fires
|
|
30
|
+
* is indistinguishable from the bug this file fixes.
|
|
31
|
+
*/
|
|
32
|
+
/** Per skill. A skill needing more than this is describing too much. */
|
|
33
|
+
export const MAX_TRIGGERS_PER_SKILL = 5;
|
|
34
|
+
/** Per pattern. Long patterns are where catastrophic backtracking hides. */
|
|
35
|
+
export const MAX_TRIGGER_LENGTH = 200;
|
|
36
|
+
/**
|
|
37
|
+
* Reject patterns that can backtrack catastrophically.
|
|
38
|
+
*
|
|
39
|
+
* RULE: a group may not be quantified. Exponential backtracking needs the
|
|
40
|
+
* engine to try many different ways to split the SAME substring, and in
|
|
41
|
+
* practice that requires a repeated group — `(a+)+`, `(a|aa)+`, `(\w+\s?)*`,
|
|
42
|
+
* `(.*a){20}`. Refusing `)` followed by `+`, `*` or `{n,m}` kills the whole
|
|
43
|
+
* family with one check that is trivial to read and cannot be argued with.
|
|
44
|
+
*
|
|
45
|
+
* This replaced a shape-matching heuristic that only caught the textbook
|
|
46
|
+
* `(a+)+`. Measured against it, `((a+))+` slipped through nested parens and
|
|
47
|
+
* `(a|aa)+` — the canonical example — slipped through because it contains no
|
|
48
|
+
* inner quantifier at all; the latter took 447ms on 36 characters and grows
|
|
49
|
+
* exponentially, so a trigger inside the 200-char cap could hang startup for
|
|
50
|
+
* every member of a team it is shared with. JavaScript has no regex timeout:
|
|
51
|
+
* once a match begins there is no way to interrupt it, which is why this must
|
|
52
|
+
* be refused BEFORE compilation rather than bounded at runtime.
|
|
53
|
+
*
|
|
54
|
+
* Cost: a legitimate quantified group must be rewritten. Every trigger shape
|
|
55
|
+
* seen in practice — word boundaries, proximity via `.{0,n}`, alternation
|
|
56
|
+
* without repetition — is unaffected.
|
|
57
|
+
*/
|
|
58
|
+
function isCatastrophic(pattern) {
|
|
59
|
+
return /\)\s*(?:[+*]|\{\d+(?:,\d*)?\})/.test(pattern);
|
|
60
|
+
}
|
|
61
|
+
function validateTrigger(pattern) {
|
|
62
|
+
if (!pattern.trim())
|
|
63
|
+
return "empty pattern";
|
|
64
|
+
if (pattern.length > MAX_TRIGGER_LENGTH)
|
|
65
|
+
return `pattern exceeds ${MAX_TRIGGER_LENGTH} chars`;
|
|
66
|
+
if (isCatastrophic(pattern))
|
|
67
|
+
return "a quantified group can backtrack catastrophically — rewrite without repeating a group";
|
|
68
|
+
try {
|
|
69
|
+
new RegExp(pattern, "i");
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return `invalid regex: ${error instanceof Error ? error.message : String(error)}`;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Strip surrounding quotes, applying YAML's double-quote escaping.
|
|
78
|
+
*
|
|
79
|
+
* This matters more than it looks. In a double-quoted YAML scalar `\\b` means
|
|
80
|
+
* a single backslash followed by b — i.e. the regex `\b` — so an author writing
|
|
81
|
+
* the natural `- "\\binvoice\\b"` must get a word-boundary matcher. Passing
|
|
82
|
+
* the raw text through instead yields `\\binvoice\\b`, which matches a literal
|
|
83
|
+
* backslash and therefore NEVER fires: a skill that looks correctly configured
|
|
84
|
+
* and is silently inert, which is the exact defect this file exists to end.
|
|
85
|
+
* Single-quoted and bare scalars are taken literally, as YAML specifies.
|
|
86
|
+
*/
|
|
87
|
+
function unquote(value) {
|
|
88
|
+
const doubleQuoted = value.match(/^"(.*)"$/);
|
|
89
|
+
if (doubleQuoted)
|
|
90
|
+
return doubleQuoted[1].replace(/\\\\/g, "\\");
|
|
91
|
+
const singleQuoted = value.match(/^'(.*)'$/);
|
|
92
|
+
if (singleQuoted)
|
|
93
|
+
return singleQuoted[1];
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Pull `prompt_triggers` out of one skill body.
|
|
98
|
+
*
|
|
99
|
+
* Supports both YAML shapes authors actually write — a block list and an inline
|
|
100
|
+
* array — without adding a YAML dependency, matching the hand-rolled frontmatter
|
|
101
|
+
* reader already used by skill_save so the two agree on what a skill file is.
|
|
102
|
+
*/
|
|
103
|
+
export function extractSkillTriggers(skillName, content) {
|
|
104
|
+
const result = { triggers: {}, errors: [] };
|
|
105
|
+
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
|
106
|
+
if (!frontmatter)
|
|
107
|
+
return result;
|
|
108
|
+
const body = frontmatter[1];
|
|
109
|
+
const raw = [];
|
|
110
|
+
const inline = body.match(/^prompt_triggers:\s*\[(.*)\]\s*$/m);
|
|
111
|
+
if (inline) {
|
|
112
|
+
for (const item of inline[1].split(",")) {
|
|
113
|
+
const value = unquote(item.trim());
|
|
114
|
+
if (value)
|
|
115
|
+
raw.push(value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const blockStart = body.match(/^prompt_triggers:\s*$/m);
|
|
120
|
+
if (blockStart) {
|
|
121
|
+
const after = body.slice(body.indexOf(blockStart[0]) + blockStart[0].length);
|
|
122
|
+
for (const line of after.split("\n")) {
|
|
123
|
+
// Stop at the next top-level key: an unterminated list must not swallow
|
|
124
|
+
// the rest of the frontmatter and turn `description:` into a trigger.
|
|
125
|
+
if (/^[A-Za-z_-]+:/.test(line))
|
|
126
|
+
break;
|
|
127
|
+
const item = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
128
|
+
if (!item)
|
|
129
|
+
continue;
|
|
130
|
+
raw.push(unquote(item[1]));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (raw.length === 0)
|
|
135
|
+
return result;
|
|
136
|
+
for (const pattern of raw.slice(0, MAX_TRIGGERS_PER_SKILL)) {
|
|
137
|
+
const problem = validateTrigger(pattern);
|
|
138
|
+
if (problem) {
|
|
139
|
+
result.errors.push({ skill: skillName, pattern: pattern.slice(0, 80), reason: problem });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
(result.triggers[pattern] ||= []).push(skillName);
|
|
143
|
+
}
|
|
144
|
+
for (const pattern of raw.slice(MAX_TRIGGERS_PER_SKILL)) {
|
|
145
|
+
result.errors.push({
|
|
146
|
+
skill: skillName,
|
|
147
|
+
pattern: pattern.slice(0, 80),
|
|
148
|
+
reason: `exceeds ${MAX_TRIGGERS_PER_SKILL} triggers per skill`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Merge the triggers declared by every cached skill body.
|
|
155
|
+
*
|
|
156
|
+
* `skills` is the `skill:<name> -> content` cache that already backs body
|
|
157
|
+
* injection, so this covers exactly the entitled set: anything routable here is
|
|
158
|
+
* something the caller is allowed to inline, and nothing else.
|
|
159
|
+
*/
|
|
160
|
+
/**
|
|
161
|
+
* Read triggers from LOCAL skill files (`~/.agents/skills`, `~/.claude/skills`).
|
|
162
|
+
*
|
|
163
|
+
* Local is a first-class scope in skill_save, but local skills are written
|
|
164
|
+
* straight to disk and never enter the `skill:<name>` settings cache, so a
|
|
165
|
+
* trigger declared in one would have been read by nothing — the author gets the
|
|
166
|
+
* same "configured and inert" experience this whole feature exists to remove.
|
|
167
|
+
*
|
|
168
|
+
* Bounded deliberately: this runs on the startup path. Files are skipped above
|
|
169
|
+
* MAX_LOCAL_FILE_BYTES, at most MAX_LOCAL_SKILLS directories are considered, and
|
|
170
|
+
* every error is swallowed per-entry — an unreadable skill directory must not be
|
|
171
|
+
* able to fail a session.
|
|
172
|
+
*/
|
|
173
|
+
const MAX_LOCAL_SKILLS = 300;
|
|
174
|
+
const MAX_LOCAL_FILE_BYTES = 64 * 1024;
|
|
175
|
+
export async function collectLocalSkillTriggers(roots, fs, join) {
|
|
176
|
+
const merged = { triggers: {}, errors: [], names: [] };
|
|
177
|
+
let budget = MAX_LOCAL_SKILLS;
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const root of roots) {
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = await fs.readdir(root);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue; // root absent — normal
|
|
186
|
+
}
|
|
187
|
+
for (const name of entries) {
|
|
188
|
+
if (budget-- <= 0)
|
|
189
|
+
break;
|
|
190
|
+
if (seen.has(name))
|
|
191
|
+
continue; // same skill mirrored in both roots
|
|
192
|
+
const path = join(root, name, "SKILL.md");
|
|
193
|
+
try {
|
|
194
|
+
const info = await fs.stat(path);
|
|
195
|
+
if (info.size > MAX_LOCAL_FILE_BYTES)
|
|
196
|
+
continue;
|
|
197
|
+
const content = await fs.readFile(path);
|
|
198
|
+
if (!content.includes("prompt_triggers"))
|
|
199
|
+
continue;
|
|
200
|
+
seen.add(name);
|
|
201
|
+
const extracted = extractSkillTriggers(name, content);
|
|
202
|
+
if (Object.keys(extracted.triggers).length > 0)
|
|
203
|
+
merged.names.push(name);
|
|
204
|
+
for (const [pattern, names] of Object.entries(extracted.triggers)) {
|
|
205
|
+
(merged.triggers[pattern] ||= []).push(...names);
|
|
206
|
+
}
|
|
207
|
+
merged.errors.push(...extracted.errors);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
continue; // not a skill dir, or unreadable
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return merged;
|
|
215
|
+
}
|
|
216
|
+
export function collectScopedTriggers(skills) {
|
|
217
|
+
const merged = { triggers: {}, errors: [] };
|
|
218
|
+
for (const [name, content] of skills) {
|
|
219
|
+
if (!content || !content.includes("prompt_triggers"))
|
|
220
|
+
continue; // cheap pre-filter
|
|
221
|
+
const extracted = extractSkillTriggers(name, content);
|
|
222
|
+
for (const [pattern, names] of Object.entries(extracted.triggers)) {
|
|
223
|
+
(merged.triggers[pattern] ||= []).push(...names);
|
|
224
|
+
}
|
|
225
|
+
merged.errors.push(...extracted.errors);
|
|
226
|
+
}
|
|
227
|
+
return merged;
|
|
228
|
+
}
|
|
@@ -172,7 +172,7 @@ export const SESSION_BOOTSTRAP_TOOL = {
|
|
|
172
172
|
"verbatim as the entire first-turn startup display, before any optional answer. Do not summarize, paraphrase, rename " +
|
|
173
173
|
"headings, reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, " +
|
|
174
174
|
"stop after the verbatim startup display. Do not guess or pass a project or depth. Prism returns a stable " +
|
|
175
|
-
"conversation_id
|
|
175
|
+
"conversation_id on the trailing <prism_session /> line; reuse it for session_save_ledger, session_save_handoff, and " +
|
|
176
176
|
"session_detect_drift throughout this conversation without adding it to the visible greeting.",
|
|
177
177
|
annotations: {
|
|
178
178
|
readOnlyHint: true,
|
|
@@ -185,7 +185,7 @@ export const SESSION_BOOTSTRAP_TOOL = {
|
|
|
185
185
|
properties: {
|
|
186
186
|
conversation_id: {
|
|
187
187
|
type: "string",
|
|
188
|
-
description: "Optional stable key for this conversation. When omitted, Prism generates one and returns it
|
|
188
|
+
description: "Optional stable key for this conversation. When omitted, Prism generates one and returns it on the trailing <prism_session /> line.",
|
|
189
189
|
},
|
|
190
190
|
prompt: {
|
|
191
191
|
type: "string",
|
|
@@ -310,13 +310,22 @@ export function _applyPromptRouting(base, prompt, promptKeywords) {
|
|
|
310
310
|
* path has no portal response, so without this it could serve a stale table
|
|
311
311
|
* indefinitely and never detect drift). The skill manifest carries one.
|
|
312
312
|
*/
|
|
313
|
-
export async function resolvePromptSkillNames(prompt, expectVersion) {
|
|
313
|
+
export async function resolvePromptSkillNames(prompt, expectVersion, scopedTriggers) {
|
|
314
314
|
if (!prompt)
|
|
315
315
|
return [];
|
|
316
316
|
const kw = await fetchKeywordTable(expectVersion);
|
|
317
|
-
|
|
317
|
+
// Scoped triggers must still route when the PUBLIC table is unavailable:
|
|
318
|
+
// they are declared in skill bodies already on this machine and owe nothing
|
|
319
|
+
// to a network fetch. Returning [] here would make a private skill's routing
|
|
320
|
+
// depend on a public file it can never appear in.
|
|
321
|
+
const publicKeywords = kw?.prompt_keywords ?? {};
|
|
322
|
+
if (!kw && !scopedTriggers)
|
|
318
323
|
return [];
|
|
319
|
-
|
|
324
|
+
const combined = { ...publicKeywords };
|
|
325
|
+
for (const [pattern, names] of Object.entries(scopedTriggers ?? {})) {
|
|
326
|
+
combined[pattern] = [...(combined[pattern] ?? []), ...names];
|
|
327
|
+
}
|
|
328
|
+
return _applyPromptRouting([], prompt, combined).map((s) => s.name);
|
|
320
329
|
}
|
|
321
330
|
/**
|
|
322
331
|
* Free tier resolves to an empty set portal-side, so adding prompt-matched
|
|
@@ -23,6 +23,7 @@ import { homedir } from "node:os";
|
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { getSetting } from "../storage/configStorage.js";
|
|
25
25
|
import { getSynaluxJwt } from "../utils/synaluxJwt.js";
|
|
26
|
+
import { extractSkillTriggers } from "./scopedSkillTriggers.js";
|
|
26
27
|
import { triggerSkillManifestSync } from "../skillManifestSync.js";
|
|
27
28
|
import { mkdirUsable } from "../utils/usableDirectory.js";
|
|
28
29
|
const STRICT_SKILL_NAME = /^[a-z0-9][a-z0-9_-]{0,127}$/;
|
|
@@ -31,8 +32,19 @@ const STRICT_SKILL_NAME = /^[a-z0-9][a-z0-9_-]{0,127}$/;
|
|
|
31
32
|
const MAX_CONTENT_BYTES = 25_000;
|
|
32
33
|
const MAX_DESCRIPTION_CHARS = 500;
|
|
33
34
|
const API_PATH = "/api/v1/prism/user-skills";
|
|
35
|
+
/**
|
|
36
|
+
* Structured data is SERIALIZED INTO the text, never returned as
|
|
37
|
+
* `structuredContent`. A host may surface structuredContent and drop the text
|
|
38
|
+
* block — Claude Code does — which silently discarded every explanation this
|
|
39
|
+
* tool produced ("saved as YOUR account skill", the floor-guard refusal, the
|
|
40
|
+
* how-to-share hint), leaving raw JSON. See buildSessionFactsLine in
|
|
41
|
+
* ledgerHandlers.ts for the measurements behind this rule.
|
|
42
|
+
*/
|
|
34
43
|
function text(message, extra, isError = false) {
|
|
35
|
-
|
|
44
|
+
const body = extra
|
|
45
|
+
? `${message}\n\n\`\`\`json\n${JSON.stringify(extra, null, 2)}\n\`\`\``
|
|
46
|
+
: message;
|
|
47
|
+
return { content: [{ type: "text", text: body }], ...(isError ? { isError } : {}) };
|
|
36
48
|
}
|
|
37
49
|
async function synaluxBaseUrl() {
|
|
38
50
|
const configured = process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim() ||
|
|
@@ -64,6 +76,23 @@ function frontmatterProblems(name, content) {
|
|
|
64
76
|
}
|
|
65
77
|
return null;
|
|
66
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Refuse a bad `prompt_triggers` entry at SAVE time.
|
|
81
|
+
*
|
|
82
|
+
* The on-device collector already skips invalid patterns, so nothing unsafe can
|
|
83
|
+
* reach the matcher either way — but a silently skipped trigger is exactly the
|
|
84
|
+
* symptom this feature was built to remove: a skill that looks configured and
|
|
85
|
+
* never fires. Failing the save is the only point where the author is present
|
|
86
|
+
* to read the reason.
|
|
87
|
+
*/
|
|
88
|
+
function triggerProblems(name, content) {
|
|
89
|
+
const { errors } = extractSkillTriggers(name, content);
|
|
90
|
+
if (errors.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
const first = errors[0];
|
|
93
|
+
return `prompt_triggers rejected ("${first.pattern}"): ${first.reason}. ` +
|
|
94
|
+
`A trigger that cannot compile would leave this skill installed but never activated.`;
|
|
95
|
+
}
|
|
67
96
|
function validateSkillInput(name, content) {
|
|
68
97
|
if (typeof name !== "string" || !STRICT_SKILL_NAME.test(name)) {
|
|
69
98
|
return "invalid skill name: lowercase letters, digits, - and _ only (max 128 chars)";
|
|
@@ -73,7 +102,10 @@ function validateSkillInput(name, content) {
|
|
|
73
102
|
if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) {
|
|
74
103
|
return `content exceeds the ${MAX_CONTENT_BYTES}-byte context-fit limit shared by every delivered skill; move reference material out or split it`;
|
|
75
104
|
}
|
|
76
|
-
|
|
105
|
+
const frontmatter = frontmatterProblems(name, content);
|
|
106
|
+
if (frontmatter)
|
|
107
|
+
return frontmatter;
|
|
108
|
+
return triggerProblems(name, content);
|
|
77
109
|
}
|
|
78
110
|
/** Local skill roots that hosts read natively. Never Prism-managed dirs. */
|
|
79
111
|
function localSkillRoots() {
|
|
@@ -145,7 +177,11 @@ export const SKILL_SAVE_TOOL = {
|
|
|
145
177
|
description: "Save a skill at one of three scopes: local (this machine only, works signed out), " +
|
|
146
178
|
"user (your account — follows you to every machine), or team (a workspace — delivered to its members; " +
|
|
147
179
|
"owner/admin only, optionally targeted with assign_to). Default when signed in is USER; team is never " +
|
|
148
|
-
"a default. Content must be a SKILL.md body with frontmatter (name, description)."
|
|
180
|
+
"a default. Content must be a SKILL.md body with frontmatter (name, description). " +
|
|
181
|
+
"To make the skill load automatically when a prompt matches, add prompt_triggers to the " +
|
|
182
|
+
"frontmatter — a list of up to 5 case-insensitive regexes. They stay as private as the skill " +
|
|
183
|
+
"itself (matching happens on-device; scoped skills cannot use the public routing table):\n" +
|
|
184
|
+
" prompt_triggers:\n - \"\\\\binvoice\\\\b.{0,20}\\\\bsubmit\\\\b\"",
|
|
149
185
|
inputSchema: {
|
|
150
186
|
type: "object",
|
|
151
187
|
properties: {
|
|
@@ -80,14 +80,30 @@ export function assembleSkillBlock(entries, budgetChars) {
|
|
|
80
80
|
const ordered = [...entries].sort(fillOrder);
|
|
81
81
|
const unbudgeted = !Number.isFinite(budgetChars) || budgetChars <= 0;
|
|
82
82
|
let block = "";
|
|
83
|
+
let unprotectedChars = 0;
|
|
83
84
|
const inlined = [];
|
|
84
85
|
const overflow = [];
|
|
85
86
|
for (const e of ordered) {
|
|
86
87
|
const piece = render(e);
|
|
87
|
-
// Protected always inline
|
|
88
|
-
|
|
88
|
+
// Protected always inline and NEVER debit the budget: the tranche is
|
|
89
|
+
// documented as ADDITIVE on top of the floor. The previous check
|
|
90
|
+
// compared block.length — which the floor had already filled — so with
|
|
91
|
+
// a ~46KB floor against 2-16KB tranches, NO unprotected skill ever
|
|
92
|
+
// inlined at any normal budget. Rank was irrelevant; the budget was
|
|
93
|
+
// pre-spent. Found 2026-08-11 tracing why a prompt-matched
|
|
94
|
+
// verified-shipping still sat in overflow while an agent shipped
|
|
95
|
+
// unverified UI claims; the 2026-08-03 note ("no unprotected universal
|
|
96
|
+
// was ever inlined") had recorded this symptom and it was treated by
|
|
97
|
+
// protecting one skill instead of fixing the accounting.
|
|
98
|
+
if (unbudgeted || e.protected) {
|
|
89
99
|
block += piece;
|
|
90
100
|
inlined.push(e.name);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (unprotectedChars + piece.length <= budgetChars) {
|
|
104
|
+
block += piece;
|
|
105
|
+
unprotectedChars += piece.length;
|
|
106
|
+
inlined.push(e.name);
|
|
91
107
|
}
|
|
92
108
|
else {
|
|
93
109
|
overflow.push(e.name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.10.0",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Persistent session memory for AI coding agents that never leaves your machine \u2014 including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
|
|
6
6
|
"module": "index.ts",
|