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 +52 -6
- package/dist/agentManifestSync.js +6 -2
- package/dist/server.js +9 -0
- package/dist/skillManifestSync.js +46 -7
- package/dist/storage/configStorage.js +6 -2
- package/dist/tools/ledgerHandlers.js +46 -4
- package/dist/tools/skillScopeHandlers.js +288 -0
- package/dist/utils/usableDirectory.js +150 -0
- package/package.json +3 -2
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
|
|
612
|
-
ollama pull dcostenco/prism-coder:4b # 3.4 GB · verifier (100%
|
|
613
|
-
ollama pull dcostenco/prism-coder:9b # 5.8 GB · default router (100%
|
|
614
|
-
ollama pull dcostenco/prism-coder:27b # 16 GB · complex tasks (100%
|
|
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
|
|
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 |
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|