velesdb-memory 0.14.1

VelesDB-memory: local-first MCP memory server for AI agents (remember/recall/relate/forget/why + deterministic context compiler).
Documentation
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
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
//! 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. Looked up by matching
    /// `ContextDecision::fragment_id`, UNLESS `fragment_index` is also
    /// given (see there) — still required even then, since it is the only
    /// disambiguator when `fragment_index` is absent. Accepts a JSON number
    /// OR a decimal string, so a `fragment_id` received under
    /// [`CompilePolicy::ids_as_strings`] can be relayed 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` for locating the
    /// decision: `compile_context` records exactly one decision per input
    /// fragment, in order, so `decisions[fragment_index]` is unambiguous
    /// even when several fragments are byte-identical and therefore share
    /// the same content-addressed `fragment_id` — a plain `fragment_id`
    /// lookup always returns the FIRST such decision (the deduplication
    /// survivor), never a dropped twin's. Absent (the default): behavior is
    /// unchanged, the decision is found 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
    /// [`crate::mcp::dto::RememberResult::id_str`]: the id is content-addressed
    /// (FNV-1a 64), so it is past 2^53 and a float-lossy JSON client rounds
    /// `id` on arrival. This was the ONE tool handing back an id without its
    /// twin while `forget`/`feedback` accept only the decimal string — the
    /// caller had no way to build the form the schema demands.
    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 filesystem path instead of
    /// inline `transcript` — the same `VELESDB_MEMORY_INGEST_ROOTS`
    /// allowlist and security pipeline as a `compile_context` fragment's
    /// `path` (V2b-1), except capped at
    /// [`crate::limits::MAX_TRANSCRIPT_BYTES`] (8 MiB) instead of the
    /// ordinary 1 MiB fragment ceiling — the transcript is segmented into
    /// sub-1-MiB pieces immediately after this 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 a
    /// `jsonl` transcript this is a slice of the raw JSON line's span, not
    /// an offset into the decoded `content` — a JSONL line's decoded text
    /// has no byte-exact mapping back into the raw (JSON-escaped) source
    /// bytes, so when a single line's decoded content is re-split (over
    /// [`crate::limits::MAX_FRAGMENT_BYTES`]) each child's range is a
    /// proportional, non-overlapping share of the line's raw range rather
    /// than a byte-precise one. Every segment's range is still distinct and
    /// non-overlapping (see [`SegmentationReport::segments`]'s struct docs).
    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, even for a `jsonl`
    /// line whose decoded `content` alone exceeds
    /// [`crate::limits::MAX_FRAGMENT_BYTES`] (1 MiB) and gets re-split into
    /// several segments (`compile_context` still gets sub-1-MiB fragments):
    /// each child's range is a proportional, non-overlapping share of the
    /// original JSON line's raw span rather than a byte-exact one, since a
    /// JSONL line's decoded text has no byte-aligned mapping back into the
    /// raw (JSON-escaped) source bytes (see `resplit_body` in
    /// `context::segment`). An extreme edge case (one transcript line over
    /// 1 MiB of decoded content), but even then every segment keeps a
    /// distinct, non-overlapping range.
    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 — deterministically, with no LLM call. Duplicates are dropped, repeated log lines collapse, code/URLs/numbers/negative constraints survive verbatim, over-budget content becomes retrievable ctx://source/ handles instead of silently vanishing, and `memory_scope` pulls relevant stored memories into the result. Each fragment's own `metadata` is capped at 64 KiB serialized. A fragment may set `path` (an absolute filesystem path) instead of inline `content` to ingest a file by reference — `path` is exclusive and cannot be combined with `content` or `media`, while `content` and `media` MAY travel together (the content is then the image's caption, and the only text lexical relevance can read); a fragment carrying none of the three is refused. Path ingestion requires the server to be started with VELESDB_MEMORY_INGEST_ROOTS set to an allowlist of directories, and the resolved file must be plain UTF-8 text under 1 MiB. Returns the assembled content plus one auditable decision per fragment (rule id, reason, risk), the sources, the retrieval handles, token-savings insights, and `warnings` — a mechanical shortlist of externalized fragments relevant enough to the query that they are worth a second look. An empty `warnings` is NOT a clean bill of health: only `retrieve` decisions at or above a relevance floor ever qualify, so a `preserve` fragment the packer could only fit partially, and an abstracted one, are real losses that never appear there — `decisions` stays the exhaustive record, and `risk` is the cheap second signal. `policy.slim_response` (default false) empties `sections`/`decisions` from the response — keep it off when you need the audit trail, or re-compile without it later (compilation is deterministic). `policy.ids_as_strings` (default false) rewrites every id field of the response into a decimal string, for MCP clients without u64-safe JSON number parsing.",
        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: deterministically segments it into turns (plain marker-based — System:/User:/Human:/Assistant:/AI:/Tool:/### User/### Assistant — or JSONL, one line per turn) and, within each turn, into code/log/body sub-segments (fenced code blocks stay atomic; runs of 8+ log-like lines collapse the same way abstract.log_dedup would), then compiles the result exactly like compile_context. Exactly one of `transcript` (inline) or `path` (an absolute filesystem path, same VELESDB_MEMORY_INGEST_ROOTS allowlist as compile_context's `path` fragments but capped at 8 MiB) must be set. `segmentation.format` forces plain or jsonl instead of auto-detecting; a forced jsonl format that fails to parse is a hard error, never a silent fallback. 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's output) plus `segmentation` — the detected format and one audit entry (turn, role, kind, byte range, fragment_id) per segment, so a caller can see exactly how the transcript was cut before trusting the compiled result.",
        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. Compilation is deterministic, so the request is re-compiled (with event/source recording off) and the fragment's exact decision (rule id, reason, relevance, risk, handle) is returned — no server-side state needed. Caveat: with a memory_scope the re-compile recalls from CURRENT memory, so decisions about pulled memories reflect the memory as it is now, not as it was; a `path` fragment is likewise re-read from disk, so the decision reflects the file's CURRENT content, not necessarily what the original compile_context call saw. Pass `fragment_index` (0-based position in request.fragments) instead of relying on `fragment_id` alone when fragments are byte-identical — a shared content-addressed id otherwise always resolves to the deduplication survivor's decision. `policy.ids_as_strings` on the request rewrites the response's id fields into decimal strings, like 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, active constraints, verified facts, open hypotheses, decisions, exact evidence, pending actions) under a project + session id — so a LATER session (a fresh agent run, a new conversation, a resumed process) can pick up exactly where this one left off instead of re-deriving context from scratch. Call this near the end of a session, or whenever the working state changes meaningfully. Saving again under the same project+session replaces the previous state (idempotent upsert) — so an entirely empty `working` is REFUSED rather than allowed to wipe what a previous save stored; fill at least one field. Serialized size is capped at 1 MiB. Returns the stored fact's id. IF THIS CALL TIMES OUT, THE SAVE MAY NOT HAVE HAPPENED — a timeout is not a slow success, and over the HTTP transport it usually means the request never reached this tool at all. Do not assume it was written: call `list_working_contexts` and check that this session's `saved_at` actually advanced, then re-send the identical call if it did not. Re-sending is safe — the write is an upsert on project + session, so it replaces rather than duplicates.",
        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 back the working context previously saved by save_working_context under the same project + session id — the goal, constraints, verified facts, open hypotheses, decisions, exact evidence, and pending actions a PRIOR session left off with. Call this at the START of a new session before doing anything else, so work continues instead of restarting. `found: false` (with `working: null`) means nothing was ever saved under that exact project + session — not an error, but check `other_sessions`: if it lists a similarly-named session, `session` was likely a typo, not a genuinely fresh start. `other_sessions` is always filled in, on a hit too: if it lists a session that looks more like the one you meant, you may have just resumed the WRONG session. Use `list_working_contexts` to browse 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 target model — looked up in a static, committed model-name to context-window table (dated \"as of\", NEVER a network call). Pass `reserve_tokens` (default 0) to reserve room for the response, mirroring compile_context's own `policy.response_reserve_tokens`. `window`/`suggested_budget` come back null when the model is not in the table — an honest \"unknown\", never a guess; extend the table in a new release instead of relying on this for an unlisted model."
    )]
    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;