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 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 in structuredContent for every",
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 in structuredContent for every",
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 in structuredContent for session_save_ledger, session_save_handoff, and",
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 returned by session_bootstrap in structuredContent for those saves and for ` +
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
- const matched = (await resolvePromptSkillNames(prompt, Number.isFinite(manifestVersion) && manifestVersion > 0 ? manifestVersion : undefined)).filter((name) => entitledSkillNames.has(name));
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 in structuredContent; reuse it for session_save_ledger, session_save_handoff, and " +
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 in structuredContent.",
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
- if (!kw)
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
- return _applyPromptRouting([], prompt, kw.prompt_keywords).map((s) => s.name);
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
- return { content: [{ type: "text", text: message }], ...(isError ? { isError } : {}), ...(extra ? { structuredContent: extra } : {}) };
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
- return frontmatterProblems(name, content);
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; others only while they fit.
88
- if (unbudgeted || e.protected || block.length + piece.length <= budgetChars) {
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.9.2",
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",