prism-mcp-server 20.2.5 โ 20.2.7
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 +82 -104
- package/dist/connect.js +124 -111
- package/dist/session/sessionContext.js +113 -15
- package/dist/storage/configStorage.js +100 -0
- package/dist/tools/graphHandlers.js +5 -4
- package/dist/tools/handlers.js +2 -2
- package/dist/tools/ledgerHandlers.js +12 -16
- package/dist/tools/prismInferHandler.js +81 -7
- package/dist/tools/queryMemoryNaturalHandler.js +514 -0
- package/dist/tools/sessionMemoryDefinitions.js +23 -5
- package/dist/tools/v12Handlers.js +2 -46
- package/dist/utils/braveApi.js +31 -48
- package/dist/utils/codingQualityPolicy.js +400 -0
- package/dist/utils/layer1.js +63 -4
- package/dist/utils/nlQuery.js +6 -33
- package/dist/utils/qualityGate.js +16 -2
- package/dist/utils/synaluxSearch.js +12 -8
- package/package.json +3 -2
- package/dist/boundaries/__tests__/boundaries.test.js +0 -46
- package/dist/session/__tests__/sessionContext.test.js +0 -134
- package/dist/tools/__tests__/ingestHandler.test.js +0 -323
- package/dist/tools/__tests__/layer1Integration.test.js +0 -590
- package/dist/tools/__tests__/ledgerHandlers.test.js +0 -1281
package/README.md
CHANGED
|
@@ -57,6 +57,38 @@ features.
|
|
|
57
57
|
<details>
|
|
58
58
|
<summary>Release history (optional)</summary>
|
|
59
59
|
|
|
60
|
+
## What's New in v20.2.7
|
|
61
|
+
|
|
62
|
+
### Session Saves Survive Agent Restarts
|
|
63
|
+
Prism now remembers that a conversation successfully loaded its project context
|
|
64
|
+
when the MCP server restarts or another Prism process handles the next request.
|
|
65
|
+
`session_save_ledger` and `session_save_handoff` no longer fail with a false
|
|
66
|
+
`context_not_loaded` error in that flow.
|
|
67
|
+
|
|
68
|
+
The recovery remains fail-closed: authorization is limited to the exact project
|
|
69
|
+
and conversation, expires with the existing context window, and stores no
|
|
70
|
+
plaintext conversation identifier. Cross-project, forged, malformed, expired,
|
|
71
|
+
or future-dated receipts are still rejected. The release also updates PostCSS
|
|
72
|
+
to the patched 8.5.23 release.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## What's New in v20.2.6
|
|
77
|
+
|
|
78
|
+
### Safer Configuration Updates Across Every Agent
|
|
79
|
+
`prism connect` now reads Claude, Cursor, Gemini, and Codex configuration
|
|
80
|
+
through a single verified file snapshot, preventing another process from
|
|
81
|
+
swapping a file between Prism's safety check and its read. Supported symlinked
|
|
82
|
+
dotfiles still work, while dangling or planted symlinks fail loudly instead of
|
|
83
|
+
being followed or overwritten. This release also carries the patched
|
|
84
|
+
dependencies and cross-platform release checks introduced in v20.2.5.
|
|
85
|
+
|
|
86
|
+
Cloud fallback is now documented consistently as Gemini 3.6 Flash. Plan
|
|
87
|
+
ceilings govern automatic `prism_infer` routing; direct use of any downloaded
|
|
88
|
+
model through local Ollama remains free on every tier.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
60
92
|
## What's New in v20.2.4
|
|
61
93
|
|
|
62
94
|
### Reliable Session Memory That Shows Work, Not Greetings
|
|
@@ -354,7 +386,7 @@ Every `prism_infer` call tracks which model handled it (local Ollama vs cloud) a
|
|
|
354
386
|
synalux-27b: 2 calls, 1,500 tokens, avg 1,100ms
|
|
355
387
|
```
|
|
356
388
|
|
|
357
|
-
**Cloud tokens saved** is the honest routing metric โ it accrues only when local Ollama handles a call that would otherwise have gone to
|
|
389
|
+
**Cloud tokens saved** is the honest routing metric โ it accrues only when local Ollama handles a call that would otherwise have gone to Synalux cloud inference. A compact version appears inline after every 5th `prism_infer` call: `๐ local 10 (83%) ยท cloud 2 (17%) ยท ~11,570 tok ยท avg 1,240ms ยท 11,570 cloud tok saved`.
|
|
358
390
|
|
|
359
391
|
Local calls use actual Ollama token counts (`prompt_eval_count` / `eval_count` from Ollama); cloud calls use char/4 estimates. Metrics are tracked locally โ no portal dependency, no env vars, works offline. Per-call data is also forwarded to the Synalux portal as best-effort analytics (independent of the display).
|
|
360
392
|
|
|
@@ -413,7 +445,7 @@ The free tier runs entirely on your machine. Paid tiers add cloud sync through t
|
|
|
413
445
|
| | Local tier (free) | Cloud tier (paid) |
|
|
414
446
|
|---|---|---|
|
|
415
447
|
| Memory storage | Local SQLite | Synalux portal (Supabase-backed) |
|
|
416
|
-
| Inference | Local Ollama models | Local models +
|
|
448
|
+
| Inference | Local Ollama models | Local models + Gemini 3.6 Flash fallback |
|
|
417
449
|
| API keys required | None | Synalux subscription key |
|
|
418
450
|
| Web search / scrape | Not included | Via Synalux portal (provider keys server-side) |
|
|
419
451
|
| What leaves your machine | Nothing | Memory text + file paths + search queries, sent to the portal over TLS (PHI-redacted before transit) |
|
|
@@ -427,15 +459,19 @@ The free tier runs entirely on your machine. Paid tiers add cloud sync through t
|
|
|
427
459
|
|
|
428
460
|
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.
|
|
429
461
|
|
|
430
|
-
`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
|
|
462
|
+
`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.
|
|
431
463
|
|
|
432
|
-
| Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/blogs/12_bfcl_v3_multi_turn.html) Accuracy | Role |
|
|
464
|
+
| Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/blogs/12_bfcl_v3_multi_turn.html) Accuracy | Role | Automatic routing tier |
|
|
433
465
|
|---|---|---|---|---|---|
|
|
434
466
|
| Qwen3.5-4B Q3_K_M | `prism-coder:2b` | 2.3 GB | 99.1% ร 3 seeds | iPhone / mobile first gate | Free |
|
|
435
467
|
| Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.4 GB | 100% ร 3 seeds | Verifier | Free |
|
|
436
468
|
| Qwen3.5-9B (LoRA) | `prism-coder:9b` | 5.8 GB | 100% ร 3 seeds | Default router | Standard+ |
|
|
437
469
|
| Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16 GB | 100% ร 3 seeds | Quality tier (DeltaNet, 28.5 tok/s) | Advanced+ |
|
|
438
470
|
|
|
471
|
+
These tiers control automatic `prism_infer` selection, not Ollama itself. Any
|
|
472
|
+
user can run any downloaded on-device model directly through Ollama on every
|
|
473
|
+
plan.
|
|
474
|
+
|
|
439
475
|
Weights: [huggingface.co/dcostenco](https://huggingface.co/dcostenco) (public GGUF). Latency depends on model size and hardware โ see [Benchmarks](#benchmarks) to measure it on your own machine rather than trusting a printed number.
|
|
440
476
|
|
|
441
477
|
### Cascade
|
|
@@ -445,7 +481,7 @@ query โ prism-coder:9b (local router, default)
|
|
|
445
481
|
โ prism-coder:4b (grounding verifier)
|
|
446
482
|
โ prism-coder:2b (iPhone / mobile, auto-selected by RAM)
|
|
447
483
|
โ prism-coder:27b (complex tasks, on demand)
|
|
448
|
-
โ cloud fallback (paid tiers, for max quality)
|
|
484
|
+
โ Gemini 3.6 Flash cloud fallback (paid tiers, for max quality)
|
|
449
485
|
```
|
|
450
486
|
|
|
451
487
|
### Multi-Layer Verification
|
|
@@ -466,15 +502,14 @@ Fail-closed on the verified path: when the grounding verifier runs (Standard tie
|
|
|
466
502
|
|
|
467
503
|
## Benchmarks
|
|
468
504
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
pip install anthropic requests
|
|
474
|
-
python3 tests/benchmarks/prism-routing-100/benchmark.py --models 2b 4b 9b 27b
|
|
475
|
-
```
|
|
505
|
+
Published benchmark numbers are concise summaries of internal deterministic
|
|
506
|
+
evaluation. Evaluators, exhaustive cases, exact tier-routing matrices, and raw
|
|
507
|
+
model outputs stay in the private engineering repository and are not included
|
|
508
|
+
in the npm package or public source tree.
|
|
476
509
|
|
|
477
|
-
**Routing
|
|
510
|
+
**Routing evaluation.** On a narrow tool-selection suite, the fleet achieved
|
|
511
|
+
near-saturated results across three seeds. This measures offline MCP routing
|
|
512
|
+
reliability, not general model capability.
|
|
478
513
|
|
|
479
514
|
| Model | Routing accuracy | Notes |
|
|
480
515
|
|---|---|---|
|
|
@@ -484,90 +519,17 @@ python3 tests/benchmarks/prism-routing-100/benchmark.py --models 2b 4b 9b 27b
|
|
|
484
519
|
|
|
485
520
|
**Memory uplift (LoCoMo-Plus, self-published).** A separate long-context dialogue benchmark ([dcostenco/Locomo-Plus](https://github.com/dcostenco/Locomo-Plus)) measures how much structured memory helps a base model retain multi-day context. Results show large gains when a model is paired with Prism memory versus running raw. Note this benchmark is authored, run, and LLM-judged by this project โ treat it as a reproducible demonstration, not an independent third-party result, and run it yourself with the commands in that repo.
|
|
486
521
|
|
|
487
|
-
|
|
522
|
+
**Code generation evaluation.** In a small July 2026 deterministic execution
|
|
523
|
+
check, the local 9B passed 2/3 tasks; the local 27B and Gemini 3.6 Flash each
|
|
524
|
+
passed 3/3. This is a self-published regression signal, not an independent
|
|
525
|
+
leaderboard or a claim of broad model equivalence.
|
|
488
526
|
|
|
489
|
-
|
|
527
|
+
### Cloud Escalation (`cloud_fallback: true`)
|
|
490
528
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
| Trie with autocomplete | `.lower()` normalization, collect+sort+slice | `__slots__` on TrieNode, early-exit DFS at limit | Both correct, Opus slightly more optimized |
|
|
496
|
-
|
|
497
|
-
<details>
|
|
498
|
-
<summary>Local 27B output โ Trie with autocomplete (hardest task)</summary>
|
|
499
|
-
|
|
500
|
-
```python
|
|
501
|
-
class TrieNode:
|
|
502
|
-
def __init__(self):
|
|
503
|
-
self.children: dict[str, 'TrieNode'] = {}
|
|
504
|
-
self.is_end_of_word: bool = False
|
|
505
|
-
|
|
506
|
-
class Trie:
|
|
507
|
-
def __init__(self):
|
|
508
|
-
self.root: TrieNode = TrieNode()
|
|
509
|
-
|
|
510
|
-
def insert(self, word: str) -> None:
|
|
511
|
-
node = self.root
|
|
512
|
-
for char in word.lower():
|
|
513
|
-
if char not in node.children:
|
|
514
|
-
node.children[char] = TrieNode()
|
|
515
|
-
node = node.children[char]
|
|
516
|
-
node.is_end_of_word = True
|
|
517
|
-
|
|
518
|
-
def search(self, word: str) -> bool:
|
|
519
|
-
node = self._get_node(word.lower())
|
|
520
|
-
return node is not None and node.is_end_of_word
|
|
521
|
-
|
|
522
|
-
def starts_with(self, prefix: str) -> bool:
|
|
523
|
-
return self._get_node(prefix.lower()) is not None
|
|
524
|
-
|
|
525
|
-
def autocomplete(self, prefix: str, limit: int = 5) -> list[str]:
|
|
526
|
-
node = self._get_node(prefix.lower())
|
|
527
|
-
if node is None:
|
|
528
|
-
return []
|
|
529
|
-
results: list[str] = []
|
|
530
|
-
self._collect_words(node, prefix.lower(), results)
|
|
531
|
-
results.sort()
|
|
532
|
-
return results[:limit]
|
|
533
|
-
|
|
534
|
-
def _get_node(self, key: str) -> 'TrieNode | None':
|
|
535
|
-
node = self.root
|
|
536
|
-
for char in key:
|
|
537
|
-
if char not in node.children:
|
|
538
|
-
return None
|
|
539
|
-
node = node.children[char]
|
|
540
|
-
return node
|
|
541
|
-
|
|
542
|
-
def _collect_words(self, node: TrieNode, prefix: str, results: list[str]) -> None:
|
|
543
|
-
if node.is_end_of_word:
|
|
544
|
-
results.append(prefix)
|
|
545
|
-
for char, child in sorted(node.children.items()):
|
|
546
|
-
self._collect_words(child, prefix + char, results)
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
</details>
|
|
550
|
-
|
|
551
|
-
| Metric | Local 27B | Cloud (Opus) |
|
|
552
|
-
|--------|-----------|-------------|
|
|
553
|
-
| Latency (Trie task) | ~30s | ~8s |
|
|
554
|
-
| Cost | $0 | ~$0.05 |
|
|
555
|
-
| Think mode | Enabled (stripped before serving) | N/A |
|
|
556
|
-
| Quality gate | Passed (no escalation needed) | N/A |
|
|
557
|
-
|
|
558
|
-
### Cloud Escalation in Practice (`cloud_fallback: true`)
|
|
559
|
-
|
|
560
|
-
The same three tasks with `cloud_fallback: true` โ the quality gate decides whether local output is good enough or needs cloud escalation.
|
|
561
|
-
|
|
562
|
-
| Task | used_cloud | Quality Gate | Latency | What happened |
|
|
563
|
-
|------|:----------:|-------------|---------|---------------|
|
|
564
|
-
| Fibonacci (simple) | **no** | Passed | 11s | 27B served directly, $0 |
|
|
565
|
-
| LRU Cache (medium) | **no** | Passed | 21s | 27B served directly, $0 |
|
|
566
|
-
| Trie (hard) | **yes** | `loop_detected` | 55s | 27B looped โ gate caught it โ escalated to cloud 27B |
|
|
567
|
-
|
|
568
|
-
The quality gate detected repeated sentences (โฅ3 of the same sentence in โฅ6 total) in the 27B's Trie output and escalated automatically. The cloud fallback returned clean code. On a second run of the same prompt, the 27B produced clean output without escalation โ the loop is stochastic, not systematic.
|
|
569
|
-
|
|
570
|
-
**Takeaway:** for ~80โ90% of coding tasks, the 27B handles everything locally at $0. The quality gate + cloud escalation exists as a safety net for the remaining cases where the local model loops, truncates, or produces empty output. Paid tiers get automatic escalation; free tier gets the local result with a warning.
|
|
529
|
+
Prism always tries an eligible local model first. If the quality gate detects
|
|
530
|
+
an empty, truncated, think-only, or looping response, paid tiers can retry the
|
|
531
|
+
request through Gemini 3.6 Flash. Free-tier routing stays local and reports the
|
|
532
|
+
quality-gate outcome without making a cloud call.
|
|
571
533
|
|
|
572
534
|
---
|
|
573
535
|
|
|
@@ -618,17 +580,17 @@ and [Amazon Q Developer](https://aws.amazon.com/q/developer/pricing/).
|
|
|
618
580
|
|
|
619
581
|
## Plans
|
|
620
582
|
|
|
621
|
-
All on-device models are free to run locally via Ollama on every tier. A subscription gates **cloud** features, higher
|
|
583
|
+
All on-device models are free to run locally via Ollama on every tier. A subscription gates **cloud** features, higher automatic-routing ceilings, and increased limits. On-device models run through your Ollama regardless of plan; the ceiling applies only to cloud inference and automatic `prism_infer` routing.
|
|
622
584
|
|
|
623
585
|
| | **Free** | **Standard** $19/mo | **Advanced** $49/mo | **Enterprise** $99/mo |
|
|
624
586
|
|---|---|---|---|---|
|
|
625
587
|
| Seats | 1 | 1 | up to 5 | up to 25 |
|
|
626
|
-
|
|
|
588
|
+
| Automatic `prism_infer` ceiling | up to 4b | up to 9b | up to 27b | up to 27b |
|
|
627
589
|
| Cloud inference | -- | โ
| โ
| โ
(priority) |
|
|
628
590
|
| Cloud Coder (Web IDE) | -- | โ
| โ
| โ
(priority) |
|
|
629
591
|
| Cloud search | -- | โ
| โ
| โ
|
|
|
630
592
|
| Max output tokens | 512 | 1,024 | 2,048 | 4,096 |
|
|
631
|
-
| Cloud fallback | -- |
|
|
593
|
+
| Cloud fallback | -- | Gemini 3.6 Flash | Gemini 3.6 Flash | Gemini 3.6 Flash (priority) |
|
|
632
594
|
| Grounding verifier (fact-check AI output) | -- | โ
| โ
| โ
|
|
|
633
595
|
| Memory sync (cloud) | -- | โ
| โ
| โ
|
|
|
634
596
|
| Knowledge / session memory | limited | unlimited | unlimited | unlimited |
|
|
@@ -650,13 +612,25 @@ Prism exposes 40+ MCP tools. The core memory loop:
|
|
|
650
612
|
| `session_save_ledger` | Append an immutable session log entry |
|
|
651
613
|
| `session_save_handoff` | Save live state for the next session |
|
|
652
614
|
| `knowledge_search` | Semantic + keyword search over all memories |
|
|
653
|
-
| `query_memory_natural` |
|
|
615
|
+
| `query_memory_natural` | Memory-first Q&A with a grounded live-source fallback on paid tiers |
|
|
654
616
|
| `session_detect_drift` | Detect when a session has drifted from its goal |
|
|
655
617
|
| `verify_behavior` | Pre-edit scenario challenge โ catch bad changes before they happen |
|
|
656
618
|
| `knowledge_ingest` | Teach Prism a codebase or document |
|
|
657
619
|
| `prism_infer` | Local-first inference (route/chat/code modes, thinking, cloud escalation) |
|
|
658
620
|
| `inference_metrics` | Session delegation or persisted MCP + VS Code panel local/cloud stats |
|
|
659
621
|
|
|
622
|
+
### `query_memory_natural` โ memory first, current sources when needed
|
|
623
|
+
|
|
624
|
+
Ask one natural-language question instead of choosing separate memory, search,
|
|
625
|
+
scrape, and inference tools. Prism searches its accumulated project memory
|
|
626
|
+
first. If no useful evidence exists, paid tiers run one bounded Synalux search
|
|
627
|
+
(Firecrawl, Gemini 3.6 Google Search grounding, then legacy Brave fallback),
|
|
628
|
+
resolve and preserve the source URLs, scrape the leading page, and ask a
|
|
629
|
+
RAM-safe local Prism Coder model to answer from that evidence. The paid-tier
|
|
630
|
+
Gemini 3.6 verifier checks the draft before it is served. Reserved or uncertain
|
|
631
|
+
clinical content never enters the web-grounded local path; it follows Prism's
|
|
632
|
+
cloud-or-refuse safety boundary.
|
|
633
|
+
|
|
660
634
|
### `prism_infer` โ local-first inference with cloud escalation
|
|
661
635
|
|
|
662
636
|
```typescript
|
|
@@ -667,7 +641,7 @@ prism_infer({
|
|
|
667
641
|
model_ceiling: "27b", // use the quality tier
|
|
668
642
|
})
|
|
669
643
|
// โ 27B generates code locally ($0), with thinking for quality
|
|
670
|
-
// โ If quality gate fails + paid tier โ auto-escalate to
|
|
644
|
+
// โ If quality gate fails + paid tier โ auto-escalate to Gemini 3.6 Flash
|
|
671
645
|
```
|
|
672
646
|
|
|
673
647
|
| Mode | Think | Model | Use case |
|
|
@@ -694,7 +668,7 @@ Call `inference_metrics` anytime mid-session to see how many `prism_infer` calls
|
|
|
694
668
|
|
|
695
669
|
The same block also appears automatically in `session_save_ledger` and `session_save_handoff` responses at session end.
|
|
696
670
|
|
|
697
|
-
**Note:** The default session view tracks this MCP process's `prism_infer` delegation. The all-time view combines persisted MCP calls with Synalux VS Code panel inference. Neither view includes
|
|
671
|
+
**Note:** The default session view tracks this MCP process's `prism_infer` delegation. The all-time view combines persisted MCP calls with Synalux VS Code panel inference. Neither view includes the host agent's own token spend; use that host's native usage reporting when available.
|
|
698
672
|
|
|
699
673
|
### Local-model delegation (default)
|
|
700
674
|
|
|
@@ -883,7 +857,11 @@ ollama pull dcostenco/prism-coder:9b # default router
|
|
|
883
857
|
export LOCAL_LLM_URL=http://localhost:11434
|
|
884
858
|
```
|
|
885
859
|
|
|
886
|
-
|
|
860
|
+
Self-hosted routing stays local: `9b โ 4b` on desktop/server and `2b` on
|
|
861
|
+
mobile/iPhone, with 27B available when installed and RAM-safe. Synalux-hosted
|
|
862
|
+
paid tiers can use Gemini 3.6 Flash as the cloud fallback. For iOS or another
|
|
863
|
+
machine on the same network, run `OLLAMA_HOST=0.0.0.0 ollama serve` and point
|
|
864
|
+
`LOCAL_LLM_URL` at the host's IP.
|
|
887
865
|
|
|
888
866
|
---
|
|
889
867
|
|
|
@@ -934,7 +912,7 @@ It reads `~/.prism-mcp/data.db` and POSTs entries to the portal. Ledger entries
|
|
|
934
912
|
|
|
935
913
|
| Feature | Details |
|
|
936
914
|
|---------|---------|
|
|
937
|
-
| Local inference | Ollama
|
|
915
|
+
| Local inference | Direct Ollama use is unrestricted; automatic `prism_infer` routing selects up to 4B |
|
|
938
916
|
| Session memory | Persistent sessions, handoffs, ledger โ all local SQLite |
|
|
939
917
|
| Knowledge search | Semantic search across session history |
|
|
940
918
|
| Skills | All skills available locally (run `sync-skills.sh` to populate) |
|
|
@@ -946,7 +924,7 @@ Everything in Free, plus:
|
|
|
946
924
|
|
|
947
925
|
| Feature | Details |
|
|
948
926
|
|---------|---------|
|
|
949
|
-
| Model ceiling |
|
|
927
|
+
| Model ceiling | Automatic `prism_infer` routing up to 27B + Gemini 3.6 Flash fallback when local is unavailable |
|
|
950
928
|
| Skill routing | Portal resolves which skills to load based on your project and prompt |
|
|
951
929
|
| Cross-device memory | Supabase cloud sync โ sessions survive across machines |
|
|
952
930
|
| Grounding verifier | L3 NLI verification on model outputs |
|
package/dist/connect.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { accessSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as win32Path } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -69,6 +69,72 @@ const CODEX_LOCAL_FIRST_POLICY = {
|
|
|
69
69
|
};
|
|
70
70
|
const CODEX_POLICY_MARKER_PREFIX = "# >>> prism connect managed: local-first";
|
|
71
71
|
const CODEX_POLICY_MARKER_SUFFIX = "# <<< prism connect managed: local-first";
|
|
72
|
+
/**
|
|
73
|
+
* Read a host-owned text file through one descriptor, then verify that the
|
|
74
|
+
* pathname still identifies that same regular file. This prevents a path from
|
|
75
|
+
* being swapped between a metadata check and the read while retaining Prism's
|
|
76
|
+
* documented support for symlinked dotfiles.
|
|
77
|
+
*/
|
|
78
|
+
function readRegularTextSnapshot(filePath, allowSymlink = true) {
|
|
79
|
+
const nonBlocking = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
|
|
80
|
+
const noFollow = !allowSymlink && typeof constants.O_NOFOLLOW === "number"
|
|
81
|
+
? constants.O_NOFOLLOW
|
|
82
|
+
: 0;
|
|
83
|
+
let descriptor;
|
|
84
|
+
try {
|
|
85
|
+
descriptor = openSync(filePath, constants.O_RDONLY | nonBlocking | noFollow);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// A dangling symlink is an existing, unsafe entry rather than an absent
|
|
89
|
+
// config. Detect it only after the descriptor open failed, so this probe
|
|
90
|
+
// cannot become a check-then-read race.
|
|
91
|
+
if (isErrno(error, "ENOENT")) {
|
|
92
|
+
let pathInfo;
|
|
93
|
+
try {
|
|
94
|
+
pathInfo = lstatSync(filePath);
|
|
95
|
+
}
|
|
96
|
+
catch (probeError) {
|
|
97
|
+
if (!isErrno(probeError, "ENOENT"))
|
|
98
|
+
throw probeError;
|
|
99
|
+
}
|
|
100
|
+
if (pathInfo?.isSymbolicLink()) {
|
|
101
|
+
throw new Error(`symlink target is unavailable: ${filePath}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const openedInfo = fstatSync(descriptor);
|
|
108
|
+
if (!openedInfo.isFile())
|
|
109
|
+
throw new Error("target is not a regular file");
|
|
110
|
+
const pathInfo = lstatSync(filePath);
|
|
111
|
+
let writePath = filePath;
|
|
112
|
+
let symlinkPath;
|
|
113
|
+
if (pathInfo.isSymbolicLink()) {
|
|
114
|
+
if (!allowSymlink)
|
|
115
|
+
throw new Error("symlink is not allowed");
|
|
116
|
+
writePath = realpathSync(filePath);
|
|
117
|
+
symlinkPath = filePath;
|
|
118
|
+
}
|
|
119
|
+
else if (!pathInfo.isFile()) {
|
|
120
|
+
throw new Error("target is not a regular file");
|
|
121
|
+
}
|
|
122
|
+
const currentInfo = statSync(writePath);
|
|
123
|
+
if (!currentInfo.isFile()
|
|
124
|
+
|| currentInfo.dev !== openedInfo.dev
|
|
125
|
+
|| currentInfo.ino !== openedInfo.ino) {
|
|
126
|
+
throw new Error("file changed while Prism was opening it; retry");
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
text: readFileSync(descriptor, "utf8"),
|
|
130
|
+
writePath,
|
|
131
|
+
symlinkPath,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
closeSync(descriptor);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
72
138
|
export function normalizeHostName(value) {
|
|
73
139
|
const normalized = value.trim().toLowerCase();
|
|
74
140
|
const host = HOST_ALIASES[normalized];
|
|
@@ -188,11 +254,7 @@ export function writeInstallationReceipt(homeDir, receipt, beforeCommit) {
|
|
|
188
254
|
}
|
|
189
255
|
let originalText;
|
|
190
256
|
try {
|
|
191
|
-
|
|
192
|
-
if (receiptInfo.isSymbolicLink() || !receiptInfo.isFile()) {
|
|
193
|
-
throw new Error("receipt must be a regular Prism-owned file");
|
|
194
|
-
}
|
|
195
|
-
originalText = readFileSync(receiptPath, "utf8");
|
|
257
|
+
originalText = readRegularTextSnapshot(receiptPath, false).text;
|
|
196
258
|
const current = JSON.parse(originalText);
|
|
197
259
|
if (!isJsonObject(current)
|
|
198
260
|
|| current.schema_version !== INSTALLATION_RECEIPT_SCHEMA_VERSION
|
|
@@ -251,14 +313,10 @@ export function migrateLegacyClaudeProjectMcp(homeDir = homedir(), cwd = process
|
|
|
251
313
|
let symlinkPath;
|
|
252
314
|
let originalText;
|
|
253
315
|
try {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
if (!statSync(writePath).isFile())
|
|
260
|
-
throw new Error("config is not a regular file");
|
|
261
|
-
originalText = readFileSync(writePath, "utf8");
|
|
316
|
+
const snapshot = readRegularTextSnapshot(configPath);
|
|
317
|
+
writePath = snapshot.writePath;
|
|
318
|
+
symlinkPath = snapshot.symlinkPath;
|
|
319
|
+
originalText = snapshot.text;
|
|
262
320
|
}
|
|
263
321
|
catch (error) {
|
|
264
322
|
throw new Error(`Could not inspect Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -321,14 +379,10 @@ export function migrateLegacyClaudeHooks(homeDir = homedir(), dryRun = false, be
|
|
|
321
379
|
let symlinkPath;
|
|
322
380
|
let originalText;
|
|
323
381
|
try {
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (!statSync(writePath).isFile())
|
|
329
|
-
throw new Error("target is not a regular file");
|
|
330
|
-
}
|
|
331
|
-
originalText = readFileSync(writePath, "utf8");
|
|
382
|
+
const snapshot = readRegularTextSnapshot(configPath);
|
|
383
|
+
writePath = snapshot.writePath;
|
|
384
|
+
symlinkPath = snapshot.symlinkPath;
|
|
385
|
+
originalText = snapshot.text;
|
|
332
386
|
}
|
|
333
387
|
catch (error) {
|
|
334
388
|
if (isErrno(error, "ENOENT"))
|
|
@@ -403,15 +457,11 @@ export function migrateLegacyClaudeInstructions(homeDir = homedir(), dryRun = fa
|
|
|
403
457
|
let originalText;
|
|
404
458
|
let instructionEntryExists = false;
|
|
405
459
|
try {
|
|
406
|
-
const
|
|
460
|
+
const snapshot = readRegularTextSnapshot(instructionPath);
|
|
407
461
|
instructionEntryExists = true;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (!statSync(writePath).isFile())
|
|
412
|
-
throw new Error("target is not a regular file");
|
|
413
|
-
}
|
|
414
|
-
originalText = readFileSync(writePath, "utf8");
|
|
462
|
+
writePath = snapshot.writePath;
|
|
463
|
+
symlinkPath = snapshot.symlinkPath;
|
|
464
|
+
originalText = snapshot.text;
|
|
415
465
|
}
|
|
416
466
|
catch (error) {
|
|
417
467
|
if (!instructionEntryExists && isErrno(error, "ENOENT")) {
|
|
@@ -447,15 +497,11 @@ export function migrateLegacyClaudeManagedStartup(homeDir = homedir(), dryRun =
|
|
|
447
497
|
let originalText;
|
|
448
498
|
let instructionEntryExists = false;
|
|
449
499
|
try {
|
|
450
|
-
const
|
|
500
|
+
const snapshot = readRegularTextSnapshot(instructionPath);
|
|
451
501
|
instructionEntryExists = true;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (!statSync(writePath).isFile())
|
|
456
|
-
throw new Error("target is not a regular file");
|
|
457
|
-
}
|
|
458
|
-
originalText = readFileSync(writePath, "utf8");
|
|
502
|
+
writePath = snapshot.writePath;
|
|
503
|
+
symlinkPath = snapshot.symlinkPath;
|
|
504
|
+
originalText = snapshot.text;
|
|
459
505
|
}
|
|
460
506
|
catch (error) {
|
|
461
507
|
if (!instructionEntryExists && isErrno(error, "ENOENT")) {
|
|
@@ -508,15 +554,11 @@ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false
|
|
|
508
554
|
let originalText;
|
|
509
555
|
let instructionEntryExists = false;
|
|
510
556
|
try {
|
|
511
|
-
const
|
|
557
|
+
const snapshot = readRegularTextSnapshot(instructionPath);
|
|
512
558
|
instructionEntryExists = true;
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (!statSync(writePath).isFile())
|
|
517
|
-
throw new Error("target is not a regular file");
|
|
518
|
-
}
|
|
519
|
-
originalText = readFileSync(writePath, "utf8");
|
|
559
|
+
writePath = snapshot.writePath;
|
|
560
|
+
symlinkPath = snapshot.symlinkPath;
|
|
561
|
+
originalText = snapshot.text;
|
|
520
562
|
}
|
|
521
563
|
catch (error) {
|
|
522
564
|
if (instructionEntryExists || !isErrno(error, "ENOENT")) {
|
|
@@ -604,15 +646,11 @@ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false
|
|
|
604
646
|
let originalText;
|
|
605
647
|
let instructionEntryExists = false;
|
|
606
648
|
try {
|
|
607
|
-
const
|
|
649
|
+
const snapshot = readRegularTextSnapshot(instructionPath);
|
|
608
650
|
instructionEntryExists = true;
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (!statSync(writePath).isFile())
|
|
613
|
-
throw new Error("target is not a regular file");
|
|
614
|
-
}
|
|
615
|
-
originalText = readFileSync(writePath, "utf8");
|
|
651
|
+
writePath = snapshot.writePath;
|
|
652
|
+
symlinkPath = snapshot.symlinkPath;
|
|
653
|
+
originalText = snapshot.text;
|
|
616
654
|
}
|
|
617
655
|
catch (error) {
|
|
618
656
|
if (instructionEntryExists || !isErrno(error, "ENOENT")) {
|
|
@@ -680,15 +718,11 @@ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommi
|
|
|
680
718
|
let originalText;
|
|
681
719
|
let instructionEntryExists = false;
|
|
682
720
|
try {
|
|
683
|
-
const
|
|
721
|
+
const snapshot = readRegularTextSnapshot(instructionPath);
|
|
684
722
|
instructionEntryExists = true;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if (!statSync(writePath).isFile())
|
|
689
|
-
throw new Error("target is not a regular file");
|
|
690
|
-
}
|
|
691
|
-
originalText = readFileSync(writePath, "utf8");
|
|
723
|
+
writePath = snapshot.writePath;
|
|
724
|
+
symlinkPath = snapshot.symlinkPath;
|
|
725
|
+
originalText = snapshot.text;
|
|
692
726
|
}
|
|
693
727
|
catch (error) {
|
|
694
728
|
if (instructionEntryExists || !isErrno(error, "ENOENT")) {
|
|
@@ -737,14 +771,10 @@ function configureJsonAgentPolicy(configPath, label, mutate, dryRun, beforeCommi
|
|
|
737
771
|
let originalText;
|
|
738
772
|
let config = {};
|
|
739
773
|
try {
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
if (!statSync(writePath).isFile())
|
|
745
|
-
throw new Error("target is not a regular file");
|
|
746
|
-
}
|
|
747
|
-
originalText = readFileSync(writePath, "utf8");
|
|
774
|
+
const snapshot = readRegularTextSnapshot(configPath);
|
|
775
|
+
writePath = snapshot.writePath;
|
|
776
|
+
symlinkPath = snapshot.symlinkPath;
|
|
777
|
+
originalText = snapshot.text;
|
|
748
778
|
const parsed = JSON.parse(originalText);
|
|
749
779
|
if (!isJsonObject(parsed))
|
|
750
780
|
throw new Error("top-level JSON value must be an object");
|
|
@@ -858,14 +888,10 @@ export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit,
|
|
|
858
888
|
let symlinkPath;
|
|
859
889
|
let originalText;
|
|
860
890
|
try {
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
if (!statSync(writePath).isFile())
|
|
866
|
-
throw new Error("target is not a regular file");
|
|
867
|
-
}
|
|
868
|
-
originalText = readFileSync(writePath, "utf8");
|
|
891
|
+
const snapshot = readRegularTextSnapshot(configPath);
|
|
892
|
+
writePath = snapshot.writePath;
|
|
893
|
+
symlinkPath = snapshot.symlinkPath;
|
|
894
|
+
originalText = snapshot.text;
|
|
869
895
|
parseToml(originalText);
|
|
870
896
|
}
|
|
871
897
|
catch (error) {
|
|
@@ -1063,18 +1089,18 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
|
1063
1089
|
let writePath = configPath;
|
|
1064
1090
|
let symlinkPath;
|
|
1065
1091
|
try {
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
}
|
|
1092
|
+
const snapshot = readRegularTextSnapshot(configPath);
|
|
1093
|
+
writePath = snapshot.writePath;
|
|
1094
|
+
symlinkPath = snapshot.symlinkPath;
|
|
1095
|
+
originalText = snapshot.text;
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
if (!isErrno(error, "ENOENT")) {
|
|
1099
|
+
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1075
1100
|
}
|
|
1101
|
+
}
|
|
1102
|
+
if (originalText !== undefined) {
|
|
1076
1103
|
try {
|
|
1077
|
-
originalText = readFileSync(writePath, "utf8");
|
|
1078
1104
|
const parsed = JSON.parse(originalText);
|
|
1079
1105
|
if (!isJsonObject(parsed)) {
|
|
1080
1106
|
throw new Error("top-level JSON value must be an object");
|
|
@@ -1085,11 +1111,6 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
|
1085
1111
|
return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1086
1112
|
}
|
|
1087
1113
|
}
|
|
1088
|
-
catch (error) {
|
|
1089
|
-
if (!isErrno(error, "ENOENT")) {
|
|
1090
|
-
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
1114
|
const currentServers = config.mcpServers;
|
|
1094
1115
|
if (currentServers !== undefined && !isJsonObject(currentServers)) {
|
|
1095
1116
|
return result(definition, "error", '"mcpServers" must be a JSON object');
|
|
@@ -1145,21 +1166,18 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
1145
1166
|
let writePath = configPath;
|
|
1146
1167
|
let symlinkPath;
|
|
1147
1168
|
try {
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
catch (error) {
|
|
1158
|
-
return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1159
|
-
}
|
|
1169
|
+
const snapshot = readRegularTextSnapshot(configPath);
|
|
1170
|
+
writePath = snapshot.writePath;
|
|
1171
|
+
symlinkPath = snapshot.symlinkPath;
|
|
1172
|
+
originalText = snapshot.text;
|
|
1173
|
+
}
|
|
1174
|
+
catch (error) {
|
|
1175
|
+
if (!isErrno(error, "ENOENT")) {
|
|
1176
|
+
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1160
1177
|
}
|
|
1178
|
+
}
|
|
1179
|
+
if (originalText !== undefined) {
|
|
1161
1180
|
try {
|
|
1162
|
-
originalText = readFileSync(writePath, "utf8");
|
|
1163
1181
|
const parsed = parseToml(originalText);
|
|
1164
1182
|
if (!isJsonObject(parsed)) {
|
|
1165
1183
|
throw new Error("top-level TOML value must be a table");
|
|
@@ -1170,11 +1188,6 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
1170
1188
|
return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1171
1189
|
}
|
|
1172
1190
|
}
|
|
1173
|
-
catch (error) {
|
|
1174
|
-
if (!isErrno(error, "ENOENT")) {
|
|
1175
|
-
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
1191
|
let managedBlock;
|
|
1179
1192
|
try {
|
|
1180
1193
|
managedBlock = locateCodexManagedBlock(originalText ?? "");
|