prism-mcp-server 20.2.6 โ 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/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 |
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session state tracking โ
|
|
2
|
+
* Session state tracking โ in-process hot path with a durable local receipt.
|
|
3
3
|
*
|
|
4
4
|
* This is NOT business logic โ it's MCP connection lifecycle state.
|
|
5
5
|
* Business logic (skill routing, budget tranching, content resolution)
|
|
6
6
|
* lives in the synalux portal at /api/v1/prism/skills.
|
|
7
7
|
*
|
|
8
|
-
* What stays here (
|
|
8
|
+
* What stays here (host lifecycle state, cannot be portal-side):
|
|
9
9
|
* markContextLoaded / requireContextLoaded โ write-gate for session tools
|
|
10
10
|
* noteInferenceForSession โ telemetry counter
|
|
11
11
|
* drift timer โ connection-scoped GATE 5 enforcement
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
* Skill routing, budget tranching, content loading, phantom detection,
|
|
15
15
|
* prompt-keyword matching, user-local skill loading, context-discovery.
|
|
16
16
|
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
17
18
|
import { BOUNDARIES_VERSION as CURRENT_BOUNDARIES_VERSION } from "../boundaries/boundaries.js";
|
|
19
|
+
import * as configStorage from "../storage/configStorage.js";
|
|
18
20
|
const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 h โ conversation-scoped
|
|
19
21
|
const MAX_SESSIONS = 10_000;
|
|
22
|
+
const RECEIPT_CLOCK_SKEW_MS = 60_000;
|
|
20
23
|
/**
|
|
21
24
|
* Connection-scoped fallback: remember the last conversation_id seen via
|
|
22
25
|
* markContextLoaded so that tools which don't carry conversation_id
|
|
@@ -81,6 +84,62 @@ export function markContextLoaded(conversationId, project, boundariesVersion) {
|
|
|
81
84
|
s.boundariesVersion = boundariesVersion;
|
|
82
85
|
lastSeenConversationId = conversationId;
|
|
83
86
|
}
|
|
87
|
+
function contextNotLoadedError(project) {
|
|
88
|
+
const projectNote = project
|
|
89
|
+
? " the requested project was not loaded for this conversation."
|
|
90
|
+
: "";
|
|
91
|
+
return {
|
|
92
|
+
blocked: true,
|
|
93
|
+
error: "context_not_loaded:" + projectNote + " Call session_bootstrap(conversation_id) or " +
|
|
94
|
+
"session_load_context(project, conversation_id) " +
|
|
95
|
+
"before this action. This project-scoped tool needs confirmed working context " +
|
|
96
|
+
"to act correctly. (Enforced server-side โ applies to every host.)",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function contextExpiredError() {
|
|
100
|
+
return {
|
|
101
|
+
blocked: true,
|
|
102
|
+
error: "context_not_loaded: session expired (6 h TTL). Call " +
|
|
103
|
+
"session_bootstrap(conversation_id) or session_load_context(project, conversation_id) again. " +
|
|
104
|
+
"(Enforced server-side โ applies to every host.)",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function hashReceiptScope(kind, value) {
|
|
108
|
+
return createHash("sha256").update(`prism-session-${kind}\0${value}`).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
async function persistContextReceipt(conversationId, project, boundariesVersion, loadedAt) {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const receipt = {
|
|
113
|
+
conversationHash: hashReceiptScope("conversation", conversationId),
|
|
114
|
+
projectHash: hashReceiptScope("project", project),
|
|
115
|
+
project,
|
|
116
|
+
boundariesVersion,
|
|
117
|
+
loadedAt,
|
|
118
|
+
lastSeen: now,
|
|
119
|
+
};
|
|
120
|
+
await configStorage.saveSessionContextReceipt(receipt, now - SESSION_TTL_MS);
|
|
121
|
+
}
|
|
122
|
+
async function persistContextReceiptBestEffort(conversationId, project, boundariesVersion, loadedAt) {
|
|
123
|
+
try {
|
|
124
|
+
await persistContextReceipt(conversationId, project, boundariesVersion, loadedAt);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// Keep the established same-process path available on read-only or damaged
|
|
128
|
+
// config stores, but make degraded restart recovery visible to operators.
|
|
129
|
+
console.error(`[sessionContext] Durable context receipt unavailable for project "${project}": ` +
|
|
130
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Registers successful context materialization and persists an opaque receipt.
|
|
135
|
+
* The conversation_id never leaves this process unhashed.
|
|
136
|
+
*/
|
|
137
|
+
export async function registerContextLoaded(conversationId, project, boundariesVersion) {
|
|
138
|
+
markContextLoaded(conversationId, project, boundariesVersion);
|
|
139
|
+
noteDriftSessionStart(conversationId);
|
|
140
|
+
const s = sessions.get(conversationId);
|
|
141
|
+
await persistContextReceiptBestEffort(conversationId, project, boundariesVersion, s?.driftSessionStart ?? Date.now());
|
|
142
|
+
}
|
|
84
143
|
/**
|
|
85
144
|
* Soft gate for handlers that need project context to be CORRECT (not safe).
|
|
86
145
|
*
|
|
@@ -108,21 +167,10 @@ export function requireContextLoaded(conversationId) {
|
|
|
108
167
|
// even if no write has triggered eviction yet. Evict immediately on detection.
|
|
109
168
|
if (s && (Date.now() - s.lastSeen) > SESSION_TTL_MS) {
|
|
110
169
|
sessions.delete(conversationId);
|
|
111
|
-
return
|
|
112
|
-
blocked: true,
|
|
113
|
-
error: "context_not_loaded: session expired (6 h TTL). Call " +
|
|
114
|
-
"session_bootstrap(conversation_id) or session_load_context(project, conversation_id) again. " +
|
|
115
|
-
"(Enforced server-side โ applies to every host.)",
|
|
116
|
-
};
|
|
170
|
+
return contextExpiredError();
|
|
117
171
|
}
|
|
118
172
|
if (!s || !s.contextLoaded) {
|
|
119
|
-
return
|
|
120
|
-
blocked: true,
|
|
121
|
-
error: "context_not_loaded: call session_bootstrap(conversation_id) or " +
|
|
122
|
-
"session_load_context(project, conversation_id) " +
|
|
123
|
-
"before this action. This project-scoped tool needs confirmed working context " +
|
|
124
|
-
"to act correctly. (Enforced server-side โ applies to every host.)",
|
|
125
|
-
};
|
|
173
|
+
return contextNotLoadedError();
|
|
126
174
|
}
|
|
127
175
|
// Touch on valid read โ maintains LRU order.
|
|
128
176
|
touch(conversationId, s);
|
|
@@ -139,6 +187,56 @@ export function requireContextLoaded(conversationId) {
|
|
|
139
187
|
}
|
|
140
188
|
return null;
|
|
141
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Project-scoped durable gate used by ledger and handoff writes.
|
|
192
|
+
*
|
|
193
|
+
* The in-memory state remains the hot path. If another MCP process loaded the
|
|
194
|
+
* requested project, or this process restarted, an unexpired hashed receipt
|
|
195
|
+
* restores only that exact project. Unknown, malformed, expired, and
|
|
196
|
+
* cross-project lookups remain fail-closed.
|
|
197
|
+
*/
|
|
198
|
+
export async function requireContextLoadedForProject(conversationId, project) {
|
|
199
|
+
if (conversationId === undefined)
|
|
200
|
+
return null;
|
|
201
|
+
if (!conversationId || !project.trim())
|
|
202
|
+
return contextNotLoadedError(project || undefined);
|
|
203
|
+
const memoryGate = requireContextLoaded(conversationId);
|
|
204
|
+
const memoryState = sessions.get(conversationId);
|
|
205
|
+
if (memoryState?.contextLoaded && memoryState.project === project &&
|
|
206
|
+
!(memoryGate && memoryGate.blocked)) {
|
|
207
|
+
await persistContextReceiptBestEffort(conversationId, project, memoryState.boundariesVersion ?? CURRENT_BOUNDARIES_VERSION, memoryState.driftSessionStart ?? memoryState.lastSeen);
|
|
208
|
+
return memoryGate;
|
|
209
|
+
}
|
|
210
|
+
let receipt = null;
|
|
211
|
+
try {
|
|
212
|
+
receipt = await configStorage.getSessionContextReceipt(hashReceiptScope("conversation", conversationId), hashReceiptScope("project", project));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
console.error(`[sessionContext] Durable context receipt lookup failed for project "${project}": ` +
|
|
216
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
217
|
+
return memoryGate && memoryGate.blocked ? memoryGate : contextNotLoadedError(project);
|
|
218
|
+
}
|
|
219
|
+
if (!receipt)
|
|
220
|
+
return memoryGate?.blocked ? memoryGate : contextNotLoadedError(project);
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const invalidReceipt = receipt.project !== project ||
|
|
223
|
+
!Number.isFinite(receipt.loadedAt) ||
|
|
224
|
+
!Number.isFinite(receipt.lastSeen) ||
|
|
225
|
+
receipt.loadedAt <= 0 ||
|
|
226
|
+
receipt.loadedAt > receipt.lastSeen ||
|
|
227
|
+
receipt.lastSeen > now + RECEIPT_CLOCK_SKEW_MS;
|
|
228
|
+
if (invalidReceipt)
|
|
229
|
+
return contextNotLoadedError(project);
|
|
230
|
+
if ((now - receipt.lastSeen) > SESSION_TTL_MS)
|
|
231
|
+
return contextExpiredError();
|
|
232
|
+
markContextLoaded(conversationId, project, receipt.boundariesVersion);
|
|
233
|
+
const restored = sessions.get(conversationId);
|
|
234
|
+
if (restored)
|
|
235
|
+
restored.driftSessionStart = receipt.loadedAt;
|
|
236
|
+
const restoredGate = requireContextLoaded(conversationId);
|
|
237
|
+
await persistContextReceiptBestEffort(conversationId, project, receipt.boundariesVersion, receipt.loadedAt);
|
|
238
|
+
return restoredGate;
|
|
239
|
+
}
|
|
142
240
|
/** Best-effort telemetry from prism_infer. Never affects a safety decision. */
|
|
143
241
|
export function noteInferenceForSession(conversationId, info) {
|
|
144
242
|
// Only update sessions that already exist โ don't create ghost stubs for
|
|
@@ -61,6 +61,17 @@ export async function initConfigStorage() {
|
|
|
61
61
|
owner TEXT NOT NULL DEFAULT 'prism',
|
|
62
62
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
63
63
|
)
|
|
64
|
+
`);
|
|
65
|
+
await client.execute(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS session_context_receipts (
|
|
67
|
+
conversation_hash TEXT NOT NULL,
|
|
68
|
+
project_hash TEXT NOT NULL,
|
|
69
|
+
project TEXT NOT NULL,
|
|
70
|
+
boundaries_version TEXT NOT NULL,
|
|
71
|
+
loaded_at INTEGER NOT NULL,
|
|
72
|
+
last_seen INTEGER NOT NULL,
|
|
73
|
+
PRIMARY KEY (conversation_hash, project_hash)
|
|
74
|
+
)
|
|
64
75
|
`);
|
|
65
76
|
// Preload all rows into the cache so subsequent reads are zero-cost.
|
|
66
77
|
const rs = await client.execute("SELECT key, value FROM system_settings");
|
|
@@ -192,6 +203,95 @@ export function getSettingSync(key, defaultValue = "") {
|
|
|
192
203
|
return defaultValue;
|
|
193
204
|
return settingsCache[key] ?? defaultValue;
|
|
194
205
|
}
|
|
206
|
+
const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
|
|
207
|
+
function validateSessionContextReceipt(receipt) {
|
|
208
|
+
if (!SHA256_HEX_RE.test(receipt.conversationHash) ||
|
|
209
|
+
!SHA256_HEX_RE.test(receipt.projectHash) ||
|
|
210
|
+
!receipt.project.trim() ||
|
|
211
|
+
!receipt.boundariesVersion.trim() ||
|
|
212
|
+
!Number.isFinite(receipt.loadedAt) ||
|
|
213
|
+
!Number.isFinite(receipt.lastSeen)) {
|
|
214
|
+
throw new Error("Invalid session context receipt");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Persists only hashes of the caller-supplied identifiers. The project name is
|
|
219
|
+
* retained so recovery can verify that a hash lookup did not cross scopes.
|
|
220
|
+
* Expired rows are pruned in the same transaction to keep the local table
|
|
221
|
+
* bounded by active sessions rather than process lifetime.
|
|
222
|
+
*/
|
|
223
|
+
export async function saveSessionContextReceipt(receipt, expiresBefore) {
|
|
224
|
+
validateSessionContextReceipt(receipt);
|
|
225
|
+
if (!Number.isFinite(expiresBefore))
|
|
226
|
+
throw new Error("Invalid session receipt expiry");
|
|
227
|
+
await initConfigStorage();
|
|
228
|
+
const client = getClient();
|
|
229
|
+
await client.batch([
|
|
230
|
+
{
|
|
231
|
+
sql: "DELETE FROM session_context_receipts WHERE last_seen < ?",
|
|
232
|
+
args: [expiresBefore],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
sql: `
|
|
236
|
+
INSERT INTO session_context_receipts (
|
|
237
|
+
conversation_hash,
|
|
238
|
+
project_hash,
|
|
239
|
+
project,
|
|
240
|
+
boundaries_version,
|
|
241
|
+
loaded_at,
|
|
242
|
+
last_seen
|
|
243
|
+
)
|
|
244
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
245
|
+
ON CONFLICT(conversation_hash, project_hash) DO UPDATE SET
|
|
246
|
+
project = excluded.project,
|
|
247
|
+
boundaries_version = excluded.boundaries_version,
|
|
248
|
+
loaded_at = excluded.loaded_at,
|
|
249
|
+
last_seen = excluded.last_seen
|
|
250
|
+
`,
|
|
251
|
+
args: [
|
|
252
|
+
receipt.conversationHash,
|
|
253
|
+
receipt.projectHash,
|
|
254
|
+
receipt.project,
|
|
255
|
+
receipt.boundariesVersion,
|
|
256
|
+
receipt.loadedAt,
|
|
257
|
+
receipt.lastSeen,
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
], "write");
|
|
261
|
+
}
|
|
262
|
+
export async function getSessionContextReceipt(conversationHash, projectHash) {
|
|
263
|
+
if (!SHA256_HEX_RE.test(conversationHash) || !SHA256_HEX_RE.test(projectHash)) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
await initConfigStorage();
|
|
267
|
+
const rs = await getClient().execute({
|
|
268
|
+
sql: `
|
|
269
|
+
SELECT project, boundaries_version, loaded_at, last_seen
|
|
270
|
+
FROM session_context_receipts
|
|
271
|
+
WHERE conversation_hash = ? AND project_hash = ?
|
|
272
|
+
LIMIT 1
|
|
273
|
+
`,
|
|
274
|
+
args: [conversationHash, projectHash],
|
|
275
|
+
});
|
|
276
|
+
if (rs.rows.length === 0)
|
|
277
|
+
return null;
|
|
278
|
+
const row = rs.rows[0];
|
|
279
|
+
const receipt = {
|
|
280
|
+
conversationHash,
|
|
281
|
+
projectHash,
|
|
282
|
+
project: String(row.project ?? ""),
|
|
283
|
+
boundariesVersion: String(row.boundaries_version ?? ""),
|
|
284
|
+
loadedAt: Number(row.loaded_at),
|
|
285
|
+
lastSeen: Number(row.last_seen),
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
validateSessionContextReceipt(receipt);
|
|
289
|
+
return receipt;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
195
295
|
export async function getSetting(key, defaultValue = "") {
|
|
196
296
|
await initConfigStorage();
|
|
197
297
|
// Serve from cache when warm (the common case after startup).
|
|
@@ -63,7 +63,7 @@ export async function knowledgeSearchHandler(args) {
|
|
|
63
63
|
}
|
|
64
64
|
// Phase 1: destructure enable_trace (defaults to false for backward compat)
|
|
65
65
|
const { project, query, category, limit = 10, enable_trace = false, activation } = args;
|
|
66
|
-
debugLog(`[knowledge_search] Searching: project=${project || "all"},
|
|
66
|
+
debugLog(`[knowledge_search] Searching: project=${project || "all"}, query_chars=${query?.length || 0}, category=${category || "any"}, limit=${limit}`);
|
|
67
67
|
// Phase 1: Capture total start time for latency measurement
|
|
68
68
|
const totalStart = performance.now();
|
|
69
69
|
const storage = await getStorage();
|
|
@@ -89,7 +89,8 @@ export async function knowledgeSearchHandler(args) {
|
|
|
89
89
|
});
|
|
90
90
|
const storageMs = performance.now() - storageStart;
|
|
91
91
|
const totalMs = performance.now() - totalStart;
|
|
92
|
-
|
|
92
|
+
const resultCount = Array.isArray(data?.results) ? data.results.length : 0;
|
|
93
|
+
if (!data || resultCount === 0) {
|
|
93
94
|
// Phase 1: Use contentBlocks array instead of inline object
|
|
94
95
|
// so we can conditionally push the trace block at content[1]
|
|
95
96
|
const contentBlocks = [{
|
|
@@ -135,14 +136,14 @@ export async function knowledgeSearchHandler(args) {
|
|
|
135
136
|
// Phase 1: Wrap in contentBlocks array for optional trace attachment
|
|
136
137
|
const contentBlocks = [{
|
|
137
138
|
type: "text",
|
|
138
|
-
text: `๐ง Found ${
|
|
139
|
+
text: `๐ง Found ${resultCount} knowledge entries:\n\n${JSON.stringify(data.results, null, 2)}`,
|
|
139
140
|
}];
|
|
140
141
|
// Phase 1: Attach MemoryTrace with strategy="keyword" and timing data
|
|
141
142
|
if (enable_trace) {
|
|
142
143
|
const trace = createMemoryTrace({
|
|
143
144
|
strategy: "keyword",
|
|
144
145
|
query: query || "",
|
|
145
|
-
resultCount
|
|
146
|
+
resultCount,
|
|
146
147
|
topScore: null, // keyword search doesn't produce similarity scores
|
|
147
148
|
threshold: null, // keyword search has no threshold concept
|
|
148
149
|
embeddingMs: 0, // no embedding needed for keyword search
|
package/dist/tools/handlers.js
CHANGED
|
@@ -57,7 +57,7 @@ export async function braveWebSearchCodeModeHandler(args) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
// 1. Fetch raw data
|
|
60
|
-
debugLog(`Fetching web search for code mode:
|
|
60
|
+
debugLog(`Fetching web search for code mode: query_chars=${query.length}`);
|
|
61
61
|
const rawDataStr = await performWebSearchRaw(query, count, offset);
|
|
62
62
|
const beforeSizeKB = (Buffer.byteLength(rawDataStr, 'utf8') / 1024).toFixed(1);
|
|
63
63
|
// 2. Run code mode sandbox
|
|
@@ -94,7 +94,7 @@ export async function braveLocalSearchCodeModeHandler(args) {
|
|
|
94
94
|
isError: true,
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
|
-
debugLog(`Fetching local search for code mode:
|
|
97
|
+
debugLog(`Fetching local search for code mode: query_chars=${query.length}`);
|
|
98
98
|
const rawDataStr = await performLocalSearchRaw(query, count);
|
|
99
99
|
const beforeSizeKB = (Buffer.byteLength(rawDataStr, "utf8") / 1024).toFixed(1);
|
|
100
100
|
debugLog("Executing local search code mode sandbox...");
|