velesdb-memory 0.15.0

VelesDB-memory: local-first MCP memory server for AI agents (remember/recall/relate/forget/why + deterministic context compiler).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
//! The context compiler's MCP tools — an *extension* of the one existing
//! server (never a second server): a second `#[tool_router]` block whose
//! router is combined with the main one in `McpServer::new`.
//!
//! Wire shapes reuse the domain types from [`crate::context`] directly
//! (`CompileRequest` *is* the tool input, `CompiledContext` the output) —
//! the only DTOs here are the thin request envelopes of the seven smaller
//! tools. Same conventions as every other tool: `spawn_blocking` around the
//! sync service, errors mapped through the transport-neutral category.

use std::sync::Arc;

use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::ErrorCode;
use rmcp::{tool, tool_router, ErrorData};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;

use super::{join_error, to_error, McpServer};
use crate::context::wire::stringify_id_fields;
use crate::context::{
    fragment_id, segment_transcript, suggest_token_budget, CompilePolicy, CompileRequest,
    CompiledContext, ContextCompiler, ContextDecision, ContextFragment, ContextSavings,
    LoadedWorkingContext, MediaRef, SegmentFormat, SegmentKind, SegmentationPolicy,
    SuggestedBudget, WorkingContext, WorkingContextSession,
};

/// Serialize `payload`, opt-in rewriting every id field into decimal-string
/// form ([`CompilePolicy::ids_as_strings`]) — the shared response-side half
/// of the wire-compat contract, reused by both `compile_context` and
/// `explain_compilation` so the id rewrite is expressed exactly once.
fn to_wire_value<T: serde::Serialize>(
    payload: &T,
    ids_as_strings: bool,
) -> Result<Value, ErrorData> {
    let mut value = serde_json::to_value(payload).map_err(|err| {
        ErrorData::internal_error(
            format!("Failed to serialize structured content: {err}"),
            None,
        )
    })?;
    if ids_as_strings {
        stringify_id_fields(&mut value);
    }
    Ok(value)
}

fn segment_for_compilation(
    text: &str,
    policy: &SegmentationPolicy,
) -> Result<(Vec<ContextFragment>, SegmentationReport), ErrorData> {
    let outcome = segment_transcript(text, policy).map_err(to_error)?;
    let segments = outcome
        .segments
        .iter()
        .enumerate()
        .map(|(index, segment)| SegmentInfo {
            index,
            turn: segment.turn,
            role: segment.role.clone(),
            kind: segment.kind,
            byte_start: segment.byte_start,
            byte_end: segment.byte_end,
            fragment_id: fragment_id(&segment.fragment.content),
        })
        .collect();
    let fragments = outcome
        .segments
        .into_iter()
        .map(|segment| segment.fragment)
        .collect();
    let report = SegmentationReport {
        format_detected: outcome.format_detected,
        segments,
        merged_segments: outcome.merged_segments,
    };
    Ok((fragments, report))
}

/// The advertised-schema half of the [`CompilePolicy::ids_as_strings`]
/// contract: the response may carry each [`ID_KEYS`] field as an integer OR
/// a decimal string, and the official MCP SDKs validate `structuredContent`
/// against the advertised `outputSchema` (spec 2025-06-18) — so those
/// fields must be typed `["integer", "string"]`, or every opted-in response
/// would fail client-side validation for exactly the clients the option
/// exists for.
pub(super) use crate::schema::wire_safe_output_schema;

/// Input-side counterpart: `fragments[].id` accepts an integer or a decimal
/// string ([`crate::context::wire::deserialize_optional_id`]), so the
/// advertised input schema announces the string form — a client generating
/// requests from the schema must be able to discover it.
///
/// Le jeu de cles n'est plus fige a `"id"` : il est passe par l'outil, comme
/// dans `mcp.rs`, parce que `save_working_context` porte des ids sous
/// d'autres noms (`fragment_id`, `memory_id`, imbriques dans
/// `WorkingContext`) tandis que `explain_compilation.fragment_id` est un
/// `u64` STRICT qu'annoncer `string` serait une promesse fausse. Un seul
/// constructeur, donc, mais toujours une decision par outil.
pub(super) use crate::schema::wire_safe_input_schema;

// --- Thin request envelopes --------------------------------------------------

/// Input of the `context_savings` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct ContextSavingsParams {
    /// Restrict the aggregation to this project facet.
    pub project: Option<String>,
}

/// Input of the `explain_compilation` tool.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct ExplainCompilationParams {
    /// The compile request to explain (compilation is deterministic, so
    /// re-submitting the request reproduces the exact decisions).
    #[serde(deserialize_with = "super::wire::lenient")]
    pub request: CompileRequest,
    // Aller-retour casse jusqu'au 2026-07-29, et casse depuis toujours :
    // `fragment_id` est le SELECTEUR d'une decision, et la decision d'ou le
    // client le tire lui arrive en CHAINE decimale des que la requete porte
    // `policy.ids_as_strings` (`fragment_id` est dans
    // `context::wire::ID_KEYS`). Ce champ etait un `u64` nu : l'outil
    // refusait litteralement les octets qu'il venait d'emettre, et comme un
    // `fragment_id` est un FNV-1a 64 — au-dela de 2^53 dans ~99,95 % des cas
    // — le repli « renvoyer un nombre » etait deja arrondi chez un client
    // JSON a nombres flottants. Les deux formes echouaient : sur un tel
    // client, l'outil etait inatteignable par son propre selecteur.
    //
    // Ce n'etait pas une omission : trois tests et deux jeux de cles d'id
    // epinglaient la forme stricte comme voulue. Ce que personne n'avait
    // fait, c'est l'aller-retour.
    /// The fragment whose decision to return, matched on
    /// `ContextDecision::fragment_id` UNLESS `fragment_index` is also given.
    /// Accepts a JSON number OR decimal string, so a `fragment_id` received
    /// under `ids_as_strings` relays back unchanged.
    #[serde(deserialize_with = "crate::model::deserialize_id")]
    pub fragment_id: u64,
    /// Optional 0-based position of the fragment in `request.fragments`;
    /// when given, TAKES PRIORITY over `fragment_id`:
    /// `decisions[fragment_index]` is unambiguous even when byte-identical
    /// fragments share one content-addressed `fragment_id` (a plain lookup
    /// returns the FIRST, the dedup survivor — never a dropped twin's).
    /// Absent: lookup by `fragment_id` alone.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "super::wire::lenient"
    )]
    pub fragment_index: Option<usize>,
}

/// Input of the `retrieve_context_source` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct RetrieveContextSourceParams {
    /// A `ctx://source/<hash>` handle from a compiled context.
    pub handle: String,
}

/// Output of the `retrieve_context_source` tool.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub(super) struct RetrieveContextSourceResult {
    /// The handle that was resolved.
    pub handle: String,
    /// The original fragment content, byte for byte.
    pub content: String,
    /// The original media payload, when the fragment carried one (US-009,
    /// PR2). Absent for every text-only source — the exact pre-PR2 shape.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub media: Option<MediaRef>,
}

/// Input of the `save_working_context` tool.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct SaveWorkingContextParams {
    /// Project facet this working context belongs to (matches `remember`'s
    /// `project` metadata convention).
    pub project: String,
    /// Session identifier — pick something stable for the agent run you want
    /// to resume later (e.g. a conversation id).
    pub session: String,
    /// The distilled state to persist: goal, active constraints, verified
    /// facts, open hypotheses, decisions taken, exact evidence, and pending
    /// actions.
    #[serde(deserialize_with = "super::wire::lenient")]
    pub working: WorkingContext,
}

/// Output of the `save_working_context` tool.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct SaveWorkingContextResult {
    /// Id of the stored system fact backing this working context.
    pub id: u64,
    /// Decimal-string twin of `id` (same contract as `remember`'s). The id
    /// is content-addressed (FNV-1a 64), past 2^53 — relay THIS to
    /// `forget`/`feedback`, never `id`.
    pub id_str: String,
}

/// Input of the `load_working_context` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct LoadWorkingContextParams {
    /// Project facet the working context was saved under.
    pub project: String,
    /// Session identifier the working context was saved under.
    pub session: String,
}

/// Input of the `list_working_contexts` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct ListWorkingContextsParams {
    /// Project facet to list saved working-context sessions for (same
    /// convention as `save_working_context`'s `project`).
    pub project: String,
}

/// Output of the `list_working_contexts` tool.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub(super) struct ListWorkingContextsResult {
    /// Every session saved under this project, most-recently-saved first.
    /// Empty (not an error) when the project never saved anything.
    pub sessions: Vec<WorkingContextSession>,
}

/// Input of the `compile_transcript` tool.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct CompileTranscriptParams {
    /// What the agent is working on — drives relevance scoring, exactly like
    /// `compile_context`'s `query`.
    pub query: String,
    /// The raw transcript text (plain, marker-based, or JSONL). Exactly one
    /// of `transcript` or `path` must be set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub transcript: Option<String>,
    /// Read the transcript from this absolute path instead of inline
    /// `transcript` — same `VELESDB_MEMORY_INGEST_ROOTS` allowlist and
    /// security pipeline as a fragment's `path` (V2b-1), except capped at
    /// `MAX_TRANSCRIPT_BYTES` (8 MiB); the transcript is segmented into
    /// sub-1-MiB pieces right after the read.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    /// Hard token ceiling for the assembled content, same as
    /// `compile_context`'s `token_budget`.
    #[serde(deserialize_with = "super::wire::lenient")]
    pub token_budget: u64,
    /// Project facet, recorded in provenance.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    /// Target model name, for cost insights.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_model: Option<String>,
    /// Per-request compile policy override, same as `compile_context`'s
    /// `policy`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy: Option<CompilePolicy>,
    /// Tuning knobs for the transcript segmentation step itself (format,
    /// merge threshold, system-turn caching). `None` uses
    /// [`SegmentationPolicy::default`].
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "super::wire::lenient"
    )]
    pub segmentation: Option<SegmentationPolicy>,
}

/// One entry of [`SegmentationReport::segments`] — the audit trail of how
/// `compile_transcript` cut the transcript up, independent of what
/// `compile_context` then did with the resulting fragments.
#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct SegmentInfo {
    /// Position of this segment in `segmentation.segments`, in transcript
    /// order.
    pub index: usize,
    /// Which turn (0-based) this segment belongs to.
    pub turn: usize,
    /// The turn's role, when one was determined. `null` for a `plain`
    /// transcript with no matching marker at all.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    /// `"body"`, `"code"`, or `"log"`.
    pub kind: SegmentKind,
    /// Start byte offset (inclusive) in the original transcript. For `jsonl`
    /// this is a slice of the raw JSON line's span, not of decoded
    /// `content` — when one line's decoded content is re-split, each child's
    /// range is a proportional, non-overlapping share of the raw range, not
    /// byte-exact. Ranges still never overlap.
    pub byte_start: usize,
    /// End byte offset (exclusive) in the original transcript. Same caveat
    /// as `byte_start`.
    pub byte_end: usize,
    /// The id this segment's fragment carries into `context.decisions` —
    /// content-addressed, same formula as every other `compile_context`
    /// fragment with no caller-supplied `id`.
    pub fragment_id: u64,
}

/// The segmentation audit trail returned alongside `context`.
#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
pub(super) struct SegmentationReport {
    /// `"plain"` or `"jsonl"` — the format actually used, never `"auto"`
    /// even when the request asked for it.
    pub format_detected: SegmentFormat,
    /// Every segment, in transcript order, with byte ranges that partition
    /// the transcript exactly — no gaps, no overlaps. Edge case: a `jsonl`
    /// line whose decoded content exceeds 1 MiB is re-split, and each child
    /// gets a proportional share of the raw line span, not a byte-exact one.
    pub segments: Vec<SegmentInfo>,
    /// How many segments [`SegmentationPolicy::min_segment_bytes`] merging
    /// eliminated.
    pub merged_segments: usize,
}

/// Output of the `compile_transcript` tool.
#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
pub(super) struct CompileTranscriptResult {
    /// The compiled context — byte-compatible with `compile_context`'s
    /// output.
    pub context: CompiledContext,
    /// How the transcript was cut into fragments before compilation.
    pub segmentation: SegmentationReport,
}

/// Input of the `suggest_budget` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct SuggestBudgetParams {
    /// The model name to look up in the static window table (e.g.
    /// `"claude-sonnet-4-5"`). Matched case-insensitively.
    pub target_model: String,
    /// Tokens to reserve for the response, subtracted from the model's
    /// window (default `0`) — mirrors
    /// [`CompilePolicy::response_reserve_tokens`].
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "super::wire::lenient"
    )]
    pub reserve_tokens: Option<u64>,
}

#[tool_router(router = context_tool_router, vis = "pub(super)")]
impl McpServer {
    /// Resolve every `path`-carrying fragment of `fragments` against this
    /// server's configured ingest roots (V2b-1), turning `path` into
    /// ordinary `content` in place before the request reaches the compiler
    /// — the adapter-side pre-pass `context::ingest` describes. A no-op
    /// when no fragment carries a `path`. Shared by `compile_context` and
    /// `explain_compilation`, the only two tools that accept a `path`
    /// fragment.
    #[cfg(not(target_arch = "wasm32"))]
    fn resolve_ingest(&self, fragments: &mut [ContextFragment]) -> Result<(), ErrorData> {
        crate::context::ingest::resolve_fragments(fragments, self.ingest_roots.as_ref())
            .map_err(to_error)
    }

    /// This crate never targets `wasm32` with the `mcp` feature on (the
    /// server pulls in `rmcp`/`tokio`), so this arm exists only to keep the
    /// call site uniform if that ever changes — a `path` fragment simply
    /// reports the same "ingestion disabled" error the pure compiler core
    /// would (see `context::validate`), since there is no adapter here to
    /// resolve it.
    #[cfg(target_arch = "wasm32")]
    fn resolve_ingest(&self, fragments: &mut [ContextFragment]) -> Result<(), ErrorData> {
        if fragments.iter().any(|f| f.path.is_some()) {
            return Err(to_error(crate::error::MemoryError::IngestDisabled));
        }
        Ok(())
    }

    /// Resolve a `compile_transcript` `path` field against this server's
    /// configured ingest roots (V2b-2) — the same security pipeline as
    /// [`Self::resolve_ingest`], but through
    /// [`crate::context::ingest::resolve_transcript_path`] so the byte cap
    /// is [`crate::limits::MAX_TRANSCRIPT_BYTES`], not the ordinary 1 MiB
    /// fragment ceiling.
    #[cfg(not(target_arch = "wasm32"))]
    fn resolve_transcript_path(&self, path: &str) -> Result<String, ErrorData> {
        let roots = self
            .ingest_roots
            .as_ref()
            .filter(|roots| roots.is_enabled())
            .ok_or_else(|| to_error(crate::error::MemoryError::IngestDisabled))?;
        crate::context::ingest::resolve_transcript_path(path, roots).map_err(to_error)
    }

    /// `mcp` never targets `wasm32` (see [`Self::resolve_ingest`]'s wasm
    /// arm) — kept for call-site uniformity.
    #[cfg(target_arch = "wasm32")]
    fn resolve_transcript_path(&self, _path: &str) -> Result<String, ErrorData> {
        Err(to_error(crate::error::MemoryError::IngestDisabled))
    }

    /// The text `compile_transcript` will segment: exactly one of `transcript`
    /// (inline) or `path` (ingested through the allowlist), and never empty.
    ///
    /// The emptiness check runs AFTER `path` is resolved, rather than being
    /// folded into the match, so an inline empty string and a `path` that
    /// resolves to an empty file are rejected identically — the ingest
    /// pipeline itself happily reads a zero-byte file, so this is the one
    /// place that catches "nothing to compile" whatever the source.
    fn resolve_transcript_text(
        &self,
        transcript: Option<String>,
        path: Option<String>,
    ) -> Result<String, ErrorData> {
        let text = match (transcript, path) {
            (Some(text), None) => text,
            (None, Some(path)) => self.resolve_transcript_path(&path)?,
            _ => {
                return Err(ErrorData::new(
                    ErrorCode::INVALID_PARAMS,
                    "exactly one of `transcript` or `path` must be set".to_owned(),
                    None,
                ));
            }
        };
        if text.is_empty() {
            return Err(ErrorData::new(
                ErrorCode::INVALID_PARAMS,
                "the transcript is empty — `transcript` must be non-empty text, or `path` must \
                 point to a non-empty file"
                    .to_owned(),
                None,
            ));
        }
        Ok(text)
    }

    #[tool(
        name = "compile_context",
        description = "Compile context fragments into a token-budgeted, provenance-audited prompt context — deterministic, no LLM call. Duplicates drop, repeated log lines collapse, code/URLs/numbers/negative constraints survive verbatim, over-budget content becomes retrievable ctx://source/ handles; `memory_scope` pulls stored memories in. Fragment `metadata` ≤64 KiB. A fragment may set `path` (absolute file, UTF-8 ≤1 MiB; requires VELESDB_MEMORY_INGEST_ROOTS) INSTEAD of `content` — `content`+`media` may combine (content = the image's caption). Returns the assembled content, one auditable decision per fragment (rule id, reason, risk), sources, handles, token-savings, and `warnings` (externalized fragments worth a second look — empty is NOT a clean bill of health; `decisions` is the exhaustive record, `risk` the cheap second signal). `policy.slim_response` drops sections/decisions (keep off when the audit trail matters); `policy.ids_as_strings` stringifies id fields for u64-unsafe clients.",
        input_schema = wire_safe_input_schema::<CompileRequest>(&["id"]),
        output_schema = wire_safe_output_schema::<CompiledContext>()
    )]
    async fn compile_context(
        &self,
        Parameters(mut request): Parameters<CompileRequest>,
    ) -> Result<Json<Value>, ErrorData> {
        self.resolve_ingest(&mut request.fragments)?;
        let ids_as_strings = request.policy.as_ref().is_some_and(|p| p.ids_as_strings);
        let service = Arc::clone(&self.service);
        let compiled = tokio::task::spawn_blocking(move || {
            service.run(|current| {
                current.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)
            })
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        Ok(Json(to_wire_value(&compiled, ids_as_strings)?))
    }

    /// **Error taxonomy (issue #1516, m2 — refines the PR #1500 review
    /// note):** a genuine budget/cap breach — oversized fence, too many
    /// fragments after merging, transcript over
    /// [`crate::limits::MAX_TRANSCRIPT_BYTES`] — surfaces as
    /// [`crate::error::MemoryError::ContextOverLimit`]. A forced `jsonl`
    /// format that fails to parse is a FORMAT failure, not a size breach, so
    /// it surfaces as the distinct
    /// [`crate::error::MemoryError::SegmentationError`] instead — no longer
    /// the misleading "over limit" wording. Both variants still map to the
    /// same `INVALID_PARAMS`-category MCP code (`ContextOverLimit` and
    /// `SegmentationError` are both [`crate::error::ErrorCategory::InvalidInput`]),
    /// so this only changes how a caller who inspects `MemoryError`
    /// programmatically (e.g. via the Rust crate directly, not over MCP)
    /// tells the two apart.
    #[tool(
        name = "compile_transcript",
        description = "One-call shortcut over compile_context for a raw agent-session transcript: segments it into turns (markers System:/User:/Human:/Assistant:/AI:/Tool:, ### User/### Assistant, or JSONL one line per turn) then into code/log/body sub-segments, then compiles exactly like compile_context. Exactly one of `transcript` (inline) or `path` (absolute file under the VELESDB_MEMORY_INGEST_ROOTS allowlist, ≤8 MiB) must be set. `segmentation.format` forces plain/jsonl — a forced jsonl that fails to parse is a hard error. The first turn is tagged cache-eligible when it looks like a system prompt (segmentation.cache_system_turn, default true). Returns `context` (byte-compatible with compile_context) plus `segmentation`: the detected format and a per-segment audit (turn, role, kind, byte range, fragment_id).",
        input_schema = wire_safe_input_schema::<CompileTranscriptParams>(&[]),
        output_schema = wire_safe_output_schema::<CompileTranscriptResult>()
    )]
    async fn compile_transcript(
        &self,
        Parameters(params): Parameters<CompileTranscriptParams>,
    ) -> Result<Json<Value>, ErrorData> {
        let CompileTranscriptParams {
            query,
            transcript,
            path,
            token_budget,
            project,
            target_model,
            policy,
            segmentation,
        } = params;
        let transcript_text = self.resolve_transcript_text(transcript, path)?;
        let segmentation_policy = segmentation.unwrap_or_default();
        let (fragments, report) = segment_for_compilation(&transcript_text, &segmentation_policy)?;
        let ids_as_strings = policy.as_ref().is_some_and(|p| p.ids_as_strings);
        let request = CompileRequest {
            query,
            fragments,
            project,
            target_model,
            token_budget,
            memory_scope: None,
            policy,
        };
        let service = Arc::clone(&self.service);
        let compiled = tokio::task::spawn_blocking(move || {
            service.run(|current| {
                current.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)
            })
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        let result = CompileTranscriptResult {
            context: compiled,
            segmentation: report,
        };
        Ok(Json(to_wire_value(&result, ids_as_strings)?))
    }

    #[tool(
        name = "context_savings",
        // Sans declaration explicite, rmcp derive un schema de sortie qui
        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
        // or les SDK MCP valident structuredContent contre ce schema.
        output_schema = wire_safe_output_schema::<ContextSavings>(),
        description = "Aggregate the token (and cost) savings of past compile_context calls, optionally per project. Figures are local estimates recorded per compilation (metadata only, never content); `truncated` reports when the sweep hit the recall cap."
    )]
    async fn context_savings(
        &self,
        Parameters(params): Parameters<ContextSavingsParams>,
    ) -> Result<Json<ContextSavings>, ErrorData> {
        let service = Arc::clone(&self.service);
        let savings = tokio::task::spawn_blocking(move || {
            service.run(|current| current.context_savings(params.project.as_deref()))
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        Ok(Json(savings))
    }

    #[tool(
        name = "explain_compilation",
        description = "Explain why one fragment of a compile_context request was preserved, abstracted, externalized, dropped, or cached: the request is re-compiled deterministically (recording off) and the fragment's decision (rule id, reason, relevance, risk, handle) returned — no server state needed. Caveats: `memory_scope` recalls from CURRENT memory and a `path` fragment is re-read from CURRENT disk content — not necessarily what the original call saw. Prefer `fragment_index` (0-based) over `fragment_id` when fragments are byte-identical — a shared id resolves to the dedup survivor's decision. `policy.ids_as_strings` applies as in compile_context.",
        input_schema = wire_safe_input_schema::<ExplainCompilationParams>(&["id", "fragment_id"]),
        output_schema = wire_safe_output_schema::<ContextDecision>()
    )]
    async fn explain_compilation(
        &self,
        Parameters(params): Parameters<ExplainCompilationParams>,
    ) -> Result<Json<Value>, ErrorData> {
        let service = Arc::clone(&self.service);
        let ExplainCompilationParams {
            mut request,
            fragment_id,
            fragment_index,
        } = params;
        self.resolve_ingest(&mut request.fragments)?;
        let ids_as_strings = request.policy.as_ref().is_some_and(|p| p.ids_as_strings);
        // The selection logic itself (record-off recompile + select by
        // index/id) lives in the memory bridge now, shared with the Node and
        // Python bindings — this tool only resolves `path` ingestion (a
        // server-config concern) and maps the result onto the wire.
        let decision = tokio::task::spawn_blocking(move || {
            service
                .run(|current| current.explain_compilation(&request, fragment_id, fragment_index))
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        Ok(Json(to_wire_value(&decision, ids_as_strings)?))
    }

    #[tool(
        name = "retrieve_context_source",
        // Sans declaration explicite, rmcp derive un schema de sortie qui
        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
        // or les SDK MCP valident structuredContent contre ce schema.
        output_schema = wire_safe_output_schema::<RetrieveContextSourceResult>(),
        description = "Fetch back the exact original content behind a `ctx://source/<hash>` handle from a compiled context — what compile_context externalized or partially packed is recoverable, not lost."
    )]
    async fn retrieve_context_source(
        &self,
        Parameters(params): Parameters<RetrieveContextSourceParams>,
    ) -> Result<Json<RetrieveContextSourceResult>, ErrorData> {
        let service = Arc::clone(&self.service);
        let RetrieveContextSourceParams { handle } = params;
        let lookup = handle.clone();
        let source = tokio::task::spawn_blocking(move || {
            service.run(|current| current.retrieve_context_source(&lookup))
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        Ok(Json(RetrieveContextSourceResult {
            handle,
            content: source.content,
            media: source.media,
        }))
    }

    #[tool(
        name = "save_working_context",
        // Sans declaration explicite, rmcp derive un schema de sortie qui
        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
        // or les SDK MCP valident structuredContent contre ce schema.
        output_schema = wire_safe_output_schema::<SaveWorkingContextResult>(),
        description = "Persist this session's distilled working state (goal, constraints, verified facts, hypotheses, decisions, evidence, pending actions) under a project + session id — so a LATER session resumes instead of re-deriving. Call near session end, or on meaningful change. Same project+session = idempotent upsert; an empty `working` is REFUSED (it would wipe a previous save). ≤1 MiB. Returns the fact's id. ON TIMEOUT, THE SAVE MAY NOT HAVE HAPPENED — check `list_working_contexts` for an advanced `saved_at`, then re-send if needed (upsert = safe).",
        input_schema = wire_safe_input_schema::<SaveWorkingContextParams>(&["fragment_id", "memory_id"])
    )]
    async fn save_working_context(
        &self,
        Parameters(params): Parameters<SaveWorkingContextParams>,
    ) -> Result<Json<SaveWorkingContextResult>, ErrorData> {
        let service = Arc::clone(&self.service);
        let SaveWorkingContextParams {
            project,
            session,
            working,
        } = params;
        let id = tokio::task::spawn_blocking(move || {
            service.run(|current| current.save_working_context(&project, &session, &working))
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        Ok(Json(SaveWorkingContextResult {
            id,
            id_str: id.to_string(),
        }))
    }

    #[tool(
        name = "load_working_context",
        // Sans declaration explicite, rmcp derive un schema de sortie qui
        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
        // or les SDK MCP valident structuredContent contre ce schema.
        output_schema = wire_safe_output_schema::<LoadedWorkingContext>(),
        description = "Resume a session: load the working context saved by save_working_context under the same project + session id. Call at session START. `found: false` (`working: null`) = nothing saved under that exact id — check `other_sessions`: a similar name means a typo, not a fresh start. `other_sessions` is filled on a hit too — a likelier session listed means you may have resumed the WRONG one. `list_working_contexts` browses a project's sessions up front."
    )]
    async fn load_working_context(
        &self,
        Parameters(params): Parameters<LoadWorkingContextParams>,
    ) -> Result<Json<Value>, ErrorData> {
        let LoadWorkingContextParams { project, session } = params;
        let service = Arc::clone(&self.service);
        // L'enveloppe entiere — `found`, `working`, `other_sessions` — est
        // composee par le pont, pas ici : les deux regles de politique
        // ("lister meme sur un hit", "ne jamais reemettre la session
        // demandee") sont les memes pour cet outil et pour les trois
        // bindings, et une regle recopiee par surface diverge en silence.
        let loaded = tokio::task::spawn_blocking(move || {
            service.run(|current| current.resume_working_context(&project, &session))
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        // Les ids sortent en CHAINE decimale, sans option, contrairement au
        // compilateur ou `ids_as_strings` est un choix de l'appelant.
        //
        // Ici il n'y a pas de choix a offrir : cet outil est la moitie
        // LECTURE d'un aller-retour dont la moitie ECRITURE
        // (`save_working_context`) n'annonce plus qu'une forme, la chaine —
        // depuis que le schema d'entree ne peut plus publier d'union. Rendre
        // un nombre la ou le jumeau n'accepte qu'une chaine oblige le client
        // a convertir ; et sur un client JSON a nombres flottants, la valeur
        // est deja arrondie a la LECTURE, donc il reecrit un id faux avec
        // l'exactitude apparente d'une chaine. Un contexte de travail existe
        // pour survivre a une perte de contexte : sa trace de provenance ne
        // peut pas se rompre en silence entre deux sessions.
        //
        // Le schema de sortie annonce deja `["integer", "string"]` sur ces
        // champs (`widen_id_properties`), donc emettre la branche chaine est
        // valide au sens du schema publie : aucun SDK ne rejette la reponse.
        let mut value = serde_json::to_value(loaded).map_err(|err| {
            ErrorData::internal_error(
                format!("Failed to serialize structured content: {err}"),
                None,
            )
        })?;
        stringify_id_fields(&mut value);
        Ok(Json(value))
    }

    #[tool(
        name = "list_working_contexts",
        // Same reason as the four tools wired in `mcp.rs`: an rmcp-derived
        // output schema keeps `$ref`s a `$defs`-blind client cannot resolve.
        output_schema = wire_safe_output_schema::<ListWorkingContextsResult>(),
        description = "List every session saved under a project via save_working_context, most-recently-saved first — so an agent can discover what is resumable before guessing a session id at load_working_context, or recover from a typo. Empty (not an error) when the project never saved anything."
    )]
    async fn list_working_contexts(
        &self,
        Parameters(params): Parameters<ListWorkingContextsParams>,
    ) -> Result<Json<ListWorkingContextsResult>, ErrorData> {
        let service = Arc::clone(&self.service);
        let ListWorkingContextsParams { project } = params;
        let sessions = tokio::task::spawn_blocking(move || {
            service.run(|current| current.list_working_contexts(&project))
        })
        .await
        .map_err(join_error)?
        .map_err(to_error)?;
        Ok(Json(ListWorkingContextsResult { sessions }))
    }

    #[tool(
        name = "suggest_budget",
        // Sans declaration explicite, rmcp derive un schema de sortie qui
        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
        // or les SDK MCP valident structuredContent contre ce schema.
        output_schema = wire_safe_output_schema::<SuggestedBudget>(),
        description = "Suggest a starting token_budget for compile_context for a named model — looked up in a static, committed model→context-window table (never a network call). `reserve_tokens` reserves room for the response, mirroring `policy.response_reserve_tokens`. `window`/`suggested_budget` come back null for an unlisted model — an honest \"unknown\", never a guess."
    )]
    async fn suggest_budget(
        &self,
        Parameters(params): Parameters<SuggestBudgetParams>,
    ) -> Result<Json<SuggestedBudget>, ErrorData> {
        let SuggestBudgetParams {
            target_model,
            reserve_tokens,
        } = params;
        Ok(Json(suggest_token_budget(
            &target_model,
            reserve_tokens.unwrap_or(0),
        )))
    }
}

#[cfg(test)]
#[path = "context_tools_tests.rs"]
mod tests;