Skip to main content

velesdb_memory/context/
model.rs

1//! Data model of the context compiler: the request/response value types.
2//!
3//! Like [`crate::model`], these are pure data with `Serialize`/`Deserialize` +
4//! `JsonSchema` derives, so the domain types double as the MCP wire types —
5//! no duplicate DTO layer. Invariants the compiler upholds over these shapes:
6//! same request ⇒ byte-identical [`CompiledContext`] (determinism), and the
7//! assembled content never exceeds the request's token budget.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value};
12
13use super::chunk::ChunkPolicy;
14use super::insights::CompilationInsights;
15
16/// What the compiler decided to do with one fragment.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "lowercase")]
19pub enum ContextAction {
20    /// Emitted verbatim — critical content (code, constraints, exact values).
21    Preserve,
22    /// Emitted as a deterministic structured reduction (never a generative
23    /// summary) — e.g. repeated log lines collapsed with a count.
24    Abstract,
25    /// Not emitted, but recoverable through its `ctx://source/<id>` handle.
26    Retrieve,
27    /// Not emitted and not externalized — redundant content (duplicates).
28    Drop,
29    /// Emitted verbatim at the front of the output, forming a stable prefix
30    /// that maximizes provider prompt-cache hits across compilations.
31    Cache,
32}
33
34/// How much fidelity a compiled context may have lost versus its input.
35///
36/// Ordered: `Low < Medium < High`, so callers can compare against a policy
37/// threshold.
38#[derive(
39    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
40)]
41#[serde(rename_all = "lowercase")]
42pub enum FidelityRisk {
43    /// Nothing was lost: everything fit, only exact duplicates were dropped.
44    #[default]
45    Low,
46    /// Recoverable reductions happened: abstractions, or non-critical
47    /// fragments externalized behind retrieval handles.
48    Medium,
49    /// Critical content (a preserve-classified fragment) could not be packed
50    /// — the caller should consider retrieving it or raising the budget.
51    High,
52}
53
54/// Inline media payload attached to a [`ContextFragment`] (US-009, PR1:
55/// screenshots/images only). `ContextFragment::content` stays the
56/// text/caption — often empty for a bare screenshot — while the pixels live
57/// here, base64-encoded so the JSON wire never needs a binary frame.
58///
59/// The fragment packs atomically (see [`super::pieces`] in the compiler)
60/// and its token cost comes from [`super::estimator::ImageTokenEstimator`].
61/// A media fragment that cannot fit the budget is externalized behind a
62/// `ctx://source` handle exactly like text (US-009, PR2: the memory bridge
63/// persists the bytes behind it — see
64/// [`crate::MemoryService::retrieve_context_source`]); the memoryless core
65/// compiler mints the same handle either way, since it never knows whether
66/// a resolver is attached. See the crate README's "media fragments"
67/// section.
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct MediaRef {
70    /// Declared MIME type (e.g. `"image/png"`, `"image/jpeg"`). Only PNG and
71    /// JPEG headers are sniffed for dimensions; any other value (or an
72    /// unreadable header) falls back to a deterministic, safe over-count
73    /// (see [`super::estimator::ImageTokenEstimator`]) — never rejected for
74    /// an unrecognized mime alone.
75    pub mime: String,
76    /// The raw media bytes, base64-encoded (standard alphabet, padded).
77    /// Capped at [`crate::limits::MAX_MEDIA_BYTES`] and validated for
78    /// well-formedness at compile time — a request carrying an oversized or
79    /// malformed payload is rejected before any other work.
80    pub bytes_b64: String,
81}
82
83/// The resolved original behind a `ctx://source/<hash>` handle (US-002:
84/// text sources; US-009 PR2 extends this with the fragment's inline media,
85/// when it carried one). `media` is `#[serde(default)]`: every source
86/// stored before PR2, and every text-only fragment since, round-trips with
87/// `media: None` — the exact pre-PR2 shape for a caller reading only
88/// `.content`.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct ContextSource {
91    /// The original fragment content, byte for byte (a media fragment's
92    /// caption — often empty for a bare screenshot).
93    pub content: String,
94    /// The original media payload, when the fragment carried one.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub media: Option<MediaRef>,
97}
98
99/// One unit of caller-supplied context to compile.
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101#[schemars(transform = crate::schema::strip_int_formats)]
102pub struct ContextFragment {
103    /// Caller-side identifier. When absent, the compiler derives a stable
104    /// content-addressed id (see [`super::fragment_id`]). Accepts a JSON
105    /// number or a decimal string on input (see
106    /// [`super::wire::deserialize_optional_id`]) — a caller that got a
107    /// `fragment_id` back as a string (e.g. under
108    /// [`CompilePolicy::ids_as_strings`]) can resubmit it unchanged.
109    #[serde(
110        default,
111        skip_serializing_if = "Option::is_none",
112        deserialize_with = "super::wire::deserialize_optional_id"
113    )]
114    pub id: Option<u64>,
115    /// The fragment text. `#[serde(default)]` (V2b-1): a `path` fragment
116    /// carries no `content` on the wire — the adapter resolves `path` into
117    /// `content` in a pre-pass before the pure compiler core ever sees the
118    /// request, so this stays the only field the core reads.
119    #[serde(default)]
120    pub content: String,
121    /// Read this file's content from disk in place of an inline `content`
122    /// (V2b-1 path ingestion). `path` is EXCLUSIVE — it is resolved into
123    /// `content` before the compiler core runs, so a fragment carrying
124    /// `path` together with `content` or `media` is rejected. `content` and
125    /// `media` together are fine, and are the intended shape for an image
126    /// and its caption. A fragment carrying none of the three is rejected
127    /// as well. Requires the server to be started
128    /// with `VELESDB_MEMORY_INGEST_ROOTS` set (a colon/semicolon-separated
129    /// allowlist of directories, platform `PATH`-list syntax); otherwise
130    /// every `path` fragment fails with an explicit "ingestion disabled"
131    /// error. The path must be absolute and resolve (after following
132    /// symlinks) to a plain file under one of the configured roots, no
133    /// larger than [`crate::limits::MAX_INGEST_FILE_BYTES`] and valid UTF-8.
134    /// The **pure compiler core never reads this field** — resolution is an
135    /// adapter-side I/O pre-pass (see the crate's `context::ingest`
136    /// module); a `path` fragment that reaches [`super::ContextCompiler`]
137    /// unresolved is rejected with
138    /// [`crate::error::MemoryError::IngestDisabled`].
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub path: Option<String>,
141    /// Free-form kind hint (`"code"`, `"log"`, `"prose"`, …) — classification
142    /// works without it, but honors it when present.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub kind: Option<String>,
145    /// Caller priority, higher packs first (default `0`).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub priority: Option<u8>,
148    /// Caller metadata. Recognized keys: `"verbatim": true` forces
149    /// [`ContextAction::Preserve`]; `"cache": true` forces
150    /// [`ContextAction::Cache`].
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub metadata: Option<Map<String, Value>>,
153    /// Inline media payload (US-009, PR1). `None` (the default) keeps every
154    /// pre-0.9.0 request wire-compatible. When set, the fragment packs as one
155    /// atomic piece — see [`MediaRef`].
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub media: Option<MediaRef>,
158}
159
160/// Which memories the compiler may pull in alongside the caller's fragments.
161/// Consumed by the memory bridge (US-002); carried in the request shape from
162/// the start so the wire contract does not change when it lands.
163#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
164#[schemars(transform = crate::schema::strip_int_formats)]
165pub struct MemoryScope {
166    /// Restrict recalled memories to this project facet.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub project: Option<String>,
169    /// How many memories to consider (adapter-clamped).
170    #[serde(
171        default,
172        skip_serializing_if = "Option::is_none",
173        deserialize_with = "crate::wire::lenient"
174    )]
175    pub k: Option<usize>,
176    /// Graph-walk depth of the fused recall (default 2). Deeper hops reach
177    /// longer cause/fix chains from the vector seed.
178    #[serde(
179        default,
180        skip_serializing_if = "Option::is_none",
181        deserialize_with = "crate::wire::lenient"
182    )]
183    pub hops: Option<usize>,
184    /// Fusion weight added to graph-reached memories (default 0.15). Raise
185    /// it (e.g. `0.5`–`0.8`) when pulling from curated fact chains built
186    /// with `relate`: evidence that shares **no vocabulary** with the query
187    /// can then out-rank lexically-noisy near-misses — the tri-engine's
188    /// answer to the purely lexical relevance of caller fragments.
189    #[serde(
190        default,
191        skip_serializing_if = "Option::is_none",
192        deserialize_with = "crate::wire::lenient"
193    )]
194    pub graph_boost: Option<f64>,
195}
196
197/// Usage-driven importance weights of the memory-bridge blend (US-002 of
198/// EPIC-P-071): how much a pulled memory's learned RL confidence and its
199/// batch-relative recency tilt the fused similarity ranking.
200///
201/// The blend only ever applies to the pool the fused vector+graph similarity
202/// already selected — confidence is *not* relevance, so a heavily reinforced
203/// but off-topic fact can never enter the pool through these weights. Per
204/// pulled memory the ranking key becomes
205/// `fused_norm + confidence_weight·(confidence − 0.5)·2 + recency_weight·recency_norm`,
206/// clock-free and deterministic (recency is min-max normalised **within the
207/// pulled batch**, never against wall time).
208///
209/// Both weights at `0.0` disable the blend entirely: the output is
210/// byte-identical to the 0.8.0 behaviour (pinned by a golden test). The
211/// defaults are **active** on purpose — upgrading from 0.8.0 with the
212/// default policy, RL-reinforced memories rank higher out of the box; zero
213/// the weights to restore the exact 0.8.0 ordering.
214///
215/// Recommended range for both weights: `[0.0, 1.0]` (at `1.0` a term can
216/// fully offset the similarity gap within the pool). Values outside that
217/// range are **accepted verbatim, never clamped** — a negative weight
218/// deliberately inverts its term (e.g. demote reinforced facts), a weight
219/// above `1.0` lets the term dominate similarity. Only the recorded
220/// decision `relevance` is clamped into `[0, 1]`; the ranking itself uses
221/// the raw blended score.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
223#[serde(default)]
224pub struct ImportanceWeights {
225    /// Weight of the learned RL confidence (`_veles_rl_*`, fed by
226    /// [`feedback`](crate::MemoryService::feedback)). A memory with no
227    /// feedback history counts as the neutral `0.5`, contributing exactly
228    /// `0`. Default `0.2`.
229    pub confidence: f64,
230    /// Weight of the batch-relative recency term. Inert unless
231    /// [`Self::recency_field`] is also set. Default `0.1`.
232    pub recency: f64,
233    /// Caller metadata key holding each memory's **numeric** timestamp-like
234    /// value. `None` (the default) disables the recency term completely —
235    /// there is no standard key to guess. The scale must be monotone and
236    /// homogeneous across the batch (e.g. `YYYYMMDD` integers as in
237    /// [`crate::format_dated_context`], or an epoch); it is documented, not
238    /// verified at run time. Values are min-max normalised over the pulled
239    /// memories that carry the key; a memory without the key contributes `0`
240    /// (never penalised), and a degenerate batch (`max == min`) contributes
241    /// `0` for all.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub recency_field: Option<String>,
244}
245
246impl Default for ImportanceWeights {
247    fn default() -> Self {
248        Self {
249            confidence: 0.2,
250            recency: 0.1,
251            recency_field: None,
252        }
253    }
254}
255
256/// Tuning knobs of one compilation. `Default` is the recommended profile.
257#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
258#[serde(default)]
259#[schemars(transform = crate::schema::strip_int_formats)]
260#[allow(clippy::struct_excessive_bools)]
261pub struct CompilePolicy {
262    /// Tokens kept aside for the model's answer; the compiler packs into
263    /// `token_budget − response_reserve_tokens`. Default `0`: the caller
264    /// knows their generation length, the compiler does not guess it.
265    pub response_reserve_tokens: u64,
266    /// Collapse near-duplicates (case/whitespace variants) in addition to
267    /// exact duplicates. Default `true`.
268    pub near_dup_dedup: bool,
269    /// Rule ids to disable (e.g. `"abstract.log_dedup"`). Disabled rules are
270    /// skipped during classification; their fragments fall through to the
271    /// next matching rule.
272    pub disabled_rules: Vec<String>,
273    /// How oversized fragments are split before packing. Only
274    /// [`ChunkPolicy::max_chunk_bytes`] and [`ChunkPolicy::boundary`] apply
275    /// here — the compiler forces `overlap_bytes` to `0`, since it emits
276    /// pieces by concatenation and an overlap prefix would duplicate content
277    /// reported as verbatim. `overlap_bytes` is honoured only by the
278    /// standalone [`crate::context::chunk::chunk_text`] API.
279    pub chunk: ChunkPolicy,
280    /// Memory bridge only: record a compilation event (metadata and hashes,
281    /// **never fragment content**) so savings stay aggregatable. Default
282    /// `true`; set `false` to opt out entirely.
283    pub record_events: bool,
284    /// Memory bridge only: store each distinct fragment's original (as an
285    /// internal system fact, invisible to normal recall) so its
286    /// `ctx://source/<hash>` handle round-trips. Default `true`.
287    pub store_sources: bool,
288    /// TTL applied to stored sources (`None` keeps them until forgotten).
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub source_ttl_seconds: Option<u64>,
291    /// TTL applied to compilation events (`None` keeps them).
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub event_ttl_seconds: Option<u64>,
294    /// Caller-supplied pricing table so the insights also report the
295    /// estimated cost avoided for [`super::model::CompileRequest::target_model`] — the
296    /// **wire channel** for cost accounting (MCP and the bindings cannot
297    /// reach the Rust-only [`super::ContextCompiler::with_pricing`] builder).
298    /// Takes precedence over a builder-injected table. `None` (default)
299    /// reports tokens only.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub pricing: Option<super::insights::PricingTable>,
302    /// Memory bridge only: usage-driven importance blend over the pulled
303    /// memories (RL confidence + batch-relative recency). The struct-level
304    /// `#[serde(default)]` keeps 0.8.0 requests wire-compatible.
305    pub importance: ImportanceWeights,
306    /// Opt-in, deterministic: before `abstract.log_dedup` groups a `kind =
307    /// "log"` fragment's repeated lines, mask each line's volatile prefix
308    /// (ISO/syslog timestamps, bracketed hex/pid counters) with **fixed**
309    /// patterns — never a caller-supplied regex, so the collapse stays
310    /// reproducible — so lines identical modulo timestamp collapse into one
311    /// annotated line instead of surviving as distinct entries. The emitted
312    /// line is still the first occurrence's exact bytes; only the grouping
313    /// key changes. Default `false`: masking is opt-in because it changes
314    /// what "duplicate" means for logs, so callers who rely on the previous
315    /// byte-exact grouping keep it unless they ask. See the crate README's
316    /// "Normalizing timestamped logs" section for the exact patterns.
317    pub normalize_log_timestamps: bool,
318    /// Wire-compat opt-in for the MCP context tools (`compile_context`,
319    /// `explain_compilation`): when `true`, every [`super::wire::ID_KEYS`]
320    /// field of the RESPONSE (`fragment_id`, `content_hash`, `memory_id`,
321    /// `fragment_ids`) is rewritten into its decimal-string form, through
322    /// the exact same tree walk the Node and WASM bindings already apply on
323    /// every response ([`super::wire::stringify_id_fields`]). A raw MCP
324    /// client — one that talks JSON-RPC directly, without either binding —
325    /// parses ids as JS `number`s (IEEE-754 doubles), which silently lose
326    /// precision above 2^53; string ids round-trip exactly. Default
327    /// `false`: existing MCP clients keep today's byte-identical numeric
328    /// response unless they opt in.
329    pub ids_as_strings: bool,
330    /// Quick win (V2a-2): when `true`, `sections` and `decisions` are
331    /// emptied out of the response after compilation — `content`,
332    /// `insights`, `risk`, `warnings`, `sources`, and `retrieval_handles`
333    /// are unaffected. The full audit trail (`sections`/`decisions`) is
334    /// still recoverable: re-compile the same request without
335    /// `slim_response` (compilation is deterministic, so nothing is lost,
336    /// only not sent this time). Default `false`.
337    pub slim_response: bool,
338}
339
340impl Default for CompilePolicy {
341    fn default() -> Self {
342        Self {
343            response_reserve_tokens: 0,
344            near_dup_dedup: true,
345            disabled_rules: Vec::new(),
346            chunk: ChunkPolicy::default(),
347            record_events: true,
348            store_sources: true,
349            source_ttl_seconds: None,
350            event_ttl_seconds: None,
351            pricing: None,
352            importance: ImportanceWeights::default(),
353            normalize_log_timestamps: false,
354            ids_as_strings: false,
355            slim_response: false,
356        }
357    }
358}
359
360/// Aggregated savings over the recorded compilation events (memory bridge).
361#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
362#[schemars(transform = crate::schema::strip_int_formats)]
363pub struct ContextSavings {
364    /// Number of compilation events aggregated.
365    pub events: u64,
366    /// Sum of estimated input tokens across events.
367    pub tokens_in: u64,
368    /// Sum of estimated output tokens across events.
369    pub tokens_out: u64,
370    /// Sum of estimated tokens saved across events.
371    pub tokens_saved: u64,
372    /// Estimated cost avoided, in micro-units, keyed by currency (events
373    /// priced under different pricing tables never silently mix).
374    pub cost_saved_micros_by_currency: std::collections::BTreeMap<String, u64>,
375    /// `true` when the aggregation hit the recall cap
376    /// ([`crate::limits::MAX_RECALL_LIMIT`]) — older events beyond the cap
377    /// were not folded in.
378    pub truncated: bool,
379}
380
381/// A full compile request: what to compile, under which budget, for whom.
382#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
383#[schemars(transform = crate::schema::strip_int_formats)]
384pub struct CompileRequest {
385    /// What the agent is working on — drives relevance scoring.
386    pub query: String,
387    /// The context fragments to compile.
388    pub fragments: Vec<ContextFragment>,
389    /// Project facet, recorded in provenance and used by the memory bridge.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub project: Option<String>,
392    /// Target model name — selects the row of the pricing table
393    /// ([`CompilePolicy::pricing`] on the wire, or the Rust
394    /// [`super::ContextCompiler::with_pricing`] builder) for cost insights.
395    /// Without a table, insights report tokens only.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub target_model: Option<String>,
398    /// Hard token ceiling for the assembled content.
399    #[serde(deserialize_with = "crate::wire::lenient")]
400    pub token_budget: u64,
401    /// Which memories may be pulled in (US-002; ignored by the memoryless core).
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub memory_scope: Option<MemoryScope>,
404    /// Per-request policy override; `None` uses the compiler's policy.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub policy: Option<CompilePolicy>,
407}
408
409/// Where a section sits in the assembled output.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
411#[serde(rename_all = "lowercase")]
412pub enum SectionKind {
413    /// The stable, cache-marked prefix.
414    Cache,
415    /// The main compiled body.
416    Body,
417}
418
419/// One contiguous block of the assembled output.
420#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
421#[schemars(transform = crate::schema::strip_int_formats)]
422pub struct CompiledSection {
423    /// Which block this is.
424    pub kind: SectionKind,
425    /// The block's text (verbatim slice of [`CompiledContext::content`]).
426    pub content: String,
427    /// Ids of the fragments emitted into this block, in emission order.
428    pub fragment_ids: Vec<u64>,
429}
430
431/// A pointer from a compiled output back to one original fragment.
432#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
433#[schemars(transform = crate::schema::strip_int_formats)]
434pub struct SourceReference {
435    /// The fragment this source refers to. Accepts a JSON number OR a
436    /// decimal string on input: a `fragment_id` is an FNV-1a 64 content hash
437    /// (see [`ContextDecision::content_hash`]), so it is almost always above
438    /// 2^53 — a float-lossy JSON client that reads one back from a compiled
439    /// context and resubmits it inside a working context would otherwise
440    /// corrupt it silently. Same accepted-forms rule as issue #1468's ids;
441    /// the serialized (output) shape is unchanged.
442    #[serde(deserialize_with = "crate::model::deserialize_id")]
443    pub fragment_id: u64,
444    /// Recoverable address of the original content (`ctx://source/<id>`).
445    pub handle: String,
446    // Un doc-comment de CHAMP est publie tel quel dans la description du
447    // schema annonce (`docs/reference/mcp-tools.json`) : l'archeologie va donc
448    // ici, en commentaire ordinaire, et le contrat seul va au-dessus.
449    //
450    // Aller-retour casse jusqu'au 2026-07-29, trouve en interrogeant le
451    // serveur plutot qu'en relisant le code : `memory_id` fait partie des
452    // `super::wire::ID_KEYS`, donc une reponse sous
453    // `CompilePolicy::ids_as_strings` l'emet en CHAINE — et
454    // `save_working_context` la refusait, alors que c'est precisement la forme
455    // qu'un client a en main lorsqu'il resoumet une `SourceReference` recue
456    // d'un contexte compile.
457    /// The memory backing this source, when it came from recall (US-002).
458    /// Accepts a JSON number OR a decimal string on input, exactly like
459    /// `fragment_id` just above.
460    #[serde(
461        default,
462        skip_serializing_if = "Option::is_none",
463        deserialize_with = "super::wire::deserialize_optional_id"
464    )]
465    pub memory_id: Option<u64>,
466}
467
468/// A not-emitted fragment the caller can fetch back on demand.
469#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
470#[schemars(transform = crate::schema::strip_int_formats)]
471pub struct RetrievalHandle {
472    /// Recoverable address of the original content (`ctx://source/<id>`).
473    pub handle: String,
474    /// The fragment behind the handle.
475    pub fragment_id: u64,
476    /// Estimated token cost of re-injecting the full original.
477    pub estimated_tokens: u64,
478}
479
480/// The auditable record of what happened to one fragment and why.
481#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
482#[schemars(transform = crate::schema::strip_int_formats)]
483pub struct ContextDecision {
484    /// The fragment this decision is about (caller id, or content-derived).
485    pub fragment_id: u64,
486    /// Content hash of the *original* fragment text (FNV-1a 64, the crate's
487    /// [`stable id`](super::fragment_id)) — lets an auditor prove which exact
488    /// bytes the decision covered even when the caller supplied its own id.
489    pub content_hash: u64,
490    /// What was done.
491    pub action: ContextAction,
492    /// The stable id of the rule that decided (e.g. `"preserve.code_fence"`).
493    pub rule_id: String,
494    /// Lexical relevance of the fragment to the request query, in `[0, 1]`.
495    pub relevance: f32,
496    /// Fidelity risk this single decision contributes.
497    pub risk: FidelityRisk,
498    /// Human-readable explanation of the decision.
499    pub reason: String,
500    /// The memory backing this fragment, when it came from recall (US-002).
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub memory_id: Option<u64>,
503    /// Recoverable address of the original content, when not fully emitted.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub handle: Option<String>,
506}
507
508/// A mechanical heads-up over one decision, surfaced in
509/// [`CompiledContext::warnings`] so a caller can check "was anything
510/// relevant cut?" without scanning every entry of `decisions` by hand
511/// (V2a-2 quick win). Only [`ContextAction::Retrieve`] decisions at or
512/// above the relevance threshold qualify.
513///
514/// **An empty list is not a clean bill of health** (#1703 DC-4). Several
515/// real losses never warn: a [`ContextAction::Preserve`] the packer could
516/// only fit partially, an [`ContextAction::Abstract`], and two of
517/// `dup_verdict`'s [`ContextAction::Drop`] shapes — a media duplicate whose
518/// caption diverges from its twin's, and a duplicate whose twin was itself
519/// not fully emitted. Both say so in their own reason strings. `decisions`
520/// remains the exhaustive record; this list is a low-noise shortcut over it,
521/// not a substitute for it.
522#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
523#[schemars(transform = crate::schema::strip_int_formats)]
524pub struct ContextWarning {
525    /// The fragment this warning is about.
526    pub fragment_id: u64,
527    /// What happened to it (always [`ContextAction::Retrieve`] today).
528    pub action: ContextAction,
529    /// Lexical relevance to the request query, in `[0, 1]` — the same value
530    /// as the matching `decisions` entry.
531    pub relevance: f32,
532    /// The matching `decisions` entry's `reason`, copied verbatim.
533    pub reason: String,
534}
535
536/// The compiler's output: the assembled context plus its full audit trail.
537#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
538#[schemars(transform = crate::schema::strip_int_formats)]
539pub struct CompiledContext {
540    /// The assembled context, ready to inject into a prompt.
541    pub content: String,
542    /// The output split into ordered blocks (cache prefix first). Emptied by
543    /// [`CompilePolicy::slim_response`].
544    pub sections: Vec<CompiledSection>,
545    /// One decision per input fragment (duplicates included). Emptied by
546    /// [`CompilePolicy::slim_response`].
547    pub decisions: Vec<ContextDecision>,
548    /// One source pointer per distinct fragment.
549    pub sources: Vec<SourceReference>,
550    /// Handles for the fragments that were externalized, not emitted.
551    pub retrieval_handles: Vec<RetrievalHandle>,
552    /// Token (and optional cost) savings of this compilation.
553    pub insights: CompilationInsights,
554    /// Overall fidelity risk (the max over all decisions).
555    pub risk: FidelityRisk,
556    /// Mechanical, low-noise heads-up over `decisions` (V2a-2 quick win):
557    /// every externalized fragment relevant enough to the query that a
558    /// caller should double-check it was not needed. `#[serde(default)]` so
559    /// a pre-0.10.0 caller reading an older stored/replayed response still
560    /// deserializes (defaults to empty).
561    #[serde(default)]
562    pub warnings: Vec<ContextWarning>,
563}
564
565/// One asserted fact inside a [`WorkingContext`].
566#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
567pub struct ContextFact {
568    /// The fact text.
569    pub text: String,
570    /// Where the fact came from, when known.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub source: Option<SourceReference>,
573}
574
575/// A lightweight pointer to a past [`ContextDecision`].
576#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
577#[schemars(transform = crate::schema::strip_int_formats)]
578pub struct ContextDecisionRef {
579    /// The fragment the decision was about. Accepts a JSON number OR a
580    /// decimal string on input, for the same reason as
581    /// [`SourceReference::fragment_id`].
582    #[serde(deserialize_with = "crate::model::deserialize_id")]
583    pub fragment_id: u64,
584    /// The rule that decided.
585    pub rule_id: String,
586}
587
588/// The distilled working state of an agent session — small enough to carry
589/// across sessions, structured enough to resume from. Persisted and reloaded
590/// by the memory bridge (US-002) under `type = working_context` metadata.
591#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
592pub struct WorkingContext {
593    /// What the session is trying to achieve.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub goal: Option<String>,
596    /// Constraints currently in force (never compressed away).
597    #[serde(default)]
598    pub active_constraints: Vec<ContextFact>,
599    /// Facts that were verified, with their sources.
600    #[serde(default)]
601    pub verified_facts: Vec<ContextFact>,
602    /// Hypotheses still open.
603    #[serde(default)]
604    pub open_hypotheses: Vec<ContextFact>,
605    /// Decisions taken so far.
606    #[serde(default)]
607    pub decisions: Vec<ContextDecisionRef>,
608    /// Exact evidence the session relies on (verbatim, addressable).
609    #[serde(default)]
610    pub exact_evidence: Vec<SourceReference>,
611    /// Actions still to do.
612    #[serde(default)]
613    pub pending_actions: Vec<String>,
614}
615
616impl WorkingContext {
617    /// Whether this working state records nothing at all: no goal (or a blank
618    /// one, which says no more than an absent one) and every list empty.
619    ///
620    /// Saving is an idempotent upsert, so an empty state would *replace* —
621    /// that is, destroy — whatever a previous save stored under the same
622    /// project and session. [`crate::MemoryService::save_working_context`]
623    /// refuses one for exactly that reason (issue #1654).
624    #[must_use]
625    pub fn is_empty(&self) -> bool {
626        self.goal.as_ref().is_none_or(|goal| goal.trim().is_empty())
627            && self.active_constraints.is_empty()
628            && self.verified_facts.is_empty()
629            && self.open_hypotheses.is_empty()
630            && self.decisions.is_empty()
631            && self.exact_evidence.is_empty()
632            && self.pending_actions.is_empty()
633    }
634}
635
636/// What a working-context lookup returns, on every surface: the MCP
637/// `load_working_context` tool AND the Node/Python/WASM bindings.
638///
639/// An envelope (not a bare `Option<WorkingContext>`): the MCP spec requires
640/// the output schema's root to be an object, so a nullable root is rejected
641/// by rmcp.
642///
643/// It lives here — in the shared model — rather than in the `mcp` module
644/// because the `mcp` module is a Cargo feature the bindings do not enable:
645/// a type declared there is unreachable from them, and each binding would
646/// have to re-declare the envelope AND re-derive its two policy rules ("list
647/// on a hit too", "never re-emit the requested session"). Four copies of a
648/// rule is four chances for it to diverge in silence. Built once, by
649/// [`MemoryService::resume_working_context`](crate::MemoryService::resume_working_context).
650#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
651#[schemars(transform = crate::schema::strip_int_formats)]
652pub struct LoadedWorkingContext {
653    /// `true` when a working context was found under this exact project +
654    /// session. Wire-additive alongside `working` (added V2a-1): a client
655    /// that only reads `working` sees no change.
656    pub found: bool,
657    /// The previously saved working context, or `null` when nothing was ever
658    /// saved under that project + session (a fresh start, not an error).
659    pub working: Option<WorkingContext>,
660    /// The OTHER sessions saved under this SAME project (never the requested
661    /// one) — helps recover from a typo in `session` instead of silently
662    /// starting fresh (e.g. `"task-1234"` saved, `"task-1235"` requested by
663    /// mistake). Populated on a hit as well as on a miss: a typo that lands
664    /// on another real session is the case the caller can least detect on its
665    /// own. Empty only when the project has no other session.
666    #[serde(default)]
667    pub other_sessions: Vec<String>,
668}
669
670/// One session recorded in a project's working-context index (V2a-1's
671/// `list_working_contexts` quick win).
672#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
673pub struct WorkingContextSession {
674    /// The session id, as passed to `save_working_context`.
675    pub session: String,
676    /// Unix seconds this session was last saved — updated on every
677    /// `save_working_context` call under this project + session, not just
678    /// the first (a resave never duplicates the entry).
679    pub saved_at: u64,
680}
681
682/// The per-project index [`save_working_context`](crate::MemoryService::save_working_context)
683/// maintains so [`list_working_contexts`](crate::MemoryService::list_working_contexts)
684/// never has to scan the whole store: one system fact per project, appended
685/// (or refreshed) on every save. The REJECTED alternative was an approximate
686/// `query_filtered` scan over working-context facts (capped at
687/// `MAX_RECALL_LIMIT`, imprecise) — this index is exact and O(1) to read.
688#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
689pub struct WorkingContextIndex {
690    /// Every session ever saved under this project.
691    #[serde(default)]
692    pub sessions: Vec<WorkingContextSession>,
693}