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 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 Claude or the Synalux portal. 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`.
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 + cloud fallback |
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 Claude via the Synalux portal.
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 | Tier |
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
- **Reproduce every number yourself.** All evals are open-source and self-contained:
470
-
471
- ```bash
472
- git clone https://github.com/dcostenco/prism-coder && cd prism-coder
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 eval (115 cases, 12 categories, 3-seed mean).** Routing accuracy includes the deterministic L3 correction layer โ€” the same rules that run in production. On this narrow tool-routing task all fleet models achieve near-perfect accuracy. Be honest with yourself about what that means: the eval is **near-saturated** for this taxonomy โ€” it measures whether the right one of a small set of MCP tools is selected, not general capability. The useful takeaway is **offline routing reliability at zero cost**, not that a 2.3 GB model rivals a frontier model in general.
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
- ### Code Generation Quality (27B vs Claude Opus)
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
- Three progressively harder Python tasks run through `prism_infer(mode:"code", think:true)` on the local 27B and compared with Claude Opus. Both produce correct, production-quality code. The 27B is slightly more verbose (docstrings, examples); Opus is slightly tighter (`__slots__`, early-exit DFS). On routine coding the 27B at $0 replaces cloud calls entirely.
527
+ ### Cloud Escalation (`cloud_fallback: true`)
490
528
 
491
- | Task | Local 27B | Claude Opus | Verdict |
492
- |------|-----------|-------------|---------|
493
- | Fibonacci with memoization | `@lru_cache`, ValueError on negative, docstring | Nested `_fib` to keep cache private | Both correct, equivalent |
494
- | LRU Cache (OrderedDict, O(1)) | `Any` keys, isinstance capacity check, `__repr__` | `Hashable` key type (more precise), same ops | Both correct, Opus marginally tighter |
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 model ceilings, and increased limits. Local model ceilings are advisory โ€” on-device models run on your Ollama regardless of plan; the ceiling gates cloud inference and `prism_infer` routing.
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
- | Local model ceiling | up to 4b | up to 9b | up to 27b | up to 27b |
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 | -- | Claude Opus 4.7 | Claude Opus 4.7 | Priority + Opus 4.7 |
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` | Natural-language Q&A over the memory store |
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 Claude
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 your host model's (Claude's) own token spend; use Claude Code's `/cost` command for that.
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
- Routing is automatic: `9b โ†’ 4b โ†’ cloud fallback` on desktop/server, `2b โ†’ cloud fallback` on mobile/iPhone. For iOS or another machine on the same network, run `OLLAMA_HOST=0.0.0.0 ollama serve` and point `LOCAL_LLM_URL` at the host's IP.
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 via `prism_infer`, capped at the 4B model tier |
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 | Up to 27B locally + cloud cascade (9B โ†’ 27B โ†’ Claude) when local is unavailable |
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 โ€” connection-scoped, in-process.
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 (connection-scoped, cannot be portal-side):
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"}, query="${query || ""}", category=${category || "any"}, limit=${limit}`);
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
- if (!data) {
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 ${data.count} knowledge entries:\n\n${JSON.stringify(data.results || data, null, 2)}`,
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: data.count,
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
@@ -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: "${query}"`);
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: "${query}"`);
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...");