prism-mcp-server 20.8.2 → 20.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,18 @@ 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
120
+
121
+ - **Your skills follow your account.** `skill_save` stores a skill at the
122
+ scope you choose: this machine only (`local`, works offline and signed out),
123
+ your account (`user` — every machine you sign into receives it), or a
124
+ workspace (`team` — shared with members, admin-managed, optionally targeted
125
+ to specific people).
126
+ - **Trim the catalog you don't use.** `skill_manage` can release platform
127
+ skills you never touch — freeing host skill-catalog budget — and restore
128
+ them any time, losslessly. Deleting a scoped skill archives its final
129
+ content locally first, so nothing is ever silently unrecoverable.
130
+
119
131
  ## What's New in v20.8.2
120
132
 
121
133
  - **Skill delivery now admits failure instead of hiding it.** A filesystem
package/dist/server.js CHANGED
@@ -68,6 +68,7 @@ import { getSyncBus } from "./sync/factory.js";
68
68
  import { startDashboardServer } from "./dashboard/server.js";
69
69
  import { acquireLock, registerShutdownHandlers } from "./lifecycle.js";
70
70
  import { verifyBehaviorHandler } from "./tools/behavioralVerifierHandler.js";
71
+ import { SKILL_SAVE_TOOL, SKILL_MANAGE_TOOL, skillSaveHandler, skillManageHandler } from "./tools/skillScopeHandlers.js";
71
72
  // ─── v2.3.6 FIX: Use Storage Abstraction for Prompts/Resources ───
72
73
  // CRITICAL FIX: Previously imported supabaseRpc/supabaseGet directly,
73
74
  // which bypassed the storage abstraction layer and caused the server
@@ -185,6 +186,8 @@ const BASE_TOOLS = [
185
186
  function buildSessionMemoryTools() {
186
187
  return [
187
188
  SESSION_BOOTSTRAP_TOOL, // session_bootstrap — hook-free configured first-turn greeting + context
189
+ SKILL_SAVE_TOOL, // skill_save — save a skill at local/user/team scope
190
+ SKILL_MANAGE_TOOL, // skill_manage — list/delete scoped skills, release/restore platform skills
188
191
  SESSION_SAVE_LEDGER_TOOL, // session_save_ledger — append immutable session log
189
192
  SESSION_SAVE_HANDOFF_TOOL, // session_save_handoff — upsert latest project state (now with OCC)
190
193
  SESSION_LOAD_CONTEXT_TOOL, // session_load_context — explicit project reload / legacy fallback
@@ -750,6 +753,12 @@ export function createServer() {
750
753
  contextLoadedByClient = true; // v5.2.1: suppress deferred auto-push
751
754
  result = await sessionLoadContextHandler(args);
752
755
  break;
756
+ case "skill_save":
757
+ result = await skillSaveHandler(args);
758
+ break;
759
+ case "skill_manage":
760
+ result = await skillManageHandler(args);
761
+ break;
753
762
  case "session_bootstrap":
754
763
  if (!SESSION_MEMORY_ENABLED)
755
764
  throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
@@ -42,6 +42,26 @@ function getClient() {
42
42
  configClient = createClient({
43
43
  url: `file:${CONFIG_PATH}`,
44
44
  });
45
+ // Multi-session machines share this one SQLite file across every host
46
+ // window's server process. libsql's default busy timeout is ZERO, so a
47
+ // writer meeting another writer fails instantly with SQLITE_BUSY instead
48
+ // of waiting its turn. Measured 2026-08-11: applying a new skill-manifest
49
+ // snapshot lost that race 10 consecutive times against ~7 live sessions'
50
+ // routine setting writes, leaving the sync permanently "partial" on a busy
51
+ // machine. Five seconds is far beyond any real transaction here and turns
52
+ // contention back into queueing. Fire-and-forget: a failure to set the
53
+ // pragma must never block storage init, and the first real query would
54
+ // surface a genuinely broken database anyway.
55
+ void configClient.execute("PRAGMA busy_timeout = 5000").catch(() => { });
56
+ // WAL is the half that actually ends the starvation: in the default
57
+ // rollback-journal mode every reader blocks the writer, and a machine
58
+ // running many host windows reads this file near-continuously (settings,
59
+ // drift reminders), so a manifest-apply transaction can wait out ANY
60
+ // timeout and still lose. WAL lets readers and the single writer proceed
61
+ // concurrently. The pragma is persistent per-database; issuing it on every
62
+ // init is an idempotent no-op that also upgrades databases created before
63
+ // this change.
64
+ void configClient.execute("PRAGMA journal_mode = WAL").catch(() => { });
45
65
  }
46
66
  return configClient;
47
67
  }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * skill_save / skill_manage — scoped skill management.
3
+ *
4
+ * A skill can live at three scopes:
5
+ * local — a plain file on this machine only (works signed-out; local-first)
6
+ * user — the signed-in account; follows the user to every machine
7
+ * team — a workspace; delivered to its members (admins may target a subset)
8
+ *
9
+ * Classification policy: an explicit scope always wins. Signed in without a
10
+ * scope → `user` (private, reversible; the result says so and how to share).
11
+ * Signed out → `local`. `team` is never a default: writing to shared state is
12
+ * an explicit act, and the server enforces the owner/admin role.
13
+ *
14
+ * skill_manage covers the recall paths: `release` excludes a PLATFORM skill
15
+ * from your (or your team's) delivery to free host catalog budget — fully
16
+ * reversible with `restore` because platform content never leaves the bundle.
17
+ * `delete` removes a scoped skill everywhere; because the stored row IS the
18
+ * content, the handler archives the final content locally before anything is
19
+ * discarded.
20
+ */
21
+ import { readFile, readdir, rm, writeFile } from "node:fs/promises";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { getSetting } from "../storage/configStorage.js";
25
+ import { getSynaluxJwt } from "../utils/synaluxJwt.js";
26
+ import { triggerSkillManifestSync } from "../skillManifestSync.js";
27
+ import { mkdirUsable } from "../utils/usableDirectory.js";
28
+ const STRICT_SKILL_NAME = /^[a-z0-9][a-z0-9_-]{0,127}$/;
29
+ // Mirrors the platform's context-fit bar so a save refused by the server is
30
+ // refused here first, with the same explanation.
31
+ const MAX_CONTENT_BYTES = 25_000;
32
+ const MAX_DESCRIPTION_CHARS = 500;
33
+ const API_PATH = "/api/v1/prism/user-skills";
34
+ function text(message, extra, isError = false) {
35
+ return { content: [{ type: "text", text: message }], ...(isError ? { isError } : {}), ...(extra ? { structuredContent: extra } : {}) };
36
+ }
37
+ async function synaluxBaseUrl() {
38
+ const configured = process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim() ||
39
+ (await getSetting("PRISM_SYNALUX_BASE_URL", "")).trim() || (await getSetting("SYNALUX_BASE_URL", "")).trim() ||
40
+ "https://synalux.ai";
41
+ if (!/^https?:\/\//i.test(configured))
42
+ throw new Error("invalid Synalux base URL");
43
+ return configured.replace(/\/+$/, "");
44
+ }
45
+ function frontmatterProblems(name, content) {
46
+ const match = content.match(/^---\n([\s\S]*?)\n---\n/);
47
+ if (!match)
48
+ return "content must open with YAML frontmatter (---) carrying name and description";
49
+ const fields = {};
50
+ for (const line of match[1].split("\n")) {
51
+ const field = line.match(/^([A-Za-z_-]+):\s*(.*)$/);
52
+ if (!field)
53
+ continue;
54
+ const raw = field[2].trim();
55
+ const quoted = raw.match(/^"(.*)"$/) ?? raw.match(/^'(.*)'$/);
56
+ fields[field[1]] = quoted ? quoted[1] : raw;
57
+ }
58
+ if (fields.name !== name)
59
+ return "frontmatter name must match the skill name";
60
+ if (!fields.description)
61
+ return "frontmatter must carry a non-empty description";
62
+ if (fields.description.length > MAX_DESCRIPTION_CHARS) {
63
+ return `frontmatter description exceeds ${MAX_DESCRIPTION_CHARS} chars (hosts budget catalog space by description length)`;
64
+ }
65
+ return null;
66
+ }
67
+ function validateSkillInput(name, content) {
68
+ if (typeof name !== "string" || !STRICT_SKILL_NAME.test(name)) {
69
+ return "invalid skill name: lowercase letters, digits, - and _ only (max 128 chars)";
70
+ }
71
+ if (typeof content !== "string" || !content.trim())
72
+ return "content is required";
73
+ if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) {
74
+ return `content exceeds the ${MAX_CONTENT_BYTES}-byte context-fit limit shared by every delivered skill; move reference material out or split it`;
75
+ }
76
+ return frontmatterProblems(name, content);
77
+ }
78
+ /** Local skill roots that hosts read natively. Never Prism-managed dirs. */
79
+ function localSkillRoots() {
80
+ return [join(homedir(), ".agents", "skills"), join(homedir(), ".claude", "skills")];
81
+ }
82
+ function archiveDir() {
83
+ return join(homedir(), ".prism-mcp", "skill-archive");
84
+ }
85
+ async function archiveContent(name, content) {
86
+ const dir = archiveDir();
87
+ await mkdirUsable(dir);
88
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
89
+ const path = join(dir, `${name}-${stamp}.md`);
90
+ await writeFile(path, content, { mode: 0o600 });
91
+ return path;
92
+ }
93
+ async function saveLocal(name, content) {
94
+ const written = [];
95
+ for (const root of localSkillRoots()) {
96
+ const dir = join(root, name);
97
+ await mkdirUsable(dir);
98
+ await writeFile(join(dir, "SKILL.md"), content, { mode: 0o600 });
99
+ written.push(dir);
100
+ }
101
+ return text(`Saved LOCALLY (this machine only): ${written.join(", ")}. ` +
102
+ `Not uploaded anywhere. Sign in and re-save with scope "user" to make it follow your account, ` +
103
+ `or scope "team" (workspace admins) to share it.`, { scope: "local", name, paths: written });
104
+ }
105
+ async function apiAuth() {
106
+ const jwt = await getSynaluxJwt().catch(() => null);
107
+ if (!jwt)
108
+ return null;
109
+ return { baseUrl: await synaluxBaseUrl(), jwt };
110
+ }
111
+ async function callApi(auth, method, body, query = "") {
112
+ const response = await fetch(`${auth.baseUrl}${API_PATH}${query}`, {
113
+ method,
114
+ headers: { Authorization: `Bearer ${auth.jwt}`, "Content-Type": "application/json" },
115
+ ...(body ? { body: JSON.stringify(body) } : {}),
116
+ });
117
+ let parsed = {};
118
+ try {
119
+ parsed = await response.json();
120
+ }
121
+ catch { /* non-JSON error body */ }
122
+ return { status: response.status, body: parsed };
123
+ }
124
+ /**
125
+ * A sync started BEFORE the save cannot contain it; joining one would report
126
+ * success while delivering nothing. Trigger has no TTL cache (single-flight
127
+ * only), so run-then-verify: if the saved name did not arrive, run once more —
128
+ * the second call cannot join a pre-save run because the first one completed.
129
+ */
130
+ async function syncUntilDelivered(name) {
131
+ const first = await triggerSkillManifestSync().catch(error => ({ status: "failed", installed: [], updated: [], pruned: [], conflicts: [], error: String(error) }));
132
+ const delivered = (result) => result.installed.includes(name) || result.updated.includes(name);
133
+ if (first.status !== "failed" && delivered(first))
134
+ return "delivered to this machine now; other machines receive it at their next sync";
135
+ const second = await triggerSkillManifestSync().catch(() => null);
136
+ if (second && second.status !== "failed" && delivered(second))
137
+ return "delivered to this machine now; other machines receive it at their next sync";
138
+ if (second && (second.status === "unchanged" || second.status === "applied")) {
139
+ return "saved server-side; local delivery reported no change (it may already be current) — verify with your host's skill list";
140
+ }
141
+ return "saved server-side; local sync did not complete — it will arrive on the next successful sync";
142
+ }
143
+ export const SKILL_SAVE_TOOL = {
144
+ name: "skill_save",
145
+ description: "Save a skill at one of three scopes: local (this machine only, works signed out), " +
146
+ "user (your account — follows you to every machine), or team (a workspace — delivered to its members; " +
147
+ "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).",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ name: { type: "string", description: "Skill name (lowercase letters, digits, - and _)" },
153
+ content: { type: "string", description: "Full SKILL.md content including frontmatter" },
154
+ scope: { type: "string", enum: ["local", "user", "team"], description: "Where the skill lives. Omit to default: user when signed in, local otherwise." },
155
+ workspace_id: { type: "string", description: "Required for team scope" },
156
+ assign_to: { type: "array", items: { type: "string" }, description: "Team scope only (admin): deliver ONLY to these member user ids; omit for all members" },
157
+ },
158
+ required: ["name", "content"],
159
+ },
160
+ };
161
+ export async function skillSaveHandler(args) {
162
+ const { name, content, scope, workspace_id: workspaceId, assign_to: assignTo } = args;
163
+ const problem = validateSkillInput(name, content);
164
+ if (problem)
165
+ return text(`Not saved: ${problem}`, undefined, true);
166
+ const skillName = name;
167
+ const skillContent = content;
168
+ if (scope !== undefined && scope !== "local" && scope !== "user" && scope !== "team") {
169
+ return text("Not saved: scope must be local, user, or team", undefined, true);
170
+ }
171
+ if (scope === "local")
172
+ return saveLocal(skillName, skillContent);
173
+ const auth = await apiAuth();
174
+ if (!auth) {
175
+ if (scope === "user" || scope === "team") {
176
+ return text("Not saved: this scope needs a signed-in Synalux account, and no credential is configured. Save with scope \"local\" to keep it on this machine.", undefined, true);
177
+ }
178
+ return saveLocal(skillName, skillContent);
179
+ }
180
+ const effectiveScope = scope ?? "user";
181
+ if (effectiveScope === "team" && (typeof workspaceId !== "string" || !workspaceId)) {
182
+ return text("Not saved: team scope requires workspace_id", undefined, true);
183
+ }
184
+ const { status, body } = await callApi(auth, "PUT", {
185
+ scope: effectiveScope,
186
+ ...(effectiveScope === "team" ? { workspace_id: workspaceId } : {}),
187
+ ...(assignTo !== undefined ? { assign_to: assignTo } : {}),
188
+ name: skillName,
189
+ content: skillContent,
190
+ });
191
+ if (status !== 200) {
192
+ return text(`Not saved (server ${status}): ${String(body.error ?? "unknown error")}`, undefined, true);
193
+ }
194
+ const delivery = await syncUntilDelivered(skillName);
195
+ const where = effectiveScope === "user"
196
+ ? `saved as YOUR account skill (version ${String(body.version)}) — say "make it a team skill" to share it with a workspace`
197
+ : `saved as a TEAM skill for workspace ${String(workspaceId)} (version ${String(body.version)})${Array.isArray(assignTo) && assignTo.length > 0 ? `, targeted to ${assignTo.length} member(s)` : ", delivered to all members"}`;
198
+ return text(`${where}. Delivery: ${delivery}.`, { scope: effectiveScope, name: skillName, version: body.version });
199
+ }
200
+ export const SKILL_MANAGE_TOOL = {
201
+ name: "skill_manage",
202
+ description: "Manage scoped skills and platform-skill activation. Actions: list (your skills, team skills, releases); " +
203
+ "delete (remove a user/team/local skill — the final content is archived locally first); " +
204
+ "release (deactivate a PLATFORM skill you never use, freeing host catalog budget — per user, or per team by admins); " +
205
+ "restore (re-activate a released platform skill; lossless).",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ action: { type: "string", enum: ["list", "delete", "release", "restore"] },
210
+ name: { type: "string", description: "Skill name (all actions except list)" },
211
+ scope: { type: "string", enum: ["local", "user", "team"], description: "delete: where the skill lives; release/restore: user (yourself) or team (admin)" },
212
+ workspace_id: { type: "string", description: "Required for team scope" },
213
+ },
214
+ required: ["action"],
215
+ },
216
+ };
217
+ export async function skillManageHandler(args) {
218
+ const { action, name, scope, workspace_id: workspaceId } = args;
219
+ if (action === "list") {
220
+ const localEntries = [];
221
+ for (const root of localSkillRoots()) {
222
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
223
+ for (const entry of entries) {
224
+ if (entry.isDirectory() && !entry.name.startsWith("."))
225
+ localEntries.push(`${entry.name} (${root})`);
226
+ }
227
+ }
228
+ const auth = await apiAuth();
229
+ if (!auth) {
230
+ return text(`Signed out — local skill directories only:\n${localEntries.join("\n") || "(none)"}`, { local: localEntries });
231
+ }
232
+ const { status, body } = await callApi(auth, "GET");
233
+ if (status !== 200)
234
+ return text(`Listing failed (server ${status}): ${String(body.error ?? "unknown")}`, undefined, true);
235
+ return text(`Account skills: ${JSON.stringify(body.user_skills)}\nTeam skills: ${JSON.stringify(body.team_skills)}\nReleased platform skills: ${JSON.stringify(body.released)}\nLocal-only dirs: ${localEntries.length}`, { ...body, local: localEntries });
236
+ }
237
+ if (typeof name !== "string" || !STRICT_SKILL_NAME.test(name)) {
238
+ return text("Invalid skill name", undefined, true);
239
+ }
240
+ if (action === "delete") {
241
+ if (scope === "local") {
242
+ const archived = [];
243
+ for (const root of localSkillRoots()) {
244
+ const path = join(root, name, "SKILL.md");
245
+ const body = await readFile(path, "utf8").catch(() => null);
246
+ if (body !== null) {
247
+ archived.push(await archiveContent(name, body));
248
+ await rm(join(root, name), { recursive: true, force: true });
249
+ }
250
+ }
251
+ if (archived.length === 0)
252
+ return text(`No local skill named ${name} found`, undefined, true);
253
+ return text(`Deleted local skill ${name}. Final content archived at: ${archived[0]} — re-save from there to recall it.`, { archived });
254
+ }
255
+ const auth = await apiAuth();
256
+ if (!auth)
257
+ return text("Deleting account/team skills needs a signed-in Synalux account", undefined, true);
258
+ const query = `?name=${encodeURIComponent(name)}&scope=${scope === "team" ? "team" : "user"}` +
259
+ (scope === "team" && typeof workspaceId === "string" ? `&workspace_id=${encodeURIComponent(workspaceId)}` : "");
260
+ const { status, body } = await callApi(auth, "DELETE", undefined, query);
261
+ if (status !== 200)
262
+ return text(`Not deleted (server ${status}): ${String(body.error ?? "unknown")}`, undefined, true);
263
+ const deleted = body.deleted;
264
+ let archivedAt = "server returned no content";
265
+ if (deleted?.content)
266
+ archivedAt = await archiveContent(name, deleted.content);
267
+ await triggerSkillManifestSync().catch(() => null); // prune from this machine
268
+ return text(`Deleted ${scope === "team" ? "team" : "account"} skill ${name}; it prunes from every machine at next sync. Final content archived at: ${archivedAt} — re-save from there to recall it.`, { archived: archivedAt });
269
+ }
270
+ if (action === "release" || action === "restore") {
271
+ const auth = await apiAuth();
272
+ if (!auth)
273
+ return text("Releasing platform skills needs a signed-in Synalux account (local installs are managed by deleting the local copy)", undefined, true);
274
+ const { status, body } = await callApi(auth, "PATCH", {
275
+ action,
276
+ scope: scope === "team" ? "team" : "user",
277
+ ...(scope === "team" ? { workspace_id: workspaceId } : {}),
278
+ name,
279
+ });
280
+ if (status !== 200)
281
+ return text(`${action} failed (server ${status}): ${String(body.error ?? "unknown")}`, undefined, true);
282
+ await triggerSkillManifestSync().catch(() => null);
283
+ return text(action === "release"
284
+ ? `Released ${name}: it leaves your delivered set and prunes from your machines at next sync, freeing host catalog budget. Fully reversible with action "restore".`
285
+ : `Restored ${name}: it returns with your next sync on every machine.`, { action, name });
286
+ }
287
+ return text("action must be list, delete, release, or restore", undefined, true);
288
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.8.2",
3
+ "version": "20.9.1",
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",