prism-mcp-server 20.8.1 → 20.9.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,6 +116,31 @@ 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
+
131
+ ## What's New in v20.8.2
132
+
133
+ - **Skill delivery now admits failure instead of hiding it.** A filesystem
134
+ permission edge case (a umask stripping the owner-execute bit) could leave
135
+ skill sync writing nothing while reporting itself current — measured at nine
136
+ days on a real machine. Broken managed directories are repaired in place,
137
+ every directory is created umask-proof, and the repair path refuses symlinks
138
+ via an `O_NOFOLLOW` descriptor.
139
+ - **A stale install tells you at startup.** Prism now tracks the generation
140
+ that actually reached disk separately from the one the database accepted; if
141
+ they diverge, the startup banner says so in a warning placed where display
142
+ truncation cannot cut it. A successful sync clears it automatically.
143
+
119
144
  ## What's New in v20.7 – v20.8.0
120
145
 
121
146
  - **First run proves the memory instead of describing it** — `session_bootstrap`
@@ -608,10 +633,10 @@ for manual configuration and host-specific paths.
608
633
  **Optional — local model fleet** for offline tool-routing. Pull whichever fits your hardware:
609
634
 
610
635
  ```bash
611
- ollama pull dcostenco/prism-coder:2b # 2.3 GB · mobile / lightweight (99.1% routing accuracy)
612
- ollama pull dcostenco/prism-coder:4b # 3.4 GB · verifier (100% accuracy)
613
- ollama pull dcostenco/prism-coder:9b # 5.8 GB · default router (100% accuracy, Qwen3.5)
614
- ollama pull dcostenco/prism-coder:27b # 16 GB · complex tasks (100% accuracy)
636
+ ollama pull dcostenco/prism-coder:2b # 2.3 GB · mobile / lightweight (99.1% on our routing suite)
637
+ ollama pull dcostenco/prism-coder:4b # 3.4 GB · verifier (100% on our routing suite)
638
+ ollama pull dcostenco/prism-coder:9b # 5.8 GB · default router (100% on our routing suite, Qwen3.5)
639
+ ollama pull dcostenco/prism-coder:27b # 16 GB · complex tasks (100% on our routing suite)
615
640
  ```
616
641
 
617
642
  Prism detects both the namespaced (`dcostenco/prism-coder:9b`) and bare (`prism-coder:9b`) Ollama tags automatically.
@@ -632,6 +657,23 @@ Every conversation feeds a persistent store. The next session loads the right co
632
657
 
633
658
  The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
634
659
 
660
+ ### Export — read the record outside the agent
661
+
662
+ `session_export_memory` writes your memory out as plain files you can read,
663
+ diff, and commit. Nothing goes through a model to produce it.
664
+
665
+ ```
666
+ markdown human-readable — drop it in a PR to show what the agent actually did
667
+ json machine-readable — import into another Prism instance
668
+ vault zipped Markdown with YAML frontmatter and [[wikilinks]] (Obsidian, Logseq)
669
+ ```
670
+
671
+ This is the surface to reach for when you want to answer "did the agent verify
672
+ this, or is it claiming it did?" — the export is a record you review after the
673
+ fact, in a diff or a pull request, rather than a live view you have to go and
674
+ open. The same data is available from the dashboard's **Export ZIP** and
675
+ **Export Vault** buttons.
676
+
635
677
  ### Knowledge Graph — semantic + keyword + graph search
636
678
 
637
679
  Ask "what did I decide about the auth flow last month?" and get an answer with citations, combining vector similarity, full-text search, and graph traversal.
@@ -741,7 +783,7 @@ air-gap. **Enterprise** includes a HIPAA Business Associate Agreement.
741
783
 
742
784
  ## Models
743
785
 
744
- The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned with LoRA (r=128, all 64 layers including DeltaNet); the 2B and 4B use stock Qwen3.5-4B at different quantization levels. The 27B scored 100% on BFCL function-calling and 100% on an internal 15-problem coding eval at $0 inference cost.
786
+ The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned with LoRA (r=128, all 64 layers including DeltaNet); the 2B and 4B use stock Qwen3.5-4B at different quantization levels. The 27B scored 100% on our internal 115-case tool-routing suite and 100% on an internal 15-problem coding eval, at $0 inference cost. These are self-run evaluations, not [BFCL](https://gorilla.cs.berkeley.edu/leaderboard.html) leaderboard submissions.
745
787
 
746
788
  `prism_infer` supports three modes: `route` (tool routing, fast, nothink), `chat` (conversation with thinking), and `code` (code generation with thinking). In chat/code modes, the model uses `<think>` blocks for chain-of-thought reasoning, which are stripped before the response is served. If the local model fails a quality gate (empty, think-only, or truncated), paid tiers automatically escalate to Gemini 3.6 Flash via the Synalux portal.
747
789
 
@@ -753,13 +795,17 @@ draft that may need correction—to Synalux for authenticated deterministic
753
795
  correction. Advertised custom host tools remain local. Set
754
796
  `route_guard: "local"` for a fully on-device route path.
755
797
 
756
- | Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/leaderboard.html) Accuracy | Role | Automatic routing tier |
798
+ | Model | Ollama tag | Size | Routing accuracy¹ | Role | Automatic routing tier |
757
799
  |---|---|---|---|---|---|
758
800
  | Qwen3.5-4B Q3_K_M | `prism-coder:2b` | 2.3 GB | 99.1% × 3 seeds | iPhone / mobile first gate | Free |
759
801
  | Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.4 GB | 100% × 3 seeds | Verifier | Free |
760
802
  | Qwen3.5-9B (LoRA) | `prism-coder:9b` | 5.8 GB | 100% × 3 seeds | Default router | Standard+ |
761
803
  | Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16 GB | 100% × 3 seeds | Quality tier (DeltaNet, 28.5 tok/s) | Advanced+ |
762
804
 
805
+ ¹ Self-run on a narrow 115-case MCP tool-selection suite, 3 seeds. It says these
806
+ models pick the right tool on our own eval, nothing more — not a general capability
807
+ measure, and not an independent benchmark result. Full methodology caveats below.
808
+
763
809
  These tiers control automatic `prism_infer` selection, not Ollama itself. Any
764
810
  user can run any downloaded on-device model directly through Ollama on every
765
811
  plan.
@@ -20,7 +20,8 @@
20
20
  * side.
21
21
  */
22
22
  import { createHash, randomUUID } from "node:crypto";
23
- import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
23
+ import { mkdirUsable, repairOwnerAccess } from "./utils/usableDirectory.js";
24
+ import { link, lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
24
25
  import { homedir } from "node:os";
25
26
  import { join } from "node:path";
26
27
  const OWNER = "prism-mcp";
@@ -306,11 +307,14 @@ function isErrno(error, code) {
306
307
  * mid-run leaves files that the next run re-proves ownership of by digest.
307
308
  */
308
309
  export async function materializeAgentDefinitions(section, targetDir, render = renderClaudeAgent) {
309
- await mkdir(targetDir, { recursive: true, mode: 0o700 });
310
+ // Same umask trap as the skill roots: a masked mkdir leaves a directory
311
+ // nothing can enter, and every write below it then fails.
312
+ await mkdirUsable(targetDir);
310
313
  const rootStat = await lstat(targetDir);
311
314
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
312
315
  throw new Error("agent root must be a real directory");
313
316
  }
317
+ await repairOwnerAccess(targetDir, rootStat.mode);
314
318
  const indexPath = join(targetDir, INDEX);
315
319
  const index = await readIndex(indexPath);
316
320
  const owned = index?.files ?? {};
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.");
@@ -1,12 +1,23 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { access, lstat, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, writeFile, } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
- import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
6
+ import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, setSetting, } from "./storage/configStorage.js";
7
7
  import { materializeAgentDefinitions, renderClaudeAgent, renderCodexAgent, renderGeminiAgent, resolveClaudeAgentsDir, resolveCodexAgentsDir, resolveGeminiAgentsDir, validateAgentSection, } from "./agentManifestSync.js";
8
8
  import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
9
9
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
10
+ import { mkdirUsable, repairOwnerAccess } from "./utils/usableDirectory.js";
11
+ /**
12
+ * Generation whose files actually reached disk, as opposed to the generation
13
+ * the config DB accepted. They diverge exactly when materialization fails, and
14
+ * that divergence is what made the 2026-08-10 outage invisible: the DB half
15
+ * commits first (deliberately -- committed names are what let a crashed run
16
+ * prune obsolete skills offline on restart), so the client reported the new
17
+ * generation while every managed root stayed frozen for nine days. Only a
18
+ * completed materialization advances this key.
19
+ */
20
+ export const MATERIALIZED_GENERATION_KEY = "skill_manifest:materialized_generation";
10
21
  const OWNER = "prism-skill-sync-v1";
11
22
  const MARKER = ".prism-managed.json";
12
23
  const INDEX = ".prism-managed-skills.json";
@@ -279,13 +290,13 @@ async function matchesIncomingSkill(path, skill) {
279
290
  }
280
291
  async function stageSkill(root, skill, generation) {
281
292
  const target = join(root, skill.name);
282
- await mkdir(target, { recursive: true, mode: 0o700 });
293
+ await mkdirUsable(target);
283
294
  const digests = Object.create(null);
284
295
  for (const [file, encoded] of Object.entries(skill.files)) {
285
296
  const path = resolve(target, file);
286
297
  if (!path.startsWith(`${target}${sep}`))
287
298
  throw new Error(`unsafe resolved path: ${file}`);
288
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
299
+ await mkdirUsable(dirname(path));
289
300
  await writeFile(path, decodeFile(encoded), { mode: 0o600 });
290
301
  digests[file] = encoded.digest;
291
302
  }
@@ -295,10 +306,23 @@ async function stageSkill(root, skill, generation) {
295
306
  }
296
307
  async function ensureRealDirectory(path) {
297
308
  if (!(await exists(path)))
298
- await mkdir(path, { recursive: true, mode: 0o700 });
309
+ await mkdirUsable(path);
299
310
  const stat = await lstat(path);
300
311
  if (!stat.isDirectory() || stat.isSymbolicLink())
301
312
  throw new Error(`managed path must be a real directory: ${path}`);
313
+ // Existing-but-unusable is the failure this repairs. mkdir's mode is masked
314
+ // by the creating process's umask, so a umask carrying the owner-execute bit
315
+ // (0o100) yields drw------- — a directory nothing can enter. Every later
316
+ // mkdtemp then throws EACCES, materialization aborts, and the run reports
317
+ // "partial" to stderr the host does not surface.
318
+ //
319
+ // Measured 2026-08-10: this exact directory sat at 0o600 for NINE days.
320
+ // The config DB half of each sync committed the new generation and digests
321
+ // while the file half never ran, so the client reported itself current while
322
+ // every managed skill root stayed frozen at its 2026-08-01 content. Checking
323
+ // "is a real directory" without checking "can I use it" made the outage
324
+ // permanent — nothing in the loop ever repaired the mode.
325
+ await repairOwnerAccess(path, stat.mode);
302
326
  }
303
327
  async function removeExpiredTransactions(base) {
304
328
  const cutoff = Date.now() - TRANSACTION_RETENTION_MS;
@@ -517,10 +541,12 @@ async function readCommittedManifestNames() {
517
541
  }
518
542
  }
519
543
  async function materializeNative(manifest, agentsSkillsDir, hooks) {
520
- await mkdir(agentsSkillsDir, { recursive: true, mode: 0o700 });
544
+ await mkdirUsable(agentsSkillsDir);
521
545
  const rootStat = await lstat(agentsSkillsDir);
522
546
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
523
547
  throw new Error("native skills root must be a real directory");
548
+ // The readdir immediately below is the first thing an unusable root breaks.
549
+ await repairOwnerAccess(agentsSkillsDir, rootStat.mode);
524
550
  await quarantineLegacyDiscoveryArtifacts(agentsSkillsDir);
525
551
  // Transaction content must remain outside the native discovery root: some
526
552
  // hosts recursively scan it and would otherwise discover staged/pruned paid
@@ -529,6 +555,10 @@ async function materializeNative(manifest, agentsSkillsDir, hooks) {
529
555
  await ensureRealDirectory(transactionBase);
530
556
  await removeExpiredTransactions(transactionBase);
531
557
  const transactionRoot = await mkdtemp(join(transactionBase, "txn-"));
558
+ // mkdtemp is umask-masked too, and nothing else revisits this path. A
559
+ // persistent restrictive umask therefore produced a REPAIRED base holding a
560
+ // brand-new unusable txn-* directory -- the original outage one level deeper.
561
+ await repairOwnerAccess(transactionRoot, (await lstat(transactionRoot)).mode);
532
562
  const stageRoot = join(transactionRoot, "stage");
533
563
  const backupRoot = join(transactionRoot, "backup");
534
564
  await ensureRealDirectory(stageRoot);
@@ -771,10 +801,15 @@ async function fetchManifest(options) {
771
801
  return manifest;
772
802
  }
773
803
  async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
774
- await mkdir(agentsSkillsDir, { recursive: true, mode: 0o700 });
804
+ await mkdirUsable(agentsSkillsDir);
775
805
  const rootStat = await lstat(agentsSkillsDir);
776
806
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
777
807
  throw new Error("native skills root must be a real directory");
808
+ // Same repair as ensureRealDirectory. This site takes the lock before any
809
+ // materialization, so a root that exists without owner rwx fails here first —
810
+ // mkdir no-ops on an existing directory and every open below throws EACCES,
811
+ // with nothing in the loop restoring the mode.
812
+ await repairOwnerAccess(agentsSkillsDir, rootStat.mode);
778
813
  const lockPath = join(agentsSkillsDir, ".prism-sync.lock");
779
814
  const deadline = Date.now() + Math.max(0, waitMs);
780
815
  const token = randomUUID();
@@ -936,6 +971,10 @@ export async function synchronizeSkillManifest(options = {}) {
936
971
  native.conflicts.push(...outcome.conflicts);
937
972
  }
938
973
  const status = native.installed.length || native.updated.length || native.pruned.length ? "applied" : "unchanged";
974
+ // Reached only when every native root materialized. A failure above throws
975
+ // past this point, leaving the previous value so the mismatch persists and
976
+ // stays visible until a sync genuinely succeeds.
977
+ await setSetting(MATERIALIZED_GENERATION_KEY, manifest.generation);
939
978
  return {
940
979
  status,
941
980
  tier: manifest.tier,
@@ -1,7 +1,8 @@
1
1
  import { createClient } from "@libsql/client";
2
2
  import { resolve, dirname } from "path";
3
3
  import { homedir } from "os";
4
- import { existsSync, mkdirSync } from "fs";
4
+ import { existsSync } from "fs";
5
+ import { mkdirUsableSync } from "../utils/usableDirectory.js";
5
6
  const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
6
7
  // We use a small, dedicated DB just for configuration settings.
7
8
  // This solves the chicken-and-egg problem: we need to know WHICH
@@ -33,7 +34,10 @@ function getClient() {
33
34
  // and libSQL throws SQLITE_CANTOPEN (error 14) without it.
34
35
  const dir = dirname(CONFIG_PATH);
35
36
  if (!existsSync(dir)) {
36
- mkdirSync(dir, { recursive: true });
37
+ // Not plain mkdirSync: recursive creation is umask-masked at every level,
38
+ // and this directory holds the entitlement snapshot, so it must be private
39
+ // and enterable regardless of the umask Prism happens to inherit.
40
+ mkdirUsableSync(dir);
37
41
  }
38
42
  configClient = createClient({
39
43
  url: `file:${CONFIG_PATH}`,
@@ -31,6 +31,7 @@ import { toKeywordArray } from "../utils/keywordExtractor.js";
31
31
  import { getLLMProvider } from "../utils/llm/factory.js";
32
32
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
33
33
  import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
34
+ import { MATERIALIZED_GENERATION_KEY } from "../skillManifestSync.js";
34
35
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
35
36
  import { resolveProject } from "../utils/projectResolver.js";
36
37
  import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
@@ -205,10 +206,34 @@ function parseNativeSkillNames(value) {
205
206
  }
206
207
  async function resolveNativeSkillManifestSnapshot(syncResult) {
207
208
  const { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } = await import("./skillRouting.js");
208
- const [storedNamesValue, storedTierValue] = await Promise.all([
209
+ const [storedNamesValue, storedTierValue, committedGeneration, materializedGeneration] = await Promise.all([
209
210
  getSetting("skill_manifest:names", "[]"),
210
211
  getSetting("skill_manifest:tier", ""),
212
+ getSetting("skill_manifest:generation", ""),
213
+ // The exported constant, not a copied literal: a key rename on either
214
+ // side would otherwise leave this read returning "" forever -- warning
215
+ // permanently silent -- with both sides' tests still green, because each
216
+ // asserts against its own copy of the string.
217
+ getSetting(MATERIALIZED_GENERATION_KEY, ""),
211
218
  ]);
219
+ // The DB half of a sync commits before files are written, by design: the
220
+ // committed names are what let a crashed run prune obsolete skills offline on
221
+ // restart. The cost is that this snapshot can describe an entitlement whose
222
+ // files never landed. A DIVERGENCE here is that exact state, and it persists
223
+ // across sessions -- a later sync reports "unchanged" because nothing needed
224
+ // writing, while the roots agents actually read stay frozen. Unreported, it
225
+ // ran for nine days on 2026-08-10.
226
+ // An EMPTY marker is "never recorded", not "never delivered": every install
227
+ // that predates the marker has a committed generation and no marker at all,
228
+ // and an offline user's failed fetch must not scream STALE at them on
229
+ // upgrade day. A false alarm here trains people to ignore the line, which is
230
+ // how the real one gets waved through. The fresh-install failure case is
231
+ // still covered -- that run's own syncStatus is "partial" and the
232
+ // validated-partial branch already reports materialization incomplete.
233
+ const undeliveredGeneration = Boolean(committedGeneration) && Boolean(materializedGeneration) &&
234
+ committedGeneration !== materializedGeneration
235
+ ? committedGeneration
236
+ : "";
212
237
  const partialNamesInput = syncResult.status === "partial" && Array.isArray(syncResult.entitledNames)
213
238
  ? syncResult.entitledNames
214
239
  : null;
@@ -245,6 +270,7 @@ async function resolveNativeSkillManifestSnapshot(syncResult) {
245
270
  tier,
246
271
  source,
247
272
  syncStatus: syncResult.status,
273
+ undeliveredGeneration,
248
274
  conflicts: [...new Set(syncResult.conflicts)],
249
275
  };
250
276
  }
@@ -293,8 +319,24 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
293
319
  `directory out of the native skills folder and rerun \`prism connect\` ` +
294
320
  `(or a session bootstrap) to reinstall the managed copy.`
295
321
  : "";
322
+ // Durable, not per-run: a later sync reports "unchanged" while the roots stay
323
+ // frozen from an earlier failed materialization. This line is the difference
324
+ // between that outage being visible on the next startup and it running for
325
+ // nine days.
326
+ //
327
+ // Rendered directly UNDER the header, never appended at the tail:
328
+ // capNativeStartupText keeps the head and cuts the tail, so a tail-placed
329
+ // warning is the first thing truncated at capped depths -- observed live in
330
+ // the divergence simulation (STALE was line 19 of 20). The most important
331
+ // line goes where truncation cannot reach it.
332
+ const undeliveredWarning = snapshot.undeliveredGeneration
333
+ ? `\n> - ⚠️ **Skill files are STALE:** the entitlement DB is at generation ` +
334
+ `\`${snapshot.undeliveredGeneration.slice(0, 12)}…\` but its files never finished ` +
335
+ `reaching the skill roots. Agents are reading older skills. Run a sync ` +
336
+ `(restart the host or \`prism connect\`) and report this if it persists.`
337
+ : "";
296
338
  if (snapshot.source === "validated-partial") {
297
- return `> **Prism System Ready**\n>\n` +
339
+ return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
298
340
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
299
341
  `> - 📦 **Entitled skills (materialization incomplete):** ${snapshot.names.length}\n` +
300
342
  `> - 📚 **Core/protected entitlements:** ${formatBoundedSkillNames(coreSkills, "entitled")}\n` +
@@ -306,7 +348,7 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
306
348
  freeTierUpgradeLine(snapshot.tier);
307
349
  }
308
350
  if (snapshot.source === "tier-fallback") {
309
- return `> **Prism System Ready**\n>\n` +
351
+ return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
310
352
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
311
353
  `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
312
354
  `> - 🧠 **Context depth:** ${depth}\n` +
@@ -314,7 +356,7 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
314
356
  conflictWarning +
315
357
  freeTierUpgradeLine(snapshot.tier);
316
358
  }
317
- return `> **Prism System Ready**\n>\n` +
359
+ return `> **Prism System Ready**\n>` + undeliveredWarning + `\n` +
318
360
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
319
361
  `> - 📦 **Provisioned skills:** ${snapshot.names.length}\n` +
320
362
  `> - 📚 **Core/protected skills provisioned:** ${formatBoundedSkillNames(coreSkills, "provisioned")}\n` +
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Directory creation and repair that a restrictive umask cannot defeat.
3
+ *
4
+ * WHY THIS EXISTS (2026-08-10)
5
+ * Skill delivery died silently for nine days. `.prism-skill-transactions` had
6
+ * been created with `mkdir(..., { mode: 0o700 })`, but mkdir's mode is masked by
7
+ * the process umask: a umask carrying the owner-execute bit yields drw-------,
8
+ * a directory that can be read and written but never entered. Every subsequent
9
+ * mkdtemp threw EACCES, materialization aborted, and — because the config DB
10
+ * half of a sync commits before the file half — the client went on reporting the
11
+ * new generation while every managed skill root stayed frozen at older content.
12
+ *
13
+ * The first fix repaired a directory that was already broken and left the cause
14
+ * alone: a umask that is still restrictive masks every directory created after
15
+ * it, so a repaired base simply gained a brand-new unusable child. Node exposes
16
+ * no per-call umask, so the only portable defeat is to create one level at a
17
+ * time and repair what we just made.
18
+ *
19
+ * These helpers are shared rather than duplicated because the same trap exists
20
+ * anywhere Prism creates a private directory — skill roots, agent roots, and the
21
+ * config DB's parent — and a guard applied at two of three sites is not a guard.
22
+ */
23
+ import { constants as fsConstants, chmodSync, lstatSync, mkdirSync } from "node:fs";
24
+ import { chmod, lstat, mkdir, open } from "node:fs/promises";
25
+ import { isAbsolute, sep } from "node:path";
26
+ /** Mode every Prism-managed directory is created with and repaired to. */
27
+ export const MANAGED_DIR_MODE = 0o700;
28
+ function isErrno(error, code) {
29
+ return typeof error === "object" && error !== null && "code" in error
30
+ && error.code === code;
31
+ }
32
+ /**
33
+ * Restore owner rwx on a managed directory that exists but cannot be entered.
34
+ *
35
+ * Restores 0o700 EXACTLY rather than OR-ing owner bits onto whatever is there.
36
+ * These directories hold entitled skill content and pre-rollback backups;
37
+ * repairing 0o066 to 0o766 would "fix" an outage by leaving them world-readable.
38
+ *
39
+ * Prism-managed directories REQUIRE owner rwx, so a deliberate sharing mode that
40
+ * withholds it — 0o670, say — is discarded rather than preserved. Conventional
41
+ * sharing is unaffected: this only fires on a directory the owner cannot use,
42
+ * which is broken by any definition. A 0o770 directory is left untouched.
43
+ *
44
+ * Applies the change through an open descriptor, not the pathname. A pathname
45
+ * chmod re-resolves the path, so a process able to swap the directory for a
46
+ * symlink between the check and the change could redirect the permission change
47
+ * somewhere else entirely. fchmod acts on the object already opened.
48
+ *
49
+ * POSIX only. On Windows chmod maps to the read-only attribute alone and lstat
50
+ * reports 0o666 for every writable directory, so the condition can never be
51
+ * satisfied and the failure being repaired has no analogue there.
52
+ */
53
+ export async function repairOwnerAccess(path, mode) {
54
+ if (process.platform === "win32")
55
+ return;
56
+ if ((mode & MANAGED_DIR_MODE) === MANAGED_DIR_MODE)
57
+ return;
58
+ let handle;
59
+ try {
60
+ // O_NOFOLLOW: a symlink at this path must fail the open rather than hand us
61
+ // a descriptor to whatever it points at.
62
+ handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
63
+ }
64
+ catch {
65
+ // Opening a directory needs owner-read, so the very modes this exists to
66
+ // repair (0o000, 0o300) land here — as does any symlink, via O_NOFOLLOW.
67
+ // An unguarded pathname chmod at this point re-opens the symlink hole: a
68
+ // link to an UNREADABLE file skipped the descriptor path entirely and got
69
+ // its target chmodded to 0o700. Proven with a live attack against the
70
+ // built helper during review, not hypothesized. lstat separates the two
71
+ // cases; the remaining lstat→chmod race is confined to unreadable real
72
+ // directories inside the user's own home.
73
+ const entry = await lstat(path);
74
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
75
+ throw new Error(`managed path is not a directory: ${path}`);
76
+ }
77
+ await chmod(path, MANAGED_DIR_MODE);
78
+ return;
79
+ }
80
+ try {
81
+ const opened = await handle.stat();
82
+ if (!opened.isDirectory())
83
+ throw new Error(`managed path is not a directory: ${path}`);
84
+ await handle.chmod(MANAGED_DIR_MODE);
85
+ }
86
+ finally {
87
+ await handle.close();
88
+ }
89
+ }
90
+ /**
91
+ * Create a directory, and any missing ancestors, that the owner can enter.
92
+ *
93
+ * `recursive: true` applies the same masked mode to every level it creates, so
94
+ * under a hostile umask the first new ancestor is already untraversable and the
95
+ * call fails partway with EACCES before anything can repair it.
96
+ *
97
+ * Repairs ONLY directories this call creates. A pre-existing ancestor — $HOME
98
+ * and everything above it — keeps exactly the mode the user configured; silently
99
+ * widening those would be a worse bug than the one being fixed.
100
+ */
101
+ export async function mkdirUsable(target) {
102
+ if (process.platform === "win32") {
103
+ await mkdir(target, { recursive: true, mode: MANAGED_DIR_MODE });
104
+ return;
105
+ }
106
+ const segments = target.split(sep).filter(Boolean);
107
+ let current = isAbsolute(target) ? "" : ".";
108
+ for (const segment of segments) {
109
+ current = current === "" ? `${sep}${segment}` : `${current}${sep}${segment}`;
110
+ try {
111
+ await mkdir(current, { mode: MANAGED_DIR_MODE });
112
+ }
113
+ catch (error) {
114
+ if (isErrno(error, "EEXIST"))
115
+ continue; // not ours — do not touch its mode
116
+ throw error;
117
+ }
118
+ await repairOwnerAccess(current, (await lstat(current)).mode);
119
+ }
120
+ }
121
+ /**
122
+ * Synchronous twin of {@link mkdirUsable}, for callers on a sync path.
123
+ *
124
+ * The config DB's parent is created before any await is possible, and it holds
125
+ * the entitlement snapshot, so it needs the same umask defeat and the same
126
+ * private mode. Kept beside the async version deliberately: two implementations
127
+ * of this rule in different files is how one of them ends up wrong.
128
+ */
129
+ export function mkdirUsableSync(target) {
130
+ if (process.platform === "win32") {
131
+ mkdirSync(target, { recursive: true, mode: MANAGED_DIR_MODE });
132
+ return;
133
+ }
134
+ const segments = target.split(sep).filter(Boolean);
135
+ let current = isAbsolute(target) ? "" : ".";
136
+ for (const segment of segments) {
137
+ current = current === "" ? `${sep}${segment}` : `${current}${sep}${segment}`;
138
+ try {
139
+ mkdirSync(current, { mode: MANAGED_DIR_MODE });
140
+ }
141
+ catch (error) {
142
+ if (isErrno(error, "EEXIST"))
143
+ continue; // not ours — do not touch its mode
144
+ throw error;
145
+ }
146
+ if ((lstatSync(current).mode & MANAGED_DIR_MODE) !== MANAGED_DIR_MODE) {
147
+ chmodSync(current, MANAGED_DIR_MODE);
148
+ }
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.8.1",
3
+ "version": "20.9.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",
@@ -82,7 +82,8 @@
82
82
  "ip-address": "^10.2.0",
83
83
  "protobufjs": "^7.6.5",
84
84
  "sharp": "^0.35.0",
85
- "tar": "^7.5.19"
85
+ "tar": "^7.5.19",
86
+ "nanoid": "^3.3.17"
86
87
  },
87
88
  "dependencies": {
88
89
  "@anthropic-ai/sdk": "^0.92.0",