prism-mcp-server 20.8.1 → 20.8.2
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,19 @@ 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.8.2
|
|
120
|
+
|
|
121
|
+
- **Skill delivery now admits failure instead of hiding it.** A filesystem
|
|
122
|
+
permission edge case (a umask stripping the owner-execute bit) could leave
|
|
123
|
+
skill sync writing nothing while reporting itself current — measured at nine
|
|
124
|
+
days on a real machine. Broken managed directories are repaired in place,
|
|
125
|
+
every directory is created umask-proof, and the repair path refuses symlinks
|
|
126
|
+
via an `O_NOFOLLOW` descriptor.
|
|
127
|
+
- **A stale install tells you at startup.** Prism now tracks the generation
|
|
128
|
+
that actually reached disk separately from the one the database accepted; if
|
|
129
|
+
they diverge, the startup banner says so in a warning placed where display
|
|
130
|
+
truncation cannot cut it. A successful sync clears it automatically.
|
|
131
|
+
|
|
119
132
|
## What's New in v20.7 – v20.8.0
|
|
120
133
|
|
|
121
134
|
- **First run proves the memory instead of describing it** — `session_bootstrap`
|
|
@@ -608,10 +621,10 @@ for manual configuration and host-specific paths.
|
|
|
608
621
|
**Optional — local model fleet** for offline tool-routing. Pull whichever fits your hardware:
|
|
609
622
|
|
|
610
623
|
```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%
|
|
624
|
+
ollama pull dcostenco/prism-coder:2b # 2.3 GB · mobile / lightweight (99.1% on our routing suite)
|
|
625
|
+
ollama pull dcostenco/prism-coder:4b # 3.4 GB · verifier (100% on our routing suite)
|
|
626
|
+
ollama pull dcostenco/prism-coder:9b # 5.8 GB · default router (100% on our routing suite, Qwen3.5)
|
|
627
|
+
ollama pull dcostenco/prism-coder:27b # 16 GB · complex tasks (100% on our routing suite)
|
|
615
628
|
```
|
|
616
629
|
|
|
617
630
|
Prism detects both the namespaced (`dcostenco/prism-coder:9b`) and bare (`prism-coder:9b`) Ollama tags automatically.
|
|
@@ -632,6 +645,23 @@ Every conversation feeds a persistent store. The next session loads the right co
|
|
|
632
645
|
|
|
633
646
|
The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
|
|
634
647
|
|
|
648
|
+
### Export — read the record outside the agent
|
|
649
|
+
|
|
650
|
+
`session_export_memory` writes your memory out as plain files you can read,
|
|
651
|
+
diff, and commit. Nothing goes through a model to produce it.
|
|
652
|
+
|
|
653
|
+
```
|
|
654
|
+
markdown human-readable — drop it in a PR to show what the agent actually did
|
|
655
|
+
json machine-readable — import into another Prism instance
|
|
656
|
+
vault zipped Markdown with YAML frontmatter and [[wikilinks]] (Obsidian, Logseq)
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
This is the surface to reach for when you want to answer "did the agent verify
|
|
660
|
+
this, or is it claiming it did?" — the export is a record you review after the
|
|
661
|
+
fact, in a diff or a pull request, rather than a live view you have to go and
|
|
662
|
+
open. The same data is available from the dashboard's **Export ZIP** and
|
|
663
|
+
**Export Vault** buttons.
|
|
664
|
+
|
|
635
665
|
### Knowledge Graph — semantic + keyword + graph search
|
|
636
666
|
|
|
637
667
|
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 +771,7 @@ air-gap. **Enterprise** includes a HIPAA Business Associate Agreement.
|
|
|
741
771
|
|
|
742
772
|
## Models
|
|
743
773
|
|
|
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
|
|
774
|
+
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
775
|
|
|
746
776
|
`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
777
|
|
|
@@ -753,13 +783,17 @@ draft that may need correction—to Synalux for authenticated deterministic
|
|
|
753
783
|
correction. Advertised custom host tools remain local. Set
|
|
754
784
|
`route_guard: "local"` for a fully on-device route path.
|
|
755
785
|
|
|
756
|
-
| Model | Ollama tag | Size |
|
|
786
|
+
| Model | Ollama tag | Size | Routing accuracy¹ | Role | Automatic routing tier |
|
|
757
787
|
|---|---|---|---|---|---|
|
|
758
788
|
| Qwen3.5-4B Q3_K_M | `prism-coder:2b` | 2.3 GB | 99.1% × 3 seeds | iPhone / mobile first gate | Free |
|
|
759
789
|
| Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.4 GB | 100% × 3 seeds | Verifier | Free |
|
|
760
790
|
| Qwen3.5-9B (LoRA) | `prism-coder:9b` | 5.8 GB | 100% × 3 seeds | Default router | Standard+ |
|
|
761
791
|
| Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16 GB | 100% × 3 seeds | Quality tier (DeltaNet, 28.5 tok/s) | Advanced+ |
|
|
762
792
|
|
|
793
|
+
¹ Self-run on a narrow 115-case MCP tool-selection suite, 3 seeds. It says these
|
|
794
|
+
models pick the right tool on our own eval, nothing more — not a general capability
|
|
795
|
+
measure, and not an independent benchmark result. Full methodology caveats below.
|
|
796
|
+
|
|
763
797
|
These tiers control automatic `prism_infer` selection, not Ollama itself. Any
|
|
764
798
|
user can run any downloaded on-device model directly through Ollama on every
|
|
765
799
|
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 ?? {};
|
|
@@ -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,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.
|
|
3
|
+
"version": "20.8.2",
|
|
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",
|