selfware 0.6.1

Your personal AI workshop — software you own, software that lasts
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
//! Grounding-enforced GLM/OpenRouter review support.
//!
//! Model prose is never returned as an unstructured authority. The response
//! must cite evidence IDs created from exact workspace lines, and items with
//! missing or unknown citations are removed before they reach the UI.

use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::time::Instant;

use crate::api::{ApiClient, Message, ThinkingMode, Usage};
use crate::config::Config;

/// Repair instruction appended as a user message after one malformed reply
/// (spec §2.1). The original system + user messages and the assistant's
/// malformed reply are kept as context.
pub const REVIEW_REPAIR_PROMPT: &str = "Your previous reply was not valid JSON matching the required schema. Respond with ONLY the JSON object.";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundingEvidence {
    pub id: String,
    pub path: String,
    pub start_line: usize,
    pub end_line: usize,
    pub excerpt: String,
    pub content_hash: String,
    pub source: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundedClaim {
    pub text: String,
    pub evidence_ids: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionHop {
    pub order: usize,
    pub action: String,
    pub target: String,
    pub evidence_ids: Vec<String>,
    pub verification: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundedRecommendation {
    pub title: String,
    pub rationale: String,
    pub evidence_ids: Vec<String>,
    #[serde(default)]
    pub hops: Vec<ActionHop>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ModelReview {
    #[serde(default)]
    claims: Vec<GroundedClaim>,
    #[serde(default)]
    recommendations: Vec<GroundedRecommendation>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewUsage {
    pub prompt_tokens: usize,
    pub completion_tokens: usize,
    pub total_tokens: usize,
    pub cost: Option<f64>,
}

impl From<Usage> for ReviewUsage {
    fn from(value: Usage) -> Self {
        Self {
            prompt_tokens: value.prompt_tokens,
            completion_tokens: value.completion_tokens,
            total_tokens: value.total_tokens,
            cost: value.cost,
        }
    }
}

/// Typed protocol failure of the grounded review path (spec §2.1). Every
/// variant keeps the model telemetry from the last chat response — telemetry
/// is never dropped, even when the model output itself is unusable.
///
/// `GroundedAssistant::review` keeps its `anyhow::Result` signature and
/// returns this as the (downcastable) error, so HTTP handlers can recover the
/// typed outcome via `err.downcast_ref::<ReviewProtocolError>()` and map it
/// to a 422 with [`ReviewProtocolError::body`].
#[derive(Debug)]
pub enum ReviewProtocolError {
    /// Output was not parseable as the review schema after one repair.
    Invalid {
        detail: String,
        model: String,
        latency_ms: u128,
        usage: ReviewUsage,
    },
    /// Parseable, but zero claims, zero recommendations, zero rejections.
    Empty {
        model: String,
        latency_ms: u128,
        usage: ReviewUsage,
    },
    /// Every item was rejected by grounding: zero surviving, some rejected.
    Ungrounded {
        rejected_items: usize,
        model: String,
        latency_ms: u128,
        usage: ReviewUsage,
    },
    /// The evidence trust gate refused the send: a high-severity injection
    /// finding in non-trusted content. Happens before any model call, so
    /// there is no model telemetry to retain (no request was made).
    TrustBlocked { findings: Vec<TrustGateFinding> },
}

/// One blocking trust-gate finding (subset of context_trust::InjectionFinding
/// plus the source path it shipped from).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustGateFinding {
    pub path: String,
    pub kind: String,
    pub severity: String,
    pub line: usize,
    pub excerpt: String,
}

/// Compact summary of the evidence trust scan, attached to every review.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustGateSummary {
    pub sources_scanned: usize,
    pub findings: usize,
    /// Worst risk_score across scanned sources (0 clean .. 100 suspicious).
    pub worst_risk_score: u32,
}

impl ReviewProtocolError {
    /// The exact 422 JSON body per spec §2.1.
    pub fn body(&self) -> serde_json::Value {
        match self {
            Self::Invalid {
                detail,
                model,
                latency_ms,
                usage,
            } => serde_json::json!({
                "error": "model_output_invalid",
                "detail": detail,
                "model": model,
                "latency_ms": latency_ms,
                "usage": usage,
            }),
            Self::Empty {
                model,
                latency_ms,
                usage,
            } => serde_json::json!({
                "error": "model_output_empty",
                "model": model,
                "latency_ms": latency_ms,
                "usage": usage,
            }),
            Self::Ungrounded {
                rejected_items,
                model,
                latency_ms,
                usage,
            } => serde_json::json!({
                "error": "model_output_ungrounded",
                "rejected_items": rejected_items,
                "model": model,
                "latency_ms": latency_ms,
                "usage": usage,
            }),
            Self::TrustBlocked { findings } => serde_json::json!({
                "error": "context_trust_blocked",
                "findings": findings,
            }),
        }
    }
}

impl std::fmt::Display for ReviewProtocolError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Invalid { detail, .. } => {
                write!(f, "model output invalid after one repair: {detail}")
            }
            Self::Empty { .. } => write!(f, "model returned an empty review"),
            Self::Ungrounded { rejected_items, .. } => write!(
                f,
                "model review fully ungrounded: {rejected_items} item(s) rejected"
            ),
            Self::TrustBlocked { findings } => write!(
                f,
                "evidence trust gate blocked the send: {} high-severity finding(s) in non-trusted content",
                findings.len()
            ),
        }
    }
}

impl std::error::Error for ReviewProtocolError {}

/// Classify an evidence path for trust scanning: first-party Rust is trusted
/// code (rules downgrade to informational); everything else is data — it
/// should never carry instructions, so injection patterns stay hot.
fn trust_classification(path: &str) -> &'static str {
    if path.ends_with(".rs") {
        "rust_source"
    } else {
        "data"
    }
}

/// Scan what is about to reach the model. High-severity findings in content
/// whose trust level is not `Trusted` block the send (the seed invariant:
/// untrusted content never reaches the model unflagged). Trusted first-party
/// code can legitimately contain these patterns — those are reported, not
/// blocked.
pub fn gate_evidence_trust(
    evidence: &[GroundingEvidence],
) -> Result<TrustGateSummary, ReviewProtocolError> {
    use super::context_trust::{analyze_source, TrustLevel};

    let mut blocking: Vec<TrustGateFinding> = Vec::new();
    let mut total_findings = 0usize;
    let mut worst_risk = 0u32;
    let mut scanned: std::collections::HashSet<&str> = std::collections::HashSet::new();

    for chunk in evidence {
        let classification = trust_classification(&chunk.path);
        let report = analyze_source(
            &chunk.path,
            super::context_trust::SourceKind::Workspace,
            classification,
            &chunk.excerpt,
        );
        scanned.insert(chunk.path.as_str());
        total_findings += report.findings.len();
        worst_risk = worst_risk.max(report.risk_score);
        if super::context_trust::trust_level(
            super::context_trust::SourceKind::Workspace,
            classification,
        ) == TrustLevel::Trusted
        {
            continue; // trusted code: informational only
        }
        blocking.extend(
            report
                .findings
                .iter()
                .filter(|f| f.severity == "high")
                .map(|f| TrustGateFinding {
                    path: chunk.path.clone(),
                    kind: f.kind.clone(),
                    severity: f.severity.clone(),
                    line: f.line,
                    excerpt: f.excerpt.clone(),
                }),
        );
    }

    if !blocking.is_empty() {
        return Err(ReviewProtocolError::TrustBlocked { findings: blocking });
    }
    Ok(TrustGateSummary {
        sources_scanned: scanned.len(),
        findings: total_findings,
        worst_risk_score: worst_risk,
    })
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundedReview {
    pub model: String,
    pub claims: Vec<GroundedClaim>,
    pub recommendations: Vec<GroundedRecommendation>,
    pub evidence: Vec<GroundingEvidence>,
    pub evidence_complete: bool,
    /// Structural citation integrity only: every retained item cites known
    /// evidence IDs. This does not claim semantic entailment.
    pub grounding_valid: bool,
    pub citation_valid: bool,
    pub grounding_scope: String,
    pub semantic_validation: String,
    pub hallucination_free_guarantee: bool,
    pub rejected_items: usize,
    /// Derived trust state (spec §3.1), computed once at construction:
    /// "verified" (citation-valid, complete evidence, semantic validation
    /// performed — unreachable while semantic validation is unimplemented),
    /// "structural" (citation-valid, complete evidence, structural checks
    /// only), or "degraded" (anything else still returning 200).
    pub trust_state: String,
    /// Evidence trust-gate summary (what was scanned before the send).
    pub trust_gate: TrustGateSummary,
    pub usage: ReviewUsage,
}

#[derive(Clone)]
pub struct GroundedAssistant {
    client: ApiClient,
    configured_model: String,
}

impl GroundedAssistant {
    pub fn new(config: &Config) -> Result<Self> {
        Ok(Self {
            client: ApiClient::new(config)?,
            configured_model: config.model.clone(),
        })
    }

    pub fn configured_model(&self) -> &str {
        &self.configured_model
    }

    /// A single non-grounded chat turn for generative tasks such as pair
    /// evolution suggestions. Unlike [`review`](Self::review), the output is not
    /// validated against evidence IDs — the caller supplies whatever context the
    /// model should reason over. Returns (text, model, usage).
    pub async fn freeform(
        &self,
        system: &str,
        user: &str,
    ) -> Result<(String, String, ReviewUsage)> {
        if user.trim().is_empty() {
            bail!("freeform prompt cannot be empty");
        }
        let response = self
            .client
            .chat(
                vec![Message::system(system), Message::user(user)],
                None,
                ThinkingMode::Disabled,
            )
            .await
            .context("model suggestion call failed")?;
        let text = response
            .choices
            .first()
            .map(|choice| choice.message.content.text_all())
            .ok_or_else(|| anyhow!("model returned no choice"))?;
        Ok((text, response.model, response.usage.into()))
    }

    pub async fn review(
        &self,
        question: &str,
        evidence: Vec<GroundingEvidence>,
        evidence_complete: bool,
    ) -> Result<GroundedReview> {
        self.review_with_orientation(question, evidence, evidence_complete, None)
            .await
    }

    /// Grounded review with an optional non-citeable workspace orientation
    /// (architectural taxonomy + component map) prepended as navigation context.
    /// The orientation lets the model place the cited evidence in the wider tree
    /// without loading every file — claims still bind to supplied evidence IDs.
    pub async fn review_with_orientation(
        &self,
        question: &str,
        evidence: Vec<GroundingEvidence>,
        evidence_complete: bool,
        orientation: Option<&str>,
    ) -> Result<GroundedReview> {
        if question.trim().is_empty() {
            bail!("review question cannot be empty");
        }
        if evidence.is_empty() {
            bail!("grounded review requires source evidence");
        }

        // Trust gate: scan what is about to reach the model. High-severity
        // findings in non-trusted content refuse the send before any API call.
        let trust_gate = gate_evidence_trust(&evidence)?;

        let evidence_json = serde_json::to_string_pretty(&evidence)?;
        let system = Message::system(
            "You are a code-review engine. Use only the supplied evidence for \
             claims. A `Workspace orientation` section may precede the question: \
             it is a non-citeable map of the codebase's architecture and public \
             symbols, for navigation only — never cite it, and ground every claim \
             in a supplied evidence ID. \
             Return one JSON object and no markdown. Schema: \
             {\"claims\":[{\"text\":string,\"evidence_ids\":[string]}],\
             \"recommendations\":[{\"title\":string,\"rationale\":string,\
             \"evidence_ids\":[string],\"hops\":[{\"order\":number,\
             \"action\":string,\"target\":string,\"evidence_ids\":[string],\
             \"verification\":string}]}]}. Every item and hop must cite at least \
             one supplied evidence ID. Every recommendation must contain at least \
             two valid action hops numbered contiguously from 1. If evidence is \
             insufficient, omit the item.",
        );
        let user = Message::user(match orientation {
            Some(text) if !text.trim().is_empty() => format!(
                "Workspace orientation (background, not citeable):\n{}\n\nQuestion:\n{}\n\nGrounding evidence:\n{}",
                text.trim(),
                question.trim(),
                evidence_json
            ),
            _ => format!(
                "Question:\n{}\n\nGrounding evidence:\n{}",
                question.trim(),
                evidence_json
            ),
        });

        let started = Instant::now();
        let mut messages = vec![system, user];
        // One budgeted repair, no loops: attempt 0 is the original call;
        // attempt 1 reuses the same token budget with the malformed reply and
        // the repair instruction appended. A second parse failure is final.
        let (payload, response) = loop {
            let response = self
                .client
                .chat(messages.clone(), None, ThinkingMode::Disabled)
                .await
                .context("grounded model review failed")?;
            let parsed = response
                .choices
                .first()
                .map(|choice| choice.message.content.text_all())
                .ok_or_else(|| anyhow!("model returned no review choice"))
                .and_then(|text| parse_model_review(&text));
            match parsed {
                Ok(payload) => break (payload, response),
                Err(error) => {
                    if messages.len() > 2 {
                        // Already repaired once — report the typed failure with
                        // telemetry from this last chat response.
                        return Err(ReviewProtocolError::Invalid {
                            detail: format!("{error:#}"),
                            model: response.model,
                            latency_ms: started.elapsed().as_millis(),
                            usage: response.usage.into(),
                        }
                        .into());
                    }
                    let previous = response
                        .choices
                        .first()
                        .map(|choice| choice.message.content.text_all())
                        .unwrap_or_default();
                    messages.push(Message::assistant(previous));
                    messages.push(Message::user(REVIEW_REPAIR_PROMPT));
                }
            }
        };
        let (claims, recommendations, rejected_items) = validate_grounding(payload, &evidence);
        if claims.is_empty() && recommendations.is_empty() {
            let latency_ms = started.elapsed().as_millis();
            let usage: ReviewUsage = response.usage.into();
            return Err(if rejected_items == 0 {
                ReviewProtocolError::Empty {
                    model: response.model,
                    latency_ms,
                    usage,
                }
            } else {
                ReviewProtocolError::Ungrounded {
                    rejected_items,
                    model: response.model,
                    latency_ms,
                    usage,
                }
            }
            .into());
        }

        let citation_valid = rejected_items == 0;
        let semantic_validation = "not_performed";
        Ok(GroundedReview {
            model: response.model,
            claims,
            recommendations,
            evidence,
            evidence_complete,
            grounding_valid: rejected_items == 0,
            citation_valid,
            grounding_scope: "snapshot_and_citation_integrity_only".to_string(),
            semantic_validation: semantic_validation.to_string(),
            hallucination_free_guarantee: false,
            rejected_items,
            trust_state: trust_state(citation_valid, evidence_complete, semantic_validation)
                .to_string(),
            trust_gate,
            usage: response.usage.into(),
        })
    }
}

/// The spec §3.1 trust-state table. `verified` is reserved: it needs semantic
/// validation, which nothing performs today, so reachable states are
/// `structural` (clean, complete, structural checks only) and `degraded`
/// (rejected items or incomplete evidence on an otherwise successful review).
fn trust_state(
    citation_valid: bool,
    evidence_complete: bool,
    semantic_validation: &str,
) -> &'static str {
    if citation_valid && evidence_complete {
        if semantic_validation == "performed" {
            "verified"
        } else {
            "structural"
        }
    } else {
        "degraded"
    }
}

/// Create line-addressed evidence chunks from an exact document snapshot.
pub fn evidence_from_document(
    path: &str,
    content: &str,
    content_hash: &str,
    max_lines: usize,
) -> (Vec<GroundingEvidence>, bool) {
    let (evidence, complete, _) =
        evidence_from_document_excluding_ranges(path, content, content_hash, max_lines, &[]);
    (evidence, complete)
}

/// Create exact evidence while omitting inclusive one-based line ranges.
/// Remaining excerpts preserve their original line numbers and bytes.
pub fn evidence_from_document_excluding_ranges(
    path: &str,
    content: &str,
    content_hash: &str,
    max_lines: usize,
    excluded_ranges: &[(usize, usize)],
) -> (Vec<GroundingEvidence>, bool, usize) {
    const CHUNK_LINES: usize = 40;
    let lines: Vec<&str> = content.lines().collect();
    let mut excluded = vec![false; lines.len()];
    for &(start, end) in excluded_ranges {
        if start == 0 || start > end {
            continue;
        }
        let first = start.saturating_sub(1).min(lines.len());
        let last = end.min(lines.len());
        excluded[first..last].fill(true);
    }
    let excluded_lines = excluded.iter().filter(|value| **value).count();
    let eligible_lines = lines.len().saturating_sub(excluded_lines);
    let take = eligible_lines.min(max_lines);
    let mut evidence = Vec::new();
    let mut cursor = 0usize;
    let mut included = 0usize;
    while cursor < lines.len() && included < take {
        while cursor < lines.len() && excluded[cursor] {
            cursor += 1;
        }
        if cursor >= lines.len() {
            break;
        }
        let start = cursor;
        let mut indices = Vec::new();
        while cursor < lines.len()
            && !excluded[cursor]
            && indices.len() < CHUNK_LINES
            && included < take
        {
            indices.push(cursor);
            cursor += 1;
            included += 1;
        }
        let end = indices.last().map(|index| index + 1).unwrap_or(start + 1);
        let excerpt = indices
            .iter()
            .map(|index| format!("{:>6} | {}", index + 1, lines[*index]))
            .collect::<Vec<_>>()
            .join("\n");
        evidence.push(GroundingEvidence {
            id: format!("E{}", evidence.len() + 1),
            path: path.to_string(),
            start_line: start + 1,
            end_line: end,
            excerpt,
            content_hash: content_hash.to_string(),
            source: "workspace_snapshot".to_string(),
        });
    }
    (evidence, take == eligible_lines, excluded_lines)
}

fn parse_model_review(text: &str) -> Result<ModelReview> {
    let trimmed = text.trim();
    if let Ok(review) = serde_json::from_str(trimmed) {
        return Ok(review);
    }
    let start = trimmed
        .find('{')
        .context("model response did not contain JSON")?;
    let end = trimmed
        .rfind('}')
        .context("model response did not contain complete JSON")?;
    serde_json::from_str(&trimmed[start..=end]).context("model returned invalid review JSON")
}

/// Parse model JSON and discard every claim, recommendation, or hop that does
/// not cite the supplied evidence set. This is public for contract tests and
/// for non-HTTP actors that share the same grounding gateway.
pub fn validate_review_json(
    text: &str,
    evidence: &[GroundingEvidence],
) -> Result<(Vec<GroundedClaim>, Vec<GroundedRecommendation>, usize)> {
    Ok(validate_grounding(parse_model_review(text)?, evidence))
}

fn validate_grounding(
    payload: ModelReview,
    evidence: &[GroundingEvidence],
) -> (Vec<GroundedClaim>, Vec<GroundedRecommendation>, usize) {
    let known: HashSet<&str> = evidence.iter().map(|item| item.id.as_str()).collect();
    let valid_ids =
        |ids: &[String]| !ids.is_empty() && ids.iter().all(|id| known.contains(id.as_str()));
    let mut rejected = 0usize;

    let claims = payload
        .claims
        .into_iter()
        .filter(|claim| {
            let valid = valid_ids(&claim.evidence_ids) && !claim.text.trim().is_empty();
            if !valid {
                rejected += 1;
            }
            valid
        })
        .collect();

    let recommendations = payload
        .recommendations
        .into_iter()
        .filter_map(|mut recommendation| {
            if !valid_ids(&recommendation.evidence_ids)
                || recommendation.title.trim().is_empty()
                || recommendation.rationale.trim().is_empty()
            {
                rejected += 1;
                return None;
            }
            recommendation.hops.retain(|hop| {
                let valid = hop.order > 0
                    && !hop.action.trim().is_empty()
                    && !hop.target.trim().is_empty()
                    && !hop.verification.trim().is_empty()
                    && valid_ids(&hop.evidence_ids);
                if !valid {
                    rejected += 1;
                }
                valid
            });
            recommendation.hops.sort_by_key(|hop| hop.order);
            let contiguous = recommendation.hops.len() >= 2
                && recommendation
                    .hops
                    .iter()
                    .enumerate()
                    .all(|(index, hop)| hop.order == index + 1);
            if !contiguous {
                rejected += 1;
                return None;
            }
            Some(recommendation)
        })
        .collect();

    (claims, recommendations, rejected)
}

#[cfg(test)]
#[path = "../../tests/unit/evolve/assistant_trust_gate_test.rs"]
mod assistant_trust_gate_test;