diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c21a68fe..3202b435 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -42,6 +42,15 @@ Vaner is a local-first predictive context engine for coding assistants.
- `vaner why [decision-id] [--list|--verbose|--json]`
- `vaner query --explain [--verbose|--json]`
+## Context preparation
+
+Vaner prepares context through explicit needs, bounded preparation policies,
+general context tools, traces, and provenance-preserving packages. Benchmark
+work must improve these reusable product capabilities instead of teaching the
+engine dataset-specific routes or answer patterns. See
+[`docs/context-preparation.md`](docs/context-preparation.md) for the current
+architecture notes and benchmark-driven development guardrails.
+
## Claude Code plugin packaging
Vaner ships a Claude Code plugin under `plugins/vaner/`, catalogued by a repo-root marketplace at `.claude-plugin/marketplace.json`. The plugin is a thin wrapper — it registers the `vaner` MCP server, the `vaner-feedback` skill, and a SessionStart hook that checks whether the `vaner` CLI is on PATH. The actual code still ships via PyPI and `scripts/install.sh`; the plugin only wires Claude Code to the installed CLI. Version parity between `pyproject.toml`, `plugins/vaner/.claude-plugin/plugin.json`, and the marketplace entry is enforced by `scripts/bump-plugin-version.sh` (pre-commit and CI).
diff --git a/docs/assets/vaner-lockup-animated.svg b/docs/assets/vaner-lockup-animated.svg
index f7c88d07..8946aa5c 100644
--- a/docs/assets/vaner-lockup-animated.svg
+++ b/docs/assets/vaner-lockup-animated.svg
@@ -1,12 +1,12 @@
diff --git a/docs/benchmarks/2026-05-05-0.9.1-medium-release-canvas.html b/docs/benchmarks/2026-05-05-0.9.1-medium-release-canvas.html
new file mode 100644
index 00000000..1a59f151
--- /dev/null
+++ b/docs/benchmarks/2026-05-05-0.9.1-medium-release-canvas.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+ Vaner 0.9.1 Benchmark Canvas
+
+
+
+
+
+
+
0.9.1 release validation · public canvas
+
Exact targeting is strong. Broad lifecycle prompts are the remaining bottleneck.
+
+ This run combines the 0.9.1 scenario benchmark, a broad public naked/RAG/Vaner comparison,
+ and the deep-run maturation suite. It uses public corpora or public-layout fixtures and reports
+ aggregate benchmark evidence without exposing local workspace details.
+
Scenario related coverage 100% related, 95% exact; promotion gate passed.
+
+
+
+
5/8
+
Quality A/B Vaner-context answers won 62% of judged pairs.
+
+
+
+
8.04
+
Broad public quality +3.24 vs naked, +0.41 vs RAG across 125 cases.
+
+
+
+
+0.842
+
Deep-run delta effective pass; raw 3/5 gates passed with scaffold-only non-blocking gates
+
+
+
+
+
+
Layer Results
+
+
Layer
Benchmark
Result
Read
+
+
Prediction / exact targeting
20 Vaner repo mechanism queries
19 exact, 1 partial, 0 no-scenario cycles
Exact targeting is now strong on named components; remaining partials are mostly broad process questions.
+
Answer quality
8 Claude-judged pairs
5 Vaner wins, 3 cold wins, 0 ties
When useful context is present, the answer improves; weak/empty context can lose.
+
Naked vs RAG vs Vaner
125 public cases
Vaner quality 8.04 vs RAG 7.62 vs naked 4.80
Vaner beats naked and edges RAG in this deterministic public harness, with similar hit/recall and lower estimated cost than RAG.
+
Deep-run maturation
80 sessions / 80 outcomes
Mean improvement +0.842; stale rate 0%
Strong reference-ceiling maturation signal; raw anti-self-judging gates remain visible and are non-blocking only in this scaffold mode.
+
Agent-with-tools layer
Harness restored and CLI import validated
Not included as a scored release claim
Needs a configured OpenAI-compatible tool-calling answer endpoint and running Vaner daemon; not faked in this pass.
+
+
+
+
+
+
Answer The Question
+
+
1
When Vaner is betterBest on codebase tasks where the needed object/path is present in prepared context. The quality judge favored Vaner in 5 of 8 paired scenario answers, and the broad public harness shows +3.24 quality over naked and +0.41 over RAG.
+
2
Why it worksIt front-loads context selection before the final answer: exact component hints, symbol/path signals, and cached packages reduce answer-time search and can correct cold-answer misunderstandings.
+
3
Where it is weakerWeakness is precision under broad lifecycle prompts: 1 of 20 turns were only partial, with cold-start still under-specified. No turns lacked relevant context.
+
4
Cost and performanceScenario precompute averaged 46.3s; cache lookup averaged 6.0s. Warm answer latency was 61.0s vs 169.0s cold in the judged sample. Broad public estimated cost was $1.155 for Vaner vs $1.230 for RAG and $0.883 naked.
+
+
+
+
+
+
+
Scenario Gate
+
+
+
Engine LLM
wired via ollama:qwen3.6:27b
+
Mean relevance
0.87
+
No-scenario cycles
0/20 (0%)
+
Gate status
5/5 passed; PASS
+
+
+
+
+
+
Broad Public Arms
+
+
Arm
Hit@3
Recall@5
Quality
Cost
+
+
Naked
0%
0%
4.80
$0.883
+
RAG
64%
68%
7.62
$1.230
+
Vaner
64%
69%
8.04
$1.155
+
+
+
+
+
+
Eval/Fix Loop
+
+
A
Promote only with gatesSeparate coverage, quality, latency, cost, and leak gates. Failed gates become tracked fixes, not rewritten conclusions.
+
B
Debug by failure sliceFirst slice for 0.9.2: named-component queries that produced zero scenarios or weak primary evidence.
+
C
Publish clean evidencePublic reports should include methodology, aggregate results, limitations, and leak scans without naming local paths or internal workspaces.
Vaner helps AI stop starting from scratch. The reactive floor is strong; continuation still needs proof.
+
+ This canvas reframes the medium two-hour run around Vaner's real product claim: preparation before the prompt.
+ The corrected A/B quality slice is strong, but the telemetry also shows that normal continuation did not activate
+ during this run.
+
+
+
+
Run date
+
2026-05-05
+
Models
+
Vaner prep: local qwen Answer: claude:sonnet Judge: claude:opus
+
+
+
+
+
+
4/4
+
Corrected quality A/B Vaner-context answers won every recovered medium case.
+
+
+
7189s
+
Preparation observed The two-hour window reached 99.8% before the original quality phase crashed.
+
+
+
0
+
Continuation rounds No normal continuation activity was recorded in telemetry.
+
+
+
21
+
Ready predictions 73 total predictions at the end: 21 ready, 52 queued.
+
+
+
+
+
+
Layer Results
+
+
Layer
Benchmark
Result
Read
+
+
+
Reactive floor
+
4 Claude-judged repo mechanism questions
+
4 Vaner wins, 0 naked wins, 0 ties
+
When prepared context is available, the primary AI answers with more exact files, identifiers, and mechanisms.
+
+
+
Preparation window
+
Normal background run, target 7200s
+
Last sample at 7188.9s; 236 telemetry samples
+
Vaner stayed active through almost the whole preparation window.
This proves background work happened. It does not prove the desired continuation policy yet.
+
+
+
+
+
+
Before / After Pattern
+
+
Without Vaner
With Vaner
User-visible improvement
+
+
+
Generic answer or caveat that repo details are unknown.
+
Specific files such as learning/reward.py, intent/scorer.py, engine.py, and store/artefacts.py.
+
Less setup, fewer clarifying turns, better grounded follow-up work.
+
+
+
Broad architecture pattern.
+
Exact constants, methods, schema fields, and flow stages when evidence was present.
+
The assistant starts from project evidence instead of rediscovery.
+
+
+
Safer but often non-actionable uncertainty.
+
More useful specificity, but still needs hallucination-risk checks in public claims.
+
Better answer quality with a clear need for evidence-precision gates.
+
+
+
+
+
+
+
Release-safe Language
+
+
OK
Safe claimIn a recovered medium quality slice after background preparation, Vaner context helped Claude Sonnet produce better answers than naked prompting on all four tested mechanism questions.
+
NO
Do not claim yetDo not say this run proves Vaner continuously explores and hardens many future scenarios over a two-hour idle window.
+
FIT
Best fitRecurring project work, codebases, research, writing, planning, and team knowledge where prepared context can be reused.
+
LOW
Weak fitGeneric one-off prompts, vague unrelated asks, and tasks where the base model already knows enough.
+
+
+
+
+
+
+
Needed Harness Fixes
+
+
A
Normalize context payloadsStructured Vaner payloads must be converted to text before prompt assembly.
+
B
Keep A/B artifacts explicitMark recovered, failed, and release-harness files separately.
+
C
Gate hallucination riskMedium-risk wins need identifier checks before marketing copy.
+
+
+
+
+
Preparation Benchmark Next
+
+
Window
Measure
+
+
0m
Cold reactive floor.
+
5m
Fast preparation payoff.
+
15m
Short work-session continuity.
+
60m
Idle lunch-break preparation.
+
2h
Normal background exploration, not deep-run.
+
Overnight
Deep preparation and stale-rate pressure.
+
+
+
+
+
+
Metrics To Add
+
+
H
Prepared Work Hit RateHow often useful work was ready before the user asked.
+
E
Evidence PrecisionWhether prepared evidence was actually the decisive evidence.
+
S
Stale RateHow often prepared work was outdated or irrelevant.
+
C
Compounding ScoreWhether usefulness improves over repeated sessions.
+
+
+
+
+
+ Source run: .vaner/bench-runs/normal-bg-medium-2h-20260505T182339Z.
+ This canvas is a recovery and interpretation artifact, not a final versioned release benchmark.
+
+
+
+
diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md
index ac8947d9..e709e915 100644
--- a/docs/benchmarks/README.md
+++ b/docs/benchmarks/README.md
@@ -39,6 +39,12 @@ but doesn't measure whether the briefing actually helps the backend LLM
produce a better answer. File-recall is still reported as a secondary
diagnostic but is not the ship gate.
+Benchmark misses should be treated as diagnostic evidence for general
+context-preparation failures, not as invitations to encode benchmark-specific
+routes or answer patterns. See
+[`docs/context-preparation.md`](../context-preparation.md) for the current
+benchmark-driven development guardrails.
+
## Methodology
See the evaluation harness for the session replay runner.
diff --git a/docs/benchmarks/public-methodology.md b/docs/benchmarks/public-methodology.md
index 44365baa..c872e8fb 100644
--- a/docs/benchmarks/public-methodology.md
+++ b/docs/benchmarks/public-methodology.md
@@ -13,5 +13,12 @@ path coverage, latency, estimated or provider-reported cost, and archetype-level
strengths and weaknesses. Public reports may name public corpora, public repos,
models, and aggregate metrics.
+Benchmark-driven product work must follow the context-preparation guardrails in
+[`docs/context-preparation.md`](../context-preparation.md): benchmarks are
+diagnostic lenses, and fixes should name the general failure mode, improve a
+reusable Vaner capability, report product-shaped metrics, avoid dataset-only
+product logic, and run context-specific behavior through explicit policies or
+tools.
+
Private training data, learned priors, raw prompts, judge transcripts, policy
weights, and internal failure traces remain outside the public repository.
diff --git a/docs/context-preparation.md b/docs/context-preparation.md
new file mode 100644
index 00000000..2c3a1a86
--- /dev/null
+++ b/docs/context-preparation.md
@@ -0,0 +1,124 @@
+# Context Preparation Architecture
+
+Vaner is a context prediction and preparation engine. Retrieval is one
+mechanism inside that engine, not the product abstraction.
+
+The intended preparation spine is:
+
+```text
+ContextNeed
+-> PreparationPolicy
+-> ContextTools
+-> ContextToolTrace
+-> ContextPackage
+```
+
+The core should infer what kind of context the next step needs, choose a
+bounded preparation policy, execute general context tools, preserve provenance,
+record coverage and gaps, and assemble the smallest sufficient context package.
+
+## Benchmark-Driven Development Guardrails
+
+EnterpriseRAG and similar benchmarks should be used as diagnostic lenses, not
+as product targets. A benchmark miss is useful when it reveals a general Vaner
+context-preparation failure. Fixes should improve Vaner's reusable preparation
+engine, not encode dataset-specific shortcuts.
+
+For any benchmark-driven change, the PR should answer the questions below.
+
+### 1. What Is The General Failure Mode?
+
+Name the underlying product failure, not only the benchmark case.
+
+Good examples:
+
+- multi-source synthesis exceeded the raw context budget;
+- scheduling evidence was discovered but not selected;
+- source-class expansion missed canonical documents;
+- aggregation lacked row-level provenance;
+- conflict or absence evidence was not represented in the final package.
+
+Bad examples:
+
+- `qst_0437` needs these documents;
+- `qst_0184` expects this Gmail path;
+- EnterpriseRAG asks this exact question shape.
+
+### 2. Why Is The Capability Non-Benchmark-Specific?
+
+The change should apply to normal Vaner use cases outside the benchmark.
+
+Examples:
+
+- `aggregate_sources` should work for postmortems, customer feedback, research
+ notes, incidents, meeting notes, and similar broad synthesis tasks.
+- `prepare_scheduling_evidence` should work for invites, calendars, email
+ threads, meeting notes, time windows, and timezone evidence generally.
+- source-class hints should be a general candidate expansion mechanism, not a
+ hidden shortcut to benchmark documents.
+
+### 3. Which Product-Shaped Metrics Improved?
+
+Do not report only raw benchmark recall if the product path is richer than raw
+top-k selection.
+
+Prefer metrics such as:
+
+- raw selected document recall;
+- aggregation input recall;
+- aggregation provenance recall;
+- scheduling evidence recall;
+- final package support coverage;
+- coverage and gap detection quality;
+- tool trace correctness;
+- provenance completeness.
+
+For multi-source synthesis, raw selected documents may not be the main success
+path. Aggregation input and provenance coverage may be the more relevant
+measures.
+
+### 4. Is Product Code Free Of Dataset-Only Language?
+
+Product code should not contain:
+
+- benchmark question IDs;
+- expected benchmark document IDs;
+- dataset-specific source names;
+- hardcoded answer shapes for one benchmark;
+- routing keyed to EnterpriseRAG-specific phrasing.
+
+Dataset-specific logic belongs in tests, fixtures, harness code, or
+diagnostics, not in Vaner's product engine.
+
+### 5. Is Context-Specific Behavior Behind Explicit Policies Or Tools?
+
+Context-specific behavior should be invoked by the selected preparation policy,
+not hidden as always-on selector behavior.
+
+Examples:
+
+- scheduling ranking belongs behind the scheduling policy or tool path;
+- aggregation belongs behind `multi_source_synthesis`;
+- source-class and canonical ranking belong behind source-class or
+ canonical-rank tools;
+- conflict logic belongs behind `conflict_resolution`;
+- absence logic belongs behind `absence_check`.
+
+The selector should become less magical over time. The preparation trace should
+explain why a behavior ran.
+
+## Review Standard
+
+A benchmark-driven PR is acceptable if it can clearly show:
+
+```text
+benchmark miss
+-> general failure mode
+-> reusable Vaner capability
+-> product-shaped metric improvement
+-> no dataset-specific product logic
+-> explicit policy/tool path
+```
+
+A benchmark-driven PR is not acceptable if it mainly teaches Vaner a
+benchmark-specific route, source, or answer pattern.
diff --git a/src/vaner/broker/__init__.py b/src/vaner/broker/__init__.py
index 66dcda64..a7d5c9d7 100644
--- a/src/vaner/broker/__init__.py
+++ b/src/vaner/broker/__init__.py
@@ -1,5 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
+from vaner.broker.agentic_preparation import (
+ build_evidence_selection_prompt,
+ build_recall_planning_prompt,
+ coerce_recall_plan,
+ coerce_support_selection,
+ support_item_budget,
+)
from vaner.broker.answerable import build_answerable_briefing, build_answerable_briefing_from_text
from vaner.broker.assembler import assemble_context_package
from vaner.broker.compressor import compress_context
@@ -12,12 +19,17 @@
__all__ = [
"assemble_context_package",
+ "build_evidence_selection_prompt",
+ "build_recall_planning_prompt",
"build_answerable_briefing",
"build_answerable_briefing_from_text",
"builtin_retrieval_floor",
"compress_context",
+ "coerce_recall_plan",
+ "coerce_support_selection",
"detect_floor_conflicts",
"score_artefact",
"select_artefacts",
"should_invoke_retrieval_floor",
+ "support_item_budget",
]
diff --git a/src/vaner/broker/agentic_preparation.py b/src/vaner/broker/agentic_preparation.py
new file mode 100644
index 00000000..7b6d8dcf
--- /dev/null
+++ b/src/vaner/broker/agentic_preparation.py
@@ -0,0 +1,180 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+
+from vaner.broker.context_preparation import infer_context_preparation_profile
+from vaner.models.agentic_preparation import AgenticRecallPlan, EvidenceCandidate, EvidenceSupportSelection
+from vaner.models.context_preparation import ContextPreparationProfile
+
+_MULTI_SOURCE_MARKERS = (
+ " across ",
+ " all ",
+ " compare",
+ " each ",
+ " every ",
+ " list ",
+ " which documents",
+ " which sources",
+ " conflict",
+ " conflicting",
+ " summarize",
+ " themes",
+ " patterns",
+ " how many",
+ " count ",
+ " counts ",
+ " by team",
+ " by owner",
+ " by dri",
+ " end-to-end",
+)
+
+
+def support_item_budget(
+ prompt: str,
+ *,
+ profile: ContextPreparationProfile | None = None,
+ default_max: int = 12,
+) -> int:
+ """Return a minimal direct-support budget for a context request.
+
+ Single-fact requests should usually preserve one direct source. Broad
+ synthesis/comparison requests need a larger support set. The decision is
+ derived from general task language and the inferred context need.
+ """
+
+ profile = profile or infer_context_preparation_profile(prompt)
+ if profile.need in {"multi_source_synthesis", "conflict_resolution", "research_mapping"}:
+ return max(1, default_max)
+ text = f" {prompt.strip().lower()} "
+ if any(marker in text for marker in _MULTI_SOURCE_MARKERS):
+ return max(1, default_max)
+ if " and " in text and any(token in text for token in ("what ", "which ", "who ", "when ", "where ", "why ", "how ")):
+ return max(1, min(default_max, 3))
+ return 1
+
+
+def excerpt_text(text: str, *, max_chars: int = 900) -> str:
+ compact = " ".join(text.split())
+ if len(compact) <= max_chars:
+ return compact
+ return compact[: max_chars - 1].rstrip() + "..."
+
+
+def candidate_rows(candidates: Iterable[EvidenceCandidate], *, max_excerpt_chars: int = 900) -> list[dict[str, object]]:
+ rows: list[dict[str, object]] = []
+ for index, candidate in enumerate(candidates, start=1):
+ rows.append(
+ {
+ "rank": candidate.rank or index,
+ "key": candidate.key,
+ "path": candidate.path,
+ "title": candidate.title,
+ "excerpt": excerpt_text(candidate.text, max_chars=max_excerpt_chars),
+ }
+ )
+ return rows
+
+
+def build_recall_planning_prompt(
+ prompt: str,
+ candidates: Iterable[EvidenceCandidate],
+ *,
+ max_queries: int = 6,
+ max_seed_candidates: int = 24,
+) -> str:
+ rows = candidate_rows(list(candidates)[:max_seed_candidates], max_excerpt_chars=500)
+ return (
+ "You are Vaner's bounded context preparation agent. Your job is to improve evidence recall before a final "
+ "user-facing answer or action model runs.\n"
+ "Generate search queries that could find direct supporting context for the user's request. Use general "
+ "work-context reasoning: exact entities, aliases, likely source vocabulary, code/config names, dates, owners, "
+ "incidents, meetings, and paraphrases. Do not use benchmark IDs, gold answers, hidden labels, or dataset "
+ "shortcuts.\n"
+ "Return JSON only. Keep queries short and lexical enough for search.\n\n"
+ f"User request: {prompt}\n\n"
+ f"Current candidate summaries:\n{rows}\n\n"
+ f"Return at most {max_queries} queries."
+ )
+
+
+def build_evidence_selection_prompt(
+ prompt: str,
+ candidates: Iterable[EvidenceCandidate],
+ *,
+ support_budget: int,
+ max_candidates: int = 96,
+ max_excerpt_chars: int = 2400,
+) -> str:
+ rows = candidate_rows(list(candidates)[:max_candidates], max_excerpt_chars=max_excerpt_chars)
+ return (
+ "You are Vaner's evidence preparation agent. Select the smallest set of candidate items that directly support "
+ "the user's request. Prefer exact evidence over topical similarity. Include multiple items only when the "
+ "request asks for comparison, completeness, conflicts, synthesis, counts, or multiple facts. Do not include "
+ "corroborating, background, nearby, follow-up, or merely topical items. For a one-fact request, select exactly "
+ "one item only if it directly contains the answer. If a candidate is clearly the requested source but the "
+ "available excerpt is incomplete, select it as partial support instead of dropping it. Return no items only "
+ "when no candidate is a plausible direct source for the request.\n"
+ "This is a general context-preparation step, not a benchmark shortcut. Do not infer hidden expected sources; "
+ "use only the request and candidate content.\n\n"
+ f"User request: {prompt}\n\n"
+ f"Candidate items:\n{rows}\n\n"
+ f"Return at most {support_budget} support keys ordered by support strength."
+ )
+
+
+def coerce_recall_plan(payload: Mapping[str, object] | None, *, max_queries: int = 6, original_prompt: str = "") -> AgenticRecallPlan:
+ raw_queries = payload.get("queries") if payload else None
+ queries: list[str] = []
+ if isinstance(raw_queries, list):
+ for value in raw_queries:
+ query = str(value).strip()
+ if query and query.lower() != original_prompt.lower() and query not in queries:
+ queries.append(query)
+ if len(queries) >= max_queries:
+ break
+ reason = str(payload.get("reason", "")) if payload else ""
+ return AgenticRecallPlan(queries=queries, reason=reason)
+
+
+def coerce_support_selection(
+ payload: Mapping[str, object] | None,
+ *,
+ allowed_keys: set[str],
+ support_budget: int,
+) -> EvidenceSupportSelection:
+ raw_keys = None
+ if payload:
+ raw_keys = payload.get("support_keys")
+ if raw_keys is None:
+ raw_keys = payload.get("document_ids")
+ support_keys: list[str] = []
+ if isinstance(raw_keys, list):
+ for value in raw_keys:
+ key = str(value).strip()
+ if key in allowed_keys and key not in support_keys:
+ support_keys.append(key)
+ if len(support_keys) >= support_budget:
+ break
+ answerability = str(payload.get("answerability", "none")) if payload else "none"
+ if answerability not in {"full", "partial", "none"}:
+ answerability = "none"
+ coverage_note = str(payload.get("coverage_note", "")) if payload else ""
+ return EvidenceSupportSelection(
+ support_keys=support_keys,
+ coverage_note=coverage_note,
+ answerability=answerability, # type: ignore[arg-type]
+ )
+
+
+def promote_support_candidates(
+ candidates: Iterable[EvidenceCandidate],
+ support_keys: Iterable[str],
+) -> list[EvidenceCandidate]:
+ support_order = list(dict.fromkeys(str(key) for key in support_keys))
+ by_key = {candidate.key: candidate for candidate in candidates}
+ promoted = [by_key[key] for key in support_order if key in by_key]
+ promoted.extend(candidate for candidate in candidates if candidate.key not in set(support_order))
+ return promoted
diff --git a/src/vaner/broker/aggregation.py b/src/vaner/broker/aggregation.py
new file mode 100644
index 00000000..3a006c2b
--- /dev/null
+++ b/src/vaner/broker/aggregation.py
@@ -0,0 +1,872 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import hashlib
+import re
+import time
+from collections import Counter, defaultdict
+from dataclasses import dataclass, field
+
+from vaner.broker.context_preparation import extract_query_keywords
+from vaner.models.artefact import Artefact, ArtefactKind
+from vaner.models.context_preparation import ContextPreparationProfile, ContextToolTrace
+
+
+def build_source_aggregation(
+ prompt: str,
+ candidates: list[Artefact],
+ profile: ContextPreparationProfile,
+ *,
+ source_by_key: dict[str, set[str]] | None = None,
+ max_sources: int = 40,
+ provenance_budget: int | None = None,
+) -> tuple[Artefact | None, ContextToolTrace]:
+ """Compress many relevant sources into a provenance-preserving evidence artefact."""
+
+ trace = ContextToolTrace(tool="aggregate_sources", input_count=len(candidates))
+ if profile.need != "multi_source_synthesis" or len(candidates) < 3:
+ trace.notes.append("skipped:not_multi_source_or_too_few_candidates")
+ return None, trace
+
+ source_candidates = _dedupe_candidates(candidates)[:max_sources]
+ if len(source_candidates) < 3:
+ trace.notes.append("skipped:dedupe_left_too_few_candidates")
+ return None, trace
+
+ resolved_provenance_budget = max_sources if provenance_budget is None else provenance_budget
+ extraction_spec = _infer_extraction_spec(prompt)
+ candidate_scope_spec = _infer_candidate_scope_spec(prompt, source_candidates)
+ eligible_sources, excluded_sources = _apply_candidate_scope(source_candidates, candidate_scope_spec)
+ aggregation_sources = eligible_sources or source_candidates
+ grouped, extracted_rows = _group_findings(prompt, aggregation_sources, extraction_spec)
+ extraction_source_keys = list(dict.fromkeys(row.source_key for row in extracted_rows))
+ extraction_source_paths = list(dict.fromkeys(row.source_path for row in extracted_rows))
+ counted_rows = [row for row in extracted_rows if row.scope_status == "counted"]
+ counted_source_keys = list(dict.fromkeys(row.source_key for row in counted_rows))
+ counted_source_paths = list(dict.fromkeys(row.source_path for row in counted_rows))
+ dropped_rows = [row for row in extracted_rows if row.scope_status != "counted"]
+ facets = _facet_coverage(profile, aggregation_sources)
+ source_classes = Counter(_source_class(artefact.source_path) for artefact in aggregation_sources)
+ source_refs = _source_refs(aggregation_sources, source_by_key or {}, provenance_budget=resolved_provenance_budget)
+ gaps = _coverage_gaps(prompt, profile, aggregation_sources, grouped)
+ if not eligible_sources and excluded_sources:
+ gaps.append("scope filter produced no eligible sources; used unfiltered candidates")
+ provenance_truncated = len(aggregation_sources) > resolved_provenance_budget
+ if provenance_truncated:
+ gaps.append("provenance truncated to budget")
+
+ sections = [
+ "Prepared source aggregation",
+ f"Question: {prompt.strip()}",
+ "",
+ "Coverage:",
+ f"- sources_considered: {len(source_candidates)}",
+ f"- eligible_sources: {len(aggregation_sources)}",
+ f"- excluded_sources: {len(excluded_sources)}",
+ "- source_classes: " + _format_counter(source_classes, limit=8),
+ "- facets_covered: " + (", ".join(facets) if facets else "none detected"),
+ f"- provenance_coverage: {min(len(aggregation_sources), resolved_provenance_budget)}/{len(aggregation_sources)}",
+ "- gaps: " + ("; ".join(gaps) if gaps else "none detected"),
+ "",
+ "Extraction:",
+ f"- item_type: {extraction_spec.item_type}",
+ f"- group_by: {extraction_spec.group_by}",
+ f"- count_basis: {extraction_spec.count_basis}",
+ f"- extracted_row_count: {len(extracted_rows)}",
+ f"- counted_row_count: {len(counted_rows)}",
+ f"- dropped_row_count: {len(dropped_rows)}",
+ "",
+ "Grouped findings:",
+ *_finding_lines(grouped),
+ "",
+ "Representative provenance:",
+ *source_refs,
+ ]
+ content = "\n".join(sections).strip()
+ digest = hashlib.sha256((prompt + "\n" + "\n".join(a.key for a in aggregation_sources)).encode("utf-8")).hexdigest()[:16]
+ metadata = {
+ "context_sources": ["aggregate_sources"],
+ "aggregation_source_count": len(aggregation_sources),
+ "aggregation_source_keys": [artefact.key for artefact in aggregation_sources[:resolved_provenance_budget]],
+ "aggregation_source_paths": [artefact.source_path for artefact in aggregation_sources[:resolved_provenance_budget]],
+ "candidate_pool_count": len(source_candidates),
+ "eligible_source_count": len(aggregation_sources),
+ "eligible_source_keys": [artefact.key for artefact in aggregation_sources[:resolved_provenance_budget]],
+ "eligible_source_paths": [artefact.source_path for artefact in aggregation_sources[:resolved_provenance_budget]],
+ "excluded_source_count": len(excluded_sources),
+ "excluded_sources": [_excluded_source_metadata(item) for item in excluded_sources[:resolved_provenance_budget]],
+ "candidate_scope_spec": _candidate_scope_spec_metadata(candidate_scope_spec),
+ "aggregation_groups": [
+ _group_metadata(group, evidence_budget=min(12, resolved_provenance_budget)) for group in grouped[:12]
+ ],
+ "aggregation_mode": _aggregation_mode(prompt),
+ "aggregation_extraction_spec": _extraction_spec_metadata(extraction_spec),
+ "count_basis": extraction_spec.count_basis,
+ "extracted_row_count": len(extracted_rows),
+ "extracted_rows": [_extracted_row_metadata(row) for row in extracted_rows[:resolved_provenance_budget]],
+ "counted_row_count": len(counted_rows),
+ "counted_rows": [_extracted_row_metadata(row) for row in counted_rows[:resolved_provenance_budget]],
+ "dropped_row_count": len(dropped_rows),
+ "dropped_rows": [_extracted_row_metadata(row) for row in dropped_rows[:resolved_provenance_budget]],
+ "extraction_confidence": _mean_confidence(extracted_rows),
+ "extraction_source_count": len(extraction_source_keys),
+ "extraction_source_keys": extraction_source_keys[:resolved_provenance_budget],
+ "extraction_source_paths": extraction_source_paths[:resolved_provenance_budget],
+ "counted_source_count": len(counted_source_keys),
+ "counted_source_keys": counted_source_keys[:resolved_provenance_budget],
+ "counted_source_paths": counted_source_paths[:resolved_provenance_budget],
+ "provenance_coverage_count": min(len(aggregation_sources), resolved_provenance_budget),
+ "provenance_truncated": provenance_truncated,
+ "provenance": "prepared_context_aggregation",
+ }
+ trace.output_count = 1
+ trace.notes.append(f"sources_considered:{len(source_candidates)}")
+ trace.notes.append(f"eligible_sources:{len(aggregation_sources)}")
+ trace.notes.append(f"excluded_sources:{len(excluded_sources)}")
+ trace.notes.append(f"groups:{len(grouped)}")
+ trace.notes.append(f"count_basis:{extraction_spec.count_basis}")
+ trace.notes.append(f"item_type:{extraction_spec.item_type}")
+ trace.notes.append(f"extracted_rows:{len(extracted_rows)}")
+ trace.notes.append(f"extraction_sources:{len(extraction_source_keys)}")
+ trace.notes.append(f"counted_rows:{len(counted_rows)}")
+ trace.notes.append(f"counted_sources:{len(counted_source_keys)}")
+ trace.notes.append(f"dropped_rows:{len(dropped_rows)}")
+ return (
+ Artefact(
+ key=f"prepared_context:source_aggregation:{digest}",
+ kind=ArtefactKind.FILE_SUMMARY,
+ source_path=f"prepared_context/source_aggregation/{digest}.md",
+ source_mtime=time.time(),
+ generated_at=time.time(),
+ model="vaner-context-tools",
+ content=content,
+ metadata=metadata,
+ ),
+ trace,
+ )
+
+
+def _dedupe_candidates(candidates: list[Artefact]) -> list[Artefact]:
+ seen: set[str] = set()
+ deduped: list[Artefact] = []
+ for artefact in candidates:
+ if artefact.key in seen:
+ continue
+ seen.add(artefact.key)
+ deduped.append(artefact)
+ return deduped
+
+
+def _source_class(path: str) -> str:
+ parts = [part for part in path.split("/") if part]
+ if len(parts) >= 3 and parts[2].lower() in {"postmortems", "runbooks", "incidents", "tickets"}:
+ return "/".join(parts[:3])
+ if len(parts) >= 2:
+ return "/".join(parts[:2])
+ return parts[0] if parts else "unknown"
+
+
+def _facet_coverage(profile: ContextPreparationProfile, candidates: list[Artefact]) -> list[str]:
+ text = "\n".join(f"{artefact.source_path}\n{artefact.content}" for artefact in candidates).lower()
+ covered = [facet.value for facet in profile.facets if facet.value.lower() in text]
+ return list(dict.fromkeys(covered))[:16]
+
+
+def _infer_candidate_scope_spec(prompt: str, candidates: list[Artefact]) -> CandidateScopeSpec:
+ required_facets = _required_scope_facets(prompt)
+ source_class = _dominant_source_class(candidates)
+ confidence = 0.72 if required_facets else 0.55
+ return CandidateScopeSpec(
+ source_class=source_class,
+ required_facets=required_facets,
+ optional_facets=extract_query_keywords(prompt, limit=8),
+ exclusion_rules=["missing_required_facets"] if required_facets else [],
+ confidence=confidence,
+ )
+
+
+def _required_scope_facets(prompt: str) -> list[str]:
+ lower = prompt.lower()
+ facets: list[str] = []
+ project_match = re.search(
+ r"\b(?:project|initiative|program|customer|client|account|system|service|component|area)\s+([A-Z][A-Za-z0-9_-]{2,})\b",
+ prompt,
+ )
+ if project_match:
+ facets.append(project_match.group(1))
+ quoted = re.findall(r"['\"]([^'\"]{3,64})['\"]", prompt)
+ facets.extend(quoted)
+ if "postmortem" in lower or "postmortems" in lower:
+ facets.append("postmortem")
+ if "design review" in lower or "design reviews" in lower:
+ facets.append("design review")
+ if "customer feedback" in lower:
+ facets.append("customer feedback")
+ if "meeting notes" in lower:
+ facets.append("meeting notes")
+ return list(dict.fromkeys(facets))[:8]
+
+
+def _dominant_source_class(candidates: list[Artefact]) -> str | None:
+ if not candidates:
+ return None
+ counts = Counter(_source_class(artefact.source_path) for artefact in candidates)
+ source_class, count = counts.most_common(1)[0]
+ return source_class if count >= max(2, len(candidates) // 3) else None
+
+
+def _apply_candidate_scope(
+ candidates: list[Artefact],
+ scope_spec: CandidateScopeSpec,
+) -> tuple[list[Artefact], list[ExcludedAggregationSource]]:
+ eligible: list[Artefact] = []
+ excluded: list[ExcludedAggregationSource] = []
+ for artefact in candidates:
+ reasons = _scope_exclusion_reasons(artefact, scope_spec)
+ if reasons:
+ excluded.append(ExcludedAggregationSource(source_key=artefact.key, source_path=artefact.source_path, reasons=reasons))
+ else:
+ eligible.append(artefact)
+ return eligible, excluded
+
+
+def _scope_exclusion_reasons(artefact: Artefact, scope_spec: CandidateScopeSpec) -> list[str]:
+ reasons: list[str] = []
+ haystack = f"{artefact.source_path}\n{artefact.content}".lower()
+ for facet in scope_spec.required_facets:
+ if facet.lower() not in haystack:
+ reasons.append(f"missing_required_facet:{facet}")
+ if scope_spec.source_class and _source_class(artefact.source_path) != scope_spec.source_class:
+ reasons.append(f"outside_source_class:{scope_spec.source_class}")
+ return reasons
+
+
+def _candidate_scope_spec_metadata(spec: CandidateScopeSpec) -> dict[str, object]:
+ return {
+ "source_class": spec.source_class,
+ "required_facets": spec.required_facets,
+ "optional_facets": spec.optional_facets,
+ "time_window": spec.time_window,
+ "entities": spec.entities,
+ "source_status": spec.source_status,
+ "canonicality": spec.canonicality,
+ "exclusion_rules": spec.exclusion_rules,
+ "max_scope_strategy": spec.max_scope_strategy,
+ "confidence": spec.confidence,
+ }
+
+
+def _excluded_source_metadata(item: ExcludedAggregationSource) -> dict[str, object]:
+ return {
+ "source_key": item.source_key,
+ "source_path": item.source_path,
+ "reasons": item.reasons,
+ }
+
+
+@dataclass(frozen=True)
+class AggregationExtractionSpec:
+ item_type: str = "generic_theme"
+ scope_hints: list[str] = field(default_factory=list)
+ group_by: str = "theme"
+ count_basis: str = "source_count"
+ evidence_required: list[str] = field(
+ default_factory=lambda: [
+ "source_key",
+ "source_path",
+ "snippet",
+ "extracted_text",
+ "normalized_group",
+ "confidence",
+ ]
+ )
+
+
+@dataclass(frozen=True)
+class CandidateScopeSpec:
+ source_class: str | None = None
+ required_facets: list[str] = field(default_factory=list)
+ optional_facets: list[str] = field(default_factory=list)
+ time_window: str | None = None
+ entities: list[str] = field(default_factory=list)
+ source_status: str | None = None
+ canonicality: str = "prefer_canonical"
+ exclusion_rules: list[str] = field(default_factory=list)
+ max_scope_strategy: str = "facet_intersection"
+ confidence: float = 0.55
+
+
+@dataclass(frozen=True)
+class ExcludedAggregationSource:
+ source_key: str
+ source_path: str
+ reasons: list[str]
+
+
+@dataclass(frozen=True)
+class AggregationEvidence:
+ source_key: str
+ path: str
+ snippet: str
+ confidence: float = 0.7
+
+
+@dataclass(frozen=True)
+class ExtractedAggregationRow:
+ item_type: str
+ extracted_text: str
+ source_key: str
+ source_path: str
+ snippet: str
+ group_type: str
+ group_raw_value: str
+ group_normalized_value: str
+ status: str | None = None
+ date: str | None = None
+ confidence: float = 0.7
+ scope_status: str = "counted"
+ scope_exclusion_reason: str | None = None
+
+
+@dataclass(frozen=True)
+class AggregationGroup:
+ value: str
+ count: int
+ sources: list[Artefact]
+ evidence: list[AggregationEvidence]
+ count_basis: str = "source_count"
+ rows: list[ExtractedAggregationRow] = field(default_factory=list)
+
+
+def _coverage_gaps(
+ prompt: str,
+ profile: ContextPreparationProfile,
+ candidates: list[Artefact],
+ grouped: list[AggregationGroup],
+) -> list[str]:
+ text = "\n".join(f"{artefact.source_path}\n{artefact.content}" for artefact in candidates).lower()
+ gaps: list[str] = []
+ for constraint in profile.constraints:
+ if constraint.kind != "restrictive_language" and constraint.required and constraint.value.lower() not in text:
+ gaps.append(f"missing required constraint: {constraint.value}")
+ if _asks_for_grouped_counts(prompt) and not grouped:
+ gaps.append("no stable grouping/count signal found")
+ if len(candidates) >= 30:
+ gaps.append("aggregation truncated to source budget")
+ return gaps[:8]
+
+
+def _group_findings(
+ prompt: str,
+ candidates: list[Artefact],
+ extraction_spec: AggregationExtractionSpec,
+) -> tuple[list[AggregationGroup], list[ExtractedAggregationRow]]:
+ if extraction_spec.count_basis == "extracted_item_count":
+ rows = _extract_rows(candidates, extraction_spec)
+ item_groups = _group_extracted_rows(candidates, rows, extraction_spec)
+ if item_groups:
+ return item_groups, rows
+ prompt_keywords = set(extract_query_keywords(prompt, limit=16))
+ group_to_sources: dict[str, list[Artefact]] = defaultdict(list)
+ group_to_evidence: dict[str, list[AggregationEvidence]] = defaultdict(list)
+ for artefact in candidates:
+ values = _group_values(artefact.content)
+ if not values and _asks_for_theme_summary(prompt):
+ values = _theme_values(artefact.content, prompt_keywords)
+ for value in sorted(values):
+ if artefact not in group_to_sources[value]:
+ group_to_sources[value].append(artefact)
+ group_to_evidence[value].append(
+ AggregationEvidence(
+ source_key=artefact.key,
+ path=artefact.source_path,
+ snippet=_best_snippet(artefact.content, value),
+ confidence=0.65,
+ )
+ )
+ grouped = [
+ AggregationGroup(
+ value=value,
+ count=len(sources),
+ sources=sources,
+ evidence=group_to_evidence[value],
+ count_basis=extraction_spec.count_basis,
+ )
+ for value, sources in group_to_sources.items()
+ ]
+ return sorted(grouped, key=lambda item: (-item.count, item.value.lower())), []
+
+
+def _group_extracted_rows(
+ candidates: list[Artefact],
+ rows: list[ExtractedAggregationRow],
+ extraction_spec: AggregationExtractionSpec,
+) -> list[AggregationGroup]:
+ source_lookup = {artefact.key: artefact for artefact in candidates}
+ group_to_sources: dict[str, list[Artefact]] = defaultdict(list)
+ group_to_evidence: dict[str, list[AggregationEvidence]] = defaultdict(list)
+ group_to_rows: dict[str, list[ExtractedAggregationRow]] = defaultdict(list)
+ group_counts: Counter[str] = Counter()
+ for row in rows:
+ value = row.group_normalized_value
+ artefact = source_lookup.get(row.source_key)
+ group_counts[value] += 1
+ if artefact is not None and artefact not in group_to_sources[value]:
+ group_to_sources[value].append(artefact)
+ group_to_rows[value].append(row)
+ group_to_evidence[value].append(
+ AggregationEvidence(
+ source_key=row.source_key,
+ path=row.source_path,
+ snippet=row.snippet,
+ confidence=row.confidence,
+ )
+ )
+ grouped = [
+ AggregationGroup(
+ value=value,
+ count=count,
+ sources=group_to_sources[value],
+ evidence=group_to_evidence[value],
+ count_basis=extraction_spec.count_basis,
+ rows=group_to_rows[value],
+ )
+ for value, count in group_counts.items()
+ ]
+ return sorted(grouped, key=lambda item: (-item.count, item.value.lower()))
+
+
+def _extract_rows(candidates: list[Artefact], extraction_spec: AggregationExtractionSpec) -> list[ExtractedAggregationRow]:
+ scoped_section_available = _has_matching_section(candidates, extraction_spec.scope_hints)
+ rows: list[ExtractedAggregationRow] = []
+ for artefact in candidates:
+ for line in _candidate_item_lines(artefact.content, extraction_spec, scoped_section_available=scoped_section_available):
+ for raw_group, normalized_group in _owner_values_from_line(line):
+ rows.append(
+ ExtractedAggregationRow(
+ item_type=extraction_spec.item_type,
+ extracted_text=line,
+ source_key=artefact.key,
+ source_path=artefact.source_path,
+ snippet=line[:260],
+ group_type=extraction_spec.group_by,
+ group_raw_value=raw_group,
+ group_normalized_value=normalized_group,
+ status=_status_from_line(line),
+ date=_date_from_line(line),
+ confidence=0.84 if scoped_section_available else 0.76,
+ )
+ )
+ return rows
+
+
+def _candidate_item_lines(
+ content: str,
+ extraction_spec: AggregationExtractionSpec,
+ *,
+ scoped_section_available: bool,
+) -> list[str]:
+ lines: list[str] = []
+ if scoped_section_available and extraction_spec.scope_hints:
+ for section_line in _section_lines_matching(content, extraction_spec.scope_hints):
+ if _line_has_grouping(section_line):
+ lines.append(section_line)
+ return lines
+
+ in_followup_section = False
+ for raw_line in content.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ if re.match(r"^#{1,4}\s+follow[- ]?up action items\b", line, re.IGNORECASE):
+ in_followup_section = True
+ continue
+ if in_followup_section and line.startswith("#"):
+ in_followup_section = False
+ if (
+ not in_followup_section
+ and not re.match(r"^(?:[-*]|\d+[.)])\s+", line)
+ and not _line_matches_item_type(line, extraction_spec)
+ ):
+ continue
+ if not _line_has_grouping(line):
+ continue
+ lines.append(line)
+ return lines
+
+
+def _has_matching_section(candidates: list[Artefact], hints: list[str]) -> bool:
+ if not hints:
+ return False
+ return any(_section_lines_matching(artefact.content, hints) for artefact in candidates)
+
+
+def _section_lines_matching(content: str, hints: list[str]) -> list[str]:
+ matched_lines: list[str] = []
+ in_matching_section = False
+ for raw_line in content.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ heading = _heading_text(line)
+ if heading is not None:
+ in_matching_section = any(hint.lower() in heading.lower() for hint in hints)
+ continue
+ if in_matching_section:
+ matched_lines.append(line)
+ return matched_lines
+
+
+def _heading_text(line: str) -> str | None:
+ match = re.match(r"^#{1,6}\s+(.+)$", line)
+ if match:
+ return match.group(1).strip()
+ match = re.match(r"^h[1-6]\.\s+(.+)$", line, re.IGNORECASE)
+ if match:
+ return match.group(1).strip()
+ return None
+
+
+def _line_matches_item_type(line: str, extraction_spec: AggregationExtractionSpec) -> bool:
+ pattern = _ITEM_TYPE_PATTERNS.get(extraction_spec.item_type)
+ if pattern is None:
+ return True
+ return bool(re.search(pattern, line, re.IGNORECASE))
+
+
+def _line_has_grouping(line: str) -> bool:
+ return bool(re.search(r"\b(owner|owning|assigned|team|dri|responsible)\b", line, re.IGNORECASE))
+
+
+def _owners_from_line(line: str) -> set[str]:
+ return {normalized for _raw, normalized in _owner_values_from_line(line)}
+
+
+def _owner_values_from_line(line: str) -> list[tuple[str, str]]:
+ owners: set[tuple[str, str]] = set()
+ patterns = (
+ r"\bowning team\s*:\s*([^.;\n]+)",
+ r"\bowner team\s*:\s*([^.;\n]+)",
+ r"\bowner\s*:\s*([^.;\n]+)",
+ r"\bassigned to\s+([^.;\n]+)",
+ r"\bresponsible team\s*:\s*([^.;\n]+)",
+ r"\bdri\s*:\s*([^.;\n]+)",
+ )
+ for pattern in patterns:
+ for match in re.finditer(pattern, line, re.IGNORECASE):
+ owners.update(_split_group_value_pairs(match.group(1)))
+ return sorted(owners, key=lambda item: item[1].lower())
+
+
+def _group_values(content: str) -> set[str]:
+ values: set[str] = set()
+ patterns = (
+ r"\b(?:owner|owned by|assigned to|responsible team|team|teams|dri|dris)\s*[:=-]?\s*(?:the\s+)?([A-Za-z][A-Za-z0-9&/_ -]{1,48})",
+ r"\b([A-Za-z][A-Za-z0-9&/_-]{1,32})\s+(?:team|squad|group)\b",
+ )
+ for pattern in patterns:
+ for match in re.finditer(pattern, content, flags=re.IGNORECASE):
+ values.update(_split_group_value(match.group(1)))
+ return {value for value in values if _valid_group_value(value)}
+
+
+def _split_group_value(raw: str) -> set[str]:
+ return {normalized for _raw, normalized in _split_group_value_pairs(raw)}
+
+
+def _split_group_value_pairs(raw: str) -> set[tuple[str, str]]:
+ cleaned = re.split(r"[.;()\n]", raw.strip(), maxsplit=1)[0]
+ cleaned = re.split(r"\s+\b(?:after|for|due|priority|target)\b", cleaned, maxsplit=1, flags=re.IGNORECASE)[0]
+ parts = re.split(r"\s*(?:,|/|\band\b|&)\s*", cleaned, flags=re.IGNORECASE)
+ values: set[tuple[str, str]] = set()
+ for part in parts:
+ stripped = part.strip()
+ if not stripped:
+ continue
+ normalized = _normalize_group_value(stripped)
+ if _valid_group_value(normalized):
+ values.add((stripped, normalized))
+ return values
+
+
+def _normalize_group_value(value: str) -> str:
+ tokens = [token for token in re.findall(r"[A-Za-z0-9_-]+", value) if token.lower() not in _GROUP_STOPWORDS]
+ while len(tokens) > 1 and tokens[0].lower() in _GROUP_PREFIX_STOPWORDS:
+ tokens.pop(0)
+ while len(tokens) > 1 and tokens[-1].lower() in _GROUP_PREFIX_STOPWORDS:
+ tokens.pop()
+ if not tokens:
+ return ""
+ return " ".join(token.upper() if token.isupper() else token[:1].upper() + token[1:].lower() for token in tokens[:4])
+
+
+def _valid_group_value(value: str) -> bool:
+ lowered = value.lower()
+ return bool(value) and len(value) <= 48 and lowered not in _GROUP_STOPWORDS and not lowered.isdigit()
+
+
+def _theme_values(content: str, prompt_keywords: set[str]) -> set[str]:
+ tokens = [
+ token.lower()
+ for token in re.findall(r"\b[A-Za-z][A-Za-z0-9_-]{3,}\b", content)
+ if token.lower() not in prompt_keywords and token.lower() not in _THEME_STOPWORDS
+ ]
+ counts = Counter(tokens)
+ return {token for token, count in counts.most_common(5) if count >= 2}
+
+
+def _asks_for_grouped_counts(prompt: str) -> bool:
+ return bool(re.search(r"\b(count|how many|most|highest number|by team|by owner|which team|which owner)\b", prompt, re.IGNORECASE))
+
+
+def _asks_for_theme_summary(prompt: str) -> bool:
+ return bool(re.search(r"\b(patterns?|themes?|summarize|synthesize|compare|across all)\b", prompt, re.IGNORECASE))
+
+
+def _finding_lines(grouped: list[AggregationGroup]) -> list[str]:
+ if not grouped:
+ return ["- no grouped finding could be inferred from candidate sources"]
+ lines: list[str] = []
+ for group in grouped[:12]:
+ unit = "item(s)" if group.count_basis == "extracted_item_count" else "source(s)"
+ paths = ", ".join(artefact.source_path for artefact in group.sources[:3])
+ if len(group.sources) > 3:
+ paths += f", +{len(group.sources) - 3} more"
+ lines.append(f"- {group.value}: {group.count} {unit}; examples: {paths}")
+ return lines
+
+
+def _group_metadata(group: AggregationGroup, *, evidence_budget: int) -> dict[str, object]:
+ evidence = group.evidence[:evidence_budget]
+ return {
+ "value": group.value,
+ "count": group.count,
+ "count_basis": group.count_basis,
+ "source_keys": [artefact.key for artefact in group.sources],
+ "source_paths": [artefact.source_path for artefact in group.sources],
+ "evidence": [
+ {
+ "source_key": item.source_key,
+ "path": item.path,
+ "snippet": item.snippet,
+ "confidence": item.confidence,
+ }
+ for item in evidence
+ ],
+ "extracted_rows": [_extracted_row_metadata(row) for row in group.rows[:evidence_budget]],
+ "evidence_truncated": len(group.evidence) > len(evidence),
+ }
+
+
+def _source_refs(candidates: list[Artefact], source_by_key: dict[str, set[str]], *, provenance_budget: int) -> list[str]:
+ refs: list[str] = []
+ for index, artefact in enumerate(candidates[:provenance_budget], start=1):
+ source_names = ",".join(sorted(source_by_key.get(artefact.key, set()))) or "candidate"
+ snippet = " ".join(artefact.content.split())[:220]
+ refs.append(f"- [{index}] {artefact.source_path} ({source_names}): {snippet}")
+ return refs
+
+
+def _format_counter(counter: Counter[str], *, limit: int) -> str:
+ if not counter:
+ return "none"
+ return ", ".join(f"{key}={value}" for key, value in counter.most_common(limit))
+
+
+def _aggregation_mode(prompt: str) -> str:
+ spec = _infer_extraction_spec(prompt)
+ if spec.count_basis == "extracted_item_count":
+ return "count_extracted_items"
+ if _asks_for_grouped_counts(prompt):
+ return "count_mentions"
+ if _asks_for_theme_summary(prompt):
+ return "summarize_themes"
+ return "group_by_entity"
+
+
+def _infer_extraction_spec(prompt: str) -> AggregationExtractionSpec:
+ lower = prompt.lower()
+ item_type = "generic_theme"
+ item_patterns = {
+ "action_item": r"\b(action items?|follow[- ]?up tasks?|tasks?|tickets?|open items?)\b",
+ "decision": r"\b(decisions?|decided|approved|adopted)\b",
+ "risk": r"\b(risks?|blockers?|concerns?)\b",
+ "customer_request": r"\b(customer requests?|feature requests?|requests?)\b",
+ "open_question": r"\b(open questions?|questions?)\b",
+ "meeting_time": r"\b(meeting time|scheduled|calendar|invite|time window)\b",
+ }
+ for candidate_type, pattern in item_patterns.items():
+ if re.search(pattern, lower):
+ item_type = candidate_type
+ break
+
+ scope_hints: list[str] = []
+ scope_terms = {
+ "follow-up": r"\bfollow[- ]?up\b",
+ "risk": r"\brisks?\b",
+ "owner": r"\bowners?\b",
+ "deadline": r"\b(deadlines?|due dates?)\b",
+ }
+ for value, pattern in scope_terms.items():
+ if re.search(pattern, lower):
+ scope_hints.append(value)
+ if not scope_hints and item_type != "generic_theme":
+ scope_hints.extend(_ITEM_TYPE_SECTION_HINTS.get(item_type, []))
+
+ if re.search(r"\b(owner|dri|assignee)\b", lower):
+ group_by = "owner"
+ elif re.search(r"\b(team|teams)\b", lower):
+ group_by = "team"
+ elif "status" in lower:
+ group_by = "status"
+ elif re.search(r"\b(date|when|deadline|due)\b", lower):
+ group_by = "date"
+ elif "source" in lower:
+ group_by = "source"
+ else:
+ group_by = "theme"
+
+ asks_for_item_rollup = item_type != "generic_theme" and (
+ _asks_for_grouped_counts(prompt) or re.search(r"\b(list|show|summarize|across all|by)\b", lower)
+ )
+ if asks_for_item_rollup:
+ count_basis = "extracted_item_count"
+ elif _asks_for_grouped_counts(prompt):
+ count_basis = "mention_count"
+ else:
+ count_basis = "source_count"
+
+ return AggregationExtractionSpec(
+ item_type=item_type,
+ scope_hints=list(dict.fromkeys(scope_hints)),
+ group_by=group_by,
+ count_basis=count_basis,
+ )
+
+
+def _extraction_spec_metadata(spec: AggregationExtractionSpec) -> dict[str, object]:
+ return {
+ "item_type": spec.item_type,
+ "scope_hints": spec.scope_hints,
+ "group_by": spec.group_by,
+ "count_basis": spec.count_basis,
+ "evidence_required": spec.evidence_required,
+ }
+
+
+def _extracted_row_metadata(row: ExtractedAggregationRow) -> dict[str, object]:
+ return {
+ "item_type": row.item_type,
+ "extracted_text": row.extracted_text,
+ "source_key": row.source_key,
+ "source_path": row.source_path,
+ "snippet": row.snippet,
+ "group": {
+ "type": row.group_type,
+ "raw_value": row.group_raw_value,
+ "normalized_value": row.group_normalized_value,
+ },
+ "status": row.status,
+ "date": row.date,
+ "scope_status": row.scope_status,
+ "scope_exclusion_reason": row.scope_exclusion_reason,
+ "confidence": row.confidence,
+ }
+
+
+def _status_from_line(line: str) -> str | None:
+ lowered = line.lower()
+ if "done" in lowered or "completed" in lowered:
+ return "completed"
+ if "blocked" in lowered:
+ return "blocked"
+ if "open" in lowered or "todo" in lowered or "pending" in lowered:
+ return "open"
+ return None
+
+
+def _date_from_line(line: str) -> str | None:
+ match = re.search(
+ r"\b(?:due|target(?: date)?)\s*:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}|[A-Za-z]{3,9}\s+\d{1,2}(?:,\s*\d{4})?)",
+ line,
+ re.IGNORECASE,
+ )
+ if match:
+ return match.group(1)
+ match = re.search(r"\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b", line)
+ return match.group(0) if match else None
+
+
+def _mean_confidence(rows: list[ExtractedAggregationRow]) -> float | None:
+ if not rows:
+ return None
+ return round(sum(row.confidence for row in rows) / len(rows), 3)
+
+
+def _best_snippet(content: str, value: str) -> str:
+ value_lower = value.lower()
+ for line in content.splitlines():
+ cleaned = " ".join(line.split())
+ if value_lower in cleaned.lower():
+ return cleaned[:260]
+ return " ".join(content.split())[:260]
+
+
+_GROUP_STOPWORDS = {
+ "and",
+ "assigned",
+ "item",
+ "items",
+ "team",
+ "teams",
+ "follow",
+ "follow-up",
+ "action",
+ "actions",
+ "owner",
+ "owned",
+ "responsible",
+ "the",
+ "to",
+ "for",
+}
+
+_GROUP_PREFIX_STOPWORDS = {
+ "eng",
+ "engineering",
+}
+
+_ITEM_TYPE_PATTERNS = {
+ "action_item": r"\b(action items?|follow[- ]?up tasks?|tasks?|tickets?|open items?|owner|owning|assigned|responsible|dri)\b",
+ "decision": r"\b(decisions?|decided|approved|adopted|accepted|rejected|owner|dri)\b",
+ "risk": r"\b(risks?|blockers?|concerns?|mitigation|owner|dri)\b",
+ "customer_request": r"\b(customer requests?|feature requests?|requests?|asks?|owner|dri|product area)\b",
+ "open_question": r"\b(open questions?|questions?|owner|dri)\b",
+ "meeting_time": r"\b(meeting time|scheduled|calendar|invite|time window|owner|dri)\b",
+}
+
+_ITEM_TYPE_SECTION_HINTS = {
+ "action_item": ["action item", "tasks", "open items"],
+ "decision": ["decision"],
+ "risk": ["risk"],
+ "customer_request": ["request"],
+ "open_question": ["open question"],
+ "meeting_time": ["meeting time", "scheduled", "calendar", "invite"],
+}
+
+_THEME_STOPWORDS = {
+ "that",
+ "this",
+ "with",
+ "from",
+ "have",
+ "were",
+ "been",
+ "their",
+ "there",
+ "source",
+ "sources",
+ "summary",
+ "evidence",
+}
diff --git a/src/vaner/broker/answerable.py b/src/vaner/broker/answerable.py
index 19417a94..903ca091 100644
--- a/src/vaner/broker/answerable.py
+++ b/src/vaner/broker/answerable.py
@@ -5,6 +5,7 @@
import re
from pathlib import Path
+from vaner.broker.compressor import EvidenceSpan, extract_evidence_spans
from vaner.broker.selector import _prompt_terms
from vaner.models.answerable import (
Answerability,
@@ -43,9 +44,9 @@ def build_answerable_briefing(
cost_sensitivity: str = "balanced",
) -> AnswerableBriefing:
terms = _prompt_terms(query)
- scored = [
- _build_item(query, terms, artefact, repo_root=repo_root, channel=_channel_for(artefact, channels_by_key)) for artefact in artefacts
- ]
+ scored: list[tuple[float, int, AnswerableEvidenceItem]] = []
+ for artefact in artefacts:
+ scored.extend(_build_items(query, terms, artefact, repo_root=repo_root, channel=_channel_for(artefact, channels_by_key)))
scored.sort(key=lambda row: (row[0], -row[1], row[2].path), reverse=True)
direct: list[AnswerableEvidenceItem] = []
@@ -170,6 +171,8 @@ def build_answerable_briefing(
}
)
transport_limited_count = dropped_direct + dropped_supporting + dropped_lower
+ if assembly_mode == "safe" and (direct_truncated or supporting_truncated or lower_truncated):
+ transport_limited_count = max(1, transport_limited_count)
if transport_limited_count:
assembly_plan = _mark_transport_limited(assembly_plan, direct + supporting + lower, transport_limited_count)
truncation_applied = direct_truncated or supporting_truncated or lower_truncated or dropped_direct > 0
@@ -280,35 +283,84 @@ def build_answerable_briefing_from_text(
return AnswerableBriefing(text=text, answer_plan=answer_plan, sections=sections, metadata=metadata)
-def _build_item(
+def _build_items(
query: str,
terms: list[str],
artefact: Artefact,
*,
repo_root: Path | None,
channel: EvidenceChannel,
-) -> tuple[float, int, AnswerableEvidenceItem]:
+) -> list[tuple[float, int, AnswerableEvidenceItem]]:
source_text = _source_text(artefact, repo_root)
- excerpt, truncated = _best_excerpt(source_text or artefact.content, terms)
+ source_artefact = artefact.model_copy(update={"content": source_text}) if source_text else artefact
path = artefact.source_path
+ spans = extract_evidence_spans(source_artefact, query, base_score=float(artefact.relevance_score or 0.0), max_spans_per_file=7)
+ if spans:
+ return [_item_from_span(query, terms, artefact, span, channel=channel) for span in spans]
+
+ excerpt, truncated = _best_excerpt(source_text or artefact.content, terms)
score = _score_path_and_excerpt(query, path.lower(), excerpt.lower(), terms)
title = _title_for(path, excerpt)
confidence = max(0.0, min(1.0, score / 24.0))
+ return [
+ (
+ score,
+ -len(path),
+ AnswerableEvidenceItem(
+ path=path,
+ title=title,
+ source=str(artefact.metadata.get("corpus_id", "default")),
+ channel=channel,
+ why_selected=_why_selected(path, terms, score),
+ excerpt=excerpt,
+ relevance_to_query=_relevance(query, path, terms, score),
+ confidence=confidence,
+ token_count=count_tokens(excerpt),
+ revision_or_hash=str(artefact.metadata.get("revision") or artefact.metadata.get("hash") or "") or None,
+ truncated=truncated,
+ ),
+ )
+ ]
+
+
+def _item_from_span(
+ query: str,
+ terms: list[str],
+ artefact: Artefact,
+ span: EvidenceSpan,
+ *,
+ channel: EvidenceChannel,
+) -> tuple[float, int, AnswerableEvidenceItem]:
+ path = artefact.source_path
+ role_bonus = {
+ "direct": 8.0,
+ "constant_or_default": 6.0,
+ "schema_or_storage": 6.0,
+ "caller_or_downstream": 5.0,
+ "test_or_example": 4.0,
+ "supporting": 1.0,
+ "provenance": 0.5,
+ }[span.role]
+ score = span.score + role_bonus + _score_path_and_excerpt(query, path.lower(), span.excerpt.lower(), terms)
+ confidence = max(0.0, min(1.0, score / 32.0))
+ symbol = f" {span.symbol}" if span.symbol else ""
+ title = f"{span.role}{symbol} L{span.start_line}-{span.end_line}"
+ why = f"{_why_selected(path, terms, score)}; role={span.role}; lines={span.start_line}-{span.end_line}; {span.reason}"
return (
score,
- -len(path),
+ -span.start_line,
AnswerableEvidenceItem(
path=path,
title=title,
source=str(artefact.metadata.get("corpus_id", "default")),
channel=channel,
- why_selected=_why_selected(path, terms, score),
- excerpt=excerpt,
+ why_selected=why,
+ excerpt=span.excerpt,
relevance_to_query=_relevance(query, path, terms, score),
confidence=confidence,
- token_count=count_tokens(excerpt),
+ token_count=count_tokens(span.excerpt),
revision_or_hash=str(artefact.metadata.get("revision") or artefact.metadata.get("hash") or "") or None,
- truncated=truncated,
+ truncated=False,
),
)
@@ -440,6 +492,15 @@ def _mark_transport_limited(
marked += 1
else:
updated.append(decision)
+ if transport_limited_count and marked == 0 and updated:
+ updated[-1] = updated[-1].model_copy(
+ update={
+ "decision": "transport_limited",
+ "reason": "evidence was constrained by the caller transport limit",
+ "would_change_output_in_shadow": True,
+ }
+ )
+ marked = 1
return assembly.model_copy(
update={
"items_transport_limited": assembly.items_transport_limited + max(transport_limited_count, marked),
@@ -465,6 +526,14 @@ def _dedupe_key(item: AnswerableEvidenceItem) -> str:
def _protected_reason(item: AnswerableEvidenceItem, role: str, conflict_paths: set[str]) -> str:
if role == "direct_answer_evidence":
return "direct answer evidence"
+ for evidence_role, reason in (
+ ("role=constant_or_default", "constant/default evidence"),
+ ("role=schema_or_storage", "schema/storage evidence"),
+ ("role=caller_or_downstream", "caller/downstream evidence"),
+ ("role=test_or_example", "test/example evidence"),
+ ):
+ if evidence_role in item.why_selected:
+ return reason
if item.path in conflict_paths:
return "conflict evidence"
if item.channel == "prediction":
diff --git a/src/vaner/broker/assembler.py b/src/vaner/broker/assembler.py
index 9f0aa817..2260be83 100644
--- a/src/vaner/broker/assembler.py
+++ b/src/vaner/broker/assembler.py
@@ -12,6 +12,7 @@
from vaner.models.answerable import EvidenceAssemblyMode
from vaner.models.artefact import Artefact
from vaner.models.context import ContextPackage, ContextSelection
+from vaner.models.context_preparation import PreparedContextDiagnostics
from vaner.models.decision import DecisionRecord, ScoreFactor, SelectionDecision
from vaner.policy.staleness import is_stale_timestamp
@@ -42,6 +43,7 @@ def assemble_context_package(
evidence_assembly_mode: EvidenceAssemblyMode = "shadow",
evidence_assembly_quality_bias: str = "protect_recall",
evidence_assembly_cost_sensitivity: str = "balanced",
+ prepared_context_diagnostics: PreparedContextDiagnostics | None = None,
return_decision: bool = False,
) -> ContextPackage | tuple[ContextPackage, DecisionRecord]:
resolved_score_map = score_map or {artefact.key: score_artefact(prompt, artefact) for artefact in artefacts}
@@ -49,6 +51,7 @@ def assemble_context_package(
artefacts,
max_tokens=max_tokens,
score_by_key=resolved_score_map,
+ query=prompt,
)
selection_decisions: list[SelectionDecision] = []
context_selections: list[ContextSelection] = []
@@ -104,7 +107,7 @@ def assemble_context_package(
)
answerable = build_answerable_briefing(
prompt,
- [artefact for artefact in artefacts if artefact.key in kept_keys],
+ artefacts,
repo_root=repo_root,
max_tokens=max_tokens,
channels_by_key={
@@ -121,6 +124,12 @@ def assemble_context_package(
)
context_package.answerable_briefing = answerable
context_package.answerability_metadata = answerable.metadata
+ context_package.prepared_context_briefing = answerable.text
+ context_package.prepared_context_mode = _prepared_context_mode(prepared_context_diagnostics, answerable.metadata.answerability)
+ if prepared_context_diagnostics is not None:
+ prepared_context_diagnostics.token_used = used
+ prepared_context_diagnostics.truncation_risk = answerable.metadata.truncation_risk
+ context_package.prepared_context_diagnostics = prepared_context_diagnostics
decision_record = DecisionRecord(
id=context_package.id,
prompt=prompt,
@@ -129,7 +138,31 @@ def assemble_context_package(
token_budget=max_tokens,
token_used=used,
selections=selection_decisions,
+ prepared_context_diagnostics=prepared_context_diagnostics,
)
if return_decision:
return context_package, decision_record
return context_package
+
+
+def _prepared_context_mode(diagnostics: PreparedContextDiagnostics | None, answerability: str) -> str:
+ if diagnostics is None:
+ return "answerable"
+ need = diagnostics.profile.need
+ if need == "implementation_support":
+ return "implementation"
+ if need == "research_mapping":
+ return "research"
+ if need == "creative_grounding":
+ return "writing"
+ if need == "decision_support":
+ return "decision"
+ if need == "scheduling":
+ return "planning"
+ if need == "conflict_resolution":
+ return "conflict_resolution"
+ if need == "absence_check":
+ return "absence_check"
+ if answerability in {"full", "weak", "conflict"}:
+ return "answerable"
+ return "planning"
diff --git a/src/vaner/broker/compressor.py b/src/vaner/broker/compressor.py
index f78e35fb..4a6bb799 100644
--- a/src/vaner/broker/compressor.py
+++ b/src/vaner/broker/compressor.py
@@ -2,23 +2,613 @@
from __future__ import annotations
+import ast
+import re
+from dataclasses import dataclass
+from typing import Literal
+
+from vaner.broker.selector import _prompt_terms
from vaner.models.artefact import Artefact
from vaner.policy.budget import count_tokens
+EvidenceSpanRole = Literal[
+ "direct",
+ "constant_or_default",
+ "schema_or_storage",
+ "caller_or_downstream",
+ "test_or_example",
+ "supporting",
+ "provenance",
+]
+
+
+@dataclass(frozen=True)
+class EvidenceSpan:
+ path: str
+ start_line: int
+ end_line: int
+ role: EvidenceSpanRole
+ symbol: str
+ excerpt: str
+ score: float
+ protected: bool
+ reason: str
+
+
+def _trim_chunk_to_budget(chunk: str, max_tokens: int) -> str:
+ if max_tokens <= 0:
+ return ""
+ if count_tokens(chunk) <= max_tokens:
+ return chunk
+ first_line, _, remainder = chunk.partition("\n")
+ marker = "\n...[trimmed]\n"
+ header = first_line.strip()
+ if count_tokens(header + marker) > max_tokens:
+ max_chars = max(1, max_tokens * 4)
+ return (header + "\n")[:max_chars].rstrip()
+ max_chars = max(120, max_tokens * 4)
+ trimmed = (header + "\n" + remainder[:max_chars]).rstrip()
+ while trimmed and count_tokens(trimmed + marker) > max_tokens:
+ trimmed = trimmed[: max(0, len(trimmed) - 120)].rstrip()
+ return trimmed + marker if trimmed else header + marker
+
+
+def _chunk_for_artefact(artefact: Artefact, *, max_chunk_tokens: int) -> str:
+ header = f"### {artefact.source_path}\n"
+ content = artefact.content or ""
+ if max_chunk_tokens <= 0:
+ return header.rstrip()
+ full = f"{header}{content}\n"
+ if count_tokens(full) <= max_chunk_tokens:
+ return full
+ sections = _important_lines(content)
+ if sections:
+ candidate = f"{header}" + "\n".join(sections) + "\n...[compacted to important implementation anchors]\n"
+ if count_tokens(candidate) <= max_chunk_tokens:
+ return candidate
+ return _trim_chunk_to_budget(full, max_chunk_tokens)
+
+
+def _important_lines(content: str) -> list[str]:
+ important: list[str] = []
+ patterns = (
+ r"^(Classes|Functions|Constants|Limits|Schema):",
+ r"\b(CREATE TABLE|CREATE INDEX|USING fts5|INSERT INTO|SELECT .* FROM)\b",
+ r"\b(class|def|async def)\s+[A-Za-z_][A-Za-z0-9_]*",
+ r"\b(max_|min_|limit|threshold|timeout|ttl|budget|window|top_n|top_k)\w*\b",
+ )
+ for line in content.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if any(re.search(pattern, stripped, flags=re.IGNORECASE) for pattern in patterns):
+ important.append(stripped[:240])
+ if len(important) >= 24:
+ break
+ return important
+
+
+def extract_evidence_spans(
+ artefact: Artefact,
+ query: str,
+ *,
+ base_score: float = 0.0,
+ max_spans_per_file: int = 8,
+) -> list[EvidenceSpan]:
+ """Return query-aware, line-exact spans for one artefact.
+
+ The extractor is deliberately deterministic. It preserves code and config
+ ingredients as copied source spans instead of asking a model to summarize
+ them, which keeps identifiers, constants, table names, and parser contracts
+ exact under tight context budgets.
+ """
+
+ text = artefact.content or ""
+ lines = text.splitlines()
+ if not lines:
+ return []
+ terms = _prompt_terms(query)
+ if not terms:
+ terms = [part for part in re.findall(r"[A-Za-z_][A-Za-z0-9_]{2,}", query.lower()) if len(part) > 2]
+ candidates = _structured_candidates(artefact.source_path, lines, terms)
+ candidates.extend(_line_candidates(artefact.source_path, lines, terms))
+ candidates = _merge_candidate_windows(candidates)
+
+ spans: list[EvidenceSpan] = []
+ for start, end, role, symbol, score, reason in candidates:
+ excerpt = _numbered_excerpt(lines, start, end)
+ if not excerpt.strip():
+ continue
+ adjusted = score + (base_score * 0.08)
+ spans.append(
+ EvidenceSpan(
+ path=artefact.source_path,
+ start_line=start,
+ end_line=end,
+ role=role,
+ symbol=symbol,
+ excerpt=excerpt,
+ score=adjusted,
+ protected=role in {"direct", "constant_or_default", "schema_or_storage", "caller_or_downstream", "test_or_example"},
+ reason=reason,
+ )
+ )
+ spans.sort(key=lambda span: (_role_priority(span.role), span.protected, span.score, -span.start_line), reverse=True)
+ return _diverse_spans(spans, max_spans_per_file=max_spans_per_file)
+
+
+def _structured_candidates(
+ path: str,
+ lines: list[str],
+ terms: list[str],
+) -> list[tuple[int, int, EvidenceSpanRole, str, float, str]]:
+ if not path.endswith(".py"):
+ return []
+ text = "\n".join(lines)
+ try:
+ tree = ast.parse(text)
+ except SyntaxError:
+ return []
+
+ candidates: list[tuple[int, int, EvidenceSpanRole, str, float, str]] = []
+ imports = [
+ node.lineno
+ for node in tree.body
+ if isinstance(node, (ast.Import, ast.ImportFrom)) and _node_line_score(lines[node.lineno - 1], terms, path) > 0
+ ]
+ if imports:
+ candidates.append(
+ (max(1, min(imports) - 1), min(len(lines), max(imports) + 1), "provenance", "imports", 4.0, "matched import provenance")
+ )
+
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ symbol = node.name
+ start = node.lineno
+ end = min(len(lines), getattr(node, "end_lineno", node.lineno))
+ block_text = "\n".join(lines[start - 1 : end]).lower()
+ score = _symbol_score(symbol, terms) + _text_term_score(block_text, terms)
+ anchor = _block_anchor_score(symbol, block_text)
+ if score <= 0 and anchor <= 0:
+ continue
+ semantic_role = _role_for_block(path, symbol, block_text)
+ role = semantic_role if semantic_role != "supporting" else "direct"
+ max_lines = 3 if isinstance(node, ast.ClassDef) else 42
+ span_start, span_end = _focused_block_window(lines, start, end, terms, max_lines=max_lines)
+ candidates.append((span_start, span_end, role, symbol, score + anchor + 3.0, f"matched {symbol} block"))
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
+ target_names = _assignment_targets(node)
+ if not target_names:
+ continue
+ start = node.lineno
+ end = min(len(lines), getattr(node, "end_lineno", node.lineno))
+ line_text = "\n".join(lines[start - 1 : end])
+ score = max((_symbol_score(name, terms) for name in target_names), default=0.0) + _node_line_score(line_text, terms, path)
+ if score <= 0 and not _constantish(line_text):
+ continue
+ candidates.append(
+ (
+ max(1, start - 1),
+ min(len(lines), end + 1),
+ "constant_or_default",
+ ", ".join(target_names[:3]),
+ score + 5.0,
+ "preserved constant/default assignment",
+ )
+ )
+ return candidates
+
+
+def _line_candidates(
+ path: str,
+ lines: list[str],
+ terms: list[str],
+) -> list[tuple[int, int, EvidenceSpanRole, str, float, str]]:
+ candidates: list[tuple[int, int, EvidenceSpanRole, str, float, str]] = []
+ for index, line in enumerate(lines, start=1):
+ stripped = line.strip()
+ if path.endswith(".py") and stripped.startswith("#"):
+ continue
+ score = _node_line_score(line, terms, path)
+ if score <= 0:
+ continue
+ role = _role_for_text(path, line)
+ if path.endswith(".py"):
+ radius = 0 if re.search(r"\b(class|def|async def)\s+[A-Za-z_][A-Za-z0-9_]*", line) else 1
+ else:
+ radius = 1 if role in {"constant_or_default", "schema_or_storage"} else 2
+ symbol = _symbol_from_line(line)
+ candidates.append(
+ (
+ max(1, index - radius),
+ min(len(lines), index + radius),
+ role,
+ symbol,
+ score,
+ f"matched {role.replace('_', ' ')} line",
+ )
+ )
+ return candidates
+
+
+def _node_line_score(text: str, terms: list[str], path: str) -> float:
+ lowered = text.lower()
+ path_lowered = path.lower()
+ score = 0.0
+ content_hit_score = 0.0
+ for term in terms:
+ if len(term) < 3:
+ continue
+ if term in lowered:
+ content_hit_score += 4.0 if "_" not in term else 7.0
+ if term in path_lowered:
+ score += 1.5
+ if content_hit_score <= 0:
+ score = 0.0
+ score += content_hit_score
+ if re.search(r"\b(class|def|async def)\s+[A-Za-z_][A-Za-z0-9_]*", text):
+ score += 2.0
+ if _constantish(text):
+ score += 4.0
+ if re.search(r"\b(CREATE TABLE|CREATE INDEX|USING fts5|ALTER TABLE|INSERT INTO|SELECT\b.+\bFROM)\b", text, re.IGNORECASE):
+ score += 7.0
+ if re.search(r"\b(json\.loads|json\.dumps|JSON_CONTRACT|ranked_files|follow_on|response_format|schema)\b", text):
+ score += 5.0
+ if re.search(r"\b(reward_total|reward_components|cache_tier|quality_lift|judge_score|similarity|raw_reward)\b", text):
+ score += 5.0
+ return score
+
+
+def _text_term_score(text: str, terms: list[str]) -> float:
+ return sum(2.0 if term in text else 0.0 for term in terms if len(term) > 3)
+
+
+def _symbol_score(symbol: str, terms: list[str]) -> float:
+ symbol_terms = set(_identifier_parts(symbol))
+ score = 0.0
+ for term in terms:
+ if term == symbol.lower():
+ score += 10.0
+ elif term in symbol_terms:
+ score += 5.0
+ elif len(term) > 3 and term in symbol.lower():
+ score += 3.0
+ return score
+
+
+def _block_anchor_score(symbol: str, block_text: str) -> float:
+ score = 0.0
+ symbol_lower = symbol.lower()
+ if any(part in symbol_lower for part in ("reward", "feature", "train", "blend", "store", "retrieve", "persist", "schema", "cache")):
+ score += 2.0
+ if any(anchor in block_text for anchor in ("create table", "json.loads", "reward_total", "threshold", "default", "ranked_files")):
+ score += 2.0
+ return score
+
+
+def _assignment_targets(node: ast.Assign | ast.AnnAssign) -> list[str]:
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
+ names: list[str] = []
+ for target in targets:
+ if isinstance(target, ast.Name):
+ names.append(target.id)
+ elif isinstance(target, ast.Attribute):
+ names.append(target.attr)
+ elif isinstance(target, (ast.Tuple, ast.List)):
+ names.extend(elt.id for elt in target.elts if isinstance(elt, ast.Name))
+ return names
+
+
+def _constantish(text: str) -> bool:
+ return bool(
+ re.search(
+ r"\b([A-Z][A-Z0-9_]{2,}|default|defaults|threshold|timeout|ttl|limit|weight|weights|ratio|budget|target|schema|contract)\b",
+ text,
+ re.IGNORECASE,
+ )
+ and "=" in text
+ )
+
+
+def _role_for_text(path: str, text: str) -> EvidenceSpanRole:
+ lowered = text.lower()
+ if path.startswith(("tests/", "test/")) or "/tests/" in path or "assert " in lowered:
+ return "test_or_example"
+ if re.search(r"\b(create table|create index|using fts5|alter table|schema|select\b.+\bfrom|insert into)\b", lowered):
+ return "schema_or_storage"
+ if re.search(r"\b(default|threshold|timeout|ttl|limit|weight|weights|ratio|budget|target|contract)\b", lowered) and "=" in text:
+ return "constant_or_default"
+ if re.search(r"\b(call|caller|consumer|downstream|insert|select)\b|retrieve|persist|compute_|load_|save_|train|blend|parse", lowered):
+ return "caller_or_downstream"
+ if re.search(r"\b(class|def|async def)\s+[A-Za-z_][A-Za-z0-9_]*", text):
+ return "direct"
+ return "supporting"
+
+
+def _role_for_block(path: str, symbol: str, block_text: str) -> EvidenceSpanRole:
+ semantic = _role_for_text(path, block_text)
+ if semantic in {"schema_or_storage", "test_or_example"}:
+ return semantic
+ lowered_symbol = symbol.lower()
+ if re.search(r"\b(downstream|consumer|caller|train|target|blend|parse|json\.loads|retrieve|persist|insert|select)\b", block_text):
+ return "caller_or_downstream"
+ if any(part in lowered_symbol for part in ("train", "target", "blend", "parse", "retrieve", "persist")):
+ return "caller_or_downstream"
+ if semantic == "constant_or_default":
+ return semantic
+ return "direct" if block_text.strip() else "supporting"
+
+
+def _role_priority(role: EvidenceSpanRole) -> int:
+ return {
+ "direct": 70,
+ "constant_or_default": 60,
+ "schema_or_storage": 55,
+ "caller_or_downstream": 50,
+ "test_or_example": 45,
+ "supporting": 25,
+ "provenance": 10,
+ }[role]
+
+
+def _focused_block_window(lines: list[str], start: int, end: int, terms: list[str], *, max_lines: int) -> tuple[int, int]:
+ if end - start + 1 <= max_lines:
+ return start, end
+ best_line = start
+ best_score = -1.0
+ for index in range(start, end + 1):
+ score = _node_line_score(lines[index - 1], terms, "")
+ if score > best_score:
+ best_line = index
+ best_score = score
+ half = max_lines // 2
+ window_start = max(start, best_line - half)
+ window_end = min(end, window_start + max_lines - 1)
+ window_start = max(start, window_end - max_lines + 1)
+ return window_start, window_end
+
+
+def _merge_candidate_windows(
+ candidates: list[tuple[int, int, EvidenceSpanRole, str, float, str]],
+) -> list[tuple[int, int, EvidenceSpanRole, str, float, str]]:
+ ordered = sorted(candidates, key=lambda row: (row[0], row[1], -row[4]))
+ merged: list[tuple[int, int, EvidenceSpanRole, str, float, str]] = []
+ for candidate in ordered:
+ start, end, role, symbol, score, reason = candidate
+ if not merged or start > merged[-1][1] + 1:
+ merged.append(candidate)
+ continue
+ prev_start, prev_end, prev_role, prev_symbol, prev_score, prev_reason = merged[-1]
+ best_role = role if _merge_role_priority(role) > _merge_role_priority(prev_role) else prev_role
+ best_symbol = symbol or prev_symbol
+ merged[-1] = (
+ prev_start,
+ max(prev_end, end),
+ best_role,
+ best_symbol,
+ max(prev_score, score),
+ prev_reason if prev_score >= score else reason,
+ )
+ return merged
+
+
+def _merge_role_priority(role: EvidenceSpanRole) -> int:
+ return {
+ "schema_or_storage": 80,
+ "constant_or_default": 75,
+ "caller_or_downstream": 70,
+ "test_or_example": 65,
+ "direct": 55,
+ "supporting": 25,
+ "provenance": 10,
+ }[role]
+
+
+def _diverse_spans(spans: list[EvidenceSpan], *, max_spans_per_file: int) -> list[EvidenceSpan]:
+ kept: list[EvidenceSpan] = []
+ seen_roles: set[str] = set()
+ for span in spans:
+ if span.role in seen_roles and len([item for item in kept if item.role == span.role]) >= 3:
+ continue
+ kept.append(span)
+ seen_roles.add(span.role)
+ if len(kept) >= max_spans_per_file:
+ break
+ kept.sort(key=lambda span: (_role_priority(span.role), span.score), reverse=True)
+ return kept
+
+
+def _numbered_excerpt(lines: list[str], start: int, end: int) -> str:
+ width = len(str(end))
+ return "\n".join(f"L{line_no:0{width}d}: {lines[line_no - 1]}" for line_no in range(start, end + 1))
+
+
+def _symbol_from_line(line: str) -> str:
+ match = re.search(r"\b(?:class|def|async def)\s+([A-Za-z_][A-Za-z0-9_]*)", line)
+ if match:
+ return match.group(1)
+ match = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*=", line)
+ if match:
+ return match.group(1)
+ return ""
+
+
+def _identifier_parts(identifier: str) -> list[str]:
+ pieces = re.split(r"[^A-Za-z0-9]+", identifier)
+ parts: list[str] = []
+ for piece in pieces:
+ if not piece:
+ continue
+ parts.append(piece.lower())
+ parts.extend(part.lower() for part in re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)|\d+", piece))
+ return [part for part in parts if len(part) > 2]
+
+
+def _chunk_for_spans(path: str, spans: list[EvidenceSpan]) -> str:
+ parts = [f"### {path}"]
+ for span in spans:
+ parts.append(_span_entry(span))
+ return "\n".join(parts).rstrip() + "\n"
+
+
+def _span_entry(span: EvidenceSpan) -> str:
+ symbol = f" symbol={span.symbol}" if span.symbol else ""
+ protected = " protected" if span.protected else ""
+ return f"@@ lines {span.start_line}-{span.end_line} role={span.role}{symbol}{protected}\n{span.excerpt}"
+
+
+def _compress_context_with_spans(
+ artefacts: list[Artefact],
+ max_tokens: int,
+ query: str,
+ score_by_key: dict[str, float] | None,
+) -> tuple[str, dict[str, int], int, set[str]]:
+ token_map: dict[str, int] = {}
+ kept_spans_by_key: dict[str, list[EvidenceSpan]] = {artefact.key: [] for artefact in artefacts}
+ spans_by_key: dict[str, list[EvidenceSpan]] = {}
+ ordered_keys = [artefact.key for artefact in artefacts]
+ if score_by_key is not None:
+ ordered_keys.sort(key=lambda key: score_by_key.get(key, 0.0), reverse=True)
+ artefacts_by_key = {artefact.key: artefact for artefact in artefacts}
+ for artefact in artefacts:
+ spans = extract_evidence_spans(artefact, query, base_score=(score_by_key or {}).get(artefact.key, 0.0))
+ if not spans:
+ fallback = _chunk_for_artefact(
+ artefact,
+ max_chunk_tokens=max(96, min(max_tokens, max_tokens // max(1, min(3, len(artefacts) or 1)))),
+ )
+ pseudo_span = EvidenceSpan(
+ path=artefact.source_path,
+ start_line=1,
+ end_line=max(1, len((artefact.content or "").splitlines())),
+ role="supporting",
+ symbol="",
+ excerpt=fallback,
+ score=(score_by_key or {}).get(artefact.key, 0.0),
+ protected=False,
+ reason="fallback compacted artefact",
+ )
+ spans = [pseudo_span] if fallback.strip() else []
+ spans_by_key[artefact.key] = spans
+
+ role_limits = _role_token_limits(max_tokens)
+ role_used = {role: 0 for role in role_limits}
+ role_kept = {role: 0 for role in role_limits}
+ used = 0
+ seen_signatures: set[str] = set()
+ all_spans: list[tuple[str, EvidenceSpan]] = []
+ for key in ordered_keys:
+ for span in spans_by_key.get(key, []):
+ all_spans.append((key, span))
+ all_spans.sort(
+ key=lambda row: (
+ row[1].protected,
+ _role_priority(row[1].role),
+ row[1].score,
+ (score_by_key or {}).get(row[0], 0.0),
+ ),
+ reverse=True,
+ )
+
+ for key, span in all_spans:
+ header_tokens = 0 if kept_spans_by_key[key] else count_tokens(f"### {artefacts_by_key[key].source_path}\n")
+ span_tokens = header_tokens + count_tokens(_span_entry(span))
+ signature = _span_signature(span)
+ if signature in seen_signatures:
+ continue
+ role_limit = role_limits.get(span.role, role_limits["supporting"])
+ if role_used.get(span.role, 0) + span_tokens > role_limit:
+ if not span.protected or role_kept.get(span.role, 0) >= 3:
+ continue
+ if used + span_tokens > max_tokens:
+ if span.protected:
+ trimmed = _trim_span(span, max(48, max_tokens - used - header_tokens))
+ trimmed_tokens = header_tokens + count_tokens(_span_entry(trimmed))
+ if used + trimmed_tokens > max_tokens:
+ continue
+ span = trimmed
+ span_tokens = trimmed_tokens
+ else:
+ continue
+ kept_spans_by_key[key].append(span)
+ seen_signatures.add(signature)
+ role_used[span.role] = role_used.get(span.role, 0) + span_tokens
+ role_kept[span.role] = role_kept.get(span.role, 0) + 1
+ used += span_tokens
+
+ kept_keys = {key for key, spans in kept_spans_by_key.items() if spans}
+ if not kept_keys and ordered_keys and max_tokens > 0:
+ key = ordered_keys[0]
+ chunk = _trim_chunk_to_budget(_chunk_for_artefact(artefacts_by_key[key], max_chunk_tokens=max_tokens), max_tokens)
+ if chunk:
+ token_map[key] = count_tokens(chunk)
+ return chunk, token_map, token_map[key], {key}
+
+ chunks: list[str] = []
+ for key in ordered_keys:
+ spans = kept_spans_by_key[key]
+ if not spans:
+ token_map[key] = count_tokens(_chunk_for_artefact(artefacts_by_key[key], max_chunk_tokens=256))
+ continue
+ spans.sort(key=lambda span: (_role_priority(span.role), span.score), reverse=True)
+ chunk = _chunk_for_spans(artefacts_by_key[key].source_path, spans)
+ token_map[key] = count_tokens(chunk)
+ chunks.append(chunk)
+ used = sum(token_map[key] for key in kept_keys)
+ return "\n".join(chunks).strip(), token_map, used, kept_keys
+
+
+def _role_token_limits(max_tokens: int) -> dict[EvidenceSpanRole, int]:
+ minimum = min(max_tokens, 96)
+ return {
+ "direct": max(minimum, int(max_tokens * 0.48)),
+ "constant_or_default": max(minimum // 2, int(max_tokens * 0.14)),
+ "schema_or_storage": max(minimum // 2, int(max_tokens * 0.12)),
+ "caller_or_downstream": max(minimum // 2, int(max_tokens * 0.12)),
+ "test_or_example": max(minimum // 2, int(max_tokens * 0.08)),
+ "supporting": max(minimum // 2, int(max_tokens * 0.05)),
+ "provenance": max(24, int(max_tokens * 0.01)),
+ }
+
+
+def _span_signature(span: EvidenceSpan) -> str:
+ normalized = re.sub(r"\s+", " ", span.excerpt.lower()).strip()
+ return f"{span.role}:{normalized[:220]}"
+
+
+def _trim_span(span: EvidenceSpan, budget: int) -> EvidenceSpan:
+ excerpt = _trim_chunk_to_budget(span.excerpt, budget)
+ return EvidenceSpan(
+ path=span.path,
+ start_line=span.start_line,
+ end_line=span.end_line,
+ role=span.role,
+ symbol=span.symbol,
+ excerpt=excerpt,
+ score=span.score,
+ protected=span.protected,
+ reason=f"{span.reason}; trimmed to fit transport budget",
+ )
+
def compress_context(
artefacts: list[Artefact],
max_tokens: int,
score_by_key: dict[str, float] | None = None,
+ query: str | None = None,
) -> tuple[str, dict[str, int], int, set[str]]:
+ if query:
+ return _compress_context_with_spans(artefacts, max_tokens, query, score_by_key)
+
token_map: dict[str, int] = {}
chunk_map: dict[str, str] = {}
ordered_keys: list[str] = []
kept_keys: set[str] = set()
used = 0
+ per_chunk_cap = max(512, min(max_tokens, max_tokens // 3 if max_tokens >= 4096 else max_tokens))
for artefact in artefacts:
- chunk = f"### {artefact.source_path}\n{artefact.content}\n"
+ chunk = _chunk_for_artefact(artefact, max_chunk_tokens=per_chunk_cap)
chunk_tokens = count_tokens(chunk)
token_map[artefact.key] = chunk_tokens
chunk_map[artefact.key] = chunk
@@ -29,9 +619,16 @@ def compress_context(
# Greedy knapsack approximation: pack highest-score artefacts first while
# still trying lower-ranked/smaller chunks that fit remaining budget.
- for key in ordered_keys:
+ for index, key in enumerate(ordered_keys):
chunk_tokens = token_map[key]
if used + chunk_tokens > max_tokens:
+ if index == 0 and not kept_keys:
+ trimmed = _trim_chunk_to_budget(chunk_map[key], max_tokens)
+ if trimmed:
+ chunk_map[key] = trimmed
+ token_map[key] = count_tokens(trimmed)
+ kept_keys.add(key)
+ used += token_map[key]
continue
kept_keys.add(key)
used += chunk_tokens
diff --git a/src/vaner/broker/context_preparation.py b/src/vaner/broker/context_preparation.py
new file mode 100644
index 00000000..993b0b45
--- /dev/null
+++ b/src/vaner/broker/context_preparation.py
@@ -0,0 +1,545 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import re
+from collections import Counter
+
+from vaner.models.artefact import Artefact
+from vaner.models.context_preparation import (
+ ContextConstraint,
+ ContextCoverageReport,
+ ContextFacet,
+ ContextPreparationPlan,
+ ContextPreparationProfile,
+ ContextSourceStats,
+ ContextToolTrace,
+ PreparedContextDiagnostics,
+)
+from vaner.semantic_aliases import engineering_semantic_aliases
+
+_PATH_RE = re.compile(r"\b(?:[\w.-]+/)+[\w.-]+\b")
+_QUOTED_RE = re.compile(r"[`'\"]([^`'\"]{3,100})[`'\"]")
+_DATE_RE = re.compile(
+ r"\b(?:\d{4}-\d{2}-\d{2}|\d{1,2}/\d{1,2}/\d{2,4}|"
+ r"jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|"
+ r"jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b",
+ re.IGNORECASE,
+)
+_RESTRICTIVE_RE = re.compile(
+ r"\b(?:only|except|before|after|latest|current|superseded|newest|oldest|no later than|at least|at most)\b",
+ re.IGNORECASE,
+)
+_QUERY_STOPWORDS = frozenset(
+ "about across after again also and any are because before between can could did does during every for from had has have"
+ " how into its list most not only our over should since than that the their them then there these this those through what"
+ " when where which while who why with would".split()
+)
+
+
+def infer_context_preparation_profile(prompt: str) -> ContextPreparationProfile:
+ lowered = prompt.lower()
+ archetype = _infer_archetype(lowered)
+ need = _infer_need(lowered, archetype)
+ constraints = _extract_constraints(prompt)
+ facets = _extract_facets(prompt, constraints)
+ source_hints = _extract_source_hints(lowered)
+ expected_evidence_count = _expected_evidence_count(lowered, need)
+ confidence = 0.55 + min(0.35, 0.05 * len(facets) + 0.06 * len(constraints))
+ notes: list[str] = []
+ if any(constraint.kind == "restrictive_language" for constraint in constraints):
+ notes.append("hard_constraints_detected")
+ if need in {"multi_source_synthesis", "source_evidence", "conflict_resolution", "research_mapping"}:
+ notes.append("coverage_sensitive")
+ return ContextPreparationProfile(
+ need=need,
+ archetype=archetype,
+ facets=facets,
+ constraints=constraints,
+ source_hints=source_hints,
+ expected_evidence_count=expected_evidence_count,
+ confidence=min(0.95, confidence),
+ notes=notes,
+ )
+
+
+def query_variants(prompt: str, profile: ContextPreparationProfile, *, max_variants: int = 6) -> list[str]:
+ variants = [prompt]
+ keywords = extract_query_keywords(prompt)
+ facet_terms = [facet.value for facet in profile.facets if len(facet.value) > 2]
+ constraint_terms = [constraint.value for constraint in profile.constraints if constraint.kind != "restrictive_language"]
+ alias_terms = sorted(engineering_semantic_aliases(prompt, [*keywords, *facet_terms], stopwords=_QUERY_STOPWORDS))
+ if keywords:
+ variants.append(" ".join(keywords[:14]))
+ if facet_terms:
+ variants.append(" ".join(facet_terms[:8]))
+ if constraint_terms:
+ variants.append(" ".join([*constraint_terms[:4], *facet_terms[:6]]))
+ if profile.need in {"multi_source_synthesis", "research_mapping", "decision_support"}:
+ variants.append(" ".join([*facet_terms[:10], *keywords[:8], "summary status decision evidence"]))
+ if _looks_like_scheduling_query(prompt):
+ variants.append(" ".join([*keywords[:10], "schedule calendar invite booking event meeting time window"]))
+ variants.append(" ".join([*keywords[:8], "technical deep dive architecture review private hosting isolated network vpc"]))
+ if profile.need == "source_evidence":
+ variants.append(" ".join([*facet_terms[:8], *keywords[:8], "source evidence claim citation provenance"]))
+ if profile.need == "conflict_resolution":
+ variants.append(" ".join([*facet_terms[:8], *keywords[:8], "current superseded conflict updated latest"]))
+ if profile.need == "implementation_support":
+ variants.append(" ".join([*facet_terms[:10], *keywords[:8], "implementation caller dependency test"]))
+ if profile.need == "absence_check":
+ variants.append(" ".join([*facet_terms[:8], *keywords[:8], "source truth reference policy"]))
+ if alias_terms:
+ variants.append(" ".join([*keywords[:8], *alias_terms[:10]]))
+ deduped = []
+ for variant in variants:
+ normalized = " ".join(variant.split())
+ if normalized and normalized.lower() not in {item.lower() for item in deduped}:
+ deduped.append(normalized)
+ if len(deduped) >= max(1, max_variants):
+ break
+ return deduped
+
+
+def source_path_hint_candidates(prompt: str, available_paths: list[str], *, limit: int = 64) -> list[str]:
+ """Return paths whose source class/path shape is directly implied by the prompt."""
+
+ lowered = prompt.lower()
+ hint_terms: set[str] = set()
+ if re.search(r"\b(postmortem|postmortems|retro|retrospective|rca|incident review)\b", lowered):
+ hint_terms.update({"postmortem", "postmortems", "rca"})
+ if re.search(r"\b(runbook|playbook|procedure|operating guide)\b", lowered):
+ hint_terms.update({"runbook", "runbooks", "playbook", "playbooks"})
+ if _looks_like_scheduling_query(prompt):
+ hint_terms.update({"calendar", "invite", "booking", "schedule", "meeting", "gmail"})
+ if re.search(r"\b(architecture|technical|deep dive|review|workshop)\b", lowered):
+ hint_terms.update({"architecture", "review", "deepdive", "deep-dive", "technical", "workshop"})
+ if re.search(r"\b(healthcare|health care|clinical|medical|hospital|patient|phi)\b", lowered):
+ hint_terms.update({"health", "healthcare", "clinical", "medical", "hospital", "patient", "phi"})
+ if re.search(r"\b(isolated network|locked-down|locked down|data center|datacenter|private|vpc|on-prem|onprem|own network)\b", lowered):
+ hint_terms.update({"private", "privhost", "vpc", "network", "hosting", "onprem", "on-prem", "datacenter", "implementation"})
+ if re.search(r"\b(issue|ticket|bug|support escalation|incident)\b", lowered):
+ hint_terms.update({"jira", "linear", "support", "incidents"})
+ if not hint_terms:
+ return []
+ ranked: list[tuple[int, str]] = []
+ for path in available_paths:
+ path_lower = path.lower()
+ score = 0
+ for term in hint_terms:
+ if term in path_lower:
+ score += 3 if f"/{term}" in path_lower or f"{term}/" in path_lower else 1
+ if "postmortem" in hint_terms and "/postmortems/" in path_lower:
+ score += 8
+ if path_lower.startswith(("confluence/", "docs/", "google_drive/")):
+ score += 6
+ elif path_lower.startswith(("slack/", "gmail/")):
+ score -= 3
+ if "gmail" in hint_terms and path_lower.startswith("gmail/"):
+ score += 4
+ if score:
+ ranked.append((score, path))
+ ranked.sort(key=lambda item: (-item[0], item[1]))
+ return [path for _, path in ranked[:limit]]
+
+
+def semantic_path_hint_candidates(
+ prompt: str,
+ available_paths: list[str],
+ *,
+ profile: ContextPreparationProfile | None = None,
+ limit: int = 64,
+) -> list[str]:
+ """Return paths/titles whose canonical terms bridge an indirect prompt.
+
+ This is an artefact-structure signal, not a benchmark shortcut. It helps
+ when a user asks in operational language but durable sources are named with
+ canonical implementation, project, customer, or workflow terms.
+ """
+
+ if not available_paths:
+ return []
+ profile = profile or infer_context_preparation_profile(prompt)
+ terms = _semantic_path_terms(prompt, profile)
+ if not terms:
+ return []
+ term_set = set(terms)
+ ranked: list[tuple[float, str]] = []
+ for path in available_paths:
+ path_terms = set(extract_query_keywords(path.replace("/", " ").replace(".", " "), limit=48))
+ if not path_terms:
+ continue
+ direct_hits = term_set & path_terms
+ if not direct_hits:
+ continue
+ score = 0.0
+ for term in direct_hits:
+ score += 3.0 if _is_high_signal_path_term(term) else 1.0
+ # Reward multiple independent bridges more than repeated single-word
+ # matches. A path that names two or three canonical concepts is a
+ # stronger source candidate than a broad folder match.
+ score += min(8.0, len(direct_hits) * len(direct_hits) * 0.65)
+ path_lower = path.lower()
+ for source_hint in profile.source_hints:
+ if source_hint and source_hint in path_lower:
+ score += 2.0
+ if len(direct_hits) >= 2 or score >= 5.0:
+ ranked.append((score, path))
+ ranked.sort(key=lambda item: (-item[0], item[1]))
+ return [path for _, path in ranked[: max(1, int(limit))]]
+
+
+def extract_query_keywords(prompt: str, *, limit: int = 24) -> list[str]:
+ """Extract FTS-friendly terms from a conversational prompt."""
+
+ keywords: list[str] = []
+ seen: set[str] = set()
+ for token in re.findall(r"\b[A-Za-z0-9][A-Za-z0-9_-]{2,}\b", prompt):
+ normalized = token.strip("_-").lower()
+ if len(normalized) < 3 or normalized in _QUERY_STOPWORDS:
+ continue
+ if normalized not in seen:
+ seen.add(normalized)
+ keywords.append(normalized)
+ for part in re.split(r"[_-]+", normalized):
+ if len(part) >= 3 and part not in _QUERY_STOPWORDS and part not in seen:
+ seen.add(part)
+ keywords.append(part)
+ if len(keywords) >= limit:
+ break
+ return keywords[:limit]
+
+
+def _semantic_path_terms(prompt: str, profile: ContextPreparationProfile, *, limit: int = 48) -> list[str]:
+ keywords = extract_query_keywords(prompt, limit=32)
+ facets = [facet.value for facet in profile.facets if len(facet.value) > 2]
+ aliases = sorted(engineering_semantic_aliases(prompt, [*keywords, *facets], stopwords=_QUERY_STOPWORDS))
+ terms: list[str] = []
+ seen: set[str] = set()
+ for term in [*keywords, *facets, *aliases]:
+ for part in extract_query_keywords(term.replace("/", " ").replace(".", " "), limit=8):
+ if part not in seen and part not in _QUERY_STOPWORDS:
+ seen.add(part)
+ terms.append(part)
+ if len(terms) >= limit:
+ break
+ return terms[:limit]
+
+
+def _is_high_signal_path_term(term: str) -> bool:
+ if len(term) >= 7:
+ return True
+ return term in {
+ "annealing",
+ "auditlog",
+ "audit",
+ "billing",
+ "bifurcation",
+ "checkpoint",
+ "egress",
+ "escrow",
+ "failover",
+ "healthcare",
+ "kernel",
+ "latency",
+ "onprem",
+ "precision",
+ "rehearse",
+ "residency",
+ "routing",
+ "telemetry",
+ "vector",
+ "embedding",
+ }
+
+
+def _looks_like_scheduling_query(prompt: str) -> bool:
+ lowered = prompt.lower()
+ return bool(
+ re.search(r"\b(when|scheduled|schedule|calendar|invite|meeting|deep dive|time window|booking|booked)\b", lowered)
+ and re.search(r"\b(client|customer|call|review|demo|technical|architecture|workshop)\b", lowered)
+ )
+
+
+def exact_reference_candidates(prompt: str, available_paths: list[str], *, limit: int = 32) -> list[str]:
+ references = set(_PATH_RE.findall(prompt))
+ references.update(match.group(1).strip() for match in _QUOTED_RE.finditer(prompt))
+ identifier_terms = {
+ token.lower()
+ for token in re.findall(r"\b[A-Za-z_][A-Za-z0-9_]{3,}\b", prompt)
+ if "_" in token or any(char.isupper() for char in token[1:])
+ }
+ ranked: list[tuple[int, str]] = []
+ for path in available_paths:
+ lowered_path = path.lower()
+ score = 0
+ for reference in references:
+ ref = reference.strip().lower()
+ if not ref:
+ continue
+ if ref == lowered_path or lowered_path.endswith(ref):
+ score += 12
+ elif ref in lowered_path:
+ score += 6
+ basename = lowered_path.rsplit("/", 1)[-1]
+ stem = basename.rsplit(".", 1)[0]
+ for term in identifier_terms:
+ if term == stem or term in basename:
+ score += 5
+ elif term in lowered_path:
+ score += 2
+ if score:
+ ranked.append((score, path))
+ ranked.sort(key=lambda item: (-item[0], item[1]))
+ return [path for _, path in ranked[:limit]]
+
+
+def hard_constraints_satisfied(prompt: str, artefact: Artefact, profile: ContextPreparationProfile) -> tuple[bool, list[str], list[str]]:
+ text = f"{artefact.source_path}\n{artefact.content}".lower()
+ satisfied: list[str] = []
+ missing: list[str] = []
+ blocking_missing: list[str] = []
+ for constraint in profile.constraints:
+ value = constraint.value.lower()
+ if constraint.kind == "restrictive_language":
+ satisfied.append(constraint.value)
+ continue
+ if value in text:
+ satisfied.append(constraint.value)
+ elif constraint.required:
+ missing.append(constraint.value)
+ if constraint.kind in {"path", "quoted_reference"}:
+ blocking_missing.append(constraint.value)
+ if blocking_missing:
+ return False, satisfied, blocking_missing
+ if _constraint_terms(prompt) and not satisfied and profile.need in {"absence_check"}:
+ return False, satisfied, missing
+ return True, satisfied, missing
+
+
+def competitive_threshold_multiplier(need: str | None) -> float:
+ if need in {"multi_source_synthesis", "source_evidence", "conflict_resolution", "research_mapping", "decision_support"}:
+ return 0.0
+ if need in {"implementation_support", "absence_check", "working_set_extension", "creative_grounding", "scheduling"}:
+ return 0.20
+ if need == "evidence_gathering":
+ return 0.30
+ return 0.45
+
+
+def build_coverage_report(
+ profile: ContextPreparationProfile,
+ selected: list[Artefact],
+ source_by_key: dict[str, set[str]] | None = None,
+) -> ContextCoverageReport:
+ selected_text = "\n".join(f"{artefact.source_path}\n{artefact.content}" for artefact in selected).lower()
+ covered_facets = [facet.value for facet in profile.facets if facet.value.lower() in selected_text]
+ missing_constraints = [
+ constraint.value
+ for constraint in profile.constraints
+ if constraint.kind != "restrictive_language" and constraint.required and constraint.value.lower() not in selected_text
+ ]
+ direct_evidence_count = sum(1 for artefact in selected if _direct_match_count(profile, artefact) > 0)
+ source_sets = [source_by_key.get(artefact.key, set()) for artefact in selected] if source_by_key else []
+ source_agreement = sum(len(sources) for sources in source_sets) / max(1, len(source_sets))
+ conflict_pair_coverage = profile.need != "conflict_resolution" or _has_conflict_pair(selected_text)
+ compactness_risk = "high" if len(selected) > max(8, profile.expected_evidence_count * 3) else "medium" if len(selected) > 6 else "low"
+ gap_flags: list[str] = []
+ if missing_constraints:
+ gap_flags.append("missing_constraints")
+ if len(covered_facets) < min(len(profile.facets), profile.expected_evidence_count):
+ gap_flags.append("facet_coverage_weak")
+ if profile.need == "conflict_resolution" and not conflict_pair_coverage:
+ gap_flags.append("conflict_pair_missing")
+ actionability = "full"
+ if gap_flags:
+ actionability = "weak"
+ if not selected:
+ actionability = "none"
+ if profile.need == "conflict_resolution" and conflict_pair_coverage:
+ actionability = "conflict"
+ return ContextCoverageReport(
+ covered_facets=covered_facets,
+ missing_constraints=missing_constraints,
+ direct_evidence_count=direct_evidence_count,
+ conflict_pair_coverage=conflict_pair_coverage,
+ actionability=actionability,
+ source_agreement=min(1.0, source_agreement / 2.0),
+ weak_expansion_dependency=any(
+ source_by_key and source_by_key.get(artefact.key) == {"generated_query_variants"} for artefact in selected
+ ),
+ compactness_risk=compactness_risk,
+ gap_flags=gap_flags,
+ )
+
+
+def build_prepared_context_diagnostics(
+ profile: ContextPreparationProfile,
+ *,
+ source_by_key: dict[str, set[str]],
+ selected: list[Artefact],
+ fused_candidate_count: int,
+ latency_ms: float,
+ preparation_plan: ContextPreparationPlan | None = None,
+ tool_traces: list[ContextToolTrace] | None = None,
+) -> PreparedContextDiagnostics:
+ selected_keys = {artefact.key for artefact in selected}
+ source_counter: Counter[str] = Counter()
+ selected_counter: Counter[str] = Counter()
+ for key, sources in source_by_key.items():
+ for source in sources:
+ source_counter[source] += 1
+ if key in selected_keys:
+ selected_counter[source] += 1
+ constraints = [constraint.value for constraint in profile.constraints]
+ coverage = build_coverage_report(profile, selected, source_by_key)
+ return PreparedContextDiagnostics(
+ profile=profile,
+ preparation_plan=preparation_plan,
+ tool_traces=tool_traces or [],
+ source_counts=[
+ ContextSourceStats(source=source, candidate_count=count, selected_count=selected_counter.get(source, 0))
+ for source, count in sorted(source_counter.items())
+ ],
+ fused_candidate_count=fused_candidate_count,
+ selected_count=len(selected),
+ aggregation_count=sum(
+ 1 for artefact in selected if str(artefact.metadata.get("provenance")) == "prepared_context_aggregation"
+ ),
+ hard_constraints_extracted=constraints,
+ hard_constraints_satisfied=[value for value in constraints if value not in coverage.missing_constraints],
+ hard_constraints_missing=coverage.missing_constraints,
+ coverage=coverage,
+ weak_expansion_dependency=coverage.weak_expansion_dependency,
+ truncation_risk=coverage.truncation_risk,
+ latency_ms=latency_ms,
+ compactness_score=1.0 if coverage.compactness_risk == "low" else 0.65 if coverage.compactness_risk == "medium" else 0.35,
+ provenance_coverage=sum(1 for artefact in selected if source_by_key.get(artefact.key)) / max(1, len(selected)),
+ )
+
+
+def _infer_archetype(lowered: str):
+ if re.search(r"\b(file|symbol|function|class|module|bug|test|diff|pr|commit|implementation|dependency|stack trace)\b", lowered):
+ return "developer"
+ if re.search(r"\b(draft|outline|tone|audience|rewrite|section|claim|essay|article|copy)\b", lowered):
+ return "writer"
+ if re.search(r"\b(paper|study|dataset|method|citation|literature|hypothesis|experiment|result)\b", lowered):
+ return "researcher"
+ if re.search(r"\b(incident|runbook|sla|handoff|customer|support|ops|oncall|status|risk)\b", lowered):
+ return "operator"
+ return "general"
+
+
+def _infer_need(lowered: str, archetype: str):
+ if re.search(r"\b(conflict|contradict|superseded|outdated|latest|current|newer|older|reconcile)\b", lowered):
+ return "conflict_resolution"
+ if re.search(r"\b(not found|unavailable|missing|is there|do we have|absence|not available|cannot find)\b", lowered):
+ return "absence_check"
+ if re.search(r"\b(according to|source|evidence|citation|cite|provenance|claim|where did .* come from)\b", lowered):
+ return "source_evidence"
+ if re.search(r"\b(when|scheduled|schedule|calendar|invite|meeting|time window|booking|booked)\b", lowered) and re.search(
+ r"\b(client|customer|call|review|demo|technical|architecture|workshop)\b",
+ lowered,
+ ):
+ return "scheduling"
+ if archetype == "developer" and re.search(r"\b(implement|fix|debug|change|affected|callers|tests?|dependency|regression)\b", lowered):
+ return "implementation_support"
+ if archetype == "writer":
+ return "creative_grounding"
+ if archetype == "researcher":
+ return "research_mapping"
+ if re.search(
+ r"\b(all|every|list|compare|across|summarize|synthesize|count|which .* most|highest number)\b",
+ lowered,
+ ):
+ return "multi_source_synthesis"
+ if re.search(r"\b(decide|decision|recommend|tradeoff|risk|should we|plan)\b", lowered):
+ return "decision_support"
+ if re.search(r"\b(where|defined|introduced|implemented|exact|which file|what is)\b", lowered):
+ return "direct_reference"
+ if re.search(r"\b(continue|next|finish|carry on|pick up)\b", lowered):
+ return "task_continuation"
+ return "evidence_gathering"
+
+
+def _extract_constraints(prompt: str) -> list[ContextConstraint]:
+ constraints: list[ContextConstraint] = []
+ for path in _PATH_RE.findall(prompt):
+ constraints.append(ContextConstraint(kind="path", value=path))
+ for quoted in _QUOTED_RE.findall(prompt):
+ constraints.append(ContextConstraint(kind="quoted_reference", value=quoted.strip()))
+ for match in _DATE_RE.finditer(prompt):
+ constraints.append(ContextConstraint(kind="date", value=match.group(0)))
+ for match in _RESTRICTIVE_RE.finditer(prompt):
+ constraints.append(ContextConstraint(kind="restrictive_language", value=match.group(0).lower()))
+ return _dedupe_constraints(constraints)
+
+
+def _extract_facets(prompt: str, constraints: list[ContextConstraint]) -> list[ContextFacet]:
+ constrained_values = {constraint.value.lower() for constraint in constraints}
+ facets: list[ContextFacet] = []
+ for token in re.findall(r"\b[A-Za-z][A-Za-z0-9_-]{3,}\b", prompt):
+ lowered = token.lower()
+ if lowered in constrained_values or lowered in _QUERY_STOPWORDS:
+ continue
+ required = any(char.isupper() for char in token[1:]) or "_" in token or "-" in token
+ facets.append(ContextFacet(name="term", value=token, required=required))
+ return _dedupe_facets(facets[:16])
+
+
+def _extract_source_hints(lowered: str) -> list[str]:
+ hints = []
+ for hint in ("slack", "gmail", "email", "github", "jira", "linear", "confluence", "docs", "meeting", "runbook", "calendar"):
+ if hint in lowered:
+ hints.append(hint)
+ return hints
+
+
+def _expected_evidence_count(lowered: str, need: str) -> int:
+ if need in {"multi_source_synthesis", "research_mapping"}:
+ return 4
+ if need == "source_evidence":
+ return 2
+ if need == "conflict_resolution":
+ return 2
+ if re.search(r"\b(all|every|complete|completeness|list)\b", lowered):
+ return 5
+ return 1
+
+
+def _constraint_terms(prompt: str) -> set[str]:
+ return {match.group(0).lower() for match in _RESTRICTIVE_RE.finditer(prompt)}
+
+
+def _direct_match_count(profile: ContextPreparationProfile, artefact: Artefact) -> int:
+ text = f"{artefact.source_path}\n{artefact.content}".lower()
+ return sum(1 for facet in profile.facets if facet.value.lower() in text)
+
+
+def _has_conflict_pair(text: str) -> bool:
+ current = any(term in text for term in ("current", "latest", "updated", "newer", "now"))
+ superseded = any(term in text for term in ("superseded", "old", "older", "deprecated", "previous"))
+ return current and superseded
+
+
+def _dedupe_constraints(constraints: list[ContextConstraint]) -> list[ContextConstraint]:
+ result = []
+ seen = set()
+ for constraint in constraints:
+ key = (constraint.kind, constraint.value.lower())
+ if key in seen:
+ continue
+ seen.add(key)
+ result.append(constraint)
+ return result
+
+
+def _dedupe_facets(facets: list[ContextFacet]) -> list[ContextFacet]:
+ result = []
+ seen = set()
+ for facet in facets:
+ key = facet.value.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ result.append(facet)
+ return result
diff --git a/src/vaner/broker/preparation_policy.py b/src/vaner/broker/preparation_policy.py
new file mode 100644
index 00000000..ebc91266
--- /dev/null
+++ b/src/vaner/broker/preparation_policy.py
@@ -0,0 +1,92 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+from vaner.models.context_preparation import ContextPreparationPlan, ContextPreparationProfile, ContextToolStep
+
+
+def choose_preparation_plan(profile: ContextPreparationProfile, prompt: str) -> ContextPreparationPlan:
+ """Choose a bounded, deterministic context-preparation plan.
+
+ The policy names general preparation capabilities, not benchmark or dataset
+ shortcuts. Tool execution remains bounded by selector/config budgets.
+ """
+
+ need = profile.need
+ if need == "multi_source_synthesis":
+ return ContextPreparationPlan(
+ name="multi_source_synthesis",
+ need=need,
+ steps=[
+ ContextToolStep(tool="source_class_search", mode="canonical", budget=96),
+ ContextToolStep(tool="semantic_search", mode="query_variants", budget=96),
+ ContextToolStep(tool="aggregate_sources", mode="structured_provenance", budget=40),
+ ContextToolStep(tool="coverage_check", mode="facets_constraints", budget=0),
+ ],
+ )
+ if need == "scheduling":
+ return ContextPreparationPlan(
+ name="scheduling_evidence",
+ need=need,
+ steps=[
+ ContextToolStep(tool="time_entity_extraction", mode="strict", budget=0),
+ ContextToolStep(tool="source_class_search", mode="calendar_invite_preferred", budget=64),
+ ContextToolStep(tool="semantic_search", mode="query_variants", budget=64),
+ ContextToolStep(tool="conflict_scan", mode="time_evidence", budget=16),
+ ContextToolStep(tool="prepare_scheduling_evidence", mode="confirmed_time_source", budget=24),
+ ContextToolStep(tool="coverage_check", mode="facets_constraints", budget=0),
+ ],
+ )
+ if need == "implementation_support":
+ return ContextPreparationPlan(
+ name="implementation_support",
+ need=need,
+ steps=[
+ ContextToolStep(tool="exact_reference_lookup", mode="paths_symbols", budget=32),
+ ContextToolStep(tool="relationship_expand", mode="dependencies", budget=48),
+ ContextToolStep(tool="working_set_lookup", mode="recent_activity", budget=32),
+ ContextToolStep(tool="test_lookup", mode="adjacent_tests", budget=24),
+ ContextToolStep(tool="risk_check", mode="regression", budget=0),
+ ],
+ )
+ if need == "source_evidence":
+ return ContextPreparationPlan(
+ name="source_evidence",
+ need=need,
+ steps=[
+ ContextToolStep(tool="exact_reference_lookup", mode="quoted_refs", budget=32),
+ ContextToolStep(tool="semantic_search", mode="evidence_spans", budget=64),
+ ContextToolStep(tool="source_class_search", mode="canonical", budget=48),
+ ContextToolStep(tool="coverage_check", mode="provenance", budget=0),
+ ],
+ )
+ if need == "conflict_resolution":
+ return ContextPreparationPlan(
+ name="conflict_resolution",
+ need=need,
+ steps=[
+ ContextToolStep(tool="semantic_search", mode="current_and_superseded", budget=80),
+ ContextToolStep(tool="source_class_search", mode="canonical", budget=64),
+ ContextToolStep(tool="conflict_scan", mode="preserve_pairs", budget=24),
+ ContextToolStep(tool="coverage_check", mode="conflict_pairs", budget=0),
+ ],
+ )
+ return ContextPreparationPlan(
+ name="evidence_gathering",
+ need=need,
+ steps=[
+ ContextToolStep(tool="lexical_search", mode="balanced", budget=64),
+ ContextToolStep(tool="semantic_search", mode="optional", budget=64),
+ ContextToolStep(tool="coverage_check", mode="facets_constraints", budget=0),
+ ],
+ )
+
+
+def plan_uses_tool(plan: ContextPreparationPlan, tool_name: str) -> bool:
+ return any(step.tool == tool_name for step in plan.steps)
+
+
+def plan_tool_names(plan: ContextPreparationPlan | None) -> set[str]:
+ if plan is None:
+ return set()
+ return {str(step.tool) for step in plan.steps}
diff --git a/src/vaner/broker/scheduling.py b/src/vaner/broker/scheduling.py
new file mode 100644
index 00000000..19b3f20e
--- /dev/null
+++ b/src/vaner/broker/scheduling.py
@@ -0,0 +1,216 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import hashlib
+import re
+import time
+from dataclasses import dataclass
+
+from vaner.models.artefact import Artefact, ArtefactKind
+from vaner.models.context_preparation import ContextPreparationProfile, ContextToolTrace
+
+
+@dataclass(frozen=True)
+class SchedulingEvidence:
+ source_key: str
+ path: str
+ snippet: str
+ score: float
+ time_windows: list[str]
+ confidence: float
+ status: str = "candidate"
+
+
+def build_scheduling_evidence(
+ prompt: str,
+ candidates: list[Artefact],
+ profile: ContextPreparationProfile,
+ *,
+ max_sources: int = 24,
+) -> tuple[Artefact | None, ContextToolTrace]:
+ """Prepare compact meeting/time evidence from scheduling candidates."""
+
+ trace = ContextToolTrace(tool="prepare_scheduling_evidence", input_count=len(candidates))
+ if profile.need != "scheduling":
+ trace.notes.append("skipped:not_scheduling")
+ return None, trace
+ evidence = [
+ item
+ for item in (
+ _scheduling_evidence_for(prompt, artefact, profile)
+ for artefact in _dedupe_candidates(candidates)
+ )
+ if item is not None
+ ]
+ evidence.sort(key=lambda item: (-item.score, item.path))
+ evidence = evidence[:max_sources]
+ if not evidence:
+ trace.notes.append("skipped:no_scheduling_evidence")
+ return None, trace
+
+ sections = [
+ "Prepared scheduling evidence",
+ f"Question: {prompt.strip()}",
+ "",
+ "Candidate time evidence:",
+ *_evidence_lines(evidence),
+ "",
+ "Coverage:",
+ f"- sources_considered: {len(candidates)}",
+ f"- evidence_sources: {len(evidence)}",
+ "- gaps: " + ("none detected" if any(item.time_windows for item in evidence) else "no explicit time window found"),
+ ]
+ digest = hashlib.sha256((prompt + "\n" + "\n".join(item.source_key for item in evidence)).encode("utf-8")).hexdigest()[:16]
+ metadata = {
+ "context_sources": ["prepare_scheduling_evidence"],
+ "scheduling_evidence_source_keys": [item.source_key for item in evidence],
+ "scheduling_evidence_source_paths": [item.path for item in evidence],
+ "scheduling_evidence": [
+ {
+ "source_key": item.source_key,
+ "path": item.path,
+ "snippet": item.snippet,
+ "score": item.score,
+ "time_windows": item.time_windows,
+ "confidence": item.confidence,
+ "status": item.status,
+ }
+ for item in evidence
+ ],
+ "provenance": "prepared_scheduling_evidence",
+ }
+ trace.output_count = 1
+ trace.notes.append(f"evidence_sources:{len(evidence)}")
+ trace.notes.append(f"top_score:{evidence[0].score:.1f}")
+ return (
+ Artefact(
+ key=f"prepared_context:scheduling_evidence:{digest}",
+ kind=ArtefactKind.FILE_SUMMARY,
+ source_path=f"prepared_context/scheduling_evidence/{digest}.md",
+ source_mtime=time.time(),
+ generated_at=time.time(),
+ model="vaner-context-tools",
+ content="\n".join(sections).strip(),
+ metadata=metadata,
+ ),
+ trace,
+ )
+
+
+def score_scheduling_evidence(prompt: str, artefact: Artefact, profile: ContextPreparationProfile | None = None) -> float:
+ item = _scheduling_evidence_for(prompt, artefact, profile)
+ return item.score if item is not None else 0.0
+
+
+def _scheduling_evidence_for(
+ prompt: str,
+ artefact: Artefact,
+ profile: ContextPreparationProfile | None = None,
+) -> SchedulingEvidence | None:
+ lowered = prompt.lower()
+ if profile is not None and profile.need != "scheduling":
+ return None
+ if not re.search(r"\b(when|scheduled|schedule|calendar|invite|meeting|time window|booking|booked)\b", lowered):
+ return None
+ path = artefact.source_path.lower()
+ text = f"{artefact.source_path}\n{artefact.content}"
+ text_lower = text.lower()
+ score = 0.0
+ if path.startswith(("gmail/", "calendar/")):
+ score += 8.0
+ elif path.startswith(("hubspot/", "fireflies/")):
+ score -= 2.0
+
+ time_windows = _time_windows(text)
+ if time_windows:
+ score += min(18.0, 8.0 + 3.0 * len(time_windows))
+ if re.search(r"\b(?:pt|pst|pdt|pacific|-0[78]00)\b", text_lower):
+ score += 6.0
+ if any(term in text_lower for term in ("calendar", "invite", ".ics", "calendar.redwood", "event/")):
+ score += 9.0
+ status = "candidate"
+ if any(term in text_lower for term in ("works for our team", "confirmed", "confirming", "accepted", "locked in")):
+ score += 12.0
+ status = "confirmed"
+ elif any(term in text_lower for term in ("proposed", "tentative", "hold", "requesting")):
+ score += 2.0
+ status = "tentative"
+
+ if "technical deep dive" in lowered and "technical deep dive" in text_lower:
+ score += 6.0
+ if "architecture" in lowered and "architecture review" in text_lower:
+ score += 5.0
+ if re.search(r"\b(isolated network|private|vpc|on-prem|own network)\b", lowered) and any(
+ term in text_lower for term in ("private", "vpc", "on-prem", "isolated", "network diagram", "private hosting")
+ ):
+ score += 8.0
+ if re.search(r"\b(healthcare|health care|clinical|medical|hospital)\b", lowered) and any(
+ term in text_lower for term in ("health", "clinical", "medical", "patient", "phi", "hipaa")
+ ):
+ score += 10.0
+
+ numeric_terms = set(re.findall(r"\b\d{2,4}\b", lowered))
+ if numeric_terms:
+ score += min(6.0, 2.0 * sum(1 for term in numeric_terms if term in text_lower))
+ if not time_windows and score < 18.0:
+ return None
+ confidence = min(0.95, 0.35 + score / 80.0)
+ return SchedulingEvidence(
+ source_key=artefact.key,
+ path=artefact.source_path,
+ snippet=_best_scheduling_snippet(artefact.content),
+ score=score,
+ time_windows=time_windows,
+ confidence=confidence,
+ status=status,
+ )
+
+
+def _time_windows(text: str) -> list[str]:
+ patterns = (
+ r"\b(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\s+[a-z]{3,9}\s+\d{1,2}\s*[—-]\s*\d{1,2}:\d{2}\s*[–-]\s*\d{1,2}:\d{2}(?:\s*[ap]m)?(?:\s*(?:pt|pst|pdt|pacific))?",
+ r"\b(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\s+[a-z]{3,9}\s+\d{1,2}\s+\d{1,2}:\d{2}(?:\s*[ap]m)?(?:\s*(?:pt|pst|pdt|pacific))?",
+ r"\b\d{1,2}:\d{2}\s*(?:am|pm)?\s*[–-]\s*\d{1,2}:\d{2}\s*(?:am|pm)?(?:\s*(?:pt|pst|pdt|pacific))?",
+ )
+ windows: list[str] = []
+ for pattern in patterns:
+ for match in re.finditer(pattern, text, flags=re.IGNORECASE):
+ value = " ".join(match.group(0).split())
+ if value not in windows:
+ windows.append(value)
+ return windows[:8]
+
+
+def _best_scheduling_snippet(content: str) -> str:
+ lines = [" ".join(line.split()) for line in content.splitlines() if line.strip()]
+ preferred = [
+ line
+ for line in lines
+ if re.search(
+ r"\b(works for our team|confirmed|confirming|invite|calendar|scheduled|proposed|pt|pst|pdt|\d{1,2}:\d{2})\b",
+ line,
+ re.IGNORECASE,
+ )
+ ]
+ return "\n".join(preferred[:5])[:700] if preferred else " ".join(content.split())[:700]
+
+
+def _evidence_lines(evidence: list[SchedulingEvidence]) -> list[str]:
+ lines = []
+ for index, item in enumerate(evidence[:8], start=1):
+ windows = "; ".join(item.time_windows) if item.time_windows else "time not explicit"
+ lines.append(f"- [{index}] {item.status} score={item.score:.1f} time={windows} source={item.path}")
+ lines.append(f" evidence: {item.snippet[:320]}")
+ return lines
+
+
+def _dedupe_candidates(candidates: list[Artefact]) -> list[Artefact]:
+ seen: set[str] = set()
+ deduped: list[Artefact] = []
+ for artefact in candidates:
+ if artefact.key in seen:
+ continue
+ seen.add(artefact.key)
+ deduped.append(artefact)
+ return deduped
diff --git a/src/vaner/broker/selector.py b/src/vaner/broker/selector.py
index 2b0ab33c..3908571a 100644
--- a/src/vaner/broker/selector.py
+++ b/src/vaner/broker/selector.py
@@ -5,11 +5,26 @@
import math
import re
import time
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
from fnmatch import fnmatch
+from vaner.broker.aggregation import build_source_aggregation
+from vaner.broker.context_preparation import (
+ build_prepared_context_diagnostics,
+ competitive_threshold_multiplier,
+ exact_reference_candidates,
+ hard_constraints_satisfied,
+ infer_context_preparation_profile,
+ query_variants,
+ semantic_path_hint_candidates,
+ source_path_hint_candidates,
+)
+from vaner.broker.preparation_policy import choose_preparation_plan, plan_tool_names, plan_uses_tool
+from vaner.broker.scheduling import build_scheduling_evidence, score_scheduling_evidence
from vaner.models.artefact import Artefact
+from vaner.models.context_preparation import ContextPreparationProfile, ContextToolTrace, PreparedContextDiagnostics
from vaner.models.decision import ScoreFactor
+from vaner.semantic_aliases import engineering_semantic_aliases
def _recency_bonus(artefact: Artefact, decay_half_life_seconds: int = 1800) -> float:
@@ -19,6 +34,19 @@ def _recency_bonus(artefact: Artefact, decay_half_life_seconds: int = 1800) -> f
return 0.1 + (0.9 * decay)
+def _source_rank_prior(artefact: Artefact) -> float:
+ """Bounded prior from an upstream context source ranking, when present."""
+
+ raw_rank = artefact.metadata.get("retrieval_rank") or artefact.metadata.get("source_rank")
+ try:
+ rank = int(raw_rank)
+ except (TypeError, ValueError):
+ return 0.0
+ if rank <= 0:
+ return 0.0
+ return min(15.0, 15.0 / math.sqrt(rank))
+
+
_COMMON_WORDS = frozenset(
"the and are for but not that with this from have been will can its also more"
" use used using used using using into they their there when than then what"
@@ -41,6 +69,8 @@ def _prompt_terms(prompt: str) -> list[str]:
terms.extend(part.lower() for part in raw.split("_") if len(part) > 2)
if lowered not in {"fastapi", "openapi"}:
terms.extend(part.lower() for part in _camel_parts(raw) if len(part) > 2)
+ terms.extend(raw for raw in re.findall(r"\b\d{2,4}\b", prompt))
+ terms.extend(engineering_semantic_aliases(prompt, terms, stopwords=_COMMON_WORDS))
return list(dict.fromkeys(term for term in terms if len(term) > 2 and term not in _COMMON_WORDS))
@@ -310,12 +340,18 @@ def _doc_domain_bonus(path_text: str, content_text: str, terms: list[str]) -> fl
if path_text in {
"src/vaner/intent/features.py",
"src/vaner/intent/trainer.py",
+ "src/vaner/intent/scorer.py",
"src/vaner/models/artefact.py",
"src/vaner/store/artefacts.py",
"tests/test_intent/test_features_follow_up.py",
"tests/test_intent/test_trainer_v4_rollover.py",
}:
bonus += 14.0
+ if "intent" in term_set and "scorer" in term_set and path_text in {
+ "src/vaner/intent/scorer.py",
+ "src/vaner/intent/features.py",
+ }:
+ bonus += 10.0
if any(
term in content_text
for term in (
@@ -348,6 +384,39 @@ def _doc_domain_bonus(path_text: str, content_text: str, terms: list[str]) -> fl
bonus += 2.0
if any(term in content_text for term in ("full_hit", "partial_hit", "warm_start", "cache_full_hit", "cache_partial_hit")):
bonus += 8.0
+ reward_terms = {"reward", "computation", "compute", "signals", "combine", "final", "value", "quality", "lift", "judge"}
+ if "reward" in term_set and len(term_set & reward_terms) >= 3:
+ if path_text in {
+ "src/vaner/learning/reward.py",
+ "eval/train_policy.py",
+ "src/vaner/intent/trainer.py",
+ "tests/test_learning/test_reward.py",
+ }:
+ bonus += 18.0
+ if any(
+ term in content_text
+ for term in (
+ "compute_reward",
+ "reward_total",
+ "reward_components",
+ "quality_lift",
+ "host_outcome",
+ "judge_score",
+ )
+ ):
+ bonus += 10.0
+ if path_text == "src/vaner/intent/scoring_policy.py":
+ bonus -= 4.0
+ store_schema_terms = {"artefactstore", "artefact", "artefacts", "persist", "retrieve", "database", "schema", "table", "tables"}
+ if len(term_set & store_schema_terms) >= 3:
+ if path_text == "src/vaner/store/artefacts.py":
+ bonus += 24.0
+ if path_text == "src/vaner/models/context.py":
+ bonus += 8.0
+ if "package.json" in path_text:
+ bonus -= 22.0
+ if any(term in content_text for term in ("create table", "from artefacts", "insert into artefacts", "select key")):
+ bonus += 12.0
llm_exploration_terms = {
"llm",
"external",
@@ -441,8 +510,15 @@ def _origin_bonus(prompt: str, content: str) -> float:
def _build_fts_query(prompt: str) -> str:
"""Build a safe FTS5 query string from a natural-language prompt."""
- filtered = _prompt_terms(prompt)[:15]
- return " ".join(filtered)
+ filtered = _prompt_terms(prompt)[:24]
+ return " OR ".join(filtered)
+
+
+def _candidate_limit(prompt: str, top_n: int) -> int:
+ terms = _prompt_terms(prompt)
+ multi_facet = prompt.count("?") + len(re.findall(r"\b(?:and|what|how|when|which)\b", prompt, flags=re.IGNORECASE))
+ requested = max(50, int(top_n) * 12, len(terms) * 8, multi_facet * 12)
+ return max(50, min(240, requested))
async def select_artefacts_fts(
@@ -457,8 +533,18 @@ async def select_artefacts_fts(
path_excludes: list[str] | None = None,
capture_factors: dict[str, list[ScoreFactor]] | None = None,
capture_drop_reasons: dict[str, str] | None = None,
+ candidate_limit: int | None = None,
+ full_scan_limit: int = 2000,
+ context_preparation_mode: str = "balanced",
+ max_query_variants: int = 6,
+ max_candidate_keys: int | None = None,
+ coverage_floor_enabled: bool = True,
+ max_expansion_passes: int = 1,
+ capture_prepared_context_diagnostics: list[PreparedContextDiagnostics] | None = None,
+ semantic_memory_enabled: bool = False,
+ semantic_embed: Callable[[list[str]], Awaitable[list[list[float]]]] | None = None,
) -> list[Artefact]:
- """Two-phase selection: FTS candidate retrieval, then scorer re-rank.
+ """Multi-source context candidate retrieval, then scorer re-rank.
Falls back to loading all artefacts when the FTS index returns no hits
or when *store* does not expose ``select_artefacts_fts``.
@@ -466,37 +552,245 @@ async def select_artefacts_fts(
from vaner.store.artefacts import ArtefactStore # avoid circular at module level
fts_available = isinstance(store, ArtefactStore)
+ started = time.monotonic()
+ profile = infer_context_preparation_profile(prompt)
+ context_enabled = context_preparation_mode != "legacy"
+ preparation_plan = choose_preparation_plan(profile, prompt) if context_enabled else None
+ enabled_context_tools = plan_tool_names(preparation_plan)
+ tool_traces: list[ContextToolTrace] = []
- # Phase 1: FTS candidate retrieval (gracefully skipped if unavailable)
- candidate_keys: set[str] = set()
+ source_by_key: dict[str, set[str]] = {}
+ source_rankings: dict[str, list[str]] = {}
+ retrieval_limit = candidate_limit if candidate_limit is not None else _candidate_limit(prompt, top_n)
+ if max_candidate_keys is not None:
+ retrieval_limit = max(50, min(retrieval_limit, max_candidate_keys))
if fts_available:
- fts_query = _build_fts_query(prompt)
- if fts_query:
+ variants = query_variants(prompt, profile, max_variants=max_query_variants) if context_enabled else [prompt]
+ for index, variant in enumerate(variants):
+ fts_query = _build_fts_query(variant)
+ if not fts_query:
+ continue
+ source = "lexical_search" if index == 0 else "generated_query_variants"
try:
- candidate_keys = set(await store.select_artefacts_fts(fts_query, limit=50)) # type: ignore[union-attr]
+ keys = list(await store.select_artefacts_fts(fts_query, limit=retrieval_limit)) # type: ignore[union-attr]
except Exception:
- candidate_keys = set()
+ keys = []
+ _record_source_ranking(source_rankings, source_by_key, source, keys)
+ if context_enabled:
+ try:
+ available_paths = await store.list_source_paths(limit=max(5000, retrieval_limit * 10)) # type: ignore[union-attr]
+ except Exception:
+ available_paths = []
+ exact_paths = set(exact_reference_candidates(prompt, available_paths, limit=min(32, max(8, top_n * 3))))
+ source_hint_paths = set(source_path_hint_candidates(prompt, available_paths, limit=min(96, max(16, top_n * 8))))
+ structure_hint_paths = set(
+ semantic_path_hint_candidates(
+ prompt,
+ available_paths,
+ profile=profile,
+ limit=min(96, max(16, top_n * 8)),
+ )
+ )
+ if exact_paths:
+ tool_traces.append(
+ ContextToolTrace(tool="exact_reference_lookup", input_count=len(available_paths), output_count=len(exact_paths))
+ )
+ if source_hint_paths or structure_hint_paths:
+ tool_traces.append(
+ ContextToolTrace(
+ tool="source_class_search",
+ input_count=len(available_paths),
+ output_count=len(source_hint_paths | structure_hint_paths),
+ notes=[
+ f"source_class_paths:{len(source_hint_paths)}",
+ f"artefact_structure_paths:{len(structure_hint_paths)}",
+ ],
+ )
+ )
+ else:
+ available_paths = []
+ exact_paths = set()
+ source_hint_paths = set()
+ structure_hint_paths = set()
+ semantic_trace = ContextToolTrace(tool="semantic_search")
+ if semantic_memory_enabled and semantic_embed is not None and hasattr(store, "select_artefacts_semantic"):
+ semantic_variants = variants if context_enabled else [prompt]
+ for index, variant in enumerate(semantic_variants):
+ try:
+ semantic_keys = list(await store.select_artefacts_semantic(variant, limit=retrieval_limit, embed=semantic_embed)) # type: ignore[attr-defined]
+ except Exception:
+ semantic_keys = []
+ source = "semantic_memory" if index == 0 else "semantic_query_variants"
+ _record_source_ranking(source_rankings, source_by_key, source, semantic_keys)
+ semantic_trace.input_count += 1
+ semantic_trace.output_count += len(semantic_keys)
+ tool_traces.append(semantic_trace)
+ else:
+ exact_paths = set()
+ source_hint_paths = set()
+
+ preferred = preferred_keys or set()
+ preferred_paths_set = preferred_paths or set()
+ if preferred:
+ _record_source_ranking(source_rankings, source_by_key, "working_set", list(preferred))
+
+ fused_keys = _fuse_source_rankings(source_rankings, limit=max_candidate_keys or retrieval_limit)
- # Phase 2: load candidates; fall back to full list on FTS miss
if fts_available:
- all_artefacts: list[Artefact] = await store.list(limit=2000) # type: ignore[union-attr]
+ if fused_keys or exact_paths or preferred:
+ loaded: list[Artefact] = []
+ load_keys = set(fused_keys) | preferred
+ loaded.extend(await store.list_by_keys(load_keys, limit=max(retrieval_limit, len(load_keys)))) # type: ignore[union-attr]
+ path_loads = set(preferred_paths_set) | exact_paths | source_hint_paths | structure_hint_paths
+ if path_loads:
+ if exact_paths:
+ exact_loaded = await store.list_by_source_paths(exact_paths, limit=max(retrieval_limit, len(exact_paths))) # type: ignore[union-attr]
+ loaded.extend(exact_loaded)
+ _record_source_ranking(source_rankings, source_by_key, "exact_reference", [artefact.key for artefact in exact_loaded])
+ if source_hint_paths:
+ hint_loaded = await store.list_by_source_paths(source_hint_paths, limit=max(retrieval_limit, len(source_hint_paths))) # type: ignore[union-attr]
+ loaded.extend(hint_loaded)
+ _record_source_ranking(source_rankings, source_by_key, "source_metadata", [artefact.key for artefact in hint_loaded])
+ if structure_hint_paths:
+ structure_loaded = await store.list_by_source_paths(
+ structure_hint_paths,
+ limit=max(retrieval_limit, len(structure_hint_paths)),
+ ) # type: ignore[union-attr]
+ loaded.extend(structure_loaded)
+ _record_source_ranking(
+ source_rankings,
+ source_by_key,
+ "artefact_structure",
+ [artefact.key for artefact in structure_loaded],
+ )
+ preferred_path_loads = preferred_paths_set - exact_paths - source_hint_paths - structure_hint_paths
+ if preferred_path_loads:
+ loaded.extend(
+ await store.list_by_source_paths(preferred_path_loads, limit=max(retrieval_limit, len(preferred_path_loads))) # type: ignore[union-attr]
+ )
+ seen: set[str] = set()
+ candidates = []
+ for artefact in loaded:
+ if artefact.key in seen:
+ continue
+ seen.add(artefact.key)
+ candidates.append(_with_context_source_metadata(artefact, source_rankings, source_by_key))
+ selected = select_artefacts(
+ prompt,
+ candidates,
+ top_n=top_n,
+ preferred_paths=preferred_paths,
+ preferred_keys=preferred_keys,
+ scorer=scorer,
+ exclude_private=exclude_private,
+ path_bonuses=path_bonuses,
+ path_excludes=path_excludes,
+ capture_factors=capture_factors,
+ capture_drop_reasons=capture_drop_reasons,
+ context_need=profile.need if context_enabled else None,
+ context_profile=profile if context_enabled else None,
+ enabled_context_tools=enabled_context_tools,
+ )
+ if context_enabled and coverage_floor_enabled and max_expansion_passes > 0:
+ diagnostics = build_prepared_context_diagnostics(
+ profile,
+ source_by_key=source_by_key,
+ selected=selected,
+ fused_candidate_count=len(fused_keys),
+ latency_ms=(time.monotonic() - started) * 1000.0,
+ preparation_plan=preparation_plan,
+ tool_traces=tool_traces,
+ )
+ if diagnostics.coverage.gap_flags:
+ recovery_terms = _coverage_recovery_terms(profile, diagnostics)
+ recovery_query = _build_fts_query(recovery_terms)
+ if recovery_query:
+ try:
+ recovery_keys = list(await store.select_artefacts_fts(recovery_query, limit=retrieval_limit)) # type: ignore[union-attr]
+ except Exception:
+ recovery_keys = []
+ new_keys = [key for key in recovery_keys if key not in {candidate.key for candidate in candidates}]
+ if new_keys:
+ _record_source_ranking(source_rankings, source_by_key, "coverage_floor", new_keys)
+ recovered = await store.list_by_keys(set(new_keys), limit=max(retrieval_limit, len(new_keys))) # type: ignore[union-attr]
+ candidates.extend(
+ _with_context_source_metadata(artefact, source_rankings, source_by_key) for artefact in recovered
+ )
+ selected = select_artefacts(
+ prompt,
+ candidates,
+ top_n=top_n,
+ preferred_paths=preferred_paths,
+ preferred_keys=preferred_keys,
+ scorer=scorer,
+ exclude_private=exclude_private,
+ path_bonuses=path_bonuses,
+ path_excludes=path_excludes,
+ capture_factors=capture_factors,
+ capture_drop_reasons=capture_drop_reasons,
+ context_need=profile.need,
+ context_profile=profile,
+ enabled_context_tools=enabled_context_tools,
+ )
+ if context_enabled and preparation_plan is not None and plan_uses_tool(preparation_plan, "aggregate_sources"):
+ aggregate_budget = _plan_budget(preparation_plan, "aggregate_sources", default=40)
+ aggregate_pool = _aggregation_candidate_pool(
+ prompt,
+ candidates,
+ selected,
+ profile,
+ exclude_private=exclude_private,
+ path_excludes=path_excludes or [],
+ source_by_key=source_by_key,
+ canonical_source_class_enabled="source_class_search" in enabled_context_tools,
+ limit=aggregate_budget,
+ )
+ aggregate, trace = build_source_aggregation(
+ prompt,
+ aggregate_pool,
+ profile,
+ source_by_key=source_by_key,
+ max_sources=aggregate_budget,
+ )
+ tool_traces.append(trace)
+ if aggregate is not None:
+ _record_source_ranking(source_rankings, source_by_key, "aggregate_sources", [aggregate.key])
+ aggregate = _with_context_source_metadata(aggregate, source_rankings, source_by_key)
+ selected = [aggregate, *[artefact for artefact in selected if artefact.key != aggregate.key]][:top_n]
+ if context_enabled and preparation_plan is not None and plan_uses_tool(preparation_plan, "prepare_scheduling_evidence"):
+ schedule_budget = _plan_budget(preparation_plan, "prepare_scheduling_evidence", default=24)
+ scheduling, trace = build_scheduling_evidence(
+ prompt,
+ candidates,
+ profile,
+ max_sources=schedule_budget,
+ )
+ tool_traces.append(trace)
+ if scheduling is not None:
+ _record_source_ranking(source_rankings, source_by_key, "prepare_scheduling_evidence", [scheduling.key])
+ scheduling = _with_context_source_metadata(scheduling, source_rankings, source_by_key)
+ selected = [scheduling, *[artefact for artefact in selected if artefact.key != scheduling.key]][:top_n]
+ _append_source_factors(capture_factors, source_by_key, selected)
+ if capture_prepared_context_diagnostics is not None:
+ capture_prepared_context_diagnostics.append(
+ build_prepared_context_diagnostics(
+ profile,
+ source_by_key=source_by_key,
+ selected=selected,
+ fused_candidate_count=len(fused_keys),
+ latency_ms=(time.monotonic() - started) * 1000.0,
+ preparation_plan=preparation_plan,
+ tool_traces=tool_traces,
+ )
+ )
+ return selected
+ all_artefacts: list[Artefact] = await store.list(limit=full_scan_limit) # type: ignore[union-attr]
else:
return []
- if candidate_keys:
- candidates = [a for a in all_artefacts if a.key in candidate_keys]
- # Always include preferred items even if not in FTS results
- preferred = preferred_keys or set()
- preferred_paths_set = preferred_paths or set()
- for a in all_artefacts:
- if a.key not in candidate_keys and (a.key in preferred or a.source_path in preferred_paths_set):
- candidates.append(a)
- else:
- candidates = all_artefacts
-
- return select_artefacts(
+ selected = select_artefacts(
prompt,
- candidates,
+ all_artefacts,
top_n=top_n,
preferred_paths=preferred_paths,
preferred_keys=preferred_keys,
@@ -505,8 +799,271 @@ async def select_artefacts_fts(
path_bonuses=path_bonuses,
path_excludes=path_excludes,
capture_factors=capture_factors,
- capture_drop_reasons=capture_drop_reasons,
- )
+ capture_drop_reasons=capture_drop_reasons,
+ context_need=profile.need if context_enabled else None,
+ context_profile=profile if context_enabled else None,
+ enabled_context_tools=enabled_context_tools,
+ )
+ if capture_prepared_context_diagnostics is not None:
+ capture_prepared_context_diagnostics.append(
+ build_prepared_context_diagnostics(
+ profile,
+ source_by_key=source_by_key,
+ selected=selected,
+ fused_candidate_count=len(all_artefacts),
+ latency_ms=(time.monotonic() - started) * 1000.0,
+ preparation_plan=preparation_plan,
+ tool_traces=tool_traces,
+ )
+ )
+ return selected
+
+
+def _record_source_ranking(
+ source_rankings: dict[str, list[str]],
+ source_by_key: dict[str, set[str]],
+ source: str,
+ keys: list[str],
+) -> None:
+ deduped = list(dict.fromkeys(key for key in keys if key))
+ if not deduped:
+ return
+ source_rankings.setdefault(source, [])
+ seen = set(source_rankings[source])
+ for key in deduped:
+ source_by_key.setdefault(key, set()).add(source)
+ if key not in seen:
+ source_rankings[source].append(key)
+ seen.add(key)
+
+
+def _fuse_source_rankings(source_rankings: dict[str, list[str]], *, limit: int) -> list[str]:
+ weights = {
+ "exact_reference": 1.45,
+ "prepared_context_cache": 1.25,
+ "working_set": 1.20,
+ "lexical_search": 1.0,
+ "generated_query_variants": 0.72,
+ "relationship_graph": 0.9,
+ "semantic_memory": 0.75,
+ "semantic_query_variants": 0.68,
+ "source_metadata": 0.82,
+ "artefact_structure": 0.88,
+ "coverage_floor": 0.85,
+ }
+ scores: dict[str, float] = {}
+ for source, keys in source_rankings.items():
+ weight = weights.get(source, 0.8)
+ for rank, key in enumerate(keys, start=1):
+ scores[key] = scores.get(key, 0.0) + weight / (60.0 + rank)
+ return [
+ key
+ for key, _ in sorted(
+ scores.items(),
+ key=lambda item: (-item[1], item[0]),
+ )[: max(1, limit)]
+ ]
+
+
+def _with_context_source_metadata(
+ artefact: Artefact,
+ source_rankings: dict[str, list[str]],
+ source_by_key: dict[str, set[str]],
+) -> Artefact:
+ sources = sorted(source_by_key.get(artefact.key, set()))
+ if not sources:
+ return artefact
+ best_rank: int | None = None
+ best_source: str | None = None
+ for source in sources:
+ try:
+ rank = source_rankings.get(source, []).index(artefact.key) + 1
+ except ValueError:
+ continue
+ if best_rank is None or rank < best_rank:
+ best_rank = rank
+ best_source = source
+ metadata = dict(artefact.metadata)
+ metadata.setdefault("context_sources", sources)
+ if best_rank is not None:
+ metadata.setdefault("retrieval_rank", best_rank)
+ metadata.setdefault("retrieval_source", best_source)
+ return artefact.model_copy(update={"metadata": metadata})
+
+
+def _append_source_factors(
+ capture_factors: dict[str, list[ScoreFactor]] | None,
+ source_by_key: dict[str, set[str]],
+ selected: list[Artefact],
+) -> None:
+ if capture_factors is None:
+ return
+ for artefact in selected:
+ sources = sorted(source_by_key.get(artefact.key, set()))
+ if not sources:
+ continue
+ capture_factors.setdefault(artefact.key, []).append(
+ ScoreFactor(
+ name="context_sources",
+ contribution=min(2.0, 0.25 * len(sources)),
+ detail="candidate appeared in context sources: " + ", ".join(sources),
+ )
+ )
+
+
+def _plan_budget(plan: object, tool_name: str, *, default: int) -> int:
+ steps = getattr(plan, "steps", [])
+ for step in steps:
+ if getattr(step, "tool", None) == tool_name and getattr(step, "budget", 0):
+ return int(step.budget)
+ return default
+
+
+def _aggregation_candidate_pool(
+ prompt: str,
+ candidates: list[Artefact],
+ selected: list[Artefact],
+ profile: ContextPreparationProfile,
+ *,
+ exclude_private: bool,
+ path_excludes: list[str],
+ source_by_key: dict[str, set[str]],
+ canonical_source_class_enabled: bool,
+ limit: int,
+) -> list[Artefact]:
+ selected_keys = {artefact.key for artefact in selected}
+ rows: list[tuple[float, Artefact]] = []
+ for artefact in candidates:
+ if exclude_private and str(artefact.metadata.get("privacy_zone", "")).lower() == "private_local":
+ continue
+ if any(fnmatch(artefact.source_path, pattern) for pattern in path_excludes):
+ continue
+ constraints_ok, _, _ = hard_constraints_satisfied(prompt, artefact, profile)
+ if not constraints_ok:
+ continue
+ sources = source_by_key.get(artefact.key, set())
+ source_signal = 0.7 if sources else 0.0
+ if sources & {"source_metadata", "artefact_structure", "semantic_memory", "semantic_query_variants", "coverage_floor"}:
+ source_signal += 1.0
+ canonical_bonus = _canonical_source_class_bonus(prompt, artefact, profile) if canonical_source_class_enabled else 0.0
+ score = score_artefact(prompt, artefact) + canonical_bonus + source_signal
+ if artefact.key in selected_keys:
+ score += 2.0
+ rows.append((score, artefact))
+ rows.sort(key=lambda item: (-item[0], item[1].source_path))
+ return [artefact for _, artefact in rows[: max(1, limit)]]
+
+
+def _diversity_bucket(artefact: Artefact, context_need: str | None) -> str:
+ path = artefact.source_path
+ if context_need == "implementation_support":
+ parts = path.split("/")
+ return "/".join(parts[:3]) if len(parts) >= 3 else path
+ source_type = str(artefact.metadata.get("source_type") or artefact.metadata.get("corpus_id") or "")
+ if source_type:
+ return source_type
+ parts = path.split("/")
+ return "/".join(parts[:2]) if len(parts) >= 2 else path
+
+
+def _coverage_recovery_terms(profile: ContextPreparationProfile, diagnostics: PreparedContextDiagnostics) -> str:
+ covered = {item.lower() for item in diagnostics.coverage.covered_facets}
+ missing_facets = [facet.value for facet in profile.facets if facet.value.lower() not in covered]
+ terms = [*diagnostics.coverage.missing_constraints, *missing_facets[:10]]
+ if profile.need == "conflict_resolution":
+ terms.extend(["current", "superseded", "latest", "updated"])
+ elif profile.need == "multi_source_synthesis":
+ terms.extend(["summary", "status", "evidence"])
+ elif profile.need == "implementation_support":
+ terms.extend(["caller", "dependency", "test", "implementation"])
+ return " ".join(term for term in terms if term)
+
+
+_COVERAGE_SENSITIVE_NEEDS = {
+ "multi_source_synthesis",
+ "source_evidence",
+ "conflict_resolution",
+ "research_mapping",
+ "decision_support",
+}
+
+
+def _artefact_context_text(artefact: Artefact) -> str:
+ return f"{artefact.source_path}\n{artefact.content}".lower()
+
+
+def _matches_facet(artefact: Artefact, facet_value: str) -> bool:
+ value = facet_value.lower()
+ text = _artefact_context_text(artefact)
+ if value in text:
+ return True
+ parts = [part for part in re.split(r"[_\-\s]+", value) if len(part) > 2]
+ return bool(parts) and all(part in text for part in parts)
+
+
+def _coverage_seed_artefacts(
+ ranked: list[tuple[float, Artefact]],
+ profile: ContextPreparationProfile | None,
+ *,
+ top_n: int,
+ min_score: float,
+) -> list[Artefact]:
+ if profile is None or profile.need not in _COVERAGE_SENSITIVE_NEEDS or top_n <= 1:
+ return []
+ selected: list[Artefact] = []
+ selected_keys: set[str] = set()
+
+ def add_best_matching(predicate: Callable[[Artefact], bool]) -> None:
+ if len(selected) >= top_n:
+ return
+ for score, artefact in ranked:
+ if score < min_score or artefact.key in selected_keys:
+ continue
+ if predicate(artefact):
+ selected.append(artefact)
+ selected_keys.add(artefact.key)
+ return
+
+ if profile.need == "conflict_resolution":
+ add_best_matching(
+ lambda artefact: any(term in _artefact_context_text(artefact) for term in ("current", "latest", "updated", "newer", "now"))
+ )
+ add_best_matching(
+ lambda artefact: any(
+ term in _artefact_context_text(artefact) for term in ("superseded", "old", "older", "deprecated", "previous")
+ )
+ )
+
+ facet_limit = min(top_n, max(1, profile.expected_evidence_count), 6)
+ facets = sorted(profile.facets, key=lambda facet: (not facet.required, len(facet.value)))
+ for facet in facets[: max(facet_limit * 2, facet_limit)]:
+ if len(selected) >= facet_limit:
+ break
+ add_best_matching(lambda artefact, value=facet.value: _matches_facet(artefact, value))
+ return selected
+
+
+def _canonical_source_class_bonus(prompt: str, artefact: Artefact, profile: ContextPreparationProfile | None) -> float:
+ if profile is None or profile.need != "multi_source_synthesis":
+ return 0.0
+ lowered = prompt.lower()
+ path = artefact.source_path.lower()
+ content_head = (artefact.content or "").lower()[:600]
+ if not re.search(r"\b(across|all|every|which .*most|highest number|count)\b", lowered):
+ return 0.0
+ bonus = 0.0
+ if re.search(r"\b(postmortem|postmortems|rca|incident review)\b", lowered):
+ if "/postmortems/" in path and path.startswith(("confluence/", "docs/", "google_drive/")):
+ bonus += 16.0
+ elif path.startswith(("slack/", "gmail/")) and "postmortem" in path:
+ bonus -= 6.0
+ if "template" in path or "template" in content_head:
+ bonus -= 14.0
+ return bonus
+
+
+def _scheduling_evidence_bonus(prompt: str, artefact: Artefact, profile: ContextPreparationProfile | None) -> float:
+ return score_scheduling_evidence(prompt, artefact, profile)
def select_artefacts(
@@ -521,12 +1078,16 @@ def select_artefacts(
path_excludes: list[str] | None = None,
capture_factors: dict[str, list[ScoreFactor]] | None = None,
capture_drop_reasons: dict[str, str] | None = None,
+ context_need: str | None = None,
+ context_profile: ContextPreparationProfile | None = None,
+ enabled_context_tools: set[str] | None = None,
) -> list[Artefact]:
preferred_paths = preferred_paths or set()
preferred_keys = preferred_keys or set()
path_bonuses = path_bonuses or []
path_excludes = path_excludes or []
apply_origin_rerank = _is_origin_question(prompt)
+ enabled_context_tools = enabled_context_tools or set()
scored_rows: list[tuple[float, Artefact]] = []
for artefact in artefacts:
@@ -538,6 +1099,13 @@ def select_artefacts(
if capture_drop_reasons is not None:
capture_drop_reasons[artefact.key] = "path_excluded"
continue
+ satisfied_constraints: list[str] = []
+ if context_profile is not None:
+ constraints_ok, satisfied_constraints, missing_constraints = hard_constraints_satisfied(prompt, artefact, context_profile)
+ if not constraints_ok:
+ if capture_drop_reasons is not None:
+ capture_drop_reasons[artefact.key] = "missing_hard_constraints:" + ",".join(missing_constraints[:3])
+ continue
factors: list[ScoreFactor] = []
if scorer is not None:
score = scorer(prompt, artefact)
@@ -605,6 +1173,54 @@ def select_artefacts(
)
)
score += pinned_path_bonus
+ if context_profile is not None and satisfied_constraints:
+ constraint_bonus = min(2.0, 0.35 * len(satisfied_constraints))
+ factors.append(
+ ScoreFactor(
+ name="hard_constraint_match",
+ contribution=constraint_bonus,
+ detail="artefact satisfies extracted hard constraints",
+ )
+ )
+ score += constraint_bonus
+ canonical_bonus = (
+ _canonical_source_class_bonus(prompt, artefact, context_profile)
+ if "source_class_search" in enabled_context_tools
+ else 0.0
+ )
+ if canonical_bonus:
+ factors.append(
+ ScoreFactor(
+ name="canonical_source_class",
+ contribution=canonical_bonus,
+ detail="aggregation over a source class prefers canonical durable sources over conversational chatter",
+ )
+ )
+ score += canonical_bonus
+ scheduling_bonus = (
+ _scheduling_evidence_bonus(prompt, artefact, context_profile)
+ if {"time_entity_extraction", "conflict_scan"} & enabled_context_tools
+ else 0.0
+ )
+ if scheduling_bonus:
+ factors.append(
+ ScoreFactor(
+ name="scheduling_evidence",
+ contribution=scheduling_bonus,
+ detail="scheduling queries prefer confirmed invites, time windows, and deployment-specific meeting context",
+ )
+ )
+ score += scheduling_bonus
+ source_prior = _source_rank_prior(artefact)
+ if source_prior:
+ factors.append(
+ ScoreFactor(
+ name="source_rank_prior",
+ contribution=source_prior,
+ detail="trusted upstream context source ranked this artefact highly",
+ )
+ )
+ score += source_prior
if capture_factors is not None:
capture_factors[artefact.key] = factors
scored_rows.append((score, artefact))
@@ -612,19 +1228,53 @@ def select_artefacts(
ranked = sorted(scored_rows, key=lambda item: item[0], reverse=True)
selected: list[Artefact] = []
seen_corpora: set[str] = set()
- min_competitive_score = ranked[0][0] * 0.45 if ranked else 0.0
+ threshold_multiplier = competitive_threshold_multiplier(context_need)
+ min_competitive_score = ranked[0][0] * threshold_multiplier if ranked else 0.0
+ selected_buckets: set[str] = set()
+ deferred_for_diversity: list[Artefact] = []
+ diversity_floor = max(1, top_n // 2)
+
+ for artefact in _coverage_seed_artefacts(ranked, context_profile, top_n=top_n, min_score=min_competitive_score):
+ if artefact in selected:
+ continue
+ selected.append(artefact)
+ seen_corpora.add(str(artefact.metadata.get("corpus_id", "default")))
+ selected_buckets.add(_diversity_bucket(artefact, context_need))
+
for score, artefact in ranked:
+ if artefact in selected:
+ continue
if score < min_competitive_score:
if capture_drop_reasons is not None:
capture_drop_reasons[artefact.key] = "below_competitive_threshold"
continue
corpus_id = str(artefact.metadata.get("corpus_id", "default"))
+ bucket = _diversity_bucket(artefact, context_need)
+ if (
+ context_need in {"multi_source_synthesis", "conflict_resolution", "research_mapping", "decision_support"}
+ and bucket in selected_buckets
+ and len(selected) < diversity_floor
+ ):
+ deferred_for_diversity.append(artefact)
+ if capture_drop_reasons is not None:
+ capture_drop_reasons[artefact.key] = "deferred_for_context_diversity"
+ continue
if selected and corpus_id not in seen_corpora:
selected.append(artefact)
seen_corpora.add(corpus_id)
+ selected_buckets.add(bucket)
elif len(selected) < top_n:
selected.append(artefact)
seen_corpora.add(corpus_id)
+ selected_buckets.add(bucket)
+ if len(selected) >= diversity_floor:
+ while deferred_for_diversity and len(selected) < top_n:
+ deferred = deferred_for_diversity.pop(0)
+ if deferred in selected:
+ continue
+ selected.append(deferred)
+ seen_corpora.add(str(deferred.metadata.get("corpus_id", "default")))
+ selected_buckets.add(_diversity_bucket(deferred, context_need))
if len(selected) >= top_n:
break
if capture_drop_reasons is not None:
diff --git a/src/vaner/cli/commands/app.py b/src/vaner/cli/commands/app.py
index 082108e9..8b99bcb6 100644
--- a/src/vaner/cli/commands/app.py
+++ b/src/vaner/cli/commands/app.py
@@ -33,6 +33,7 @@
from vaner import VERSION, api
from vaner.broker.prompting import build_evidence_bound_context_prompt
from vaner.cli.commands import mcp_clients
+from vaner.cli.commands.bundles import bundle_app
from vaner.cli.commands.clients import clients_app
from vaner.cli.commands.config import load_config, set_compute_value, set_config_value
from vaner.cli.commands.daemon import (
@@ -50,6 +51,7 @@
from vaner.cli.commands.deep_run import deep_run_app
from vaner.cli.commands.distill import distill_skill_file
from vaner.cli.commands.explain import render_human, render_json
+from vaner.cli.commands.external_state import external_state_app
from vaner.cli.commands.focus import focus_app, jobs_app, resources_app
from vaner.cli.commands.guidance import guidance_app
from vaner.cli.commands.hooks import HOOK_SURFACES, write_hooks
@@ -1227,6 +1229,11 @@ def mcp_server(
transport: str = typer.Option("stdio", "--transport", "-t", help="Transport: stdio | sse"),
host: str = typer.Option("127.0.0.1", "--host", help="SSE server host (sse transport only)"),
port: int = typer.Option(8472, "--port", "-p", help="SSE server port (sse transport only)"),
+ tool_name_format: str = typer.Option(
+ "canonical",
+ "--tool-name-format",
+ help="MCP tool naming: canonical dotted names, or strict regex-safe aliases for Claude Desktop.",
+ ),
) -> None:
"""Start the Vaner MCP server for native IDE integration.
@@ -1251,16 +1258,18 @@ def mcp_server(
_fail(f"MCP not available: {exc}", hint="Install optional extras: pip install 'vaner[mcp]'.")
repo_root = _repo_root(path)
+ if tool_name_format not in {"canonical", "strict"}:
+ _fail("Invalid --tool-name-format value.", hint="Use `canonical` or `strict`.")
if transport == "sse":
typer.echo(f"Starting Vaner MCP server (SSE) on {host}:{port} repo={repo_root}")
try:
- _asyncio.run(run_sse(repo_root, host=host, port=port))
+ _asyncio.run(run_sse(repo_root, host=host, port=port, tool_name_format=tool_name_format))
except RuntimeError as exc:
_fail(str(exc), hint="Install optional extras: pip install 'vaner[mcp]'.")
else:
try:
- _asyncio.run(run_stdio(repo_root))
+ _asyncio.run(run_stdio(repo_root, tool_name_format=tool_name_format))
except RuntimeError as exc:
_fail(str(exc), hint="Install optional extras: pip install 'vaner[mcp]'.")
@@ -2436,7 +2445,9 @@ def upgrade() -> None:
app.add_typer(sources_app, name="sources", rich_help_panel="Background and local")
app.add_typer(guidance_app, name="guidance", rich_help_panel="Use with an agent")
app.add_typer(integrations_app, name="integrations", rich_help_panel="Inspect and debug")
+app.add_typer(external_state_app, name="external-state", rich_help_panel="Configure")
app.add_typer(clients_app, name="clients", rich_help_panel="Connect MCP clients")
+app.add_typer(bundle_app, name="bundle", rich_help_panel="Configure")
app.add_typer(setup_app, name="setup", rich_help_panel="Get started")
diff --git a/src/vaner/cli/commands/bundles.py b/src/vaner/cli/commands/bundles.py
new file mode 100644
index 00000000..d79f5821
--- /dev/null
+++ b/src/vaner/cli/commands/bundles.py
@@ -0,0 +1,85 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Annotated
+
+import typer
+
+from vaner.engine import VanerEngine
+from vaner.intent.adapter import CodeRepoAdapter
+from vaner.intent.bundles import bundle_public_summary, bundle_rejection_reason
+
+bundle_app = typer.Typer(help="Install and inspect trained Vaner bundles", no_args_is_help=True)
+
+
+@bundle_app.command("inspect")
+def inspect_bundle(
+ bundle_dir: Annotated[Path, typer.Argument(exists=True, file_okay=False, dir_okay=True, readable=True)],
+ as_json: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
+) -> None:
+ summary = bundle_public_summary(bundle_dir)
+ if as_json:
+ typer.echo(json.dumps(summary, indent=2, sort_keys=True))
+ return
+ typer.echo(f"Bundle: {summary['bundle_id']}")
+ typer.echo(f"Records: {summary.get('records_used')}")
+ typer.echo(f"Backend: {summary.get('scorer_backend')}")
+ typer.echo(f"Trained: {summary.get('trained')}")
+ if summary.get("rejection_reason"):
+ typer.secho(f"Rejected: {summary['rejection_reason']}", fg=typer.colors.YELLOW)
+ else:
+ typer.secho("Installable: yes", fg=typer.colors.GREEN)
+
+
+@bundle_app.command("install")
+def install_bundle(
+ bundle_dir: Annotated[Path, typer.Argument(exists=True, file_okay=False, dir_okay=True, readable=True)],
+ path: Annotated[Path, typer.Option("--path", "-p", help="Workspace/repository root to install into.")] = Path("."),
+ allow_rejected: Annotated[
+ bool,
+ typer.Option("--allow-rejected", help="Install a rejected bundle for local diagnostics."),
+ ] = False,
+ allow_experimental_data: Annotated[
+ bool,
+ typer.Option(
+ "--allow-experimental-data",
+ help="Install a locally trained bundle whose data is not release-eligible but passed training and temporal gates.",
+ ),
+ ] = False,
+ as_json: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
+) -> None:
+ reason = bundle_rejection_reason(bundle_dir, allow_experimental_data=allow_experimental_data)
+ if reason and not allow_rejected:
+ payload = {"installed": False, "reason": reason, "bundle": bundle_public_summary(bundle_dir)}
+ if as_json:
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
+ else:
+ typer.secho(f"Refusing rejected bundle: {reason}", fg=typer.colors.RED, err=True)
+ typer.echo("Use --allow-rejected only for local diagnostics.")
+ raise typer.Exit(code=2)
+
+ async def _install() -> bool:
+ engine = VanerEngine(adapter=CodeRepoAdapter(path.resolve()))
+ return await engine.load_bundle(
+ bundle_dir,
+ allow_rejected=allow_rejected,
+ allow_experimental_data=allow_experimental_data,
+ )
+
+ installed = asyncio.run(_install())
+ payload = {
+ "installed": installed,
+ "experimental_data": bool(allow_experimental_data and not allow_rejected),
+ "bundle": bundle_public_summary(bundle_dir),
+ }
+ if as_json:
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
+ raise typer.Exit(code=0 if installed else 1)
+ if not installed:
+ typer.secho("Bundle was not installed; no compatible scorer model was loaded.", fg=typer.colors.RED, err=True)
+ raise typer.Exit(code=1)
+ typer.secho("Bundle installed.", fg=typer.colors.GREEN)
diff --git a/src/vaner/cli/commands/config.py b/src/vaner/cli/commands/config.py
index 68e848d9..f8fba8fd 100644
--- a/src/vaner/cli/commands/config.py
+++ b/src/vaner/cli/commands/config.py
@@ -15,6 +15,7 @@
BackendConfig,
ComputeConfig,
ExplorationConfig,
+ ExternalStateConfig,
GatewayConfig,
GenerationConfig,
IntegrationsConfig,
@@ -108,6 +109,7 @@ def load_config(repo_root: Path) -> VanerConfig:
sources_section = _section_dict(parsed, "sources")
refinement_section = _section_dict(parsed, "refinement")
integrations_section = _section_dict(parsed, "integrations")
+ external_state_section = _section_dict(parsed, "external_state")
setup_section = _section_dict(parsed, "setup")
policy_section = _section_dict(parsed, "policy")
limits_section = _section_dict(parsed, "limits")
@@ -166,6 +168,8 @@ def load_config(repo_root: Path) -> VanerConfig:
"exploration_backend": exploration_section.get("backend", "auto"),
"embedding_model": exploration_section.get("embedding_model", "all-MiniLM-L6-v2"),
"embedding_device": exploration_section.get("embedding_device", "cpu"),
+ "runtime_options": exploration_section.get("runtime_options", {}),
+ "sampling_options": exploration_section.get("sampling_options", {}),
}
exploration = ExplorationConfig(**mapped_exploration)
else:
@@ -173,11 +177,12 @@ def load_config(repo_root: Path) -> VanerConfig:
sources = _build_section(SourcesConfig, "sources", sources_section)
refinement = _build_section(RefinementConfig, "refinement", refinement_section)
integrations = _build_section(IntegrationsConfig, "integrations", integrations_section)
+ external_state = _build_section(ExternalStateConfig, "external_state", external_state_section)
setup = _build_section(SetupConfig, "setup", setup_section)
policy = _build_section(PolicyConfig, "policy", policy_section)
max_age_seconds = _coerce_limit(limits_section, "max_age_seconds", 3600)
- max_context_tokens = _coerce_limit(limits_section, "max_context_tokens", 4096)
+ max_context_tokens = _coerce_limit(limits_section, "max_context_tokens", 8192)
store_path = repo_root / ".vaner" / "store.db"
telemetry_path = repo_root / ".vaner" / "telemetry.db"
@@ -199,6 +204,7 @@ def load_config(repo_root: Path) -> VanerConfig:
sources=sources,
refinement=refinement,
integrations=integrations,
+ external_state=external_state,
setup=setup,
policy=policy,
)
diff --git a/src/vaner/cli/commands/external_state.py b/src/vaner/cli/commands/external_state.py
new file mode 100644
index 00000000..bbe92026
--- /dev/null
+++ b/src/vaner/cli/commands/external_state.py
@@ -0,0 +1,204 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import json
+import os
+import shlex
+from pathlib import Path
+from typing import Annotated
+
+import typer
+from rich.console import Console
+from rich.table import Table
+
+from vaner.cli.commands.config import load_config
+from vaner.external_state.configurator import (
+ ProviderConfigInput,
+ discover_provider_sync,
+ external_state_payload,
+ save_external_state_enabled,
+ save_finance_settings,
+ save_provider_config,
+)
+
+external_state_app = typer.Typer(help="External data provider controls")
+providers_app = typer.Typer(help="Manage MCP external-state providers")
+finance_app = typer.Typer(help="Finance external-state permissions")
+external_state_app.add_typer(providers_app, name="providers")
+external_state_app.add_typer(finance_app, name="finance")
+console = Console()
+
+
+def _repo_root(path: str | None) -> Path:
+ if path:
+ return Path(path).expanduser().resolve()
+ env_path = os.environ.get("VANER_PATH", "").strip()
+ return Path(env_path).expanduser().resolve() if env_path else Path.cwd()
+
+
+def _parse_args(raw: list[str]) -> list[str]:
+ if len(raw) == 1:
+ return shlex.split(raw[0])
+ return list(raw)
+
+
+def _parse_env(values: list[str]) -> dict[str, str]:
+ env: dict[str, str] = {}
+ for item in values:
+ if "=" not in item:
+ raise typer.BadParameter("--env values must use KEY=VALUE")
+ key, value = item.split("=", 1)
+ key = key.strip()
+ if not key:
+ raise typer.BadParameter("--env key must not be empty")
+ env[key] = value
+ return env
+
+
+@external_state_app.command("show")
+def show_external_state(
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+ as_json: Annotated[bool, typer.Option("--json", help="Print JSON")] = False,
+) -> None:
+ """Show configured external-state providers without exposing env values."""
+
+ config = load_config(_repo_root(repo))
+ payload = external_state_payload(config)
+ if as_json:
+ console.print_json(json.dumps(payload.model_dump(mode="json")))
+ return
+ table = Table("Provider", "Transport", "Command/URL", "Tools", "Capabilities")
+ for provider in payload.providers:
+ endpoint = provider.url if provider.transport == "streamable_http" else " ".join([provider.command, *provider.args]).strip()
+ table.add_row(
+ provider.id,
+ provider.transport,
+ endpoint or "-",
+ str(len(provider.allowed_tools)),
+ str(len(provider.capability_tools)),
+ )
+ console.print(f"external_state: {'enabled' if payload.enabled else 'disabled'}")
+ finance = payload.finance
+ console.print(
+ "finance: "
+ f"{'enabled' if finance.get('enabled') else 'disabled'}, "
+ f"market_data={'on' if finance.get('market_data_enabled') else 'off'}, "
+ f"account_state={'on' if finance.get('account_state_enabled') else 'off'}, "
+ f"provider={finance.get('provider') or '-'}"
+ )
+ console.print(table)
+
+
+@external_state_app.command("enable")
+def enable_external_state(
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+) -> None:
+ save_external_state_enabled(_repo_root(repo), enabled=True)
+ console.print("External-state access enabled.")
+
+
+@external_state_app.command("disable")
+def disable_external_state(
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+) -> None:
+ save_external_state_enabled(_repo_root(repo), enabled=False)
+ console.print("External-state access disabled.")
+
+
+@providers_app.command("add")
+def add_provider(
+ provider_id: Annotated[str, typer.Argument(help="Provider id, e.g. local_broker")],
+ command: Annotated[str, typer.Option("--command", "-c", help="MCP server command")] = "",
+ arg: Annotated[list[str] | None, typer.Option("--arg", help="MCP server argument. Repeat or pass a quoted arg string.")] = None,
+ transport: Annotated[str, typer.Option("--transport", help="stdio or streamable_http")] = "stdio",
+ url: Annotated[str, typer.Option("--url", help="streamable_http URL")] = "",
+ env: Annotated[list[str] | None, typer.Option("--env", help="Environment override KEY=VALUE. Repeatable.")] = None,
+ timeout_ms: Annotated[int, typer.Option("--timeout-ms", min=100)] = 10000,
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+) -> None:
+ """Register a provider connection. Discovery happens separately."""
+
+ save_provider_config(
+ _repo_root(repo),
+ ProviderConfigInput(
+ provider_id=provider_id,
+ transport=transport,
+ command=command,
+ args=_parse_args(arg or []),
+ url=url,
+ env=_parse_env(env or []),
+ timeout_ms=timeout_ms,
+ ),
+ )
+ console.print(f"Provider `{provider_id}` saved. Run `vaner external-state providers discover {provider_id}`.")
+
+
+@providers_app.command("discover")
+def discover_provider_command(
+ provider_id: Annotated[str, typer.Argument(help="Configured provider id")],
+ apply: Annotated[bool, typer.Option("--apply", help="Persist safe discovered read tools and capability mappings")] = False,
+ trust_unknown_read: Annotated[
+ bool,
+ typer.Option(
+ "--trust-unknown-read",
+ help="Treat finance-looking tools with missing annotations as read-only provider metadata.",
+ ),
+ ] = False,
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+ as_json: Annotated[bool, typer.Option("--json", help="Print JSON")] = False,
+) -> None:
+ """List provider tools, classify safety, and propose finance capabilities."""
+
+ config = load_config(_repo_root(repo))
+ payload = discover_provider_sync(config, provider_id, apply=apply, trust_unknown_read=trust_unknown_read)
+ if as_json:
+ console.print_json(json.dumps(payload.model_dump(mode="json")))
+ return
+ table = Table("Tool", "Safety", "Capability", "Scope", "Selected")
+ for tool in payload.tools:
+ table.add_row(
+ tool.name,
+ tool.safety_reason,
+ tool.proposed_capability or "-",
+ tool.access_scope,
+ "yes" if tool.auto_selected else "",
+ )
+ console.print(table)
+ if apply:
+ console.print(
+ f"Applied {len(payload.applied_allowed_tools)} tools and "
+ f"{len(payload.applied_capability_tools)} finance capability mappings."
+ )
+
+
+@finance_app.command("enable")
+def enable_finance(
+ provider: Annotated[str, typer.Option("--provider", "-p", help="Provider id to use for finance")] = "",
+ market_data: Annotated[bool, typer.Option("--market-data/--no-market-data")] = True,
+ account_state: Annotated[bool, typer.Option("--account-state/--no-account-state")] = False,
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+) -> None:
+ """Enable finance recognition plus opt-in external finance data access."""
+
+ repo_root = _repo_root(repo)
+ save_external_state_enabled(repo_root, enabled=True)
+ save_finance_settings(
+ repo_root,
+ enabled=True,
+ provider=provider or None,
+ market_data_enabled=market_data,
+ account_state_enabled=account_state,
+ )
+ console.print(
+ "Finance external data enabled "
+ f"(market_data={'on' if market_data else 'off'}, account_state={'on' if account_state else 'off'})."
+ )
+
+
+@finance_app.command("disable")
+def disable_finance(
+ repo: Annotated[str | None, typer.Option("--repo", help="Repository root")] = None,
+) -> None:
+ save_finance_settings(_repo_root(repo), enabled=False, market_data_enabled=False, account_state_enabled=False)
+ console.print("Finance external data disabled.")
diff --git a/src/vaner/cli/commands/init.py b/src/vaner/cli/commands/init.py
index cbc0cd0c..7aeac5e4 100644
--- a/src/vaner/cli/commands/init.py
+++ b/src/vaner/cli/commands/init.py
@@ -121,7 +121,7 @@
[limits]
max_age_seconds = 3600
-max_context_tokens = 4096
+max_context_tokens = 8192
[intent]
enabled = true
diff --git a/src/vaner/cli/commands/mcp_clients.py b/src/vaner/cli/commands/mcp_clients.py
index ae7a59bf..5fc5c90d 100644
--- a/src/vaner/cli/commands/mcp_clients.py
+++ b/src/vaner/cli/commands/mcp_clients.py
@@ -334,6 +334,21 @@ def _json_entry(container_key: str, launcher_cmd: str, launcher_args: list[str])
return {"command": launcher_cmd, "args": launcher_args}
+def _launcher_args_for_client(client_id: str, launcher_args: list[str]) -> list[str]:
+ if client_id != "claude-desktop":
+ return list(launcher_args)
+ if "--tool-name-format" in launcher_args:
+ return list(launcher_args)
+ adjusted = list(launcher_args)
+ try:
+ path_index = adjusted.index("--path")
+ except ValueError:
+ adjusted.extend(["--tool-name-format", "strict"])
+ else:
+ adjusted[path_index:path_index] = ["--tool-name-format", "strict"]
+ return adjusted
+
+
def _merge_json_server(
*,
client_id: str,
@@ -517,6 +532,7 @@ def write_client(
) -> WriteResult:
spec = detected.spec
target_path = path_override or detected.path
+ effective_launcher_args = _launcher_args_for_client(spec.id, launcher_args)
if spec.kind == "json-mcpServers":
assert target_path is not None
return _merge_json_server(
@@ -524,7 +540,7 @@ def write_client(
path=target_path,
container_key="mcpServers",
launcher_cmd=launcher_cmd,
- launcher_args=launcher_args,
+ launcher_args=effective_launcher_args,
server_key=server_key,
dry_run=dry_run,
force=force,
@@ -536,7 +552,7 @@ def write_client(
path=target_path,
container_key="servers",
launcher_cmd=launcher_cmd,
- launcher_args=launcher_args,
+ launcher_args=effective_launcher_args,
server_key=server_key,
dry_run=dry_run,
force=force,
@@ -548,7 +564,7 @@ def write_client(
path=target_path,
container_key="context_servers",
launcher_cmd=launcher_cmd,
- launcher_args=launcher_args,
+ launcher_args=effective_launcher_args,
server_key=server_key,
dry_run=dry_run,
force=force,
@@ -559,7 +575,7 @@ def write_client(
client_id=spec.id,
path=target_path,
launcher_cmd=launcher_cmd,
- launcher_args=launcher_args,
+ launcher_args=effective_launcher_args,
dry_run=dry_run,
)
if spec.kind == "cli-claude":
diff --git a/src/vaner/cli/commands/setup.py b/src/vaner/cli/commands/setup.py
index 5a505101..55950061 100644
--- a/src/vaner/cli/commands/setup.py
+++ b/src/vaner/cli/commands/setup.py
@@ -1033,7 +1033,8 @@ def catalog_refresh_cmd(
f"{len(skipped)} skipped[/dim])"
)
for entry in skipped:
- _console.print(f" [yellow]skipped[/yellow] {entry['id']} — {entry['reason']}")
+ family = entry.get("family") or entry.get("id") or "unknown"
+ _console.print(f" [yellow]skipped[/yellow] {family} — {entry['reason']}")
@catalog_app.command(
diff --git a/src/vaner/cli/commands/supervisor.py b/src/vaner/cli/commands/supervisor.py
index f40e750e..bb7f9223 100644
--- a/src/vaner/cli/commands/supervisor.py
+++ b/src/vaner/cli/commands/supervisor.py
@@ -205,7 +205,7 @@ def run_up(
"--path",
str(repo_root),
"--interval-seconds",
- "45",
+ str(max(5, int(interval_seconds))),
"--startup-delay-seconds",
"10",
],
diff --git a/src/vaner/clients/endpoint_pool.py b/src/vaner/clients/endpoint_pool.py
index 380bec98..eed1b1bc 100644
--- a/src/vaner/clients/endpoint_pool.py
+++ b/src/vaner/clients/endpoint_pool.py
@@ -138,7 +138,18 @@ def from_endpoints(
if ep.weight <= 0:
continue
if ep.backend == "ollama":
- client: LLMCallable = ollama_llm(model=ep.model, base_url=ep.url, timeout=timeout)
+ options = {
+ str(key): value
+ for source in (getattr(ep, "runtime_options", {}) or {}, getattr(ep, "sampling_options", {}) or {})
+ for key, value in source.items()
+ if value is not None and value != ""
+ }
+ client: LLMCallable = ollama_llm(
+ model=ep.model,
+ base_url=ep.url,
+ timeout=timeout,
+ extra_body={"options": options} if options else None,
+ )
else:
api_key = (_env(ep.api_key_env) if ep.api_key_env else None) or ""
if not api_key and not cls._is_local_url(ep.url):
diff --git a/src/vaner/clients/ollama.py b/src/vaner/clients/ollama.py
index a46b904f..287fcd6f 100644
--- a/src/vaner/clients/ollama.py
+++ b/src/vaner/clients/ollama.py
@@ -17,6 +17,9 @@ def ollama_llm(
model: str,
base_url: str = "http://127.0.0.1:11434",
timeout: float = 120.0,
+ max_tokens: int | None = None,
+ extra_body: dict | None = None,
+ reasoning_mode: ReasoningMode = "off",
) -> Callable[[str], Awaitable[str]]:
"""Ollama inference client (bare-string return — legacy contract).
@@ -27,7 +30,9 @@ def ollama_llm(
model=model,
base_url=base_url,
timeout=timeout,
- reasoning_mode="off",
+ max_tokens=max_tokens,
+ extra_body=extra_body,
+ reasoning_mode=reasoning_mode,
)
async def _call(prompt: str) -> str:
@@ -79,7 +84,11 @@ async def _call(prompt: str, *, max_tokens: int | None = max_tokens) -> LLMRespo
body = {"model": model, "messages": [{"role": "user", "content": prompt}], "stream": False}
else:
body = {"model": model, "prompt": prompt, "stream": False}
+ merged_extra = dict(extra_body or {})
+ extra_options = merged_extra.pop("options", None)
options: dict = {}
+ if isinstance(extra_options, dict):
+ options.update(extra_options)
if max_tokens is not None and max_tokens > 0:
options["num_predict"] = int(max_tokens)
if options:
@@ -88,7 +97,6 @@ async def _call(prompt: str, *, max_tokens: int | None = max_tokens) -> LLMRespo
rf_type = response_format.get("type")
if rf_type in ("json_object", "json"):
body["format"] = "json"
- merged_extra = dict(extra_body or {})
if reasoning_mode == "off":
# Modern Ollama reasoning models support a top-level ``think``
# switch and return reasoning in a separate ``thinking`` field.
diff --git a/src/vaner/daemon/engine/generator.py b/src/vaner/daemon/engine/generator.py
index ddc1d48a..441d1b57 100644
--- a/src/vaner/daemon/engine/generator.py
+++ b/src/vaner/daemon/engine/generator.py
@@ -14,11 +14,14 @@
from vaner.models.artefact import Artefact, ArtefactKind
from vaner.models.config import VanerConfig
+from vaner.policy.internal_llm import EVIDENCE_SUMMARY_POLICY, internal_llm_policy
from vaner.policy.privacy import redact_text
logger = logging.getLogger(__name__)
-FILE_SUMMARY_PROMPT = """You are generating a precise implementation reference for a context system.
+FILE_SUMMARY_PROMPT = (
+ internal_llm_policy(EVIDENCE_SUMMARY_POLICY)
+ + """\n\nYou are generating a precise implementation reference for a context system.
A model will later use this summary to answer exact questions about this file.
Your summary MUST enable correct answers — not just plausible ones.
@@ -42,8 +45,11 @@
{content}
---
Implementation reference:"""
+)
-DIFF_SUMMARY_PROMPT = """Summarize the following workspace change for a context system.
+DIFF_SUMMARY_PROMPT = (
+ internal_llm_policy(EVIDENCE_SUMMARY_POLICY)
+ + """\n\nSummarize the following workspace change for a context system.
State clearly what changed, which modules/functions were affected, and likely intent.
Call out exact limits/guards/conditionals when visible.
Be concise.
@@ -51,10 +57,33 @@
{diff}
---
Summary:"""
+)
+
+
+def _format_python_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
+ args: list[str] = []
+ for arg in list(node.args.posonlyargs) + list(node.args.args):
+ item = arg.arg
+ if arg.annotation is not None:
+ item += f": {ast.unparse(arg.annotation)}"
+ args.append(item)
+ if node.args.vararg is not None:
+ args.append(f"*{node.args.vararg.arg}")
+ for arg in node.args.kwonlyargs:
+ item = arg.arg
+ if arg.annotation is not None:
+ item += f": {ast.unparse(arg.annotation)}"
+ args.append(item)
+ if node.args.kwarg is not None:
+ args.append(f"**{node.args.kwarg.arg}")
+ signature = f"{node.name}({', '.join(args)})"
+ if node.returns is not None:
+ signature += f" -> {ast.unparse(node.returns)}"
+ return signature[:180]
def _extract_python_shapes(text: str) -> tuple[list[str], list[str]]:
- """Extract class names, top-level functions, and class method names from Python sources."""
+ """Extract class names, top-level functions, and class method signatures."""
try:
tree = ast.parse(text)
except SyntaxError:
@@ -65,15 +94,15 @@ def _extract_python_shapes(text: str) -> tuple[list[str], list[str]]:
for node in tree.body:
if isinstance(node, ast.ClassDef):
- classes.append(node.name)
+ bases = [ast.unparse(base) for base in node.bases]
+ classes.append(f"{node.name}({', '.join(bases)})" if bases else node.name)
# Include method names so artefact content matches method-specific queries
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
if not item.name.startswith("_"):
- functions.append(item.name)
+ functions.append(_format_python_args(item))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- args = [arg.arg for arg in node.args.args]
- functions.append(f"{node.name}({', '.join(args)})")
+ functions.append(_format_python_args(node))
return classes[:8], list(dict.fromkeys(functions))[:20]
@@ -104,6 +133,30 @@ def _extract_constants(text: str) -> list[str]:
return constants[:12]
+def _extract_sql_schema(text: str) -> list[str]:
+ schema_lines: list[str] = []
+ collecting = False
+ buffer: list[str] = []
+ for raw_line in text.splitlines():
+ stripped = " ".join(raw_line.strip().split())
+ lowered = stripped.lower()
+ if not stripped:
+ continue
+ if any(marker in lowered for marker in ("create table", "create index", "using fts5", "insert into", "select ")):
+ collecting = True
+ buffer = [stripped]
+ elif collecting:
+ buffer.append(stripped)
+ if collecting and (stripped.endswith(")") or stripped.endswith(')"') or stripped.endswith(";") or len(buffer) >= 8):
+ joined = " ".join(buffer)
+ schema_lines.append(joined[:240])
+ collecting = False
+ buffer = []
+ if len(schema_lines) >= 16:
+ break
+ return schema_lines
+
+
def _summarize_text(
text: str,
source_path: Path,
@@ -124,8 +177,9 @@ def _summarize_text(
ast_source = full_text if full_text is not None else text
sections: list[str] = []
- constants = _extract_constants(text)
- limits = _extract_limits(text)
+ constants = _extract_constants(ast_source)
+ limits = _extract_limits(ast_source)
+ sql_schema = _extract_sql_schema(ast_source)
if source_path.suffix == ".py":
classes, functions = _extract_python_shapes(ast_source)
@@ -138,9 +192,11 @@ def _summarize_text(
sections.append("Constants: " + "; ".join(constants[:6]))
if limits:
sections.append("Limits: " + "; ".join(limits[:6]))
+ if sql_schema:
+ sections.append("Schema: " + "; ".join(sql_schema[:8]))
sections.append("Snippet: " + " ".join(lines[:max_lines])[:900])
- return "\n".join(sections)[:1600]
+ return "\n".join(sections)[:2400]
def _build_artefact(
diff --git a/src/vaner/daemon/http.py b/src/vaner/daemon/http.py
index 61ff2d1a..7cabe19c 100644
--- a/src/vaner/daemon/http.py
+++ b/src/vaner/daemon/http.py
@@ -21,6 +21,15 @@
from vaner.daemon.live_work import read_live_work_events
from vaner.daemon.precompute_worker import read_prediction_snapshot, read_worker_status, request_precompute_wake, request_worker_control
from vaner.events.bus import build_stage_payloads
+from vaner.external_state.configurator import (
+ ProviderConfigInput,
+ discover_provider,
+ external_state_payload,
+ save_external_state_enabled,
+ save_finance_settings,
+ save_model_native_search_settings,
+ save_provider_config,
+)
from vaner.focus import FocusManager
from vaner.intent.prediction_serialization import compact_serialized_prediction
from vaner.mcp.contracts import EvidenceItem, Provenance, Resolution
@@ -1351,6 +1360,134 @@ async def backend_presets() -> JSONResponse:
}
)
+ @app.get("/external-state")
+ async def get_external_state() -> JSONResponse:
+ return JSONResponse(external_state_payload(config).model_dump(mode="json"))
+
+ @app.post("/external-state")
+ async def update_external_state(payload: dict[str, Any]) -> JSONResponse:
+ nonlocal config
+ allowed = {"enabled", "max_calls_per_cycle", "max_cycle_ms"}
+ for key in payload:
+ if key not in allowed:
+ raise HTTPException(status_code=400, detail=f"Unsupported external-state key: {key}")
+ save_external_state_enabled(
+ config.repo_root,
+ enabled=bool(payload["enabled"]) if "enabled" in payload else None,
+ max_calls_per_cycle=int(payload["max_calls_per_cycle"]) if "max_calls_per_cycle" in payload else None,
+ max_cycle_ms=int(payload["max_cycle_ms"]) if "max_cycle_ms" in payload else None,
+ )
+ config = load_config(config.repo_root)
+ focus_manager.config = config
+ return JSONResponse(external_state_payload(config).model_dump(mode="json"))
+
+ @app.post("/external-state/providers")
+ async def upsert_external_state_provider(payload: dict[str, Any]) -> JSONResponse:
+ nonlocal config
+ provider_id = str(payload.get("id") or payload.get("provider_id") or "").strip()
+ if not provider_id:
+ raise HTTPException(status_code=400, detail="provider id is required")
+ args = payload.get("args", [])
+ if isinstance(args, str):
+ args = [item for item in args.split(" ") if item]
+ if not isinstance(args, list):
+ raise HTTPException(status_code=400, detail="args must be a list")
+ env = payload.get("env", {})
+ if not isinstance(env, dict):
+ raise HTTPException(status_code=400, detail="env must be an object")
+ try:
+ save_provider_config(
+ config.repo_root,
+ ProviderConfigInput(
+ provider_id=provider_id,
+ transport=str(payload.get("transport") or "stdio"),
+ command=str(payload.get("command") or ""),
+ args=[str(item) for item in args],
+ url=str(payload.get("url") or ""),
+ env={str(key): str(value) for key, value in env.items()},
+ timeout_ms=int(payload.get("timeout_ms") or 10000),
+ ),
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ config = load_config(config.repo_root)
+ focus_manager.config = config
+ return JSONResponse(external_state_payload(config).model_dump(mode="json"))
+
+ @app.post("/external-state/providers/{provider_id}/discover")
+ async def discover_external_state_provider(provider_id: str, payload: dict[str, Any] | None = None) -> JSONResponse:
+ nonlocal config
+ body = payload or {}
+ try:
+ discovery = await discover_provider(
+ config,
+ provider_id,
+ apply=bool(body.get("apply", False)),
+ trust_unknown_read=bool(body.get("trust_unknown_read", False)),
+ )
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail="provider not found") from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"provider discovery failed: {exc}") from exc
+ if discovery.applied:
+ config = load_config(config.repo_root)
+ focus_manager.config = config
+ return JSONResponse(discovery.model_dump(mode="json"))
+
+ @app.post("/external-state/finance")
+ async def update_external_state_finance(payload: dict[str, Any]) -> JSONResponse:
+ nonlocal config
+ allowed = {"enabled", "provider", "market_data_enabled", "account_state_enabled", "capability_tools"}
+ for key in payload:
+ if key not in allowed:
+ raise HTTPException(status_code=400, detail=f"Unsupported finance external-state key: {key}")
+ capability_tools = payload.get("capability_tools")
+ if capability_tools is not None and not isinstance(capability_tools, dict):
+ raise HTTPException(status_code=400, detail="capability_tools must be an object")
+ try:
+ save_finance_settings(
+ config.repo_root,
+ enabled=bool(payload["enabled"]) if "enabled" in payload else None,
+ provider=str(payload["provider"]) if "provider" in payload and payload.get("provider") is not None else None,
+ market_data_enabled=bool(payload["market_data_enabled"]) if "market_data_enabled" in payload else None,
+ account_state_enabled=bool(payload["account_state_enabled"]) if "account_state_enabled" in payload else None,
+ capability_tools={str(key): str(value) for key, value in capability_tools.items()}
+ if isinstance(capability_tools, dict)
+ else None,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ config = load_config(config.repo_root)
+ focus_manager.config = config
+ return JSONResponse(external_state_payload(config).model_dump(mode="json"))
+
+ @app.post("/external-state/model-native-search")
+ async def update_model_native_search(payload: dict[str, Any]) -> JSONResponse:
+ nonlocal config
+ allowed = {"enabled", "provider", "base_url", "api_key_env", "max_results", "timeout_seconds"}
+ for key in payload:
+ if key not in allowed:
+ raise HTTPException(status_code=400, detail=f"Unsupported model-native search key: {key}")
+ try:
+ save_model_native_search_settings(
+ config.repo_root,
+ enabled=bool(payload["enabled"]) if "enabled" in payload else None,
+ provider=str(payload["provider"]) if "provider" in payload and payload.get("provider") is not None else None,
+ base_url=str(payload["base_url"]) if "base_url" in payload and payload.get("base_url") is not None else None,
+ api_key_env=str(payload["api_key_env"])
+ if "api_key_env" in payload and payload.get("api_key_env") is not None
+ else None,
+ max_results=int(payload["max_results"]) if "max_results" in payload else None,
+ timeout_seconds=float(payload["timeout_seconds"]) if "timeout_seconds" in payload else None,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ config = load_config(config.repo_root)
+ focus_manager.config = config
+ return JSONResponse(external_state_payload(config).model_dump(mode="json"))
+
@app.get("/skills")
async def list_skills() -> JSONResponse:
from vaner.intent.skills_discovery import discover_skills
@@ -1421,6 +1558,92 @@ async def list_items(kind: str | None = None, limit: int = 10, visibility: str =
)
return JSONResponse({"count": len(rows), "visibility": visibility_mode, "scenarios": [_scenario_payload(row) for row in rows]})
+ async def _heatmap_replay_payload(
+ *,
+ from_ts: float | None = None,
+ to_ts: float | None = None,
+ range_seconds: float | None = None,
+ limit: int = 100,
+ ) -> dict[str, Any]:
+ end_ts = float(to_ts or time.time())
+ default_range = float(range_seconds or 15 * 60)
+ start_ts = float(from_ts if from_ts is not None else end_ts - default_range)
+ if start_ts > end_ts:
+ start_ts, end_ts = end_ts, start_ts
+ capped_limit = max(1, min(int(limit), 200))
+ rows = await _best_effort(
+ scenario_store.list_top(limit=capped_limit, visibility="all"),
+ [],
+ label="heatmap scenarios",
+ timeout=2.0,
+ )
+ scenario_ids = [str(row.id) for row in rows]
+ samples = await _best_effort(
+ scenario_store.list_samples(scenario_ids=scenario_ids, start_ts=start_ts, end_ts=end_ts, limit=50_000),
+ [],
+ label="heatmap samples",
+ timeout=2.0,
+ )
+ live_events = [
+ row
+ for row in read_live_work_events(config.repo_root, limit=1000)
+ if start_ts <= float(row.get("ts") or 0.0) <= end_ts
+ ]
+ return {
+ "from_ts": start_ts,
+ "to_ts": end_ts,
+ "scenarios": [_scenario_payload(row) for row in rows],
+ "samples": [sample.__dict__ for sample in samples],
+ "events": live_events,
+ "metadata": {
+ "sample_source": "scenario_samples",
+ "event_source": "live_work_events",
+ "synthetic": False,
+ "sample_count": len(samples),
+ "event_count": len(live_events),
+ "scenario_count": len(rows),
+ "complete": bool(samples),
+ },
+ }
+
+ @app.get("/heatmap/replay/stream")
+ async def heatmap_replay_stream(
+ from_ts: float | None = None,
+ to_ts: float | None = None,
+ range_seconds: float | None = None,
+ limit: int = 100,
+ ) -> StreamingResponse:
+ async def event_gen() -> AsyncIterator[str]:
+ last_fingerprint = ""
+ last_keepalive = time.monotonic()
+ sent = 0
+ while True:
+ payload = await _heatmap_replay_payload(
+ from_ts=from_ts if to_ts is not None else None,
+ to_ts=to_ts,
+ range_seconds=range_seconds,
+ limit=limit,
+ )
+ serialized = json.dumps(payload, sort_keys=True, default=str)
+ if serialized != last_fingerprint:
+ yield f"event: replay_snapshot\ndata: {serialized}\n\n"
+ last_fingerprint = serialized
+ last_keepalive = time.monotonic()
+ sent += 1
+ if limit is not None and sent >= max(1, int(limit)):
+ return
+ now = time.monotonic()
+ if now - last_keepalive >= 10.0:
+ yield ": keepalive\n\n"
+ last_keepalive = now
+ await asyncio.sleep(1.0)
+
+ return StreamingResponse(event_gen(), media_type="text/event-stream")
+
+ @app.get("/heatmap/replay")
+ async def heatmap_replay(from_ts: float | None = None, to_ts: float | None = None, limit: int = 100) -> JSONResponse:
+ return JSONResponse(await _heatmap_replay_payload(from_ts=from_ts, to_ts=to_ts, limit=limit))
+
@app.get("/scenarios/{scenario_id}")
async def fetch_item(scenario_id: str) -> JSONResponse:
row = await scenario_store.get(scenario_id)
@@ -1537,7 +1760,13 @@ async def _work_product_store() -> Any:
else:
from vaner.store.artefacts import ArtefactStore
- store = ArtefactStore(config.repo_root / ".vaner" / "artefacts.db")
+ # Runtime worker state lives in config.store_path. Older
+ # cockpit-only tests and pre-0.9 installs may still seed the
+ # legacy artefacts DB directly, so keep that as a fallback
+ # only when the configured engine store has not been created.
+ legacy_path = config.repo_root / ".vaner" / "artefacts.db"
+ store_path = config.store_path if config.store_path.exists() else legacy_path
+ store = ArtefactStore(store_path)
await store.initialize()
work_product_store = store
return store
diff --git a/src/vaner/daemon/precompute_worker.py b/src/vaner/daemon/precompute_worker.py
index 1007f292..c9e726fa 100644
--- a/src/vaner/daemon/precompute_worker.py
+++ b/src/vaner/daemon/precompute_worker.py
@@ -197,6 +197,17 @@ async def run_forever(self) -> None:
engine = build_default_engine(self.repo_root, config)
await engine.initialize()
+ external_state_manager = None
+ if bool(getattr(config.external_state, "enabled", False)):
+ from vaner.external_state import ExternalStateManager, model_search_provider_from_config
+
+ external_state_manager = ExternalStateManager(
+ config.external_state,
+ model_search_provider=model_search_provider_from_config(config.external_state),
+ )
+ await external_state_manager.start()
+ if hasattr(engine, "set_external_state_manager"):
+ engine.set_external_state_manager(external_state_manager)
if hasattr(engine, "set_prediction_event_listener"):
engine.set_prediction_event_listener(self._record_prediction_event)
if hasattr(engine, "set_live_work_event_listener"):
@@ -216,6 +227,8 @@ async def run_forever(self) -> None:
finally:
scheduler.cancel()
controller.cancel()
+ if external_state_manager is not None:
+ await external_state_manager.stop()
self._set_state("stopping", explanation="worker stopping")
async def _schedule_periodic(self, focus_manager: FocusManager) -> None:
@@ -367,6 +380,11 @@ async def _run_job(self, job: WorkerJob, *, engine: Any, focus_manager: FocusMan
profile["precompute_ms"] = (time.monotonic() - precompute_start) * 1000.0
profile["total_ms"] = (time.monotonic() - started) * 1000.0
profile["produced"] = float(produced or 0)
+ if hasattr(engine, "get_last_cycle_profile"):
+ try:
+ profile.update(engine.get_last_cycle_profile())
+ except Exception:
+ logger.debug("failed to read engine cycle profile", exc_info=True)
self._write_predictions(engine, cycle_id=cycle_id)
self._status["profile"] = profile
self._set_state("completed", job=job, phase="idle", cycle_id=cycle_id, produced=int(produced or 0))
@@ -533,9 +551,13 @@ def _record_prediction_event(self, event: Any) -> None:
stage = "progress"
status = "running"
if isinstance(payload, dict):
+ tokens_used = int(payload.get("tokens_used") or 0)
+ token_budget = int(payload.get("token_budget") or 0)
+ overage = max(0, tokens_used - token_budget)
+ budget_label = f"{token_budget} token target" if token_budget else "token target"
+ overage_label = f" ({overage} over target)" if overage else ""
summary = (
- f"Progress: {int(payload.get('tokens_used') or 0)}/"
- f"{int(payload.get('token_budget') or 0)} tokens, "
+ f"Progress: {tokens_used} tokens used / {budget_label}{overage_label}, "
f"{int(payload.get('scenarios_complete') or 0)} scenarios complete."
)
elif kind == "prediction.artifact_added":
diff --git a/src/vaner/defaults/catalog_seed.json b/src/vaner/defaults/catalog_seed.json
index 5e740e8d..74dce4e6 100644
--- a/src/vaner/defaults/catalog_seed.json
+++ b/src/vaner/defaults/catalog_seed.json
@@ -1,99 +1,305 @@
{
"schema_version": 2,
- "generated_at": "2026-05-01",
- "comment": "Family-level seed for the catalog refresher. The refresher (vaner.setup.catalog_refresh) emits one row per family using the family's configured Ollama tag, derives params/size from the manifest's vnd.ollama.image.model layer bytes, and computes memory budgets. The seed carries family-level metadata that doesn't depend on size — display name, workload tags, ranks, sampling defaults, the model's max_context_window — plus the Ollama family name and an optional HF repo template. context_window here is the architectural ceiling; recommend_local_model picks a hardware/archetype-aware effective_context_window at recommend time, floored at 32K.",
+ "generated_at": "2026-05-05",
+ "comment": "Ollama-first, release-curated model recommendation seed. Hugging Face and Ollama public metadata can inform refreshes, but Vaner does not depend on a live recommendation endpoint. The refresher emits one registry row per configured model tag, verifies Ollama tags through registry manifests when online, derives pull size from model layers, and computes memory budgets. context_window is the architectural ceiling; recommend_local_model picks a hardware/archetype-aware effective context window at setup time.",
"quantization_profiles": {
- "Q4_K_M": { "bytes_per_param": 0.55, "label": "Q4_K_M", "ratio_to_fp16": 0.275 },
- "Q5_K_M": { "bytes_per_param": 0.7, "label": "Q5_K_M", "ratio_to_fp16": 0.35 },
- "Q6_K": { "bytes_per_param": 0.84, "label": "Q6_K", "ratio_to_fp16": 0.42 },
- "Q8_0": { "bytes_per_param": 1.06, "label": "Q8_0", "ratio_to_fp16": 0.53 },
- "FP16": { "bytes_per_param": 2.0, "label": "FP16", "ratio_to_fp16": 1.0 }
+ "Q4_K_M": {
+ "bytes_per_param": 0.55,
+ "label": "Q4_K_M",
+ "ratio_to_fp16": 0.275
+ },
+ "Q5_K_M": {
+ "bytes_per_param": 0.7,
+ "label": "Q5_K_M",
+ "ratio_to_fp16": 0.35
+ },
+ "Q6_K": {
+ "bytes_per_param": 0.84,
+ "label": "Q6_K",
+ "ratio_to_fp16": 0.42
+ },
+ "Q8_0": {
+ "bytes_per_param": 1.06,
+ "label": "Q8_0",
+ "ratio_to_fp16": 0.53
+ },
+ "NVFP4": {
+ "bytes_per_param": 0.63,
+ "label": "NVFP4",
+ "ratio_to_fp16": 0.315
+ },
+ "MXFP4": {
+ "bytes_per_param": 0.555,
+ "label": "MXFP4",
+ "ratio_to_fp16": 0.2775
+ },
+ "MXFP8": {
+ "bytes_per_param": 1.1,
+ "label": "MXFP8",
+ "ratio_to_fp16": 0.55
+ },
+ "FP16": {
+ "bytes_per_param": 2.0,
+ "label": "FP16",
+ "ratio_to_fp16": 1.0
+ }
},
"default_quantization": "Q4_K_M",
"families": [
{
- "id": "qwen3.6",
- "display_name": "Qwen 3.6",
+ "id": "gpt-oss-120b",
+ "display_name": "GPT-OSS 120B",
+ "runtime": "ollama",
+ "ollama_family": "gpt-oss",
+ "ollama_tag": "120b",
+ "hf_org": "openai",
+ "hf_repo_template": "openai/gpt-oss-120b",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "reasoning",
+ "agentic",
+ "long_context"
+ ],
+ "stability_rank": 87,
+ "quality_rank": 99,
+ "recency_rank": 98,
+ "default_params_b": 117,
+ "default_download_size_gb": 65,
+ "active_params_b": 5.1,
+ "architecture": "moe",
+ "quantization": "MXFP4",
+ "accelerator_tags": [
+ "nvidia",
+ "blackwell",
+ "unified_memory",
+ "dgx_spark",
+ "moe"
+ ],
+ "parameters": {
+ "context_window": 131072,
+ "reasoning_mode": "allowed",
+ "reasoning_effort": "medium",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 1.0,
+ "top_p": 0.95,
+ "top_k": 40,
+ "repeat_penalty": 1.0,
+ "min_p": 0.0
+ }
+ },
+ {
+ "id": "qwen3.6-35b-a3b-coding-nvfp4",
+ "display_name": "Qwen 3.6 35B A3B Coding NVFP4",
"runtime": "ollama",
"ollama_family": "qwen3.6",
- "ollama_tag": "27b",
- "hf_org": "Qwen",
- "hf_repo_template": "Qwen/Qwen3.6-{size}-Instruct",
- "workload_tags": ["general", "coding", "summarization"],
- "stability_rank": 85,
- "quality_rank": 98,
+ "ollama_tag": "35b-a3b-coding-nvfp4",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "long_context"
+ ],
+ "stability_rank": 82,
+ "quality_rank": 99,
+ "recency_rank": 100,
+ "default_params_b": 35,
+ "default_download_size_gb": 22,
+ "active_params_b": 3,
+ "architecture": "moe",
+ "quantization": "NVFP4",
+ "accelerator_tags": [
+ "nvidia",
+ "blackwell",
+ "moe"
+ ],
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
+ "top_p": 0.95,
+ "top_k": 20,
+ "repeat_penalty": 1.0,
+ "min_p": 0.0
+ }
+ },
+ {
+ "id": "qwen3.6-27b-coding-nvfp4",
+ "display_name": "Qwen 3.6 27B Coding NVFP4",
+ "runtime": "ollama",
+ "ollama_family": "qwen3.6",
+ "ollama_tag": "27b-coding-nvfp4",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "coding",
+ "agentic",
+ "general",
+ "long_context"
+ ],
+ "stability_rank": 82,
+ "quality_rank": 97,
"recency_rank": 100,
"default_params_b": 27.1,
- "default_download_size_gb": 14.9,
+ "default_download_size_gb": 20,
+ "architecture": "dense",
+ "quantization": "NVFP4",
+ "accelerator_tags": [
+ "nvidia",
+ "blackwell"
+ ],
"parameters": {
"context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 8192,
- "temperature": 0.7,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
- "min_p": 0.0,
- "repeat_penalty": 1.05
+ "repeat_penalty": 1.0,
+ "min_p": 0.0
}
},
{
- "id": "qwen3.5",
- "display_name": "Qwen 3.5",
+ "id": "qwen3.6-35b-a3b-coding-mxfp8",
+ "display_name": "Qwen 3.6 35B A3B Coding MXFP8",
"runtime": "ollama",
- "ollama_family": "qwen3.5",
- "hf_org": "Qwen",
- "hf_repo_template": "Qwen/Qwen3.5-{size}-Instruct",
- "workload_tags": ["general", "coding", "summarization"],
- "stability_rank": 90,
- "quality_rank": 95,
- "recency_rank": 90,
+ "ollama_family": "qwen3.6",
+ "ollama_tag": "35b-a3b-coding-mxfp8",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "long_context"
+ ],
+ "stability_rank": 84,
+ "quality_rank": 99,
+ "recency_rank": 100,
+ "default_params_b": 35,
+ "default_download_size_gb": 38,
+ "active_params_b": 3,
+ "architecture": "moe",
+ "quantization": "MXFP8",
+ "accelerator_tags": [
+ "apple_silicon",
+ "mlx",
+ "moe"
+ ],
"parameters": {
- "context_window": 131072,
+ "context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 8192,
- "temperature": 0.7,
- "top_p": 0.8,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
+ "top_p": 0.95,
"top_k": 20,
- "min_p": 0.0,
- "repeat_penalty": 1.05
+ "repeat_penalty": 1.0,
+ "min_p": 0.0
}
},
{
- "id": "qwen3-coder-next",
- "display_name": "Qwen 3 Coder Next",
+ "id": "qwen3.6-27b-coding-mxfp8",
+ "display_name": "Qwen 3.6 27B Coding MXFP8",
"runtime": "ollama",
- "ollama_family": "qwen3-coder-next",
- "hf_org": "Qwen",
- "hf_repo_template": "Qwen/Qwen3-Coder-Next-{size}",
- "workload_tags": ["coding", "general"],
- "stability_rank": 85,
- "quality_rank": 92,
- "recency_rank": 95,
+ "ollama_family": "qwen3.6",
+ "ollama_tag": "27b-coding-mxfp8",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "coding",
+ "agentic",
+ "general",
+ "long_context"
+ ],
+ "stability_rank": 84,
+ "quality_rank": 97,
+ "recency_rank": 100,
+ "default_params_b": 27.1,
+ "default_download_size_gb": 31,
+ "architecture": "dense",
+ "quantization": "MXFP8",
+ "accelerator_tags": [
+ "apple_silicon",
+ "mlx"
+ ],
"parameters": {
"context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 8192,
- "temperature": 0.2,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
- "min_p": 0.0,
- "repeat_penalty": 1.05
+ "repeat_penalty": 1.0,
+ "min_p": 0.0
}
},
{
- "id": "qwen3-next",
- "display_name": "Qwen 3 Next",
+ "id": "qwen3.6-35b",
+ "display_name": "Qwen 3.6 35B A3B",
"runtime": "ollama",
- "ollama_family": "qwen3-next",
- "hf_org": "Qwen",
- "hf_repo_template": "Qwen/Qwen3-Next-{size}",
- "workload_tags": ["general", "coding"],
+ "ollama_family": "qwen3.6",
+ "ollama_tag": "35b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "research",
+ "long_context"
+ ],
"stability_rank": 85,
- "quality_rank": 90,
- "recency_rank": 92,
+ "quality_rank": 98,
+ "recency_rank": 100,
+ "default_params_b": 35,
+ "default_download_size_gb": 24,
+ "active_params_b": 3,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.7,
+ "top_p": 0.95,
+ "top_k": 20,
+ "repeat_penalty": 1.05,
+ "min_p": 0.0
+ }
+ },
+ {
+ "id": "qwen3.6-27b",
+ "display_name": "Qwen 3.6 27B",
+ "runtime": "ollama",
+ "ollama_family": "qwen3.6",
+ "ollama_tag": "27b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "long_context"
+ ],
+ "stability_rank": 86,
+ "quality_rank": 97,
+ "recency_rank": 100,
+ "default_params_b": 27.1,
+ "default_download_size_gb": 17,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
"parameters": {
"context_window": 262144,
"reasoning_mode": "allowed",
@@ -102,26 +308,37 @@
"temperature": 0.7,
"top_p": 0.95,
"top_k": 20,
- "min_p": 0.0,
- "repeat_penalty": 1.05
+ "repeat_penalty": 1.05,
+ "min_p": 0.0
}
},
{
- "id": "llama4",
- "display_name": "Llama 4",
+ "id": "gemma4-e2b",
+ "display_name": "Gemma 4 E2B",
"runtime": "ollama",
- "ollama_family": "llama4",
- "hf_org": "meta-llama",
- "hf_repo_template": "meta-llama/Llama-4-{size}-Instruct",
- "workload_tags": ["general", "summarization"],
+ "ollama_family": "gemma4",
+ "ollama_tag": "e2b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "summarization",
+ "lightweight",
+ "vision"
+ ],
"stability_rank": 90,
- "quality_rank": 94,
- "recency_rank": 95,
+ "quality_rank": 84,
+ "recency_rank": 96,
+ "default_params_b": 13,
+ "default_download_size_gb": 7.2,
+ "active_params_b": 2,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
"parameters": {
- "context_window": 1048576,
- "reasoning_mode": "none",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 0,
+ "context_window": 131072,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 3072,
+ "reasoning_token_budget": 4096,
"temperature": 0.6,
"top_p": 0.9,
"top_k": 50,
@@ -129,71 +346,248 @@
}
},
{
- "id": "gemma4",
- "display_name": "Gemma 4",
+ "id": "gemma4-e4b",
+ "display_name": "Gemma 4 E4B",
"runtime": "ollama",
"ollama_family": "gemma4",
- "hf_org": "google",
- "hf_repo_template": "google/gemma-4-{size}-it",
- "workload_tags": ["general", "summarization", "lightweight"],
- "stability_rank": 88,
- "quality_rank": 78,
- "recency_rank": 92,
+ "ollama_tag": "e4b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "summarization",
+ "coding",
+ "lightweight",
+ "vision"
+ ],
+ "stability_rank": 90,
+ "quality_rank": 88,
+ "recency_rank": 96,
+ "default_params_b": 17,
+ "default_download_size_gb": 9.6,
+ "active_params_b": 4,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
"parameters": {
"context_window": 131072,
"reasoning_mode": "allowed",
- "max_response_tokens": 3072,
+ "max_response_tokens": 4096,
"reasoning_token_budget": 4096,
- "temperature": 0.8,
- "top_p": 0.95,
- "top_k": 64,
- "repeat_penalty": 1.0
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1
}
},
{
- "id": "deepseek-r2",
- "display_name": "DeepSeek-R2",
+ "id": "gemma4-26b",
+ "display_name": "Gemma 4 26B",
"runtime": "ollama",
- "ollama_family": "deepseek-r2",
- "hf_org": "deepseek-ai",
- "hf_repo_template": "deepseek-ai/DeepSeek-R2-Distill-Qwen-{size}",
- "workload_tags": ["general", "coding", "research"],
- "stability_rank": 80,
- "quality_rank": 96,
+ "ollama_family": "gemma4",
+ "ollama_tag": "26b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "vision",
+ "long_context"
+ ],
+ "stability_rank": 88,
+ "quality_rank": 94,
"recency_rank": 96,
+ "default_params_b": 26,
+ "default_download_size_gb": 18,
+ "active_params_b": 4,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
"parameters": {
- "context_window": 131072,
- "reasoning_mode": "forced",
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
"max_response_tokens": 4096,
+ "reasoning_token_budget": 8192,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1
+ }
+ },
+ {
+ "id": "gemma4-31b",
+ "display_name": "Gemma 4 31B",
+ "runtime": "ollama",
+ "ollama_family": "gemma4",
+ "ollama_tag": "31b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "vision",
+ "long_context"
+ ],
+ "stability_rank": 89,
+ "quality_rank": 95,
+ "recency_rank": 96,
+ "default_params_b": 31,
+ "default_download_size_gb": 20,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 8192,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1
+ }
+ },
+ {
+ "id": "nemotron-cascade-2-30b",
+ "display_name": "Nemotron Cascade 2 30B A3B",
+ "runtime": "ollama",
+ "ollama_family": "nemotron-cascade-2",
+ "ollama_tag": "30b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "reasoning",
+ "agentic",
+ "long_context"
+ ],
+ "stability_rank": 80,
+ "quality_rank": 93,
+ "recency_rank": 95,
+ "default_params_b": 30,
+ "default_download_size_gb": 24,
+ "active_params_b": 3,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": [
+ "nvidia",
+ "moe"
+ ],
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 8192,
"reasoning_token_budget": 16384,
"temperature": 0.6,
"top_p": 0.95,
"top_k": 40,
- "repeat_penalty": 1.0
+ "repeat_penalty": 1.05
+ }
+ },
+ {
+ "id": "nemotron3-33b",
+ "display_name": "Nemotron 3 Nano Omni 33B",
+ "runtime": "ollama",
+ "ollama_family": "nemotron3",
+ "ollama_tag": "33b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "summarization",
+ "vision",
+ "agentic"
+ ],
+ "stability_rank": 78,
+ "quality_rank": 88,
+ "recency_rank": 100,
+ "default_params_b": 33,
+ "default_download_size_gb": 28,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": [
+ "nvidia"
+ ],
+ "parameters": {
+ "context_window": 131072,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 8192,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 40,
+ "repeat_penalty": 1.05
}
},
{
- "id": "qwen3",
- "display_name": "Qwen 3",
+ "id": "mistral-medium-3.5-128b",
+ "display_name": "Mistral Medium 3.5 128B",
"runtime": "ollama",
- "ollama_family": "qwen3",
- "hf_org": "Qwen",
- "hf_repo_template": "Qwen/Qwen3-{size}-Instruct",
- "workload_tags": ["general", "lightweight"],
- "stability_rank": 95,
- "quality_rank": 75,
- "recency_rank": 80,
+ "ollama_family": "mistral-medium-3.5",
+ "ollama_tag": "128b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "reasoning",
+ "agentic",
+ "vision",
+ "long_context"
+ ],
+ "stability_rank": 82,
+ "quality_rank": 96,
+ "recency_rank": 100,
+ "default_params_b": 128,
+ "default_download_size_gb": 80,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
"parameters": {
- "context_window": 32768,
+ "context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 2048,
- "reasoning_token_budget": 2048,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
"temperature": 0.7,
- "top_p": 0.8,
- "top_k": 20,
- "min_p": 0.0,
+ "top_p": 0.95,
+ "top_k": 40,
"repeat_penalty": 1.05
}
+ },
+ {
+ "id": "llama4-scout",
+ "display_name": "Llama 4 Scout 16x17B",
+ "runtime": "ollama",
+ "ollama_family": "llama4",
+ "ollama_tag": "16x17b",
+ "hf_org": "",
+ "hf_repo_template": "",
+ "workload_tags": [
+ "general",
+ "summarization",
+ "vision",
+ "long_context"
+ ],
+ "stability_rank": 88,
+ "quality_rank": 87,
+ "recency_rank": 65,
+ "default_params_b": 109,
+ "default_download_size_gb": 67,
+ "active_params_b": 17,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "parameters": {
+ "context_window": 10485760,
+ "reasoning_mode": "none",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 0,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1
+ }
}
]
}
diff --git a/src/vaner/defaults/model_registry.json b/src/vaner/defaults/model_registry.json
index ccd167b1..0f323a49 100644
--- a/src/vaner/defaults/model_registry.json
+++ b/src/vaner/defaults/model_registry.json
@@ -1,6 +1,6 @@
{
"schema_version": 1,
- "verified_at": "2026-05-01",
+ "verified_at": "2026-05-05",
"generator": "vaner.setup.catalog_refresh",
"online": true,
"sources": [
@@ -9,111 +9,257 @@
],
"models": [
{
- "id": "qwen3.6:27b",
- "display_name": "Qwen 3.6 27B",
+ "id": "gpt-oss:120b",
+ "display_name": "GPT-OSS 120B",
"runtime": "ollama",
"workload_tags": [
"general",
"coding",
- "summarization"
+ "summarization",
+ "reasoning",
+ "agentic",
+ "long_context"
],
- "quality_rank": 98,
- "stability_rank": 85,
+ "quality_rank": 99,
+ "stability_rank": 87,
+ "recency_rank": 98,
+ "download_size_gb": 60.9,
+ "min_effective_memory_gb": 73.8,
+ "recommended_effective_memory_gb": 73.8,
+ "parameters": {
+ "context_window": 131072,
+ "reasoning_mode": "allowed",
+ "reasoning_effort": "medium",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 1.0,
+ "top_p": 0.95,
+ "top_k": 40,
+ "repeat_penalty": 1.0,
+ "min_p": 0.0,
+ "num_ctx": 131072
+ },
+ "family_id": "gpt-oss-120b",
+ "params_b": 109.7,
+ "active_params_b": 5.1,
+ "architecture": "moe",
+ "quantization": "MXFP4",
+ "accelerator_tags": [
+ "nvidia",
+ "blackwell",
+ "unified_memory",
+ "dgx_spark",
+ "moe"
+ ]
+ },
+ {
+ "id": "qwen3.6:35b-a3b-coding-nvfp4",
+ "display_name": "Qwen 3.6 35B A3B Coding NVFP4",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "long_context"
+ ],
+ "quality_rank": 99,
+ "stability_rank": 82,
"recency_rank": 100,
- "download_size_gb": 14.9,
- "min_effective_memory_gb": 17.2,
- "recommended_effective_memory_gb": 37.9,
+ "download_size_gb": 22.0,
+ "min_effective_memory_gb": 31.8,
+ "recommended_effective_memory_gb": 32.8,
"parameters": {
"context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 8192,
- "temperature": 0.7,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
+ "repeat_penalty": 1.0,
+ "min_p": 0.0,
+ "num_ctx": 262144
+ },
+ "family_id": "qwen3.6-35b-a3b-coding-nvfp4",
+ "params_b": 35.0,
+ "active_params_b": 3.0,
+ "architecture": "moe",
+ "quantization": "NVFP4",
+ "accelerator_tags": [
+ "nvidia",
+ "blackwell",
+ "moe"
+ ]
+ },
+ {
+ "id": "qwen3.6:27b-coding-nvfp4",
+ "display_name": "Qwen 3.6 27B Coding NVFP4",
+ "runtime": "ollama",
+ "workload_tags": [
+ "coding",
+ "agentic",
+ "general",
+ "long_context"
+ ],
+ "quality_rank": 97,
+ "stability_rank": 82,
+ "recency_rank": 100,
+ "download_size_gb": 20.0,
+ "min_effective_memory_gb": 19.6,
+ "recommended_effective_memory_gb": 43.2,
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
+ "top_p": 0.95,
+ "top_k": 20,
+ "repeat_penalty": 1.0,
"min_p": 0.0,
- "repeat_penalty": 1.05,
"num_ctx": 262144
},
- "family_id": "qwen3.6",
+ "family_id": "qwen3.6-27b-coding-nvfp4",
"params_b": 27.1,
- "quantization": "Q4_K_M"
+ "active_params_b": 0.0,
+ "architecture": "dense",
+ "quantization": "NVFP4",
+ "accelerator_tags": [
+ "nvidia",
+ "blackwell"
+ ]
},
{
- "id": "qwen3.5:latest",
- "display_name": "Qwen 3.5",
+ "id": "qwen3.6:35b-a3b-coding-mxfp8",
+ "display_name": "Qwen 3.6 35B A3B Coding MXFP8",
"runtime": "ollama",
"workload_tags": [
"general",
"coding",
- "summarization"
+ "summarization",
+ "agentic",
+ "long_context"
],
- "quality_rank": 95,
- "stability_rank": 90,
- "recency_rank": 90,
- "download_size_gb": 6.1,
- "min_effective_memory_gb": 7.4,
- "recommended_effective_memory_gb": 12.1,
+ "quality_rank": 99,
+ "stability_rank": 84,
+ "recency_rank": 100,
+ "download_size_gb": 38.0,
+ "min_effective_memory_gb": 49.6,
+ "recommended_effective_memory_gb": 51.3,
"parameters": {
- "context_window": 131072,
+ "context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 8192,
- "temperature": 0.7,
- "top_p": 0.8,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
+ "top_p": 0.95,
"top_k": 20,
+ "repeat_penalty": 1.0,
"min_p": 0.0,
- "repeat_penalty": 1.05,
- "num_ctx": 131072
+ "num_ctx": 262144
},
- "family_id": "qwen3.5",
- "params_b": 11.2,
- "quantization": "Q4_K_M"
+ "family_id": "qwen3.6-35b-a3b-coding-mxfp8",
+ "params_b": 35.0,
+ "active_params_b": 3.0,
+ "architecture": "moe",
+ "quantization": "MXFP8",
+ "accelerator_tags": [
+ "apple_silicon",
+ "mlx",
+ "moe"
+ ]
},
{
- "id": "qwen3-coder-next:latest",
- "display_name": "Qwen 3 Coder Next",
+ "id": "qwen3.6:27b-coding-mxfp8",
+ "display_name": "Qwen 3.6 27B Coding MXFP8",
"runtime": "ollama",
"workload_tags": [
"coding",
- "general"
+ "agentic",
+ "general",
+ "long_context"
],
- "quality_rank": 92,
- "stability_rank": 85,
- "recency_rank": 95,
- "download_size_gb": 48.2,
- "min_effective_memory_gb": 54.5,
- "recommended_effective_memory_gb": 119.1,
+ "quality_rank": 97,
+ "stability_rank": 84,
+ "recency_rank": 100,
+ "download_size_gb": 31.0,
+ "min_effective_memory_gb": 33.9,
+ "recommended_effective_memory_gb": 74.2,
"parameters": {
"context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 8192,
- "temperature": 0.2,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
"top_p": 0.95,
"top_k": 20,
+ "repeat_penalty": 1.0,
"min_p": 0.0,
- "repeat_penalty": 1.05,
"num_ctx": 262144
},
- "family_id": "qwen3-coder-next",
- "params_b": 87.6,
- "quantization": "Q4_K_M"
+ "family_id": "qwen3.6-27b-coding-mxfp8",
+ "params_b": 27.1,
+ "active_params_b": 0.0,
+ "architecture": "dense",
+ "quantization": "MXFP8",
+ "accelerator_tags": [
+ "apple_silicon",
+ "mlx"
+ ]
},
{
- "id": "qwen3-next:latest",
- "display_name": "Qwen 3 Next",
+ "id": "qwen3.6:35b",
+ "display_name": "Qwen 3.6 35B A3B",
"runtime": "ollama",
"workload_tags": [
"general",
- "coding"
+ "coding",
+ "summarization",
+ "research",
+ "long_context"
],
- "quality_rank": 90,
+ "quality_rank": 98,
"stability_rank": 85,
- "recency_rank": 92,
- "download_size_gb": 46.9,
- "min_effective_memory_gb": 53.0,
- "recommended_effective_memory_gb": 116.0,
+ "recency_rank": 100,
+ "download_size_gb": 22.3,
+ "min_effective_memory_gb": 32.1,
+ "recommended_effective_memory_gb": 32.8,
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.7,
+ "top_p": 0.95,
+ "top_k": 20,
+ "repeat_penalty": 1.05,
+ "min_p": 0.0,
+ "num_ctx": 262144
+ },
+ "family_id": "qwen3.6-35b",
+ "params_b": 40.5,
+ "active_params_b": 3.0,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
+ },
+ {
+ "id": "qwen3.6:27b",
+ "display_name": "Qwen 3.6 27B",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "long_context"
+ ],
+ "quality_rank": 97,
+ "stability_rank": 86,
+ "recency_rank": 100,
+ "download_size_gb": 16.2,
+ "min_effective_memory_gb": 18.7,
+ "recommended_effective_memory_gb": 41.1,
"parameters": {
"context_window": 262144,
"reasoning_mode": "allowed",
@@ -122,108 +268,302 @@
"temperature": 0.7,
"top_p": 0.95,
"top_k": 20,
- "min_p": 0.0,
"repeat_penalty": 1.05,
+ "min_p": 0.0,
"num_ctx": 262144
},
- "family_id": "qwen3-next",
- "params_b": 85.3,
- "quantization": "Q4_K_M"
+ "family_id": "qwen3.6-27b",
+ "params_b": 29.5,
+ "active_params_b": 0.0,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
},
{
- "id": "llama4:latest",
- "display_name": "Llama 4",
+ "id": "gemma4:e2b",
+ "display_name": "Gemma 4 E2B",
"runtime": "ollama",
"workload_tags": [
"general",
- "summarization"
+ "summarization",
+ "lightweight",
+ "vision"
],
- "quality_rank": 94,
+ "quality_rank": 84,
"stability_rank": 90,
- "recency_rank": 95,
- "download_size_gb": 62.8,
- "min_effective_memory_gb": 70.8,
- "recommended_effective_memory_gb": 426.1,
+ "recency_rank": 96,
+ "download_size_gb": 6.7,
+ "min_effective_memory_gb": 15.2,
+ "recommended_effective_memory_gb": 16.7,
"parameters": {
- "context_window": 1048576,
- "reasoning_mode": "none",
- "max_response_tokens": 4096,
- "reasoning_token_budget": 0,
+ "context_window": 131072,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 3072,
+ "reasoning_token_budget": 4096,
"temperature": 0.6,
"top_p": 0.9,
"top_k": 50,
"repeat_penalty": 1.1,
- "num_ctx": 1048576
+ "num_ctx": 131072
},
- "family_id": "llama4",
- "params_b": 114.2,
- "quantization": "Q4_K_M"
+ "family_id": "gemma4-e2b",
+ "params_b": 12.1,
+ "active_params_b": 2.0,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
},
{
- "id": "gemma4:latest",
- "display_name": "Gemma 4",
+ "id": "gemma4:e4b",
+ "display_name": "Gemma 4 E4B",
"runtime": "ollama",
"workload_tags": [
"general",
"summarization",
- "lightweight"
+ "coding",
+ "lightweight",
+ "vision"
],
- "quality_rank": 78,
- "stability_rank": 88,
- "recency_rank": 92,
+ "quality_rank": 88,
+ "stability_rank": 90,
+ "recency_rank": 96,
"download_size_gb": 8.9,
- "min_effective_memory_gb": 10.5,
- "recommended_effective_memory_gb": 16.9,
+ "min_effective_memory_gb": 17.7,
+ "recommended_effective_memory_gb": 19.0,
"parameters": {
"context_window": 131072,
"reasoning_mode": "allowed",
- "max_response_tokens": 3072,
+ "max_response_tokens": 4096,
"reasoning_token_budget": 4096,
- "temperature": 0.8,
- "top_p": 0.95,
- "top_k": 64,
- "repeat_penalty": 1.0,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1,
"num_ctx": 131072
},
- "family_id": "gemma4",
+ "family_id": "gemma4-e4b",
"params_b": 16.3,
- "quantization": "Q4_K_M"
+ "active_params_b": 4.0,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
+ },
+ {
+ "id": "gemma4:26b",
+ "display_name": "Gemma 4 26B",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "vision",
+ "long_context"
+ ],
+ "quality_rank": 94,
+ "stability_rank": 88,
+ "recency_rank": 96,
+ "download_size_gb": 16.8,
+ "min_effective_memory_gb": 26.1,
+ "recommended_effective_memory_gb": 27.9,
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 8192,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1,
+ "num_ctx": 262144
+ },
+ "family_id": "gemma4-26b",
+ "params_b": 30.5,
+ "active_params_b": 4.0,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
+ },
+ {
+ "id": "gemma4:31b",
+ "display_name": "Gemma 4 31B",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "coding",
+ "summarization",
+ "agentic",
+ "vision",
+ "long_context"
+ ],
+ "quality_rank": 95,
+ "stability_rank": 89,
+ "recency_rank": 96,
+ "download_size_gb": 18.5,
+ "min_effective_memory_gb": 21.2,
+ "recommended_effective_memory_gb": 46.6,
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 8192,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1,
+ "num_ctx": 262144
+ },
+ "family_id": "gemma4-31b",
+ "params_b": 33.6,
+ "active_params_b": 0.0,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
+ },
+ {
+ "id": "nemotron-cascade-2:30b",
+ "display_name": "Nemotron Cascade 2 30B A3B",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "coding",
+ "reasoning",
+ "agentic",
+ "long_context"
+ ],
+ "quality_rank": 93,
+ "stability_rank": 80,
+ "recency_rank": 95,
+ "download_size_gb": 22.6,
+ "min_effective_memory_gb": 32.4,
+ "recommended_effective_memory_gb": 33.2,
+ "parameters": {
+ "context_window": 262144,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
+ "temperature": 0.6,
+ "top_p": 0.95,
+ "top_k": 40,
+ "repeat_penalty": 1.05,
+ "num_ctx": 262144
+ },
+ "family_id": "nemotron-cascade-2-30b",
+ "params_b": 41.1,
+ "active_params_b": 3.0,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": [
+ "nvidia",
+ "moe"
+ ]
+ },
+ {
+ "id": "nemotron3:33b",
+ "display_name": "Nemotron 3 Nano Omni 33B",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "summarization",
+ "vision",
+ "agentic"
+ ],
+ "quality_rank": 88,
+ "stability_rank": 78,
+ "recency_rank": 100,
+ "download_size_gb": 25.7,
+ "min_effective_memory_gb": 29.3,
+ "recommended_effective_memory_gb": 45.8,
+ "parameters": {
+ "context_window": 131072,
+ "reasoning_mode": "allowed",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 8192,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 40,
+ "repeat_penalty": 1.05,
+ "num_ctx": 131072
+ },
+ "family_id": "nemotron3-33b",
+ "params_b": 46.8,
+ "active_params_b": 0.0,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": [
+ "nvidia"
+ ]
},
{
- "id": "qwen3:latest",
- "display_name": "Qwen 3",
+ "id": "mistral-medium-3.5:128b",
+ "display_name": "Mistral Medium 3.5 128B",
"runtime": "ollama",
"workload_tags": [
"general",
- "lightweight"
+ "coding",
+ "summarization",
+ "reasoning",
+ "agentic",
+ "vision",
+ "long_context"
],
- "quality_rank": 75,
- "stability_rank": 95,
- "recency_rank": 80,
- "download_size_gb": 4.9,
- "min_effective_memory_gb": 5.9,
- "recommended_effective_memory_gb": 7.2,
+ "quality_rank": 96,
+ "stability_rank": 82,
+ "recency_rank": 100,
+ "download_size_gb": 74.7,
+ "min_effective_memory_gb": 84.2,
+ "recommended_effective_memory_gb": 183.9,
"parameters": {
- "context_window": 32768,
+ "context_window": 262144,
"reasoning_mode": "allowed",
- "max_response_tokens": 2048,
- "reasoning_token_budget": 2048,
+ "max_response_tokens": 8192,
+ "reasoning_token_budget": 16384,
"temperature": 0.7,
- "top_p": 0.8,
- "top_k": 20,
- "min_p": 0.0,
+ "top_p": 0.95,
+ "top_k": 40,
"repeat_penalty": 1.05,
- "num_ctx": 32768
+ "num_ctx": 262144
},
- "family_id": "qwen3",
- "params_b": 8.8,
- "quantization": "Q4_K_M"
- }
- ],
- "skipped": [
+ "family_id": "mistral-medium-3.5-128b",
+ "params_b": 135.9,
+ "active_params_b": 0.0,
+ "architecture": "dense",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
+ },
{
- "family": "deepseek-r2",
- "reason": "ollama_latest_not_found"
+ "id": "llama4:16x17b",
+ "display_name": "Llama 4 Scout 16x17B",
+ "runtime": "ollama",
+ "workload_tags": [
+ "general",
+ "summarization",
+ "vision",
+ "long_context"
+ ],
+ "quality_rank": 87,
+ "stability_rank": 88,
+ "recency_rank": 65,
+ "download_size_gb": 62.8,
+ "min_effective_memory_gb": 75.8,
+ "recommended_effective_memory_gb": 609.4,
+ "parameters": {
+ "context_window": 10485760,
+ "reasoning_mode": "none",
+ "max_response_tokens": 4096,
+ "reasoning_token_budget": 0,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 50,
+ "repeat_penalty": 1.1,
+ "num_ctx": 10485760
+ },
+ "family_id": "llama4-scout",
+ "params_b": 114.2,
+ "active_params_b": 17.0,
+ "architecture": "moe",
+ "quantization": "Q4_K_M",
+ "accelerator_tags": []
}
]
}
diff --git a/src/vaner/engine.py b/src/vaner/engine.py
index 9422859e..b7d3eabc 100644
--- a/src/vaner/engine.py
+++ b/src/vaner/engine.py
@@ -31,6 +31,7 @@
from vaner.intent.allocator import PortfolioAllocator
from vaner.intent.arcs import ArcObservation, ConversationArcModel, classify_query_category, derive_prompt_macro
from vaner.intent.briefing import BriefingAssembler
+from vaner.intent.bundles import bundle_rejection_reason
from vaner.intent.cache import TieredPredictionCache
from vaner.intent.deep_run import (
DeepRunFocus,
@@ -93,7 +94,7 @@
from vaner.intent.trainer import IntentTrainer
from vaner.intent.transfer import bootstrap_transfer_priors
from vaner.intent.volatility import semantic_volatility_profile
-from vaner.intent.work_products import generate_work_products
+from vaner.intent.work_products import generate_external_finance_work_products, generate_work_products
from vaner.intent.work_style_priors import (
IntentPriorAdjustments,
)
@@ -112,6 +113,7 @@
from vaner.models.decision import DecisionRecord, PredictionLink, ScoreFactor
from vaner.models.signal import KIND_COMPOSER_LIFECYCLE, SignalEvent
from vaner.plan_drafts import TERMINAL_PLAN_DRAFT_STATUSES, list_plan_drafts
+from vaner.policy.internal_llm import JSON_CONTRACT_POLICY, PREDICTION_POLICY, internal_llm_policy
from vaner.setup.apply import AppliedPolicy, apply_policy_bundle
from vaner.setup.catalog import bundle_by_id
from vaner.signals.composer import ComposerSignalPump, DraftIntentSnapshot
@@ -334,9 +336,36 @@ def _is_cold_start_intent_text(text: str) -> bool:
),
),
)
+_CONTINUATION_AGENDAS: tuple[tuple[str, tuple[str, ...], float], ...] = (
+ (
+ "normal continuation: review quality, risks, policy, and failure modes",
+ ("review", "risk", "policy", "contract", "error", "failure", "fallback", "timeout", "security"),
+ 0.78,
+ ),
+ (
+ "normal continuation: validate behavior, tests, fixtures, and benchmark evidence",
+ ("test", "spec", "fixture", "benchmark", "eval", "ci", "verify", "check"),
+ 0.76,
+ ),
+ (
+ "normal continuation: harden persistence, cache, config, and schema paths",
+ ("store", "cache", "config", "schema", "sql", "db", "toml", "yaml", "settings"),
+ 0.74,
+ ),
+ (
+ "normal continuation: inspect daemon, worker, queue, signal, and event lifecycle",
+ ("daemon", "worker", "runner", "queue", "signal", "event", "watch", "stream", "background"),
+ 0.72,
+ ),
+ (
+ "normal continuation: prepare documentation, handoff, release, and report context",
+ ("readme", "docs", "guide", "changelog", "release", "report", "handoff", "setup"),
+ 0.70,
+ ),
+)
-def _merge_llm_ranked_with_seed_paths(ranked_files: list[str], seed_paths: list[str]) -> list[str]:
+def _merge_llm_ranked_with_seed_paths(ranked_files: list[str], seed_paths: list[str], *, max_paths: int = 8) -> list[str]:
"""Keep deterministic seed evidence when an LLM rerank is incomplete.
The LLM is useful for reordering and adding adjacent files, but it should
@@ -345,7 +374,12 @@ def _merge_llm_ranked_with_seed_paths(ranked_files: list[str], seed_paths: list[
paths are appended as a backstop and then filtered/deduped.
"""
- return list(dict.fromkeys(filter_evidence_paths([*ranked_files, *seed_paths])))[:8]
+ filtered_ranked = filter_evidence_paths(ranked_files)
+ filtered_seeds = list(dict.fromkeys(filter_evidence_paths(seed_paths)))
+ seed_set = set(filtered_seeds)
+ ranked_without_seeds = [path for path in filtered_ranked if path not in seed_set]
+ ranked_slots = max(0, int(max_paths) - len(filtered_seeds))
+ return list(dict.fromkeys([*ranked_without_seeds[:ranked_slots], *filtered_seeds]))[:max_paths]
def _core_group_matches_recent_query(reason: str, recent_queries: list[str]) -> bool:
@@ -363,6 +397,24 @@ def _core_group_matches_recent_query(reason: str, recent_queries: list[str]) ->
return len(reason_terms & query_terms) >= 2
+def _core_group_paths_for_query(query: str, available_paths: list[str], *, max_paths: int = 12) -> list[str]:
+ """Return core architecture files whose group labels directly match a query."""
+
+ if not query:
+ return []
+ available_path_set = set(available_paths)
+ matched: list[str] = []
+ for reason, candidate_paths in _CORE_ARCHITECTURE_GROUPS:
+ if not _core_group_matches_recent_query(reason, [query]):
+ continue
+ for path in candidate_paths:
+ if path in available_path_set and path not in matched:
+ matched.append(path)
+ if len(matched) >= max_paths:
+ return matched
+ return matched
+
+
# Phase 4 / WS2: richer LLM callable that returns a structured
# ``LLMResponse`` (thinking + content + raw). The engine prefers this when
# available so reasoning-model preambles are captured rather than discarded.
@@ -482,6 +534,7 @@ def __init__(
self._last_explored_scenarios: list[ExploredScenario] = []
self._last_no_scenario_reason: str = ""
self._last_refinement_outcomes: list[dict[str, object]] = []
+ self._external_state_manager: Any | None = None
self._core_group_rotation_index = 0
# Phase 4 / WS6: prediction registry persists across cycles. First
# created on the first ``precompute_cycle``; thereafter reused and
@@ -639,6 +692,9 @@ def __init__(
"hedge_ratio": 0.20,
"invest_ratio": 0.10,
"no_regret_ratio": 0.20,
+ "continuation_agenda_cursor": 0.0,
+ "continuation_rounds_last_cycle": 0.0,
+ "continuation_admitted_last_cycle": 0.0,
}
def _refresh_work_style_adjustments(self) -> None:
@@ -1066,6 +1122,8 @@ async def predict(self, top_k: int = 5) -> list[IntentPrediction]:
async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int = 8) -> ContextPackage:
await self.initialize()
started_at = time.time()
+ context_budget = _effective_context_budget(self.config, requested=max_tokens)
+ selection_top_n = _adaptive_selection_top_n(prompt, requested=top_n, max_context_tokens=context_budget)
self._notify_user_request_start()
# Fold the freshly-arrived prompt into the timing model so
# subsequent precompute cycles size their budgets against the
@@ -1084,18 +1142,18 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
prior_prediction_probs = {
item.category: max(0.0, float(item.confidence)) / total_conf for item in prior_predictions
}
- _quick_artefacts = await self.store.list(limit=2000)
- _available_quick_paths = sorted({artefact.source_path for artefact in _quick_artefacts if artefact.source_path})
+ _available_quick_paths = await self.store.list_source_paths(limit=5000)
+ _quick_selected = await select_artefacts_fts(
+ prompt,
+ self.store,
+ top_n=selection_top_n,
+ exclude_private=self.config.privacy.exclude_private,
+ path_bonuses=self._pinned_focus_paths,
+ path_excludes=self._pinned_avoid_paths,
+ )
_quick_paths = {
artefact.source_path
- for artefact in select_artefacts(
- prompt,
- _quick_artefacts,
- top_n=8,
- exclude_private=self.config.privacy.exclude_private,
- path_bonuses=self._pinned_focus_paths,
- path_excludes=self._pinned_avoid_paths,
- )
+ for artefact in _quick_selected
if artefact.source_path
}
exact_symbol_paths = set(
@@ -1103,7 +1161,7 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
self.config.repo_root,
prompt,
available_paths=_available_quick_paths,
- max_paths=8,
+ max_paths=min(16, max(8, selection_top_n)),
)
)
_quick_paths |= exact_symbol_paths
@@ -1111,7 +1169,7 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
# scoring even when the heuristic selector disagrees. Without this
# union, the bench finds 96% of precompute entries never get consumed
# because their anchor_units don't overlap with the heuristic's picks.
- _quick_paths = _quick_paths | await self._cache.candidate_anchor_units(prompt, top_k=3)
+ _quick_paths = _quick_paths | await self._cache.candidate_anchor_units(prompt, top_k=max(3, min(8, selection_top_n // 2)))
cache_result = await self._cache.match(prompt, relevant_paths=_quick_paths)
observation = self._arc_model.observe_detail(prompt)
category = observation.category
@@ -1246,28 +1304,35 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
# Use the LLM-curated files from the precomputed package as primary selection,
# then fill remaining top_n slots from the heuristic selector.
cache_keys = {s.artefact_key for s in cache_result.package.selections}
- all_artefacts = await self.store.list(limit=2000)
- artefacts_by_key = {a.key: a for a in all_artefacts}
- selected = [artefacts_by_key[k] for k in cache_keys if k in artefacts_by_key]
+ selected = await self.store.list_by_keys(cache_keys, limit=max(selection_top_n, len(cache_keys)))
factor_map: dict[str, list[ScoreFactor]] = {}
drop_reasons: dict[str, str] = {}
- if len(selected) < top_n:
- heuristic_picks = select_artefacts(
+ prepared_context_diagnostics = []
+ if len(selected) < selection_top_n:
+ heuristic_picks = await select_artefacts_fts(
prompt,
- all_artefacts,
- top_n=top_n,
+ self.store,
+ top_n=selection_top_n,
exclude_private=self.config.privacy.exclude_private,
path_bonuses=self._pinned_focus_paths,
path_excludes=self._pinned_avoid_paths,
capture_factors=factor_map,
capture_drop_reasons=drop_reasons,
+ context_preparation_mode=self.config.context_preparation.mode,
+ max_query_variants=self.config.context_preparation.max_query_variants,
+ max_candidate_keys=self.config.context_preparation.max_candidate_keys,
+ coverage_floor_enabled=self.config.context_preparation.coverage_floor_enabled,
+ max_expansion_passes=self.config.context_preparation.max_expansion_passes,
+ semantic_memory_enabled=self.config.context_preparation.semantic_memory_enabled,
+ semantic_embed=self.embed,
+ capture_prepared_context_diagnostics=prepared_context_diagnostics,
)
seen = {a.key for a in selected}
for pick in heuristic_picks:
if pick.key not in seen:
selected.append(pick)
seen.add(pick.key)
- if len(selected) >= top_n:
+ if len(selected) >= selection_top_n:
break
source_key = selected[0].key if selected else None
features = await extract_hybrid_features(self.store, prompt=prompt, source_key=source_key)
@@ -1281,7 +1346,7 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
package, decision_record = assemble_context_package(
prompt,
selected,
- max_tokens if max_tokens is not None else self.config.max_context_tokens,
+ context_budget,
repo_root=self.config.repo_root,
max_age_seconds=self.config.max_age_seconds,
score_map=score_map,
@@ -1290,6 +1355,7 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
evidence_assembly_mode=self.config.evidence_assembly.mode,
evidence_assembly_quality_bias=self.config.evidence_assembly.quality_bias,
evidence_assembly_cost_sensitivity=self.config.evidence_assembly.cost_sensitivity,
+ prepared_context_diagnostics=prepared_context_diagnostics[-1] if prepared_context_diagnostics else None,
return_decision=True,
)
else:
@@ -1332,8 +1398,8 @@ async def query(self, prompt: str, *, max_tokens: int | None = None, top_n: int
preferred_keys |= set(str(key) for key in cache_result.enrichment.get("relevant_keys", []))
package, selected, decision_record = await self._build_package_for_prompt(
prompt,
- max_tokens=max_tokens,
- top_n=top_n,
+ max_tokens=context_budget,
+ top_n=selection_top_n,
preferred_keys=preferred_keys,
include_working_set_preferences=not cold_start_prompt,
)
@@ -1899,6 +1965,41 @@ async def precompute_cycle(
# newly observed user turn can surface as prepared context immediately.
await self._prime_latest_history_query_prediction(recent_query_text)
+ # Latest-query anchors are stronger than broad periodic coverage. Seed
+ # deterministic exact-symbol paths and matching core groups before the
+ # heuristic, graph, arc, and rotating-core seeders can crowd them out.
+ latest_query_anchor_paths: list[str] = []
+ if recent_query_text:
+ latest_query = recent_query_text[-1]
+ latest_query_anchor_paths.extend(
+ rank_exact_paths(
+ self.config.repo_root,
+ latest_query,
+ available_paths=available_paths,
+ max_paths=12,
+ )
+ )
+ latest_query_anchor_paths.extend(
+ _core_group_paths_for_query(
+ latest_query,
+ available_paths,
+ max_paths=12,
+ )
+ )
+ latest_query_anchor_paths = list(dict.fromkeys(filter_evidence_paths(latest_query_anchor_paths)))
+ if latest_query_anchor_paths:
+ frontier.seed_from_focus_paths(
+ self._rank_paths_for_recent_intent(
+ latest_query_anchor_paths,
+ recent_query_text,
+ focused_paths=set(latest_query_anchor_paths),
+ ),
+ available_paths,
+ reason="latest query exact symbol/core anchor focus",
+ priority_floor=1.18,
+ source="structured_direct",
+ )
+
# Order matters: Jaccard-dedup is first-admitted-wins, and
# structured v2 predictions carry the most concrete evidence targets.
# Seed them before heuristic/core/arc scenarios so direct evidence
@@ -2406,7 +2507,149 @@ async def _process_scenario(scenario: ExplorationScenario) -> None:
# frontier empties we still drain in-flight tasks in case LLM branches
# push new work back onto the queue.
max_inflight = max(2, effective_concurrency * 2)
- while governor.should_continue() and not frontier.is_saturated():
+ continuation_rounds = 0
+ continuation_admitted = 0
+ continuation_chunk_limit = max(1, math.ceil(len(available_paths) / 8))
+ if self._active_deep_run_session is not None:
+ continuation_chunk_limit *= 2
+ # This is a duplicate/finite-work guard, not a short-run policy cap.
+ # Normal background prep should spend the cycle budget when useful work
+ # exists; it just must not spin forever on repeated equivalent chunks.
+ continuation_chunk_limit = max(len(_CONTINUATION_AGENDAS), min(256, continuation_chunk_limit))
+ continuation_cursor = int(self._cycle_policy_state.get("continuation_agenda_cursor", 0.0))
+
+ def _cycle_has_budget_for_continuation() -> bool:
+ if not governor.should_continue():
+ return False
+ if cycle_deadline is None:
+ return True
+ # Keep enough tail room for persistence, cleanup, and snapshot writes.
+ return (cycle_deadline - time.monotonic()) > 2.0
+
+ def _rank_continuation_paths(paths: list[str], keywords: tuple[str, ...]) -> list[str]:
+ changed_set = set(changed_paths) | set(changed_paths_for_horizon)
+ keyword_set = {kw.lower() for kw in keywords}
+
+ def score(path: str) -> tuple[int, str]:
+ lower_path = path.lower()
+ basename = lower_path.rsplit("/", 1)[-1]
+ value = 0
+ if path in changed_set:
+ value += 40
+ if path not in covered_paths:
+ value += 18
+ if lower_path.endswith(_SOURCE_EXTENSIONS):
+ value += 8
+ if lower_path.startswith(("src/", "lib/", "app/", "packages/")):
+ value += 6
+ if lower_path.startswith(("tests/", "test/")):
+ value += 5
+ for keyword in keyword_set:
+ if keyword in basename:
+ value += 12
+ elif keyword in lower_path:
+ value += 5
+ if lower_path.startswith(("docs/", "README".lower())):
+ value += 2
+ return value, path
+
+ return sorted(dict.fromkeys(paths), key=lambda p: (-score(p)[0], score(p)[1]))
+
+ def _seed_continuation_agenda() -> int:
+ nonlocal continuation_rounds, continuation_admitted, continuation_cursor
+ if continuation_rounds >= continuation_chunk_limit or not _cycle_has_budget_for_continuation():
+ return 0
+
+ agenda_count = len(_CONTINUATION_AGENDAS)
+ available_set = set(available_paths)
+ fallback_paths = [
+ path
+ for path in [*core_source_paths, *sorted(core_group_paths), *latest_query_anchor_paths]
+ if path in available_set
+ ]
+ attempts = 0
+ while attempts < agenda_count:
+ agenda_index = (continuation_cursor + attempts) % agenda_count
+ reason, keywords, priority_floor = _CONTINUATION_AGENDAS[agenda_index]
+ matched = [
+ path
+ for path in available_paths
+ if path in available_set and any(keyword in path.lower() for keyword in keywords)
+ ]
+ if not matched:
+ attempts += 1
+ continue
+ ranked = _rank_continuation_paths(matched, keywords)
+ uncovered = [path for path in ranked if path not in covered_paths]
+ candidates = uncovered or ranked
+ stride = 8
+ round_offset = (continuation_rounds // max(1, agenda_count)) * stride
+ if candidates:
+ offset = round_offset % max(1, len(candidates))
+ candidates = [*candidates[offset:], *candidates[:offset]]
+ admitted = frontier.seed_from_focus_paths(
+ candidates[:stride],
+ available_paths,
+ reason=reason,
+ priority_floor=priority_floor,
+ source="horizon",
+ )
+ continuation_cursor = (agenda_index + 1) % agenda_count
+ if admitted:
+ continuation_rounds += 1
+ continuation_admitted += admitted
+ self._emit_live_work_event(
+ {
+ "entity_type": "worker",
+ "entity_id": "precompute-cycle",
+ "stage": "continuation",
+ "status": "queued",
+ "summary": reason,
+ "cycle_id": str(self._precompute_cycles),
+ "targets": candidates[:stride],
+ "metadata": {
+ "round": continuation_rounds,
+ "normal_background": self._active_deep_run_session is None,
+ },
+ }
+ )
+ return admitted
+ attempts += 1
+
+ # If named agendas are exhausted by dedup/coverage, keep a small
+ # architecture slice moving so normal background prep still broadens
+ # without requiring a declared Deep-Run session.
+ fallback_ranked = [path for path in _rank_continuation_paths(fallback_paths, ()) if path not in covered_paths]
+ if fallback_ranked:
+ admitted = frontier.seed_from_focus_paths(
+ fallback_ranked[:8],
+ available_paths,
+ reason="normal continuation: broaden uncovered architecture context",
+ priority_floor=0.68,
+ source="core_architecture",
+ )
+ if admitted:
+ continuation_rounds += 1
+ continuation_admitted += admitted
+ self._emit_live_work_event(
+ {
+ "entity_type": "worker",
+ "entity_id": "precompute-cycle",
+ "stage": "continuation",
+ "status": "queued",
+ "summary": "normal continuation: broaden uncovered architecture context",
+ "cycle_id": str(self._precompute_cycles),
+ "targets": fallback_ranked[:8],
+ "metadata": {
+ "round": continuation_rounds,
+ "normal_background": self._active_deep_run_session is None,
+ },
+ }
+ )
+ return admitted
+ return 0
+
+ while governor.should_continue():
if cycle_deadline is not None and time.monotonic() >= cycle_deadline and self._last_explored_scenarios:
break
@@ -2416,8 +2659,11 @@ async def _process_scenario(scenario: ExplorationScenario) -> None:
if scenario is None:
# No pending work right now. If tasks are in flight they may
# push follow-ons on completion, so wait for one to finish and
- # check again. Otherwise we're done.
+ # check again. Otherwise refill from normal continuation
+ # agendas before ending the cycle.
if not in_flight:
+ if _seed_continuation_agenda():
+ continue
break
done, pending = await asyncio.wait(in_flight, return_when=asyncio.FIRST_COMPLETED)
in_flight = [t for t in pending]
@@ -2459,7 +2705,8 @@ async def _process_scenario(scenario: ExplorationScenario) -> None:
"arc": 4,
"pattern": 5,
"skill": 6,
- "llm_branch": 7,
+ "horizon": 7,
+ "llm_branch": 8,
}
self._last_explored_scenarios.sort(
key=lambda item: (
@@ -2558,6 +2805,9 @@ async def _process_scenario(scenario: ExplorationScenario) -> None:
used_ms=allocation.no_regret_ms * allocation_scale,
bucket="no_regret",
)
+ self._cycle_policy_state["continuation_agenda_cursor"] = float(continuation_cursor)
+ self._cycle_policy_state["continuation_rounds_last_cycle"] = float(continuation_rounds)
+ self._cycle_policy_state["continuation_admitted_last_cycle"] = float(continuation_admitted)
_record_idle_usage_seconds(self.config, cycle_elapsed_s)
if not self._last_explored_scenarios and not self._last_no_scenario_reason:
if cycle_deadline is not None and time.monotonic() >= cycle_deadline:
@@ -2576,6 +2826,15 @@ def get_last_no_scenario_reason(self) -> str:
"""Return the latest best-effort reason a cycle explored no scenarios."""
return self._last_no_scenario_reason
+ def get_last_cycle_profile(self) -> dict[str, float | str]:
+ """Return compact, non-persistent telemetry for the most recent cycle."""
+ return {
+ "continuation_rounds": float(self._cycle_policy_state.get("continuation_rounds_last_cycle", 0.0)),
+ "continuation_admitted": float(self._cycle_policy_state.get("continuation_admitted_last_cycle", 0.0)),
+ "continuation_agenda_cursor": float(self._cycle_policy_state.get("continuation_agenda_cursor", 0.0)),
+ "no_scenario_reason": self._last_no_scenario_reason,
+ }
+
def get_active_predictions(self) -> list[PredictedPrompt]:
"""Return non-terminal PredictedPrompts for the active cycle.
@@ -2758,6 +3017,9 @@ def get_last_refinement_outcomes(self) -> list[dict[str, object]]:
return [dict(item) for item in self._last_refinement_outcomes]
+ def set_external_state_manager(self, manager: Any | None) -> None:
+ self._external_state_manager = manager
+
async def _run_work_product_pass(self, *, cycle_deadline: float | None) -> int:
"""Prepare a small set of Vaner-owned artifacts during idle cycles.
@@ -2779,6 +3041,28 @@ async def _run_work_product_pass(self, *, cycle_deadline: float | None) -> int:
artefacts=artefacts,
max_products=4,
)
+ manager = self._external_state_manager
+ if (
+ manager is not None
+ and bool(getattr(getattr(self.config, "external_state", None), "enabled", False))
+ and (cycle_deadline is None or time.monotonic() < cycle_deadline)
+ ):
+ try:
+ if hasattr(manager, "reset_cycle_budget"):
+ manager.reset_cycle_budget()
+ snapshots = await manager.collect_finance_snapshots(recent_queries)
+ for snapshot in snapshots:
+ await self.store.upsert_external_state_snapshot(snapshot)
+ products.extend(
+ generate_external_finance_work_products(
+ repo_root=self.config.repo_root,
+ recent_queries=recent_queries,
+ snapshots=snapshots,
+ max_products=max(1, 4 - len(products)),
+ )
+ )
+ except Exception:
+ logger.debug("external finance work product pass skipped", exc_info=True)
await self.store.refresh_work_product_staleness(self.config.repo_root)
written = 0
for product in products:
@@ -4121,6 +4405,7 @@ async def _explore_scenario_with_llm(
priority_tag = ""
prompt = (
+ f"{internal_llm_policy(JSON_CONTRACT_POLICY, PREDICTION_POLICY)}\n\n"
f"You are a code-context exploration engine.{priority_tag} Evaluate this scenario and decide "
"which files are most relevant and what adjacent scenarios are worth exploring next.\n\n"
f"Developer context:\n"
@@ -4143,7 +4428,7 @@ async def _explore_scenario_with_llm(
" need this scenario addresses (e.g. 'authentication middleware, JWT validation').\n"
" This is used for matching future queries to this cached context.\n"
"4. Set confidence (0.0-1.0): how likely is this area to be needed next?\n\n"
- "Return JSON only (no markdown fences):\n"
+ "Return JSON only (no markdown fences, no extra keys):\n"
"{\n"
' "ranked_files": ["path/a.py", "path/b.py"],\n'
' "semantic_intent": "...",\n'
@@ -5347,6 +5632,7 @@ async def _run_reasoner_loop_iteration(
# LLM focuses purely on the long-tail predictions they can't make.
graph_covered = "\n".join(sorted(covered_paths)[:20]) or "none"
prompt = (
+ f"{internal_llm_policy(JSON_CONTRACT_POLICY, PREDICTION_POLICY)}\n\n"
"You are Vaner's speculative prediction engine — the long-tail layer.\n"
"The system has ALREADY pre-built context packages for:\n"
" • Dependency-graph neighborhoods (structural, high-confidence)\n"
@@ -5356,7 +5642,8 @@ async def _run_reasoner_loop_iteration(
" – Config or infra files triggered by a specific code change\n"
" – Third-party API surfaces the developer will need to read\n"
" – Novel debugging paths not implied by recent errors\n\n"
- "Return a JSON array with fields: question, file_paths, confidence, rationale.\n"
+ "Return a JSON array only, with fields: question, file_paths, confidence, rationale.\n"
+ "Keep rationale as a short observed-signal evidence note.\n"
"Limit to 3-5 predictions.\n\n"
f"Recent queries:\n{recent_hint or 'none'}\n\n"
f"Feedback summary:\n{feedback_summary}\n\n"
@@ -5495,7 +5782,7 @@ async def _build_package_for_paths(
package, _decision_record = assemble_context_package(
question,
selected[:8],
- self.config.max_context_tokens,
+ _effective_context_budget(self.config),
repo_root=self.config.repo_root,
max_age_seconds=self.config.max_age_seconds,
score_map=score_map,
@@ -5526,7 +5813,13 @@ async def train_batch(self, output_dir: Path | None = None) -> Path | None:
await self._persist_learning_state()
return model_path
- async def load_bundle(self, bundle_dir: Path | str) -> bool:
+ async def load_bundle(
+ self,
+ bundle_dir: Path | str,
+ *,
+ allow_rejected: bool = False,
+ allow_experimental_data: bool = False,
+ ) -> bool:
"""Apply a pre-trained bundle to this engine's store.
A bundle is a directory produced by ``eval/train_policy.py`` containing:
@@ -5538,7 +5831,11 @@ async def load_bundle(self, bundle_dir: Path | str) -> bool:
the engine and persisted to the store so future ``initialize()`` calls
load them automatically.
- Returns True if the bundle was applied successfully, False on failure.
+ Rejected training bundles are refused by default. Use
+ ``allow_rejected=True`` only for local diagnostics.
+
+ Returns True if the bundle's scorer model was applied successfully,
+ False on failure or when the bundle is rejected.
"""
import json as _json
@@ -5546,6 +5843,14 @@ async def load_bundle(self, bundle_dir: Path | str) -> bool:
bundle_path = Path(bundle_dir)
if not bundle_path.exists():
return False
+ if not allow_rejected and bundle_rejection_reason(
+ bundle_path,
+ allow_experimental_data=allow_experimental_data,
+ ):
+ return False
+
+ previous_policy = self._scoring_policy
+ previous_cache_policy = self._cache.scoring_policy
# Load scoring policy
policy_file = bundle_path / "scoring_policy.json"
@@ -5566,6 +5871,10 @@ async def load_bundle(self, bundle_dir: Path | str) -> bool:
model_path = Path(model_path_str)
if not model_path.is_absolute():
model_path = bundle_path / model_path_str
+ elif not model_path.exists():
+ bundled_model = bundle_path / model_path.name
+ if bundled_model.exists():
+ model_path = bundled_model
if model_path.exists():
loaded = self._intent_scorer.load_model(
model_path,
@@ -5589,6 +5898,11 @@ async def load_bundle(self, bundle_dir: Path | str) -> bool:
if isinstance(influence, (int, float)) and model_loaded:
self._intent_scorer.set_model_influence(float(influence))
+ if not model_loaded:
+ self._scoring_policy = previous_policy
+ self._cache.scoring_policy = previous_cache_policy
+ return False
+
self._mark_policy_state_dirty()
await self._persist_learning_state(force=True)
return model_loaded
@@ -5759,6 +6073,7 @@ async def _persist_learning_state(self, *, force: bool = False) -> None:
"draft_evidence_threshold",
"draft_volatility_ceiling",
"draft_budget_min_ms",
+ "continuation_agenda_cursor",
}
cycle_state_to_persist = {k: v for k, v in self._cycle_policy_state.items() if k in persistent_cycle_keys}
await self.store.upsert_learning_state(
@@ -5778,10 +6093,10 @@ async def _build_package_for_prompt(
preferred_keys: set[str] | None = None,
include_working_set_preferences: bool = True,
) -> tuple[ContextPackage, list, DecisionRecord]:
- artefacts = await self.store.list(limit=2000)
- if not artefacts:
+ context_budget = _effective_context_budget(self.config, requested=max_tokens)
+ top_n = _adaptive_selection_top_n(prompt, requested=top_n, max_context_tokens=context_budget)
+ if not await self.store.list(limit=1):
await self.prepare()
- artefacts = await self.store.list(limit=2000)
repo_root = self.config.repo_root
git_state = read_git_state(repo_root)
@@ -5801,6 +6116,7 @@ def _score_with_model(question: str, artefact) -> float:
factor_map: dict[str, list[ScoreFactor]] = {}
drop_reasons: dict[str, str] = {}
+ prepared_context_diagnostics = []
selected = await select_artefacts_fts(
prompt,
self.store,
@@ -5813,6 +6129,14 @@ def _score_with_model(question: str, artefact) -> float:
path_excludes=self._pinned_avoid_paths,
capture_factors=factor_map,
capture_drop_reasons=drop_reasons,
+ context_preparation_mode=self.config.context_preparation.mode,
+ max_query_variants=self.config.context_preparation.max_query_variants,
+ max_candidate_keys=self.config.context_preparation.max_candidate_keys,
+ coverage_floor_enabled=self.config.context_preparation.coverage_floor_enabled,
+ max_expansion_passes=self.config.context_preparation.max_expansion_passes,
+ semantic_memory_enabled=self.config.context_preparation.semantic_memory_enabled,
+ semantic_embed=self.embed,
+ capture_prepared_context_diagnostics=prepared_context_diagnostics,
)
if source_key is None and selected:
source_key = selected[0].key
@@ -5833,12 +6157,20 @@ def _score_with_source(question: str, artefact) -> float:
path_excludes=self._pinned_avoid_paths,
capture_factors=factor_map,
capture_drop_reasons=drop_reasons,
+ context_preparation_mode=self.config.context_preparation.mode,
+ max_query_variants=self.config.context_preparation.max_query_variants,
+ max_candidate_keys=self.config.context_preparation.max_candidate_keys,
+ coverage_floor_enabled=self.config.context_preparation.coverage_floor_enabled,
+ max_expansion_passes=self.config.context_preparation.max_expansion_passes,
+ semantic_memory_enabled=self.config.context_preparation.semantic_memory_enabled,
+ semantic_embed=self.embed,
+ capture_prepared_context_diagnostics=prepared_context_diagnostics,
)
score_map = {artefact.key: self._intent_scorer.score(prompt, artefact, features=features) for artefact in selected}
package, decision_record = assemble_context_package(
prompt,
selected,
- max_tokens if max_tokens is not None else self.config.max_context_tokens,
+ context_budget,
repo_root=self.config.repo_root,
max_age_seconds=self.config.max_age_seconds,
score_map=score_map,
@@ -5847,6 +6179,7 @@ def _score_with_source(question: str, artefact) -> float:
evidence_assembly_mode=self.config.evidence_assembly.mode,
evidence_assembly_quality_bias=self.config.evidence_assembly.quality_bias,
evidence_assembly_cost_sensitivity=self.config.evidence_assembly.cost_sensitivity,
+ prepared_context_diagnostics=prepared_context_diagnostics[-1] if prepared_context_diagnostics else None,
return_decision=True,
)
return package, selected, decision_record
@@ -5869,6 +6202,7 @@ def _build_decision_record_from_package(
partial_similarity=partial_similarity,
token_budget=package.token_budget,
token_used=package.token_used,
+ prepared_context_diagnostics=package.prepared_context_diagnostics,
selections=[
{
"artefact_key": selection.artefact_key,
@@ -5929,7 +6263,16 @@ def _resolve_llm(self, llm: LLMCallable | str | None) -> LLMCallable | None:
model = llm.split(":", 1)[1]
if not model:
return None
- return ollama_llm(model=model, timeout=float(self.config.backend.request_timeout_seconds))
+ return ollama_llm(
+ model=model,
+ timeout=float(self.config.backend.request_timeout_seconds),
+ max_tokens=int(self.config.backend.max_response_tokens),
+ extra_body=_ollama_options_extra_body(
+ self.config.backend.runtime_options,
+ self.config.backend.sampling_options,
+ ),
+ reasoning_mode=self.config.backend.reasoning_mode,
+ )
if llm.startswith("vllm:"):
# vllm: or vllm:@:
from vaner.clients.openai import openai_llm
@@ -5993,6 +6336,11 @@ def _resolve_structured_llm(self, llm: str) -> StructuredLLMCallable | None:
model=model,
timeout=timeout,
response_format=response_format,
+ max_tokens=int(self.config.backend.max_response_tokens),
+ extra_body=_ollama_options_extra_body(
+ self.config.backend.runtime_options,
+ self.config.backend.sampling_options,
+ ),
reasoning_mode=reasoning_mode,
)
return None
@@ -6452,6 +6800,81 @@ def _probe_ollama_endpoint(base_url: str, timeout: float = 2.0) -> tuple[bool, l
return False, []
+def _ollama_options_extra_body(runtime_options: dict[str, Any] | None, sampling_options: dict[str, Any] | None) -> dict[str, Any] | None:
+ options = {
+ str(key): value
+ for source in (runtime_options or {}, sampling_options or {})
+ for key, value in source.items()
+ if value is not None and value != ""
+ }
+ return {"options": options} if options else None
+
+
+def _adaptive_selection_top_n(prompt: str, *, requested: int, max_context_tokens: int) -> int:
+ """Expand retrieval breadth for multi-facet questions when context allows."""
+
+ base = max(1, int(requested))
+ if max_context_tokens < 4096:
+ return base
+ lowered = prompt.lower()
+ facet_count = prompt.count("?") + len(re.findall(r"\b(?:and|what|how|when|which|where)\b", lowered))
+ identifier_count = len(re.findall(r"\b[A-Za-z_][A-Za-z0-9_]{3,}\b", prompt))
+ extra = 0
+ if facet_count >= 3:
+ extra += 2
+ if identifier_count >= 8:
+ extra += 2
+ if any(term in lowered for term in ("schema", "pipeline", "flow", "walk me through", "how does")):
+ extra += 2
+ capacity_cap = 16 if max_context_tokens >= 16384 else 12
+ return min(capacity_cap, base + extra)
+
+
+def _configured_context_windows(config: VanerConfig) -> list[int]:
+ windows: list[int] = []
+ for source in (
+ getattr(config.backend, "runtime_options", {}) or {},
+ getattr(config.exploration, "runtime_options", {}) or {},
+ ):
+ if not isinstance(source, dict):
+ continue
+ for key in ("num_ctx", "context_window", "max_context_tokens"):
+ try:
+ value = int(source.get(key) or 0)
+ except (TypeError, ValueError):
+ value = 0
+ if value > 0:
+ windows.append(value)
+ for endpoint in getattr(config.exploration, "endpoints", []) or []:
+ try:
+ value = int(getattr(endpoint, "context_window", 0) or 0)
+ except (TypeError, ValueError):
+ value = 0
+ if value > 0:
+ windows.append(value)
+ return windows
+
+
+def _effective_context_budget(config: VanerConfig, *, requested: int | None = None) -> int:
+ """Return Vaner's evidence-package budget for the current runtime.
+
+ Explicit per-call budgets stay authoritative. Otherwise use the configured
+ package budget as a floor, then expand when the local runtime advertises a
+ large context window. Vaner leaves most of the model window for the user's
+ prompt, final answer, and reasoning overhead.
+ """
+
+ if requested is not None:
+ return max(1, int(requested))
+ configured = max(1, int(getattr(config, "max_context_tokens", 8192) or 8192))
+ windows = _configured_context_windows(config)
+ if not windows:
+ return max(8192, configured)
+ largest_window = max(windows)
+ adaptive = min(262_144, max(16_384, largest_window // 3))
+ return max(configured, adaptive)
+
+
def _build_exploration_llm(ecfg: ExplorationConfig) -> LLMCallable | None:
"""Resolve the exploration LLM from ExplorationConfig, probing endpoints as needed."""
import logging as _logging
@@ -6516,7 +6939,11 @@ def _make_ollama(base_url: str, m: str) -> LLMCallable:
from vaner.clients.ollama import ollama_llm
_log.info("Vaner exploration LLM: Ollama at %s model=%s", base_url, m)
- return ollama_llm(model=m, base_url=base_url)
+ return ollama_llm(
+ model=m,
+ base_url=base_url,
+ extra_body=_ollama_options_extra_body(ecfg.runtime_options, ecfg.sampling_options),
+ )
# ------------------------------------------------------------------
# Explicit endpoint given: probe or trust based on backend hint
diff --git a/src/vaner/external_state/__init__.py b/src/vaner/external_state/__init__.py
new file mode 100644
index 00000000..64d57335
--- /dev/null
+++ b/src/vaner/external_state/__init__.py
@@ -0,0 +1,27 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from vaner.external_state.manager import ExternalStateManager
+from vaner.external_state.model_search import (
+ ModelNativeSearchProvider,
+ build_model_search_news_snapshot,
+ model_search_provider_from_config,
+)
+from vaner.external_state.models import (
+ ExternalStateFreshnessClass,
+ ExternalStateSensitivity,
+ ExternalStateSnapshot,
+ FinanceCapability,
+)
+from vaner.external_state.telemetry import redacted_snapshot_telemetry
+
+__all__ = [
+ "ExternalStateFreshnessClass",
+ "ExternalStateManager",
+ "ExternalStateSensitivity",
+ "ExternalStateSnapshot",
+ "FinanceCapability",
+ "ModelNativeSearchProvider",
+ "build_model_search_news_snapshot",
+ "model_search_provider_from_config",
+ "redacted_snapshot_telemetry",
+]
diff --git a/src/vaner/external_state/configurator.py b/src/vaner/external_state/configurator.py
new file mode 100644
index 00000000..347548b4
--- /dev/null
+++ b/src/vaner/external_state/configurator.py
@@ -0,0 +1,505 @@
+# SPDX-License-Identifier: Apache-2.0
+
+"""User-facing configuration helpers for external-state MCP providers."""
+
+from __future__ import annotations
+
+import asyncio
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+from vaner.external_state.models import ExternalStateSensitivity, FinanceCapability
+from vaner.integrations.mcp_consumer.client import discover_mcp_tools
+from vaner.integrations.mcp_consumer.safety import McpToolDescriptor, ReadOnlyToolPolicy
+from vaner.models.config import ExternalStateConfig, McpConsumerConfig, VanerConfig
+from vaner.setup.config_io import update_toml_section
+
+_PROVIDER_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
+
+_ACCOUNT_CAPABILITIES = {
+ FinanceCapability.LIST_ACCOUNTS.value,
+ FinanceCapability.GET_BALANCE.value,
+ FinanceCapability.LIST_POSITIONS.value,
+ FinanceCapability.LIST_ORDERS.value,
+ FinanceCapability.LIST_ALERTS.value,
+ FinanceCapability.REVIEW_STRATEGY_POSITIONS.value,
+}
+
+_CAPABILITY_PATTERNS: tuple[tuple[FinanceCapability, tuple[str, ...]], ...] = (
+ (FinanceCapability.ANALYZE_OPTION_STRATEGY, ("analyze_option_strategy", "analyse_option_strategy", "option_strategy_analy")),
+ (FinanceCapability.SCREEN_OPTION_STRATEGIES, ("screen_option_strateg", "scan_option_strateg", "find_option_strateg")),
+ (FinanceCapability.GET_OPTION_CHAIN, ("option_chain", "options_chain", "chain_options", "get_chain")),
+ (FinanceCapability.LIST_OPTION_EXPIRIES, ("option_expir", "option_expiries", "expiration", "expiry")),
+ (FinanceCapability.LIST_POSITIONS, ("list_positions", "positions", "holdings", "portfolio_positions")),
+ (FinanceCapability.LIST_ORDERS, ("list_orders", "orders", "order_activity")),
+ (FinanceCapability.GET_BALANCE, ("balance", "balances", "cash", "margin")),
+ (FinanceCapability.LIST_ALERTS, ("alerts", "watch_alerts")),
+ (FinanceCapability.LIST_ACCOUNTS, ("accounts", "account_summary")),
+ (FinanceCapability.GET_CHART, ("chart", "candles", "candle", "ohlc", "bars")),
+ (
+ FinanceCapability.SEARCH_NEWS,
+ ("search_news", "market_news", "news_search", "news_sentiment", "web_search", "search_web", "headlines"),
+ ),
+ (FinanceCapability.SCREEN_MARKET, ("screen_market", "market_screen", "scanner", "scan_market", "search_market", "gainers", "losers")),
+ (FinanceCapability.GET_QUOTE, ("quote", "quotes", "infoprice", "info_price", "market_price", "snapshot_price")),
+)
+
+
+class DiscoveredExternalTool(BaseModel):
+ name: str
+ annotations: dict[str, Any] = Field(default_factory=dict)
+ read_only_hint: bool | None = None
+ destructive_hint: bool | None = None
+ open_world_hint: bool | None = None
+ provider_risk: str | None = None
+ safety_allowed: bool
+ safety_reason: str
+ proposed_capability: str | None = None
+ sensitivity_class: str = ExternalStateSensitivity.PUBLIC_MARKET_ONLY.value
+ access_scope: str = "market_data"
+ auto_selected: bool = False
+
+
+class ExternalStateProviderPayload(BaseModel):
+ id: str
+ transport: str
+ command: str = ""
+ args: list[str] = Field(default_factory=list)
+ url: str = ""
+ env_keys: list[str] = Field(default_factory=list)
+ timeout_ms: int
+ allowed_tools: list[str] = Field(default_factory=list)
+ tool_risks: dict[str, str] = Field(default_factory=dict)
+ capability_tools: dict[str, str] = Field(default_factory=dict)
+
+
+class ExternalStateSettingsPayload(BaseModel):
+ enabled: bool
+ max_calls_per_cycle: int
+ max_cycle_ms: int
+ providers: list[ExternalStateProviderPayload] = Field(default_factory=list)
+ finance: dict[str, Any] = Field(default_factory=dict)
+ model_native_search: dict[str, Any] = Field(default_factory=dict)
+
+
+class ProviderDiscoveryPayload(BaseModel):
+ provider_id: str
+ tools: list[DiscoveredExternalTool]
+ applied: bool = False
+ applied_allowed_tools: list[str] = Field(default_factory=list)
+ applied_capability_tools: dict[str, str] = Field(default_factory=dict)
+ blocked_count: int = 0
+
+
+@dataclass(frozen=True, slots=True)
+class ProviderConfigInput:
+ provider_id: str
+ transport: str = "stdio"
+ command: str = ""
+ args: list[str] | None = None
+ url: str = ""
+ env: dict[str, str] | None = None
+ timeout_ms: int = 10000
+
+
+def validate_provider_id(provider_id: str) -> str:
+ provider_id = provider_id.strip()
+ if not _PROVIDER_ID_RE.match(provider_id):
+ raise ValueError("provider id must match ^[a-zA-Z0-9_-]{1,64}$")
+ return provider_id
+
+
+def external_state_payload(config: VanerConfig) -> ExternalStateSettingsPayload:
+ finance = config.external_state.finance
+ providers: list[ExternalStateProviderPayload] = []
+ for provider_id, consumer in sorted(config.external_state.consumers.items()):
+ capabilities = {
+ capability: tool
+ for capability, tool in finance.capability_tools.items()
+ if tool in set(consumer.allowed_tools) or provider_id == finance.provider
+ }
+ providers.append(
+ ExternalStateProviderPayload(
+ id=provider_id,
+ transport=consumer.transport,
+ command=consumer.command,
+ args=list(consumer.args),
+ url=consumer.url,
+ env_keys=sorted(consumer.env),
+ timeout_ms=consumer.timeout_ms,
+ allowed_tools=list(consumer.allowed_tools),
+ tool_risks=dict(consumer.tool_risks),
+ capability_tools=capabilities,
+ )
+ )
+ return ExternalStateSettingsPayload(
+ enabled=config.external_state.enabled,
+ max_calls_per_cycle=config.external_state.max_calls_per_cycle,
+ max_cycle_ms=config.external_state.max_cycle_ms,
+ providers=providers,
+ finance=finance.model_dump(mode="json"),
+ model_native_search=config.external_state.model_native_search.model_dump(mode="json"),
+ )
+
+
+def provider_input_from_config(config: VanerConfig, provider_id: str) -> ProviderConfigInput:
+ provider_id = validate_provider_id(provider_id)
+ consumer = config.external_state.consumers.get(provider_id)
+ if consumer is None:
+ raise KeyError(provider_id)
+ return ProviderConfigInput(
+ provider_id=provider_id,
+ transport=consumer.transport,
+ command=consumer.command,
+ args=list(consumer.args),
+ url=consumer.url,
+ env=dict(consumer.env),
+ timeout_ms=consumer.timeout_ms,
+ )
+
+
+async def discover_provider(
+ config: VanerConfig,
+ provider_id: str,
+ *,
+ apply: bool = False,
+ trust_unknown_read: bool = False,
+) -> ProviderDiscoveryPayload:
+ provider_id = validate_provider_id(provider_id)
+ consumer = config.external_state.consumers.get(provider_id)
+ if consumer is None:
+ raise KeyError(provider_id)
+ descriptors = await discover_mcp_tools(
+ transport=consumer.transport,
+ command=consumer.command,
+ args=list(consumer.args),
+ url=consumer.url,
+ env=dict(consumer.env),
+ timeout_ms=consumer.timeout_ms,
+ )
+ payload = build_discovery_payload(
+ provider_id,
+ descriptors,
+ consumer,
+ finance_market_enabled=config.external_state.finance.market_data_enabled,
+ finance_account_enabled=config.external_state.finance.account_state_enabled,
+ trust_unknown_read=trust_unknown_read,
+ )
+ if apply:
+ apply_discovery(config.repo_root, config.external_state, provider_id, payload, trust_unknown_read=trust_unknown_read)
+ payload.applied = True
+ return payload
+
+
+def discover_provider_sync(
+ config: VanerConfig,
+ provider_id: str,
+ *,
+ apply: bool = False,
+ trust_unknown_read: bool = False,
+) -> ProviderDiscoveryPayload:
+ return asyncio.run(discover_provider(config, provider_id, apply=apply, trust_unknown_read=trust_unknown_read))
+
+
+def build_discovery_payload(
+ provider_id: str,
+ descriptors: list[McpToolDescriptor],
+ consumer: McpConsumerConfig,
+ *,
+ finance_market_enabled: bool,
+ finance_account_enabled: bool,
+ trust_unknown_read: bool = False,
+) -> ProviderDiscoveryPayload:
+ names = [descriptor.name for descriptor in descriptors]
+ policy_tool_risks = dict(consumer.tool_risks)
+ if trust_unknown_read:
+ for descriptor in descriptors:
+ if _infer_finance_capability(descriptor.name) is not None:
+ policy_tool_risks.setdefault(descriptor.name, "read")
+ policy = ReadOnlyToolPolicy(allowed_tools=set(names), tool_risks=policy_tool_risks)
+ tools: list[DiscoveredExternalTool] = []
+ applied_allowed: list[str] = []
+ applied_caps: dict[str, str] = {}
+ applied_cap_scores: dict[str, int] = {}
+ for descriptor in sorted(descriptors, key=lambda item: item.name):
+ descriptor = McpToolDescriptor(
+ name=descriptor.name,
+ annotations=dict(descriptor.annotations),
+ provider_risk=policy_tool_risks.get(descriptor.name),
+ )
+ decision = policy.validate(descriptor)
+ capability = _infer_finance_capability(descriptor.name)
+ scope = _capability_scope(capability.value if capability is not None else None)
+ # A discovery/apply action is itself the explicit market-data opt-in.
+ # Account-state tools remain separately gated because positions,
+ # balances, orders, and alerts are materially more sensitive.
+ scope_enabled = finance_account_enabled if scope == "account_state" else True
+ auto_selected = bool(decision.allowed and capability is not None and scope_enabled)
+ sensitivity = _capability_sensitivity(capability.value if capability is not None else None)
+ if auto_selected:
+ applied_allowed.append(descriptor.name)
+ score = _capability_tool_score(capability, descriptor.name)
+ if score > applied_cap_scores.get(capability.value, -1):
+ applied_caps[capability.value] = descriptor.name
+ applied_cap_scores[capability.value] = score
+ tools.append(
+ DiscoveredExternalTool(
+ name=descriptor.name,
+ annotations=dict(descriptor.annotations),
+ read_only_hint=_bool_or_none(descriptor.annotations.get("readOnlyHint")),
+ destructive_hint=_bool_or_none(descriptor.annotations.get("destructiveHint")),
+ open_world_hint=_bool_or_none(descriptor.annotations.get("openWorldHint")),
+ provider_risk=descriptor.provider_risk,
+ safety_allowed=decision.allowed,
+ safety_reason=decision.reason,
+ proposed_capability=capability.value if capability is not None else None,
+ sensitivity_class=sensitivity,
+ access_scope=scope,
+ auto_selected=auto_selected,
+ )
+ )
+ return ProviderDiscoveryPayload(
+ provider_id=provider_id,
+ tools=tools,
+ applied_allowed_tools=applied_allowed,
+ applied_capability_tools=applied_caps,
+ blocked_count=len([tool for tool in tools if not tool.safety_allowed]),
+ )
+
+
+def save_provider_config(repo_root: Path, provider: ProviderConfigInput) -> Path:
+ provider_id = validate_provider_id(provider.provider_id)
+ config_path = _ensure_config(repo_root)
+ text = config_path.read_text(encoding="utf-8")
+ text = update_toml_section(
+ text,
+ f"external_state.consumers.{provider_id}",
+ {
+ "transport": provider.transport,
+ "command": provider.command,
+ "args": list(provider.args or []),
+ "url": provider.url,
+ "env": dict(provider.env or {}),
+ "timeout_ms": int(provider.timeout_ms),
+ },
+ )
+ config_path.write_text(text, encoding="utf-8")
+ return config_path
+
+
+def save_external_state_enabled(
+ repo_root: Path,
+ *,
+ enabled: bool | None = None,
+ max_calls_per_cycle: int | None = None,
+ max_cycle_ms: int | None = None,
+) -> Path:
+ config_path = _ensure_config(repo_root)
+ values: dict[str, object] = {}
+ if enabled is not None:
+ values["enabled"] = bool(enabled)
+ if max_calls_per_cycle is not None:
+ values["max_calls_per_cycle"] = int(max_calls_per_cycle)
+ if max_cycle_ms is not None:
+ values["max_cycle_ms"] = int(max_cycle_ms)
+ text = update_toml_section(config_path.read_text(encoding="utf-8"), "external_state", values)
+ config_path.write_text(text, encoding="utf-8")
+ return config_path
+
+
+def save_finance_settings(
+ repo_root: Path,
+ *,
+ enabled: bool | None = None,
+ provider: str | None = None,
+ market_data_enabled: bool | None = None,
+ account_state_enabled: bool | None = None,
+ capability_tools: dict[str, str] | None = None,
+) -> Path:
+ if provider:
+ provider = validate_provider_id(provider)
+ config_path = _ensure_config(repo_root)
+ values: dict[str, object] = {}
+ if enabled is not None:
+ values["enabled"] = bool(enabled)
+ if provider is not None:
+ values["provider"] = provider
+ if market_data_enabled is not None:
+ values["market_data_enabled"] = bool(market_data_enabled)
+ if account_state_enabled is not None:
+ values["account_state_enabled"] = bool(account_state_enabled)
+ if capability_tools is not None:
+ values["capability_tools"] = dict(capability_tools)
+ text = update_toml_section(config_path.read_text(encoding="utf-8"), "external_state.finance", values)
+ config_path.write_text(text, encoding="utf-8")
+ return config_path
+
+
+def save_model_native_search_settings(
+ repo_root: Path,
+ *,
+ enabled: bool | None = None,
+ provider: str | None = None,
+ base_url: str | None = None,
+ api_key_env: str | None = None,
+ max_results: int | None = None,
+ timeout_seconds: float | None = None,
+) -> Path:
+ config_path = _ensure_config(repo_root)
+ values: dict[str, object] = {}
+ if enabled is not None:
+ values["enabled"] = bool(enabled)
+ if provider is not None:
+ if provider not in {"ollama", "brave"}:
+ raise ValueError("model-native search provider must be 'ollama' or 'brave'")
+ values["provider"] = provider
+ if api_key_env is None and provider == "brave":
+ values["api_key_env"] = "BRAVE_SEARCH_API_KEY"
+ if base_url is None and provider == "brave":
+ values["base_url"] = "https://api.search.brave.com/res/v1"
+ if api_key_env is None and provider == "ollama":
+ values["api_key_env"] = "OLLAMA_API_KEY"
+ if base_url is None and provider == "ollama":
+ values["base_url"] = "https://ollama.com/api"
+ if base_url is not None:
+ values["base_url"] = base_url
+ if api_key_env is not None:
+ values["api_key_env"] = api_key_env
+ if max_results is not None:
+ values["max_results"] = int(max_results)
+ if timeout_seconds is not None:
+ values["timeout_seconds"] = float(timeout_seconds)
+ text = update_toml_section(config_path.read_text(encoding="utf-8"), "external_state.model_native_search", values)
+ config_path.write_text(text, encoding="utf-8")
+ return config_path
+
+
+def apply_discovery(
+ repo_root: Path,
+ external_state: ExternalStateConfig,
+ provider_id: str,
+ discovery: ProviderDiscoveryPayload,
+ *,
+ trust_unknown_read: bool = False,
+) -> Path:
+ provider_id = validate_provider_id(provider_id)
+ consumer = external_state.consumers.get(provider_id, McpConsumerConfig())
+ allowed_tools = sorted(set(discovery.applied_allowed_tools))
+ tool_risks = dict(consumer.tool_risks)
+ if trust_unknown_read:
+ for tool in discovery.tools:
+ if tool.auto_selected and tool.provider_risk == "read":
+ tool_risks[tool.name] = "read"
+ config_path = _ensure_config(repo_root)
+ text = config_path.read_text(encoding="utf-8")
+ text = update_toml_section(
+ text,
+ f"external_state.consumers.{provider_id}",
+ {
+ "transport": consumer.transport,
+ "command": consumer.command,
+ "args": list(consumer.args),
+ "url": consumer.url,
+ "env": dict(consumer.env),
+ "timeout_ms": int(consumer.timeout_ms),
+ "allowed_tools": allowed_tools,
+ "tool_risks": tool_risks,
+ },
+ )
+ capability_tools = dict(external_state.finance.capability_tools)
+ capability_tools.update(discovery.applied_capability_tools)
+ applied_market_data = any(_capability_scope(capability) == "market_data" for capability in discovery.applied_capability_tools)
+ applied_primary_finance = any(
+ capability != FinanceCapability.SEARCH_NEWS.value and _capability_scope(capability) == "market_data"
+ for capability in discovery.applied_capability_tools
+ ) or any(_capability_scope(capability) == "account_state" for capability in discovery.applied_capability_tools)
+ finance_provider = (
+ provider_id
+ if not external_state.finance.provider or applied_primary_finance
+ else external_state.finance.provider
+ )
+ text = update_toml_section(
+ text,
+ "external_state",
+ {"enabled": True},
+ )
+ text = update_toml_section(
+ text,
+ "external_state.finance",
+ {
+ "enabled": True,
+ "provider": finance_provider,
+ "market_data_enabled": bool(external_state.finance.market_data_enabled or applied_market_data),
+ "account_state_enabled": bool(external_state.finance.account_state_enabled),
+ "capability_tools": capability_tools,
+ },
+ )
+ config_path.write_text(text, encoding="utf-8")
+ return config_path
+
+
+def _ensure_config(repo_root: Path) -> Path:
+ config_path = repo_root / ".vaner" / "config.toml"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ if not config_path.exists():
+ config_path.write_text("", encoding="utf-8")
+ return config_path
+
+
+def _infer_finance_capability(tool_name: str) -> FinanceCapability | None:
+ normalized = re.sub(r"[^a-z0-9]+", "_", tool_name.lower()).strip("_")
+ if "alert" in normalized or "user_settings" in normalized:
+ return None
+ if "spread_quote" in normalized:
+ return None
+ if "stock_strateg" in normalized:
+ return None
+ for capability, patterns in _CAPABILITY_PATTERNS:
+ if any(pattern in normalized for pattern in patterns):
+ return capability
+ return None
+
+
+def _capability_scope(capability: str | None) -> str:
+ if capability in _ACCOUNT_CAPABILITIES:
+ return "account_state"
+ return "market_data"
+
+
+def _capability_sensitivity(capability: str | None) -> str:
+ if capability == FinanceCapability.LIST_ORDERS.value:
+ return ExternalStateSensitivity.ORDER_ACTIVITY.value
+ if capability in {FinanceCapability.LIST_POSITIONS.value, FinanceCapability.REVIEW_STRATEGY_POSITIONS.value}:
+ return ExternalStateSensitivity.POSITION_SPECIFIC.value
+ if capability in {FinanceCapability.LIST_ACCOUNTS.value, FinanceCapability.GET_BALANCE.value}:
+ return ExternalStateSensitivity.ACCOUNT_SUMMARY.value
+ if capability == FinanceCapability.LIST_ALERTS.value:
+ return ExternalStateSensitivity.USER_WATCHLIST.value
+ return ExternalStateSensitivity.PUBLIC_MARKET_ONLY.value
+
+
+def _capability_tool_score(capability: FinanceCapability, tool_name: str) -> int:
+ normalized = re.sub(r"[^a-z0-9]+", "_", tool_name.lower()).strip("_")
+ exact_suffix = capability.value
+ if normalized.endswith(exact_suffix):
+ return 100
+ if capability == FinanceCapability.LIST_POSITIONS:
+ if "closed" in normalized:
+ return 10
+ if "net_positions" in normalized:
+ return 80
+ if "review_strategy_positions" in normalized:
+ return 20
+ if "list_positions" in normalized:
+ return 100
+ if capability == FinanceCapability.LIST_OPTION_EXPIRIES and "standard_option_expiries" in normalized:
+ return 80
+ return 50
+
+
+def _bool_or_none(value: Any) -> bool | None:
+ return value if isinstance(value, bool) else None
diff --git a/src/vaner/external_state/manager.py b/src/vaner/external_state/manager.py
new file mode 100644
index 00000000..6965deaf
--- /dev/null
+++ b/src/vaner/external_state/manager.py
@@ -0,0 +1,445 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from typing import Any
+
+from vaner.external_state.model_search import ModelNativeSearchProvider
+from vaner.external_state.models import (
+ ExternalStateFreshnessClass,
+ ExternalStateSensitivity,
+ ExternalStateSnapshot,
+ FinanceCapability,
+)
+from vaner.integrations.mcp_consumer.client import McpConsumerClient
+from vaner.integrations.mcp_consumer.safety import ReadOnlyToolPolicy
+from vaner.models.config import ExternalStateConfig
+
+_ACCOUNT_CAPABILITIES = {
+ FinanceCapability.LIST_ACCOUNTS.value,
+ FinanceCapability.GET_BALANCE.value,
+ FinanceCapability.LIST_POSITIONS.value,
+ FinanceCapability.LIST_ORDERS.value,
+ FinanceCapability.LIST_ALERTS.value,
+ FinanceCapability.REVIEW_STRATEGY_POSITIONS.value,
+}
+
+_NEWS_TERMS = {
+ "catalyst",
+ "earnings",
+ "fed",
+ "headline",
+ "headlines",
+ "macro",
+ "news",
+ "sentiment",
+}
+
+_OPTION_TERMS = {
+ "call",
+ "covered call",
+ "delta",
+ "expiry",
+ "greek",
+ "iv",
+ "option",
+ "premium",
+ "put",
+ "spread",
+ "strike",
+ "theta",
+ "vega",
+ "volatility",
+}
+
+_TRADING_EXPLORATION_TERMS = {
+ "branch",
+ "branches",
+ "explore",
+ "setup",
+ "setups",
+ "trade",
+ "trades",
+ "trading",
+}
+
+_MARKET_EXPLORATION_BRANCHES: tuple[dict[str, Any], ...] = (
+ {"preset": "top_gainers", "market": "us", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "top_losers", "market": "us", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "premarket_gainers", "market": "us", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "premarket_losers", "market": "us", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "top_gainers", "market": "us_nasdaq", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "top_losers", "market": "us_nasdaq", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "top_gainers", "market": "us_nyse", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+ {"preset": "top_losers", "market": "us_nyse", "assetType": "Stock", "limit": 10, "maxInstruments": 100},
+)
+
+
+class ExternalStateManager:
+ """Owns configured external-state MCP consumers for one Vaner engine."""
+
+ def __init__(self, config: ExternalStateConfig, *, model_search_provider: ModelNativeSearchProvider | None = None) -> None:
+ self.config = config
+ self._model_search_provider = model_search_provider
+ self._clients: dict[str, McpConsumerClient] = {}
+ self._cycle_calls = 0
+
+ @property
+ def enabled(self) -> bool:
+ return bool(self.config.enabled)
+
+ async def start(self) -> None:
+ if not self.enabled:
+ return
+ for name, consumer in self.config.consumers.items():
+ policy = ReadOnlyToolPolicy(allowed_tools=set(consumer.allowed_tools), tool_risks=dict(consumer.tool_risks))
+ client = McpConsumerClient(
+ server_name=name,
+ transport=consumer.transport,
+ command=consumer.command,
+ args=consumer.args,
+ url=consumer.url,
+ env=consumer.env,
+ timeout_ms=consumer.timeout_ms,
+ policy=policy,
+ )
+ await client.start()
+ self._clients[name] = client
+
+ async def stop(self) -> None:
+ for client in list(self._clients.values()):
+ await client.stop()
+ self._clients.clear()
+
+ def reset_cycle_budget(self) -> None:
+ self._cycle_calls = 0
+
+ def _check_cycle_budget(self) -> bool:
+ if self.config.max_calls_per_cycle <= 0:
+ return False
+ if self._cycle_calls >= self.config.max_calls_per_cycle:
+ return False
+ self._cycle_calls += 1
+ return True
+
+ async def call_finance_capability(
+ self,
+ capability: FinanceCapability | str,
+ args: dict[str, Any] | None = None,
+ *,
+ query_key: str = "",
+ freshness_class: ExternalStateFreshnessClass = ExternalStateFreshnessClass.MARKET_SNAPSHOT,
+ sensitivity_class: ExternalStateSensitivity = ExternalStateSensitivity.PUBLIC_MARKET_ONLY,
+ ttl_seconds: float | None = 300.0,
+ ) -> ExternalStateSnapshot | None:
+ if not self.enabled or not self.config.finance.enabled:
+ return None
+ capability_name = capability.value if isinstance(capability, FinanceCapability) else str(capability)
+ if capability_name in _ACCOUNT_CAPABILITIES and not self.config.finance.account_state_enabled:
+ return None
+ if capability_name not in _ACCOUNT_CAPABILITIES and not self.config.finance.market_data_enabled:
+ return None
+ tool_name = self.config.finance.capability_tools.get(capability_name, "")
+ if not tool_name:
+ return None
+ provider = self._provider_for_tool(tool_name)
+ if not provider or provider not in self._clients:
+ return None
+ if not self._check_cycle_budget():
+ return None
+ result = await self._clients[provider].call_tool(tool_name, args or {})
+ if not result.ok:
+ return None
+ payload = _payload_to_jsonable(result.payload)
+ if _payload_is_error(payload):
+ return None
+ return ExternalStateSnapshot.build(
+ provider_id=provider,
+ capability=capability_name,
+ query_key=query_key,
+ source_tool=tool_name,
+ freshness_class=freshness_class,
+ sensitivity_class=sensitivity_class,
+ payload=payload,
+ captured_at=time.time(),
+ ttl_seconds=ttl_seconds,
+ )
+
+ async def collect_finance_snapshots(self, recent_queries: list[str]) -> list[ExternalStateSnapshot]:
+ query_text = " ".join(recent_queries[-5:]).strip()
+ if not query_text:
+ return []
+ snapshots: list[ExternalStateSnapshot] = []
+ screen_criteria = _screen_market_criteria(query_text)
+ screen = await self.call_finance_capability(
+ FinanceCapability.SCREEN_MARKET,
+ screen_criteria,
+ query_key=_safe_query_key(query_text),
+ freshness_class=ExternalStateFreshnessClass.MARKET_SNAPSHOT,
+ sensitivity_class=ExternalStateSensitivity.PUBLIC_MARKET_ONLY,
+ ttl_seconds=300.0,
+ )
+ if screen is not None:
+ snapshots.append(screen)
+ for branch_index, branch_criteria in enumerate(_market_exploration_branches(query_text, screen_criteria)):
+ branch = await self.call_finance_capability(
+ FinanceCapability.SCREEN_MARKET,
+ branch_criteria,
+ query_key=_safe_query_key(f"market-branch:{branch_index}:{query_text}"),
+ freshness_class=ExternalStateFreshnessClass.MARKET_SNAPSHOT,
+ sensitivity_class=ExternalStateSensitivity.PUBLIC_MARKET_ONLY,
+ ttl_seconds=300.0,
+ )
+ if branch is not None:
+ snapshots.append(branch)
+ if _looks_option_related(query_text) and FinanceCapability.SCREEN_OPTION_STRATEGIES.value in self.config.finance.capability_tools:
+ account_key: str | None = None
+ if self.config.finance.account_state_enabled:
+ accounts = await self.call_finance_capability(
+ FinanceCapability.LIST_ACCOUNTS,
+ {},
+ query_key="account_accounts",
+ freshness_class=ExternalStateFreshnessClass.ACCOUNT_SNAPSHOT,
+ sensitivity_class=ExternalStateSensitivity.ACCOUNT_SUMMARY,
+ ttl_seconds=120.0,
+ )
+ if accounts is not None:
+ snapshots.append(accounts)
+ account_key = _first_account_key(accounts.payload)
+ if account_key is None:
+ return snapshots
+ strategies = await self.call_finance_capability(
+ FinanceCapability.SCREEN_OPTION_STRATEGIES,
+ _option_strategy_criteria(query_text, account_key=account_key),
+ query_key=_safe_query_key(f"option-strategies:{query_text}"),
+ freshness_class=ExternalStateFreshnessClass.DERIVED_ANALYSIS,
+ sensitivity_class=ExternalStateSensitivity.MIXED_SENSITIVE,
+ ttl_seconds=300.0,
+ )
+ if strategies is not None:
+ snapshots.append(strategies)
+ news = await self._collect_news_snapshot(query_text)
+ if news is not None:
+ snapshots.append(news)
+ if self.config.finance.account_state_enabled:
+ positions = await self.call_finance_capability(
+ FinanceCapability.LIST_POSITIONS,
+ {},
+ query_key="account_positions",
+ freshness_class=ExternalStateFreshnessClass.ACCOUNT_SNAPSHOT,
+ sensitivity_class=ExternalStateSensitivity.POSITION_SPECIFIC,
+ ttl_seconds=120.0,
+ )
+ if positions is not None:
+ snapshots.append(positions)
+ return snapshots
+
+ async def _collect_news_snapshot(self, query_text: str) -> ExternalStateSnapshot | None:
+ if not _looks_news_related(query_text):
+ return None
+ if not self.enabled or not self.config.finance.enabled or not self.config.finance.market_data_enabled:
+ return None
+ criteria = _news_search_criteria(query_text)
+ if FinanceCapability.SEARCH_NEWS.value in self.config.finance.capability_tools:
+ news = await self.call_finance_capability(
+ FinanceCapability.SEARCH_NEWS,
+ criteria,
+ query_key=_safe_query_key(f"news:{query_text}"),
+ freshness_class=ExternalStateFreshnessClass.NEWS_SNAPSHOT,
+ sensitivity_class=ExternalStateSensitivity.PUBLIC_MARKET_ONLY,
+ ttl_seconds=900.0,
+ )
+ if news is not None:
+ return news
+ if self._model_search_provider is None or not self._check_cycle_budget():
+ return None
+ try:
+ return await self._model_search_provider.search_news(
+ str(criteria["query"]),
+ topics=list(criteria["topics"]),
+ lookback_days=int(criteria["lookback_days"]),
+ limit=int(criteria["limit"]),
+ )
+ except Exception:
+ return None
+
+ def _provider_for_tool(self, tool_name: str) -> str:
+ preferred = self.config.finance.provider
+ if preferred and preferred in self._clients:
+ consumer = self.config.consumers.get(preferred)
+ if consumer is None or tool_name in set(consumer.allowed_tools):
+ return preferred
+ for provider, consumer in self.config.consumers.items():
+ if provider in self._clients and tool_name in set(consumer.allowed_tools):
+ return provider
+ return preferred
+
+
+def _payload_to_jsonable(payload: Any) -> dict[str, Any]:
+ if hasattr(payload, "model_dump"):
+ data = payload.model_dump(mode="json")
+ elif isinstance(payload, dict):
+ data = payload
+ else:
+ data = {"result": repr(payload)}
+ return data if isinstance(data, dict) else {"result": data}
+
+
+def _payload_is_error(payload: dict[str, Any]) -> bool:
+ return payload.get("isError") is True
+
+
+def _first_account_key(payload: dict[str, Any]) -> str | None:
+ data = _payload_data(payload)
+ queue: list[Any] = [data]
+ while queue:
+ current = queue.pop(0)
+ if isinstance(current, dict):
+ for key in ("AccountKey", "accountKey", "account_key"):
+ value = current.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ queue.extend(current.values())
+ elif isinstance(current, list):
+ queue.extend(current)
+ return None
+
+
+def _payload_data(payload: dict[str, Any]) -> Any:
+ content = payload.get("content")
+ if isinstance(content, list):
+ for item in content:
+ if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str):
+ text = item["text"].strip()
+ if not text:
+ continue
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ return payload
+ return payload
+
+
+def _safe_query_key(query: str) -> str:
+ return hashlib.sha1(query.encode("utf-8")).hexdigest()[:16] # noqa: S324
+
+
+def _looks_option_related(query: str) -> bool:
+ text = query.lower()
+ return any(term in text for term in _OPTION_TERMS)
+
+
+def _looks_news_related(query: str) -> bool:
+ text = query.lower()
+ return any(term in text for term in _NEWS_TERMS)
+
+
+def _looks_trading_exploration_related(query: str) -> bool:
+ text = query.lower()
+ return any(term in text for term in _TRADING_EXPLORATION_TERMS)
+
+
+def _market_exploration_branches(query: str, primary: dict[str, Any]) -> list[dict[str, Any]]:
+ if not _looks_trading_exploration_related(query):
+ return []
+ primary_key = (str(primary.get("preset") or ""), str(primary.get("market") or ""))
+ candidates = [
+ dict(branch)
+ for branch in _MARKET_EXPLORATION_BRANCHES
+ if (str(branch.get("preset") or ""), str(branch.get("market") or "")) != primary_key
+ ]
+ if not candidates:
+ return []
+ # Rotate the exploration branch every cycle instead of replaying the same
+ # branch set forever. This keeps a stable refresh path while adding novelty
+ # pressure across adjacent market regimes.
+ bucket = int(time.time() // 120)
+ offset = bucket % len(candidates)
+ rotated = candidates[offset:] + candidates[:offset]
+ return rotated[:2]
+
+
+def _screen_market_criteria(query: str) -> dict[str, Any]:
+ """Build provider-neutral structured criteria for the market-screen capability."""
+
+ text = query.lower()
+ premarket = "premarket" in text or "pre-market" in text
+ downside = any(term in text for term in ("decliner", "decliners", "down", "loser", "losers", "selloff"))
+ if premarket:
+ preset = "premarket_losers" if downside else "premarket_gainers"
+ else:
+ preset = "top_losers" if downside else "top_gainers"
+
+ market = "us"
+ market_terms = (
+ ("denmark", ("denmark", "danish", "copenhagen")),
+ ("nordics", ("nordic", "nordics")),
+ ("sweden", ("sweden", "swedish", "stockholm")),
+ ("norway", ("norway", "norwegian", "oslo")),
+ ("finland", ("finland", "finnish", "helsinki")),
+ ("europe", ("europe", "european")),
+ ("us_nasdaq", ("nasdaq",)),
+ ("us_nyse", ("nyse",)),
+ )
+ for candidate, terms in market_terms:
+ if any(term in text for term in terms):
+ market = candidate
+ break
+
+ return {
+ "preset": preset,
+ "market": market,
+ "assetType": "Stock",
+ "limit": 10,
+ "maxInstruments": 100,
+ }
+
+
+def _news_search_criteria(query: str) -> dict[str, Any]:
+ return {
+ "query": query[:300],
+ "topics": ["markets", "finance"],
+ "lookback_days": 7,
+ "limit": 10,
+ }
+
+
+def _option_strategy_criteria(query: str, *, account_key: str) -> dict[str, Any]:
+ screen = _screen_market_criteria(query)
+ market = screen["market"]
+ if market not in {"us", "us_nasdaq", "us_nyse"}:
+ market = "us"
+ text = query.lower()
+ strategies: list[str]
+ playbook = "income_30_60d"
+ if "condor" in text:
+ strategies = ["iron_condor"]
+ elif any(term in text for term in ("bear", "downside", "put spread", "credit spread")):
+ strategies = ["put_credit_spread", "call_credit_spread"]
+ elif any(term in text for term in ("leap", "long call", "debit")):
+ strategies = ["long_call", "debit_spread"]
+ playbook = "long_term_directional"
+ else:
+ strategies = ["cash_secured_put", "put_credit_spread", "iron_condor"]
+ return {
+ "accountKey": account_key,
+ "market": market,
+ "underlyingUniverse": "auto",
+ "underlyingPreset": screen["preset"],
+ "playbook": playbook,
+ "riskProfile": "balanced",
+ "strategies": strategies,
+ "maxUnderlyings": 10,
+ "maxUnderlyingScan": 120,
+ "maxSymbolsToPlan": 3,
+ "maxPlans": 6,
+ "includeAccountContext": True,
+ "includeTechnicalContext": True,
+ "includeVolatilityContext": True,
+ "includeNewsContext": False,
+ "requireGreeks": False,
+ "riskBudgetPercent": 1,
+ }
diff --git a/src/vaner/external_state/model_search.py b/src/vaner/external_state/model_search.py
new file mode 100644
index 00000000..c7a1b5c0
--- /dev/null
+++ b/src/vaner/external_state/model_search.py
@@ -0,0 +1,241 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import os
+import time
+from typing import Any, Protocol
+from urllib.parse import urlparse
+
+import httpx
+
+from vaner.external_state.models import (
+ ExternalStateFreshnessClass,
+ ExternalStateSensitivity,
+ ExternalStateSnapshot,
+ FinanceCapability,
+)
+from vaner.models.config import ExternalStateConfig
+
+
+class ModelNativeSearchProvider(Protocol):
+ """Runtime hook for model/client-native search tools.
+
+ Vaner core deliberately does not know whether the implementation is a
+ hosted model tool, a browser/search MCP server, or a local search gateway.
+ The runtime may inject an implementation when that capability is available.
+ """
+
+ async def search_news(
+ self,
+ query: str,
+ *,
+ topics: list[str],
+ lookback_days: int,
+ limit: int,
+ ) -> ExternalStateSnapshot | None:
+ ...
+
+
+def build_model_search_news_snapshot(
+ *,
+ provider_id: str,
+ query: str,
+ payload: dict[str, Any],
+ source_tool: str = "model_native_search",
+ ttl_seconds: float = 900.0,
+) -> ExternalStateSnapshot:
+ return ExternalStateSnapshot.build(
+ provider_id=provider_id,
+ capability=FinanceCapability.SEARCH_NEWS.value,
+ query_key=f"news:{query[:200]}",
+ source_tool=source_tool,
+ freshness_class=ExternalStateFreshnessClass.NEWS_SNAPSHOT,
+ sensitivity_class=ExternalStateSensitivity.PUBLIC_MARKET_ONLY,
+ payload=payload,
+ captured_at=time.time(),
+ ttl_seconds=ttl_seconds,
+ )
+
+
+class OllamaWebSearchProvider:
+ """Adapter for Ollama's web search API.
+
+ This uses Ollama's provider-side search endpoint, not the local model chat
+ endpoint. It is opt-in and requires an API key configured through env.
+ """
+
+ def __init__(
+ self,
+ *,
+ api_key: str,
+ base_url: str = "https://ollama.com/api",
+ max_results: int = 5,
+ timeout_seconds: float = 15.0,
+ ) -> None:
+ self.api_key = api_key
+ self.base_url = base_url.rstrip("/")
+ self.max_results = max(1, min(int(max_results), 10))
+ self.timeout_seconds = float(timeout_seconds)
+
+ async def search_news(
+ self,
+ query: str,
+ *,
+ topics: list[str],
+ lookback_days: int,
+ limit: int,
+ ) -> ExternalStateSnapshot | None:
+ search_query = _search_query(query, topics=topics, lookback_days=lookback_days)
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ response = await client.post(
+ f"{self.base_url}/web_search",
+ headers={"Authorization": f"Bearer {self.api_key}"},
+ json={"query": search_query, "max_results": min(int(limit), self.max_results)},
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict):
+ payload = {"result": payload}
+ return build_model_search_news_snapshot(
+ provider_id="ollama_web_search",
+ query=query,
+ source_tool="ollama.web_search",
+ payload={
+ "query": search_query,
+ "topics": topics,
+ "lookback_days": lookback_days,
+ "results": payload.get("results", payload),
+ },
+ )
+
+
+class BraveWebSearchProvider:
+ """Adapter for Brave Search's web endpoint."""
+
+ def __init__(
+ self,
+ *,
+ api_key: str,
+ base_url: str = "https://api.search.brave.com/res/v1",
+ max_results: int = 5,
+ timeout_seconds: float = 15.0,
+ ) -> None:
+ self.api_key = api_key
+ self.base_url = base_url.rstrip("/")
+ self.max_results = max(1, min(int(max_results), 10))
+ self.timeout_seconds = float(timeout_seconds)
+
+ async def search_news(
+ self,
+ query: str,
+ *,
+ topics: list[str],
+ lookback_days: int,
+ limit: int,
+ ) -> ExternalStateSnapshot | None:
+ search_query = _search_query(query, topics=topics, lookback_days=lookback_days)
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
+ response = await client.get(
+ f"{self.base_url}/web/search",
+ headers={
+ "Accept": "application/json",
+ "X-Subscription-Token": self.api_key,
+ },
+ params={
+ "q": search_query,
+ "count": min(int(limit), self.max_results),
+ "search_lang": "en",
+ "spellcheck": 1,
+ },
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict):
+ payload = {"result": payload}
+ return build_model_search_news_snapshot(
+ provider_id="brave_search",
+ query=query,
+ source_tool="brave.web_search",
+ payload={
+ "query": search_query,
+ "topics": topics,
+ "lookback_days": lookback_days,
+ "results": _brave_results(payload),
+ },
+ )
+
+
+def model_search_provider_from_config(config: ExternalStateConfig) -> ModelNativeSearchProvider | None:
+ search = config.model_native_search
+ if not search.enabled:
+ return None
+ provider = search.provider
+ api_key_env = _api_key_env(provider, search.api_key_env)
+ api_key = os.environ.get(api_key_env, "").strip()
+ if not api_key:
+ return None
+ if provider == "ollama":
+ base_url = search.base_url or "https://ollama.com/api"
+ if _hostname(base_url) == "api.search.brave.com":
+ base_url = "https://ollama.com/api"
+ return OllamaWebSearchProvider(
+ api_key=api_key,
+ base_url=base_url,
+ max_results=search.max_results,
+ timeout_seconds=search.timeout_seconds,
+ )
+ if provider == "brave":
+ base_url = search.base_url or "https://api.search.brave.com/res/v1"
+ if _hostname(base_url) == "ollama.com":
+ base_url = "https://api.search.brave.com/res/v1"
+ return BraveWebSearchProvider(
+ api_key=api_key,
+ base_url=base_url,
+ max_results=search.max_results,
+ timeout_seconds=search.timeout_seconds,
+ )
+ return None
+
+
+def _api_key_env(provider: str, configured: str) -> str:
+ if provider == "brave" and (not configured or configured == "OLLAMA_API_KEY"):
+ return "BRAVE_SEARCH_API_KEY"
+ if provider == "ollama" and not configured:
+ return "OLLAMA_API_KEY"
+ return configured
+
+
+def _hostname(url: str) -> str:
+ return (urlparse(url).hostname or "").lower()
+
+
+def _brave_results(payload: dict[str, Any]) -> list[dict[str, Any]]:
+ results: list[dict[str, Any]] = []
+ for bucket in ("news", "web"):
+ section = payload.get(bucket)
+ if not isinstance(section, dict):
+ continue
+ items = section.get("results")
+ if not isinstance(items, list):
+ continue
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ results.append(
+ {
+ "type": bucket,
+ "title": item.get("title"),
+ "url": item.get("url"),
+ "description": item.get("description"),
+ "age": item.get("age"),
+ "page_age": item.get("page_age"),
+ }
+ )
+ return results or [payload]
+
+
+def _search_query(query: str, *, topics: list[str], lookback_days: int) -> str:
+ terms = ", ".join(topic for topic in topics if topic)
+ suffix = f" recent {terms} news last {max(1, int(lookback_days))} days".strip()
+ return f"{query[:300]} {suffix}".strip()[:380]
diff --git a/src/vaner/external_state/models.py b/src/vaner/external_state/models.py
new file mode 100644
index 00000000..d1b06971
--- /dev/null
+++ b/src/vaner/external_state/models.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from enum import StrEnum
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class ExternalStateFreshnessClass(StrEnum):
+ STATIC_REFERENCE = "static_reference"
+ SLOW_MARKET_CONTEXT = "slow_market_context"
+ MARKET_SNAPSHOT = "market_snapshot"
+ ACCOUNT_SNAPSHOT = "account_snapshot"
+ NEWS_SNAPSHOT = "news_snapshot"
+ DERIVED_ANALYSIS = "derived_analysis"
+
+
+class ExternalStateSensitivity(StrEnum):
+ PUBLIC_MARKET_ONLY = "public_market_only"
+ USER_WATCHLIST = "user_watchlist"
+ ACCOUNT_SUMMARY = "account_summary"
+ POSITION_SPECIFIC = "position_specific"
+ ORDER_ACTIVITY = "order_activity"
+ MIXED_SENSITIVE = "mixed_sensitive"
+
+
+class FinanceCapability(StrEnum):
+ GET_QUOTE = "get_quote"
+ GET_CHART = "get_chart"
+ SEARCH_NEWS = "search_news"
+ SCREEN_MARKET = "screen_market"
+ GET_OPTION_CHAIN = "get_option_chain"
+ LIST_OPTION_EXPIRIES = "list_option_expiries"
+ ANALYZE_OPTION_STRATEGY = "analyze_option_strategy"
+ SCREEN_OPTION_STRATEGIES = "screen_option_strategies"
+ REVIEW_STRATEGY_POSITIONS = "review_strategy_positions"
+ LIST_ACCOUNTS = "list_accounts"
+ GET_BALANCE = "get_balance"
+ LIST_POSITIONS = "list_positions"
+ LIST_ORDERS = "list_orders"
+ LIST_ALERTS = "list_alerts"
+
+
+class ExternalStateSnapshot(BaseModel):
+ id: str
+ provider_id: str
+ capability: str
+ query_key: str = ""
+ source_tool: str = ""
+ freshness_class: ExternalStateFreshnessClass
+ sensitivity_class: ExternalStateSensitivity
+ captured_at: float = Field(default_factory=time.time)
+ expires_at: float | None = None
+ payload_fingerprint: str = ""
+ payload: dict[str, Any] = Field(default_factory=dict)
+
+ @classmethod
+ def build(
+ cls,
+ *,
+ provider_id: str,
+ capability: str,
+ freshness_class: ExternalStateFreshnessClass,
+ sensitivity_class: ExternalStateSensitivity,
+ payload: dict[str, Any],
+ query_key: str = "",
+ source_tool: str = "",
+ captured_at: float | None = None,
+ ttl_seconds: float | None = None,
+ ) -> ExternalStateSnapshot:
+ ts = time.time() if captured_at is None else float(captured_at)
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
+ fingerprint = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+ digest = hashlib.sha256(
+ f"{provider_id}\n{capability}\n{query_key}\n{fingerprint}\n{ts:.6f}".encode()
+ ).hexdigest()[:20]
+ return cls(
+ id=f"ext-{digest}",
+ provider_id=provider_id,
+ capability=capability,
+ query_key=query_key,
+ source_tool=source_tool,
+ freshness_class=freshness_class,
+ sensitivity_class=sensitivity_class,
+ captured_at=ts,
+ expires_at=(ts + float(ttl_seconds)) if ttl_seconds is not None else None,
+ payload_fingerprint=fingerprint,
+ payload=payload,
+ )
+
+ def is_stale(self, *, now: float | None = None) -> bool:
+ if self.expires_at is None:
+ return False
+ return (time.time() if now is None else float(now)) >= self.expires_at
diff --git a/src/vaner/external_state/telemetry.py b/src/vaner/external_state/telemetry.py
new file mode 100644
index 00000000..d2584f44
--- /dev/null
+++ b/src/vaner/external_state/telemetry.py
@@ -0,0 +1,23 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+from typing import Any
+
+from vaner.external_state.models import ExternalStateSnapshot
+
+
+def redacted_snapshot_telemetry(snapshot: ExternalStateSnapshot, *, status: str, latency_ms: float) -> dict[str, Any]:
+ """Return telemetry-safe metadata for an external-state snapshot."""
+
+ return {
+ "provider_id": snapshot.provider_id,
+ "capability": snapshot.capability,
+ "status": status,
+ "latency_ms": round(max(0.0, float(latency_ms)), 3),
+ "sensitivity_class": snapshot.sensitivity_class.value,
+ "freshness_class": snapshot.freshness_class.value,
+ "captured_at": snapshot.captured_at,
+ "expires_at": snapshot.expires_at,
+ "payload_fingerprint": snapshot.payload_fingerprint,
+ }
diff --git a/src/vaner/integrations/capability.py b/src/vaner/integrations/capability.py
index bb5e83e3..4446dc77 100644
--- a/src/vaner/integrations/capability.py
+++ b/src/vaner/integrations/capability.py
@@ -33,10 +33,94 @@ class ClientCapabilityTier(IntEnum):
TIER_4 = 4
+class ClientFamily(IntEnum):
+ """Normalised host family derived from ``clientInfo.name``.
+
+ Tier detection answers *what the host can render*. ClientFamily answers
+ *which host is rendering it* — useful for tiny per-host UX tweaks
+ (markdown vs plain, slash-command hints, dashboard wording) without
+ branching on capability flags. ``UNKNOWN`` covers everything we don't
+ have a tested heuristic for; treat it the same as a generic Tier-aware
+ host.
+ """
+
+ UNKNOWN = 0
+ CLAUDE_CODE = 1
+ CLAUDE_DESKTOP = 2
+ CLAUDE_WEB = 3
+ CHATGPT = 4
+ CODEX = 5
+ CURSOR = 6
+ VSCODE = 7
+ ZED = 8
+ GOOSE = 9
+ WINDSURF = 10
+ CLINE = 11
+ CONTINUE = 12
+
+
_UI_EXT_KEY = "io.modelcontextprotocol/ui"
_INJECTION_EXT_KEY = "vaner.context_injection"
+def classify_client(name: str | None) -> ClientFamily:
+ """Map a ``clientInfo.name`` string into a :class:`ClientFamily`.
+
+ Matching is case-insensitive and substring-tolerant because hosts are
+ inconsistent ("claude-code" vs "Claude Code" vs "claude-code-cli"). New
+ hosts default to ``UNKNOWN`` — adding one is intentional, not automatic,
+ so we can document the heuristic per host.
+ """
+
+ if not isinstance(name, str) or not name.strip():
+ return ClientFamily.UNKNOWN
+ raw = name.strip().lower()
+ # Normalise whitespace and underscores to hyphens so "Claude Code",
+ # "claude_code", and "claude-code" all classify identically.
+ n = raw.replace("_", "-")
+ n_compact = " ".join(n.split())
+ n = n_compact.replace(" ", "-")
+ # Order matters: more specific prefixes first.
+ if "claude-code" in n:
+ return ClientFamily.CLAUDE_CODE
+ if "claude-desktop" in n:
+ return ClientFamily.CLAUDE_DESKTOP
+ if n in {"claude.ai", "claude-ai", "claude-web", "claudeai"}:
+ return ClientFamily.CLAUDE_WEB
+ if n.startswith("chatgpt") or "openai-chatgpt" in n:
+ return ClientFamily.CHATGPT
+ if "codex" in n:
+ return ClientFamily.CODEX
+ if "cursor" in n:
+ return ClientFamily.CURSOR
+ if "vscode" in n or "vs-code" in n or "visual-studio-code" in n or "copilot" in n:
+ return ClientFamily.VSCODE
+ if n.startswith("zed"):
+ return ClientFamily.ZED
+ if "goose" in n:
+ return ClientFamily.GOOSE
+ if "windsurf" in n:
+ return ClientFamily.WINDSURF
+ if "cline" in n:
+ return ClientFamily.CLINE
+ if n.startswith("continue") or "continue.dev" in n:
+ return ClientFamily.CONTINUE
+ return ClientFamily.UNKNOWN
+
+
+def is_terminal_host(family: ClientFamily) -> bool:
+ """Return True for hosts that render output as terminal text (no iframes)."""
+
+ return family in {
+ ClientFamily.CLAUDE_CODE,
+ ClientFamily.CODEX,
+ ClientFamily.ZED,
+ ClientFamily.CLINE,
+ ClientFamily.CONTINUE,
+ ClientFamily.WINDSURF,
+ }
+
+
@dataclass(frozen=True)
class TierDetection:
"""Explainable tier classification — includes the signals used."""
@@ -193,6 +277,15 @@ def current_detection(session: Any) -> TierDetection | None:
return _CACHE.get(session)
+def current_client_family(session: Any) -> ClientFamily:
+ """Return the :class:`ClientFamily` for *session*, or UNKNOWN if not seen."""
+
+ detection = _CACHE.get(session)
+ if detection is None:
+ return ClientFamily.UNKNOWN
+ return classify_client(detection.client_name)
+
+
def reset_cache() -> None:
"""Test-only helper — clear the per-session cache."""
_CACHE._by_id.clear()
diff --git a/src/vaner/integrations/mcp_consumer/__init__.py b/src/vaner/integrations/mcp_consumer/__init__.py
new file mode 100644
index 00000000..4460339b
--- /dev/null
+++ b/src/vaner/integrations/mcp_consumer/__init__.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from vaner.integrations.mcp_consumer.safety import (
+ McpToolDescriptor,
+ ReadOnlyToolPolicy,
+ ToolSafetyDecision,
+)
+
+__all__ = [
+ "McpToolDescriptor",
+ "ReadOnlyToolPolicy",
+ "ToolSafetyDecision",
+]
diff --git a/src/vaner/integrations/mcp_consumer/client.py b/src/vaner/integrations/mcp_consumer/client.py
new file mode 100644
index 00000000..a0689aab
--- /dev/null
+++ b/src/vaner/integrations/mcp_consumer/client.py
@@ -0,0 +1,194 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import asyncio
+import os
+from dataclasses import dataclass
+from typing import Any
+
+from vaner.integrations.mcp_consumer.safety import McpToolDescriptor, ReadOnlyToolPolicy, ToolSafetyDecision
+
+
+@dataclass(frozen=True, slots=True)
+class McpToolCallResult:
+ ok: bool
+ payload: Any = None
+ error: str = ""
+ safety_reason: str = ""
+
+
+class McpConsumerClient:
+ """Managed MCP client for proactive read-only external-state calls."""
+
+ def __init__(
+ self,
+ *,
+ server_name: str,
+ transport: str,
+ command: str = "",
+ args: list[str] | None = None,
+ url: str = "",
+ env: dict[str, str] | None = None,
+ timeout_ms: int = 10000,
+ policy: ReadOnlyToolPolicy,
+ ) -> None:
+ self.server_name = server_name
+ self.transport = transport
+ self.command = command
+ self.args = list(args or [])
+ self.url = url
+ self.env = dict(env or {})
+ self.timeout_ms = timeout_ms
+ self.policy = policy
+ self._session: Any = None
+ self._stdio_cm: Any = None
+ self._streams_cm: Any = None
+ self._tool_descriptors: dict[str, McpToolDescriptor] = {}
+
+ async def start(self) -> None:
+ try:
+ from mcp import ClientSession
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+ from mcp.client.streamable_http import streamablehttp_client
+ except ImportError as exc: # pragma: no cover - optional dependency guard
+ raise RuntimeError("mcp consumer support requires the 'mcp' optional dependency") from exc
+
+ if self.transport == "stdio":
+ params = StdioServerParameters(command=self.command, args=self.args, env=_merged_env(self.env))
+ self._stdio_cm = stdio_client(params)
+ read, write = await self._stdio_cm.__aenter__()
+ elif self.transport == "streamable_http":
+ self._streams_cm = streamablehttp_client(self.url)
+ read, write, _ = await self._streams_cm.__aenter__()
+ else:
+ raise ValueError(f"unsupported MCP consumer transport: {self.transport}")
+ try:
+ self._session = ClientSession(read, write)
+ await self._session.__aenter__()
+ await self._session.initialize()
+ decisions = await self.refresh_tools()
+ missing = sorted(self.policy.allowed_tools - set(self._tool_descriptors))
+ if missing:
+ raise RuntimeError(f"MCP consumer {self.server_name} allowlist references unknown tools: {', '.join(missing)}")
+ unsafe = [decision for decision in decisions if decision.tool_name in self.policy.allowed_tools and not decision.allowed]
+ if unsafe:
+ details = ", ".join(f"{decision.tool_name}:{decision.reason}" for decision in unsafe)
+ raise RuntimeError(f"MCP consumer {self.server_name} allowlist contains unsafe tools: {details}")
+ except Exception:
+ await self.stop()
+ raise
+
+ async def stop(self) -> None:
+ if self._session is not None:
+ await self._session.__aexit__(None, None, None)
+ self._session = None
+ if self._stdio_cm is not None:
+ await self._stdio_cm.__aexit__(None, None, None)
+ self._stdio_cm = None
+ if self._streams_cm is not None:
+ await self._streams_cm.__aexit__(None, None, None)
+ self._streams_cm = None
+
+ async def refresh_tools(self) -> list[ToolSafetyDecision]:
+ if self._session is None:
+ raise RuntimeError("MCP consumer client is not started")
+ response = await asyncio.wait_for(self._session.list_tools(), timeout=self.timeout_ms / 1000)
+ descriptors: dict[str, McpToolDescriptor] = {}
+ for tool in getattr(response, "tools", []) or []:
+ annotations = getattr(tool, "annotations", None)
+ if annotations is None:
+ annotation_payload: dict[str, Any] = {}
+ elif hasattr(annotations, "model_dump"):
+ annotation_payload = annotations.model_dump(exclude_none=True)
+ else:
+ annotation_payload = dict(annotations)
+ descriptors[str(getattr(tool, "name", ""))] = McpToolDescriptor(
+ name=str(getattr(tool, "name", "")),
+ annotations=annotation_payload,
+ )
+ self._tool_descriptors = descriptors
+ return self.policy.validate_all(list(descriptors.values()))
+
+ async def call_tool(self, tool_name: str, args: dict[str, Any] | None = None) -> McpToolCallResult:
+ if self._session is None:
+ return McpToolCallResult(ok=False, error="client_not_started")
+ descriptor = self._tool_descriptors.get(tool_name, McpToolDescriptor(name=tool_name))
+ decision = self.policy.validate(descriptor)
+ if not decision.allowed:
+ return McpToolCallResult(ok=False, error="tool_blocked", safety_reason=decision.reason)
+ try:
+ payload = await asyncio.wait_for(self._session.call_tool(tool_name, args or {}), timeout=self.timeout_ms / 1000)
+ except Exception as exc: # pragma: no cover - transport boundary
+ return McpToolCallResult(ok=False, error=str(exc), safety_reason=decision.reason)
+ return McpToolCallResult(ok=True, payload=payload, safety_reason=decision.reason)
+
+
+async def discover_mcp_tools(
+ *,
+ transport: str,
+ command: str = "",
+ args: list[str] | None = None,
+ url: str = "",
+ env: dict[str, str] | None = None,
+ timeout_ms: int = 10000,
+) -> list[McpToolDescriptor]:
+ """Connect to an MCP server long enough to list tools.
+
+ Discovery never calls provider tools and intentionally does not apply a
+ consumer allowlist. The caller is responsible for running the returned
+ descriptors through :class:`ReadOnlyToolPolicy` before persisting any tool
+ as callable external state.
+ """
+
+ try:
+ from mcp import ClientSession
+ from mcp.client.stdio import StdioServerParameters, stdio_client
+ from mcp.client.streamable_http import streamablehttp_client
+ except ImportError as exc: # pragma: no cover - optional dependency guard
+ raise RuntimeError("mcp consumer support requires the 'mcp' optional dependency") from exc
+
+ stdio_cm: Any = None
+ streams_cm: Any = None
+ session: Any = None
+ try:
+ if transport == "stdio":
+ params = StdioServerParameters(command=command, args=list(args or []), env=_merged_env(env))
+ stdio_cm = stdio_client(params)
+ read, write = await stdio_cm.__aenter__()
+ elif transport == "streamable_http":
+ streams_cm = streamablehttp_client(url)
+ read, write, _ = await streams_cm.__aenter__()
+ else:
+ raise ValueError(f"unsupported MCP consumer transport: {transport}")
+
+ session = ClientSession(read, write)
+ await session.__aenter__()
+ await session.initialize()
+ response = await asyncio.wait_for(session.list_tools(), timeout=timeout_ms / 1000)
+ descriptors: list[McpToolDescriptor] = []
+ for tool in getattr(response, "tools", []) or []:
+ annotations = getattr(tool, "annotations", None)
+ if annotations is None:
+ annotation_payload: dict[str, Any] = {}
+ elif hasattr(annotations, "model_dump"):
+ annotation_payload = annotations.model_dump(exclude_none=True)
+ else:
+ annotation_payload = dict(annotations)
+ name = str(getattr(tool, "name", "")).strip()
+ if name:
+ descriptors.append(McpToolDescriptor(name=name, annotations=annotation_payload))
+ return descriptors
+ finally:
+ if session is not None:
+ await session.__aexit__(None, None, None)
+ if stdio_cm is not None:
+ await stdio_cm.__aexit__(None, None, None)
+ if streams_cm is not None:
+ await streams_cm.__aexit__(None, None, None)
+
+
+def _merged_env(env: dict[str, str] | None) -> dict[str, str]:
+ merged = dict(os.environ)
+ merged.update(dict(env or {}))
+ return merged
diff --git a/src/vaner/integrations/mcp_consumer/safety.py b/src/vaner/integrations/mcp_consumer/safety.py
new file mode 100644
index 00000000..8d8c997d
--- /dev/null
+++ b/src/vaner/integrations/mcp_consumer/safety.py
@@ -0,0 +1,71 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+_DENY_NAME_RE = re.compile(
+ r"(^|[_\-.])("
+ r"auth|oauth|token|login|logout|"
+ r"precheck|place|modify|cancel|create|update|delete|submit|execute|write"
+ r")([_\-.]|$)",
+ re.IGNORECASE,
+)
+
+
+@dataclass(frozen=True, slots=True)
+class McpToolDescriptor:
+ name: str
+ annotations: dict[str, Any] = field(default_factory=dict)
+ provider_risk: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class ToolSafetyDecision:
+ allowed: bool
+ reason: str
+ tool_name: str
+
+
+class ReadOnlyToolPolicy:
+ """Provider-neutral guard for proactive MCP consumer calls.
+
+ The policy is intentionally strict. A tool must be explicitly allowed and
+ must be positively classified as read-only by annotations or provider risk
+ metadata. Name patterns are a final deny layer, not the primary signal.
+ """
+
+ def __init__(
+ self,
+ *,
+ allowed_tools: set[str] | list[str] | tuple[str, ...],
+ tool_risks: dict[str, str] | None = None,
+ ) -> None:
+ self.allowed_tools = frozenset(str(tool) for tool in allowed_tools)
+ self.tool_risks = {str(name): str(risk).strip().lower() for name, risk in (tool_risks or {}).items()}
+
+ def validate(self, descriptor: McpToolDescriptor) -> ToolSafetyDecision:
+ name = descriptor.name
+ if name not in self.allowed_tools:
+ return ToolSafetyDecision(False, "tool_not_in_allowlist", name)
+ if _DENY_NAME_RE.search(name):
+ return ToolSafetyDecision(False, "tool_name_matches_deny_pattern", name)
+ risk = (descriptor.provider_risk or self.tool_risks.get(name) or "").strip().lower()
+ if risk and risk != "read":
+ return ToolSafetyDecision(False, f"provider_risk_not_read:{risk}", name)
+ annotations = descriptor.annotations or {}
+ read_hint = annotations.get("readOnlyHint")
+ destructive_hint = annotations.get("destructiveHint")
+ open_world_hint = annotations.get("openWorldHint")
+ if destructive_hint is True:
+ return ToolSafetyDecision(False, "destructive_hint_true", name)
+ if open_world_hint is True and read_hint is not True:
+ return ToolSafetyDecision(False, "open_world_without_readonly_hint", name)
+ if read_hint is not True and risk != "read":
+ return ToolSafetyDecision(False, "missing_positive_readonly_classification", name)
+ return ToolSafetyDecision(True, "read_only_allowlisted", name)
+
+ def validate_all(self, descriptors: list[McpToolDescriptor]) -> list[ToolSafetyDecision]:
+ return [self.validate(descriptor) for descriptor in descriptors]
diff --git a/src/vaner/intent/bundles.py b/src/vaner/intent/bundles.py
new file mode 100644
index 00000000..7ccc3a19
--- /dev/null
+++ b/src/vaner/intent/bundles.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ try:
+ parsed = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return parsed if isinstance(parsed, dict) else {}
+
+
+def bundle_rejection_reason(bundle_dir: Path | str, *, allow_experimental_data: bool = False) -> str | None:
+ """Return why a training bundle should not be installed by default."""
+
+ bundle_path = Path(bundle_dir)
+ manifest = _load_json(bundle_path / "manifest.json")
+ metrics = manifest.get("training_metrics")
+ if isinstance(metrics, dict):
+ if metrics.get("rejected_for_threshold") is True:
+ return "bundle was rejected by the training promotion threshold"
+ if metrics.get("trained") is False:
+ return "bundle manifest says the scorer was not promoted"
+ validation = manifest.get("finance_validation")
+ if isinstance(validation, dict) and validation.get("passed") is False:
+ return "bundle finance temporal validation did not pass"
+ governance = manifest.get("dataset_governance")
+ if (
+ isinstance(governance, dict)
+ and governance.get("release_eligible") is False
+ and not allow_experimental_data
+ ):
+ return "bundle dataset governance is not release eligible"
+
+ metadata = _load_json(bundle_path / "intent_scorer_metadata.json")
+ if str(metadata.get("reason", "")).strip().lower() == "rejected":
+ return "bundle scorer metadata marks the model as rejected"
+ return None
+
+
+def bundle_public_summary(bundle_dir: Path | str) -> dict[str, Any]:
+ """Small non-sensitive summary for CLI/status output."""
+
+ bundle_path = Path(bundle_dir)
+ manifest = _load_json(bundle_path / "manifest.json")
+ metrics = manifest.get("training_metrics") if isinstance(manifest.get("training_metrics"), dict) else {}
+ quality = manifest.get("training_quality") if isinstance(manifest.get("training_quality"), dict) else {}
+ return {
+ "bundle_id": manifest.get("bundle_id") or bundle_path.name,
+ "created_at": manifest.get("created_at"),
+ "feature_schema_version": manifest.get("feature_schema_version"),
+ "records_used": manifest.get("records_used"),
+ "scorer_backend": manifest.get("scorer_backend"),
+ "trained": metrics.get("trained") if isinstance(metrics, dict) else None,
+ "rejected_for_threshold": metrics.get("rejected_for_threshold") if isinstance(metrics, dict) else None,
+ "mae": metrics.get("mae") if isinstance(metrics, dict) else None,
+ "baseline_mae": metrics.get("baseline_mae") if isinstance(metrics, dict) else None,
+ "improvement": metrics.get("improvement") if isinstance(metrics, dict) else None,
+ "feature_coverage_pct": quality.get("feature_coverage_pct") if isinstance(quality, dict) else None,
+ "release_eligible": (
+ manifest.get("dataset_governance", {}).get("release_eligible")
+ if isinstance(manifest.get("dataset_governance"), dict)
+ else None
+ ),
+ "finance_temporal_passed": (
+ manifest.get("finance_validation", {}).get("passed") if isinstance(manifest.get("finance_validation"), dict) else None
+ ),
+ "finance_temporal_improvement": (
+ manifest.get("finance_validation", {}).get("improvement")
+ if isinstance(manifest.get("finance_validation"), dict)
+ else None
+ ),
+ "rejection_reason": bundle_rejection_reason(bundle_path),
+ "experimental_data_installable": bundle_rejection_reason(bundle_path, allow_experimental_data=True) is None,
+ }
diff --git a/src/vaner/intent/drafter.py b/src/vaner/intent/drafter.py
index cdff6753..dc7db17c 100644
--- a/src/vaner/intent/drafter.py
+++ b/src/vaner/intent/drafter.py
@@ -32,6 +32,7 @@
from vaner.clients.llm_response import approx_tokens
from vaner.intent.briefing import Briefing, BriefingAssembler
from vaner.intent.prediction import PredictedPrompt
+from vaner.policy.internal_llm import DRAFT_POLICY, PREDICTION_POLICY, internal_llm_policy
_log = logging.getLogger(__name__)
@@ -168,6 +169,7 @@ async def draft_for_prediction(
else:
recent_hint = "\n".join(recent_queries[-5:]) or "(no recent queries)"
rewrite_prompt = (
+ f"{internal_llm_policy(PREDICTION_POLICY)}\n\n"
"Rewrite the likely next developer prompt as one concrete sentence.\n"
"Stay semantically equivalent and concise.\n\n"
f"Candidate prompt: {predicted_prompt}\n"
@@ -190,6 +192,7 @@ async def draft_for_prediction(
summaries_text = "\n".join(file_summaries) or "(no artefact summaries available)"
recent_hint = "\n".join(recent_queries[-5:]) or "(no recent queries)"
draft_prompt_text = (
+ f"{internal_llm_policy(DRAFT_POLICY)}\n\n"
"You are Vaner, a context engine drafting a speculative answer for a\n"
"prompt the developer is likely to send next. Stay honest: if the\n"
"evidence below is insufficient to produce a confident draft, say so\n"
diff --git a/src/vaner/intent/evidence_resolver.py b/src/vaner/intent/evidence_resolver.py
index 84c4644a..427c886c 100644
--- a/src/vaner/intent/evidence_resolver.py
+++ b/src/vaner/intent/evidence_resolver.py
@@ -11,6 +11,7 @@
from vaner.intent.prediction_v2 import StructuredPrediction
from vaner.intent.symbol_index import RELATION_PRIORITY, SymbolCandidate
from vaner.intent.target_normalization import component_terms, normalize_component
+from vaner.semantic_aliases import engineering_semantic_aliases
_STOPWORDS = {
"about",
@@ -131,6 +132,7 @@ def _alias_tokens(text: str) -> set[str]:
if token.endswith(suffix) and len(token) > len(suffix) + 2:
aliases.add(token[: -len(suffix)])
aliases.add(suffix)
+ aliases.update(engineering_semantic_aliases(text, tokens, stopwords=_STOPWORDS))
return aliases
diff --git a/src/vaner/intent/features.py b/src/vaner/intent/features.py
index f08686b5..b5a0465e 100644
--- a/src/vaner/intent/features.py
+++ b/src/vaner/intent/features.py
@@ -154,6 +154,22 @@ def feature_vector_for_artefact(features: dict[str, float], artefact: Artefact)
features.get("skill_presence", 0.0),
features.get("skill_kind_match", 0.0),
features.get("follow_up_offer_strength", 0.0),
+ # Provider-neutral finance preparation signals (FEATURE_SCHEMA_VERSION v5).
+ # These are ex-ante inputs only; realized returns and keep/drop labels stay
+ # in training labels and are intentionally not part of the runtime vector.
+ features.get("finance_public_market_plane", 0.0),
+ features.get("finance_account_state_plane", 0.0),
+ features.get("finance_horizon_days", 0.0),
+ features.get("finance_option_strategy_entry_debit", 0.0),
+ features.get("finance_option_strategy_leg_count", 0.0),
+ features.get("finance_option_strategy_is_single_leg", 0.0),
+ features.get("finance_option_strategy_is_debit_spread", 0.0),
+ features.get("finance_option_strategy_is_call", 0.0),
+ features.get("finance_option_strategy_is_put", 0.0),
+ features.get("finance_option_strategy_min_abs_delta", 0.0),
+ features.get("finance_option_strategy_max_abs_delta", 0.0),
+ features.get("finance_option_strategy_mean_iv", 0.0),
+ features.get("finance_liquidity_penalty", 0.0),
# Artefact-level fields (always last, matches trainer.py FEATURE_KEYS[-2:])
float(artefact.access_count),
float(max(0.0, time.time() - artefact.generated_at)),
diff --git a/src/vaner/intent/prepared_work.py b/src/vaner/intent/prepared_work.py
index 6c3a4cb0..dc48b65a 100644
--- a/src/vaner/intent/prepared_work.py
+++ b/src/vaner/intent/prepared_work.py
@@ -35,10 +35,16 @@
WorkProductType.DOCS_DRIFT: PreparedWorkKind.DOCS,
WorkProductType.VIRTUAL_DIFF: PreparedWorkKind.DIFF,
WorkProductType.RESEARCH_BRIEF: PreparedWorkKind.BRIEF,
+ WorkProductType.FINANCE_MORNING_BRIEF: PreparedWorkKind.FINANCE,
+ WorkProductType.FINANCE_POSITION_BRIEF: PreparedWorkKind.FINANCE,
+ WorkProductType.FINANCE_SCREENING_RESULT: PreparedWorkKind.FINANCE,
+ WorkProductType.FINANCE_OPTION_STRATEGY_PLAN: PreparedWorkKind.FINANCE,
+ WorkProductType.FINANCE_TRADE_BRIEF: PreparedWorkKind.FINANCE,
}
_KIND_PRIORITY: dict[PreparedWorkKind, float] = {
PreparedWorkKind.DIFF: 1.0,
+ PreparedWorkKind.FINANCE: 0.96,
PreparedWorkKind.BRIEF: 0.92,
PreparedWorkKind.REVIEW: 0.82,
PreparedWorkKind.DRAFT: 0.8,
@@ -248,8 +254,8 @@ def _card_from_work_product(
source_id=product.id,
source_type=PreparedWorkSourceType.WORK_PRODUCT,
kind=kind,
- title=str(sanitize_no_absolute_paths(product.title)),
- summary=str(sanitize_no_absolute_paths(product.summary)),
+ title=_display_title(product),
+ summary=_display_summary(product),
badge=_badge_for_kind(kind),
confidence_label=_confidence_label(product.confidence),
freshness_label=_freshness_label(product.updated_at, now),
@@ -257,6 +263,10 @@ def _card_from_work_product(
target_label=target_label,
why_prepared=_why_prepared(product, target_label),
action_note=_action_note(product),
+ sensitivity_class=product.sensitivity_class.value,
+ fresh_precheck_required=product.fresh_precheck_required,
+ external_input_count=len(product.external_inputs),
+ prohibited_actions=list(product.prohibited_actions),
evidence_count=len(product.evidence_refs),
created_at=product.created_at,
updated_at=product.updated_at,
@@ -516,6 +526,10 @@ def build_work_product_inspection(product: WorkProduct, *, now: float | None = N
confidence_label=_confidence_label(product.confidence),
freshness_label=_freshness_label(product.updated_at, ts),
freshness_state=_freshness_state(product, ts),
+ sensitivity_class=product.sensitivity_class.value,
+ fresh_precheck_required=product.fresh_precheck_required,
+ external_input_count=len(product.external_inputs),
+ prohibited_actions=list(product.prohibited_actions),
target_label=target_label,
evidence_count=len(product.evidence_refs),
evidence_refs=evidence_refs,
@@ -564,6 +578,8 @@ def _why_prepared_prediction(prompt: Any) -> str:
def _action_note(product: WorkProduct) -> str:
+ if product.fresh_precheck_required:
+ return "Fresh external-state precheck required before use; execution is prohibited."
if product.type == WorkProductType.VIRTUAL_DIFF:
if product.can_export(now=time.time()):
return "Export returns the prepared diff only; Vaner will not apply it automatically."
@@ -573,8 +589,38 @@ def _action_note(product: WorkProduct) -> str:
return "Inspect this lead before using it."
+def _display_title(product: WorkProduct) -> str:
+ if product.type == WorkProductType.FINANCE_POSITION_BRIEF:
+ return "Trading strategy prep"
+ if product.type == WorkProductType.FINANCE_SCREENING_RESULT:
+ return "Trading screen results"
+ if product.type == WorkProductType.FINANCE_OPTION_STRATEGY_PLAN:
+ return "Options strategy plan"
+ if product.type == WorkProductType.FINANCE_TRADE_BRIEF:
+ return "Trade brief"
+ if product.type == WorkProductType.FINANCE_MORNING_BRIEF:
+ return "Market morning brief"
+ return str(sanitize_no_absolute_paths(product.title))
+
+
+def _display_summary(product: WorkProduct) -> str:
+ if product.type.value.startswith("finance_"):
+ capabilities = sorted({item.capability for item in product.external_inputs})
+ suffix = f" Uses {len(product.external_inputs)} fresh external input{'s' if len(product.external_inputs) != 1 else ''}."
+ if capabilities:
+ suffix += f" Capabilities: {', '.join(capabilities)}."
+ return str(sanitize_no_absolute_paths(product.summary + suffix))
+ return str(sanitize_no_absolute_paths(product.summary))
+
+
def _inspection_warnings(product: WorkProduct) -> list[str]:
warnings: list[str] = []
+ if product.fresh_precheck_required:
+ warnings.append("Fresh external-state precheck is required before using this prepared work.")
+ if product.prohibited_actions:
+ warnings.append("Prohibited action: " + ", ".join(sorted(set(product.prohibited_actions))) + ".")
+ if product.sensitivity_class.value not in {"general", "public_market_only"}:
+ warnings.append(f"Sensitivity class: {product.sensitivity_class.value}.")
if product.freshness == WorkProductFreshness.STALE:
warnings.append("This prepared item is stale. Regenerate it before export or adoption.")
elif product.freshness == WorkProductFreshness.UNKNOWN:
@@ -665,6 +711,7 @@ def _badge_for_kind(kind: PreparedWorkKind) -> str:
PreparedWorkKind.DOCS: "Docs",
PreparedWorkKind.DIFF: "Diff",
PreparedWorkKind.BRIEF: "Brief",
+ PreparedWorkKind.FINANCE: "Trading",
PreparedWorkKind.DRAFT: "Draft",
PreparedWorkKind.PREDICTION: "Ready",
}[kind]
diff --git a/src/vaner/intent/symbol_index.py b/src/vaner/intent/symbol_index.py
index cbdc9da2..201b32ee 100644
--- a/src/vaner/intent/symbol_index.py
+++ b/src/vaner/intent/symbol_index.py
@@ -180,15 +180,20 @@ def rank_exact_paths(
max_paths: int = 8,
) -> list[str]:
candidates = symbol_candidates_for_text(repo_root, text, available_paths=available_paths)
- ranked: list[str] = []
- seen: set[str] = set()
+ if not candidates:
+ return []
+ by_path: dict[str, float] = {}
+ matched_terms_by_path: dict[str, set[str]] = {}
for candidate in candidates:
- if candidate.path not in seen:
- ranked.append(candidate.path)
- seen.add(candidate.path)
- if len(ranked) >= max_paths:
- break
- return ranked
+ matched_terms_by_path.setdefault(candidate.path, set()).update(candidate.matched_terms)
+ by_path[candidate.path] = by_path.get(candidate.path, 0.0) + _path_candidate_score(candidate)
+ for path, terms in matched_terms_by_path.items():
+ # Multi-term matches are usually better implementation anchors than
+ # single generic word hits, especially for prompts like "reward
+ # computation signals" where many signal modules mention "signal".
+ by_path[path] = by_path.get(path, 0.0) + min(2.5, max(0, len(terms) - 1) * 0.45)
+ ranked = sorted(by_path, key=lambda path: (-by_path[path], path))
+ return ranked[:max_paths]
def _candidate_paths(root: Path, available_paths: list[str] | tuple[str, ...] | None, *, max_files: int) -> list[str]:
@@ -259,6 +264,58 @@ def _dedupe_and_sort(candidates: list[SymbolCandidate]) -> list[SymbolCandidate]
)
+_LOW_SPECIFICITY_TERMS = {
+ "combined",
+ "combine",
+ "computation",
+ "context",
+ "database",
+ "extract",
+ "feature",
+ "final",
+ "model",
+ "package",
+ "persist",
+ "produce",
+ "retrieve",
+ "schema",
+ "signal",
+ "signals",
+ "table",
+ "value",
+}
+
+
+def _path_candidate_score(candidate: SymbolCandidate) -> float:
+ relation_weight = float(RELATION_PRIORITY[candidate.relation]) * 4.0
+ score = relation_weight + candidate.score
+ normalized_path_terms = set(path_component_terms(candidate.path))
+ matched_terms = set(candidate.matched_terms)
+ basename = Path(candidate.path).stem
+ normalized_basename = normalize_component(basename)
+ normalized_symbol = normalize_component(candidate.symbol)
+
+ if matched_terms & normalized_path_terms:
+ score += 5.0
+ if normalized_basename and normalized_basename in matched_terms:
+ score += 8.0
+ if normalized_symbol and normalized_symbol in matched_terms:
+ score += 4.0
+ if candidate.relation == "definition_match" and not candidate.symbol.startswith("_"):
+ score += 3.0
+ if candidate.symbol.startswith("_"):
+ score -= 2.0
+ if matched_terms and matched_terms <= _LOW_SPECIFICITY_TERMS and candidate.relation == "usage_match":
+ score -= 12.0
+ if candidate.relation == "usage_match" and not (matched_terms & normalized_path_terms):
+ score -= 8.0
+ if candidate.path.startswith(("src/", "lib/", "app/", "packages/")):
+ score += 0.5
+ if candidate.path.startswith(("tests/", "test/")):
+ score -= 0.5
+ return score
+
+
def _score_for_relation(relation: SymbolRelation) -> float:
return {
"definition_match": 1.0,
diff --git a/src/vaner/intent/taxonomy.py b/src/vaner/intent/taxonomy.py
index 5f3b6051..f497b206 100644
--- a/src/vaner/intent/taxonomy.py
+++ b/src/vaner/intent/taxonomy.py
@@ -5,7 +5,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
-DOMAINS: tuple[str, ...] = ("coding", "research", "writing", "ops")
+DOMAINS: tuple[str, ...] = ("coding", "research", "writing", "ops", "finance")
MODES: tuple[str, ...] = ("understand", "implement", "debug", "validate", "plan", "explain")
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
@@ -13,6 +13,25 @@
"research": ("research", "paper", "benchmark", "evidence", "study", "compare", "analysis"),
"writing": ("write", "copy", "draft", "tone", "grammar", "edit", "summary"),
"ops": ("deploy", "infra", "incident", "monitor", "latency", "runtime", "oncall", "k8s"),
+ "finance": (
+ "option",
+ "strike",
+ "expiry",
+ "delta",
+ "vega",
+ "theta",
+ "iv",
+ "underlying",
+ "position",
+ "portfolio",
+ "ticker",
+ "spread",
+ "premium",
+ "assignment",
+ "hedge",
+ "watchlist",
+ "earnings",
+ ),
}
_MODE_KEYWORDS: dict[str, tuple[str, ...]] = {
diff --git a/src/vaner/intent/trainer.py b/src/vaner/intent/trainer.py
index 3e0137bf..70268967 100644
--- a/src/vaner/intent/trainer.py
+++ b/src/vaner/intent/trainer.py
@@ -10,7 +10,7 @@
from vaner.learning.reward import RewardInput, compute_reward
from vaner.store.artefacts import ArtefactStore
-FEATURE_SCHEMA_VERSION = "v4"
+FEATURE_SCHEMA_VERSION = "v5"
FEATURE_KEYS: tuple[str, ...] = (
"signal_count_recent_15m",
"query_count_total",
@@ -39,6 +39,19 @@
"skill_presence",
"skill_kind_match",
"follow_up_offer_strength",
+ "finance_public_market_plane",
+ "finance_account_state_plane",
+ "finance_horizon_days",
+ "finance_option_strategy_entry_debit",
+ "finance_option_strategy_leg_count",
+ "finance_option_strategy_is_single_leg",
+ "finance_option_strategy_is_debit_spread",
+ "finance_option_strategy_is_call",
+ "finance_option_strategy_is_put",
+ "finance_option_strategy_min_abs_delta",
+ "finance_option_strategy_max_abs_delta",
+ "finance_option_strategy_mean_iv",
+ "finance_liquidity_penalty",
"access_count",
"artefact_age_seconds",
)
@@ -172,7 +185,7 @@ async def train_batch(self, output_dir: Path) -> Path | None:
backend = self.config.scorer_backend
extension = ".txt"
- if backend == "xgboost":
+ if backend in {"auto", "xgboost"}:
extension = ".json"
elif backend == "catboost":
extension = ".cbm"
diff --git a/src/vaner/intent/work_products.py b/src/vaner/intent/work_products.py
index a75bc960..a288a097 100644
--- a/src/vaner/intent/work_products.py
+++ b/src/vaner/intent/work_products.py
@@ -4,19 +4,23 @@
import difflib
import hashlib
+import json
import re
import time
from pathlib import Path
from typing import Any
from vaner.daemon.signals.git_reader import read_content_hashes, read_git_state, read_head_sha
+from vaner.external_state.models import ExternalStateSnapshot
from vaner.models.artefact import Artefact
from vaner.models.work_product import (
WorkProduct,
WorkProductAdoptability,
WorkProductEvidenceRef,
+ WorkProductExternalInput,
WorkProductFreshness,
WorkProductSelfEval,
+ WorkProductSensitivity,
WorkProductSourceSnapshot,
WorkProductStatus,
WorkProductType,
@@ -38,6 +42,31 @@
}
_DOC_SUFFIXES = {".md", ".mdx", ".rst", ".txt"}
_WORK_PRODUCT_VERSION = "work_product.v1"
+_FINANCE_TERMS = {
+ "account",
+ "assignment",
+ "balance",
+ "delta",
+ "earnings",
+ "expiry",
+ "greek",
+ "hedge",
+ "iv",
+ "option",
+ "order",
+ "portfolio",
+ "position",
+ "premium",
+ "screen",
+ "spread",
+ "strike",
+ "theta",
+ "ticker",
+ "underlying",
+ "vega",
+ "volatility",
+ "watchlist",
+}
def _stable_id(kind: WorkProductType, target_key: str, body: str) -> str:
@@ -352,6 +381,353 @@ def _generate_research_brief(repo_root: Path, recent_queries: list[str], artefac
)
+def _finance_signal(text: str) -> bool:
+ lowered = text.lower()
+ return any(term in lowered for term in _FINANCE_TERMS)
+
+
+def _finance_sensitivity(text: str) -> WorkProductSensitivity:
+ lowered = text.lower()
+ if any(term in lowered for term in ("order", "fill", "activity", "cancel", "modify")):
+ return WorkProductSensitivity.ORDER_ACTIVITY
+ if any(term in lowered for term in ("position", "holding", "assignment", "short leg", "long leg")):
+ return WorkProductSensitivity.POSITION_SPECIFIC
+ if any(term in lowered for term in ("account", "balance", "margin", "cash", "portfolio")):
+ return WorkProductSensitivity.ACCOUNT_SUMMARY
+ if "watchlist" in lowered:
+ return WorkProductSensitivity.USER_WATCHLIST
+ return WorkProductSensitivity.PUBLIC_MARKET_ONLY
+
+
+def _finance_kind(query_text: str, evidence_text: str) -> WorkProductType:
+ lowered = f"{query_text}\n{evidence_text}".lower()
+ if any(term in lowered for term in ("position", "portfolio", "balance", "order", "assignment")):
+ return WorkProductType.FINANCE_POSITION_BRIEF
+ if any(term in lowered for term in ("option", "spread", "strike", "expiry", "delta", "theta", "vega", "iv")):
+ return WorkProductType.FINANCE_OPTION_STRATEGY_PLAN
+ if any(term in lowered for term in ("screen", "watchlist", "candidate")):
+ return WorkProductType.FINANCE_SCREENING_RESULT
+ if any(term in lowered for term in ("trade", "entry", "exit", "hedge", "rebalance")):
+ return WorkProductType.FINANCE_TRADE_BRIEF
+ return WorkProductType.FINANCE_MORNING_BRIEF
+
+
+def _decode_external_payload(payload: dict[str, Any]) -> Any:
+ content = payload.get("content")
+ if isinstance(content, list):
+ decoded: list[Any] = []
+ for item in content:
+ text = item.get("text") if isinstance(item, dict) else None
+ if not isinstance(text, str) or not text.strip():
+ continue
+ try:
+ decoded.append(json.loads(text))
+ except json.JSONDecodeError:
+ decoded.append({"text": text})
+ if decoded:
+ return decoded[0] if len(decoded) == 1 else decoded
+ return payload
+
+
+def _candidate_lists(value: Any, *, depth: int = 0) -> list[list[dict[str, Any]]]:
+ if depth > 4:
+ return []
+ if isinstance(value, list):
+ if value and all(isinstance(item, dict) for item in value):
+ return [list(value)]
+ lists: list[list[dict[str, Any]]] = []
+ for item in value:
+ lists.extend(_candidate_lists(item, depth=depth + 1))
+ return lists
+ if not isinstance(value, dict):
+ return []
+ preferred_keys = (
+ "candidates",
+ "results",
+ "items",
+ "instruments",
+ "securities",
+ "strategies",
+ "quotes",
+ "data",
+ "rows",
+ )
+ lists = []
+ for key in preferred_keys:
+ if key in value:
+ lists.extend(_candidate_lists(value[key], depth=depth + 1))
+ if lists:
+ return lists
+ for nested in value.values():
+ lists.extend(_candidate_lists(nested, depth=depth + 1))
+ return lists
+
+
+def _field_value(item: dict[str, Any], names: tuple[str, ...]) -> Any:
+ lowered = {str(key).lower(): value for key, value in item.items()}
+ for name in names:
+ if name.lower() in lowered:
+ return lowered[name.lower()]
+ for value in item.values():
+ if isinstance(value, dict):
+ nested = _field_value(value, names)
+ if nested not in (None, ""):
+ return nested
+ return None
+
+
+def _candidate_label(item: dict[str, Any]) -> str:
+ value = _field_value(
+ item,
+ (
+ "symbol",
+ "ticker",
+ "displaySymbol",
+ "identifier",
+ "uic",
+ "name",
+ "description",
+ "instrument",
+ ),
+ )
+ if isinstance(value, dict):
+ value = _field_value(value, ("symbol", "ticker", "name", "description", "uic"))
+ label = str(value or "candidate").strip()
+ return sanitize_no_absolute_paths(label[:80])
+
+
+def _score_finance_candidate(item: dict[str, Any], query_terms: set[str], *, snapshot_stale: bool) -> tuple[float, list[str]]:
+ label = _candidate_label(item).lower()
+ present_fields = 0
+ field_groups: tuple[tuple[str, ...], ...] = (
+ ("symbol", "ticker", "uic", "identifier"),
+ ("name", "description"),
+ ("price", "lastPrice", "last", "close"),
+ ("change", "changePercent", "percentChange"),
+ ("volume", "turnover"),
+ ("score", "rank", "rankScore"),
+ )
+ for group in field_groups:
+ if _field_value(item, group) not in (None, ""):
+ present_fields += 1
+ relevance_hits = sum(1 for term in query_terms if len(term) > 2 and term in label)
+ score = 0.25 + min(0.4, present_fields * 0.075) + min(0.2, relevance_hits * 0.08)
+ reasons = ["complete-enough evidence"] if present_fields >= 3 else ["thin evidence"]
+ if relevance_hits:
+ reasons.append("matches recent finance intent")
+ if not snapshot_stale:
+ score += 0.1
+ reasons.append("fresh snapshot")
+ else:
+ score -= 0.2
+ reasons.append("stale snapshot")
+ return (max(0.0, min(1.0, score)), reasons)
+
+
+def _finance_exploration_summary(snapshots: list[ExternalStateSnapshot], query_text: str) -> dict[str, Any]:
+ query_terms = set(re.findall(r"[a-zA-Z0-9_.$-]+", query_text.lower()))
+ candidate_rows: list[tuple[float, str, str, list[str]]] = []
+ for snapshot in snapshots:
+ payload = _decode_external_payload(snapshot.payload)
+ snapshot_stale = snapshot.is_stale()
+ for candidate_list in _candidate_lists(payload):
+ for item in candidate_list[:100]:
+ score, reasons = _score_finance_candidate(item, query_terms, snapshot_stale=snapshot_stale)
+ candidate_rows.append((score, snapshot.capability, _candidate_label(item), reasons))
+ candidate_rows.sort(key=lambda row: row[0], reverse=True)
+ kept = candidate_rows[:5]
+ pruning_rules = [
+ "prefer fresh snapshots over stale branches",
+ "prefer candidates with enough comparable market fields",
+ "prefer candidates aligned with recent user finance intent",
+ "prune low-evidence branches before requesting deeper provider data",
+ ]
+ return {
+ "objective": "risk_adjusted_preparation",
+ "candidate_count": len(candidate_rows),
+ "kept_candidate_count": len(kept),
+ "pruned_candidate_count": max(0, len(candidate_rows) - len(kept)),
+ "kept_candidates": [
+ {"label": label, "score": round(score, 3), "capability": capability, "reasons": reasons}
+ for score, capability, label, reasons in kept
+ ],
+ "pruning_rules": pruning_rules,
+ "abstentions": [] if candidate_rows else ["external snapshot did not expose a comparable candidate list"],
+ }
+
+
+def _generate_finance_brief(repo_root: Path, recent_queries: list[str], artefacts: list[Artefact]) -> WorkProduct | None:
+ query_text = " ".join(recent_queries[-5:])
+ finance_artefacts = [
+ artefact
+ for artefact in artefacts
+ if Path(str(artefact.source_path)).suffix.lower() in _DOC_SUFFIXES
+ and _finance_signal(f"{artefact.source_path}\n{artefact.content}")
+ ]
+ if not finance_artefacts and not _finance_signal(query_text):
+ return None
+ selected = finance_artefacts[:3]
+ evidence_text = "\n".join(str(artefact.content) for artefact in selected)
+ if not selected:
+ return None
+ paths = [str(artefact.source_path) for artefact in selected]
+ sensitivity = _finance_sensitivity(f"{query_text}\n{evidence_text}")
+ kind = _finance_kind(query_text, evidence_text)
+ bullets: list[str] = []
+ for artefact in selected:
+ excerpt = " ".join(str(artefact.content).split())[:260]
+ bullets.append(f"- `{artefact.source_path}`: {excerpt}")
+ body = (
+ "Finance preparation from local workspace evidence only.\n\n"
+ + "\n".join(bullets)
+ + "\n\nFreshness note: no external market/account snapshot was used. "
+ "Treat this as planning context, not an execution-ready recommendation."
+ )
+ target_digest = hashlib.sha1(f"{kind.value}\n{query_text}\n{','.join(paths)}".encode()).hexdigest()[:12] # noqa: S324
+ product = _make_product(
+ repo_root=repo_root,
+ kind=kind,
+ title="Prepared finance brief",
+ summary="Local finance notes prepared without external market or account data.",
+ body=body,
+ paths=paths,
+ evidence_reason="local finance evidence; external data access was not required",
+ confidence=0.62 if query_text else 0.55,
+ adoptability=WorkProductAdoptability.ADVISORY,
+ target_key=f"{kind.value}:local:{target_digest}",
+ provenance={
+ "finance": {
+ "external_state_enabled": False,
+ "abstention_or_downgrade": "local_only_no_external_snapshot",
+ }
+ },
+ )
+ product.sensitivity_class = sensitivity
+ product.fresh_precheck_required = False
+ product.stale_after = product.expires_at
+ product.prohibited_actions = ["execution"]
+ product.self_eval.stale_risk = max(product.self_eval.stale_risk, 0.35)
+ return product
+
+
+def generate_external_finance_work_products(
+ *,
+ repo_root: Path,
+ recent_queries: list[str],
+ snapshots: list[ExternalStateSnapshot],
+ max_products: int = 2,
+) -> list[WorkProduct]:
+ if not snapshots:
+ return []
+ now = time.time()
+ strict_expiry = min((snapshot.expires_at for snapshot in snapshots if snapshot.expires_at is not None), default=now + 300)
+ sensitivity = WorkProductSensitivity.PUBLIC_MARKET_ONLY
+ if any(snapshot.sensitivity_class.value in {"position_specific", "order_activity"} for snapshot in snapshots):
+ sensitivity = WorkProductSensitivity.POSITION_SPECIFIC
+ elif any(snapshot.sensitivity_class.value == "account_summary" for snapshot in snapshots):
+ sensitivity = WorkProductSensitivity.ACCOUNT_SUMMARY
+ query_text = " ".join(recent_queries[-5:])
+ capabilities = ", ".join(sorted({snapshot.capability for snapshot in snapshots}))
+ exploration = _finance_exploration_summary(snapshots, query_text)
+ candidate_lines = ""
+ if exploration["kept_candidates"]:
+ bullets = [
+ f"- {item['label']} ({item['capability']}, score {item['score']}): {', '.join(item['reasons'])}"
+ for item in exploration["kept_candidates"]
+ ]
+ candidate_lines = "\n\nCandidate branches kept for review:\n" + "\n".join(bullets)
+ else:
+ candidate_lines = (
+ "\n\nNo comparable candidate list was available from the external snapshots, "
+ "so Vaner abstained from candidate-level ranking."
+ )
+ body = (
+ "Finance preparation from fresh external-state snapshots.\n\n"
+ f"Capabilities observed: {capabilities}.\n\n"
+ "Exploration summary: "
+ f"evaluated {exploration['candidate_count']} candidate records, "
+ f"kept {exploration['kept_candidate_count']}, "
+ f"pruned {exploration['pruned_candidate_count']} lower-evidence branches."
+ f"{candidate_lines}\n\n"
+ "Pruning rules: "
+ + "; ".join(exploration["pruning_rules"])
+ + ".\n\n"
+ "This prepared work is advisory. It ranks branches for risk-adjusted preparation, summarizes read-only provider data, "
+ "and requires a fresh precheck before any action. It is not execution guidance."
+ )
+ kind = (
+ WorkProductType.FINANCE_POSITION_BRIEF
+ if sensitivity in {WorkProductSensitivity.POSITION_SPECIFIC, WorkProductSensitivity.ACCOUNT_SUMMARY}
+ else WorkProductType.FINANCE_SCREENING_RESULT
+ )
+ target_digest = hashlib.sha1( # noqa: S324
+ f"{kind.value}\n{query_text}\n{','.join(snapshot.id for snapshot in snapshots)}".encode()
+ ).hexdigest()[:12]
+ product = WorkProduct(
+ id=_stable_id(kind, f"{kind.value}:external:{target_digest}", body),
+ type=kind,
+ title="Prepared finance snapshot brief",
+ summary="Read-only external finance snapshots prepared for inspection.",
+ body=body,
+ evidence_refs=[
+ WorkProductEvidenceRef(
+ kind="record",
+ reason=f"external-state snapshot for {snapshot.capability}",
+ confidence=0.72,
+ )
+ for snapshot in snapshots
+ ],
+ source_snapshot=build_source_snapshot(
+ repo_root,
+ [],
+ generated_at=now,
+ generator_version=f"{_WORK_PRODUCT_VERSION}.external_finance",
+ ),
+ confidence=0.72,
+ freshness=WorkProductFreshness.FRESH,
+ expires_at=strict_expiry,
+ status=WorkProductStatus.SURFACED,
+ adoptability=WorkProductAdoptability.ADVISORY,
+ provenance={
+ "generator": f"{_WORK_PRODUCT_VERSION}.external_finance",
+ "finance": {
+ "fresh_precheck_required": True,
+ "external_state_enabled": True,
+ "snapshot_count": len(snapshots),
+ "exploration": exploration,
+ },
+ },
+ self_eval=_self_eval(
+ evidence_count=len(snapshots),
+ confidence=0.72,
+ stale_risk=0.45,
+ contradiction_risk=0.12,
+ reason="external finance snapshots decay and remain advisory",
+ ),
+ feedback_state="none",
+ sensitivity_class=sensitivity,
+ fresh_precheck_required=True,
+ external_inputs=[
+ WorkProductExternalInput(
+ provider_id=snapshot.provider_id,
+ capability=snapshot.capability,
+ snapshot_id=snapshot.id,
+ freshness_class=snapshot.freshness_class.value,
+ captured_at=snapshot.captured_at,
+ expires_at=snapshot.expires_at,
+ payload_fingerprint=snapshot.payload_fingerprint,
+ )
+ for snapshot in snapshots
+ ],
+ stale_after=strict_expiry,
+ prohibited_actions=["execution"],
+ created_at=now,
+ updated_at=now,
+ target_key=f"{kind.value}:external:{target_digest}",
+ )
+ return [product][: max(1, int(max_products))]
+
+
def generate_work_products(
*,
repo_root: Path,
@@ -382,4 +758,11 @@ def generate_work_products(
research = None
if research is not None:
products.append(research)
+ if len(products) < max_products:
+ try:
+ finance = _generate_finance_brief(repo_root, recent_queries, artefacts)
+ except Exception:
+ finance = None
+ if finance is not None:
+ products.append(finance)
return products[:max_products]
diff --git a/src/vaner/intent/work_style_priors.py b/src/vaner/intent/work_style_priors.py
index 255c1e58..451f8d47 100644
--- a/src/vaner/intent/work_style_priors.py
+++ b/src/vaner/intent/work_style_priors.py
@@ -161,6 +161,18 @@ class IntentPriorAdjustments:
preferred_artefact_templates=("debugging_brief", "patch_proposal"),
composer_signal_weight_multiplier=1.0,
),
+ "trading": IntentPriorAdjustments(
+ # Trading/finance is preparation-oriented: favour strong evidence,
+ # broad branch pruning, and conservative draft surfacing because
+ # external market/account state can decay quickly.
+ artefact_alignment_weight_multiplier=1.4,
+ long_horizon_bonus_multiplier=1.1,
+ possible_branch_bonus_multiplier=1.4,
+ drafting_evidence_floor=0.6,
+ drafting_volatility_ceiling_multiplier=0.7,
+ preferred_artefact_templates=("finance_morning_brief", "position_brief", "option_strategy_plan"),
+ composer_signal_weight_multiplier=1.2,
+ ),
"general": IntentPriorAdjustments(
# General-purpose: very gentle nudges, no template preference.
artefact_alignment_weight_multiplier=1.1,
diff --git a/src/vaner/mcp/apps/__init__.py b/src/vaner/mcp/apps/__init__.py
index 8e152299..1ffd9c7d 100644
--- a/src/vaner/mcp/apps/__init__.py
+++ b/src/vaner/mcp/apps/__init__.py
@@ -33,8 +33,47 @@
ACTIVE_PREDICTIONS_HTML: str = _ACTIVE_PREDICTIONS_PATH.read_text(encoding="utf-8")
"""Full HTML bundle as a string. Consumers serve this verbatim."""
-PREPARED_WORK_HTML: str = _PREPARED_WORK_PATH.read_text(encoding="utf-8")
-"""Full Prepared Work HTML bundle as a string."""
+
+
+_SDK_START_SENTINEL = "// === @modelcontextprotocol/ext-apps@0.4.0"
+_SDK_END_SENTINEL = ";const App = gc;"
+
+
+def _extract_ext_apps_sdk(html: str) -> str:
+ """Pull the vendored ext-apps SDK + ``const App = gc`` rebind from a bundle.
+
+ ``active_predictions.html`` is the single source of truth for the
+ vendored SDK; other bundles (currently ``prepared_work.html``) inline
+ the same block via a ``__EXT_APPS_SDK__`` placeholder substitution at
+ import time. Keeping one copy avoids drift on re-vendor and means we
+ only ship the ~300KB SDK twice if a host actually loads both bundles.
+ """
+ start = html.find(_SDK_START_SENTINEL)
+ if start < 0:
+ raise RuntimeError("ext-apps SDK start sentinel missing from active_predictions.html")
+ end = html.find(_SDK_END_SENTINEL, start)
+ if end < 0:
+ raise RuntimeError("ext-apps SDK end sentinel missing from active_predictions.html")
+ end += len(_SDK_END_SENTINEL)
+ return html[start:end]
+
+
+EXT_APPS_SDK_JS: str = _extract_ext_apps_sdk(ACTIVE_PREDICTIONS_HTML)
+"""Vendored ``@modelcontextprotocol/ext-apps`` SDK + ``const App = gc`` rebind.
+
+Sliced from ``active_predictions.html`` between the vendor banner and the
+trailing ``const App = gc`` rebinding. Bundles that need the SDK include
+``__EXT_APPS_SDK__`` as a placeholder; :func:`_inline_sdk` replaces it at
+import time.
+"""
+
+
+def _inline_sdk(template: str) -> str:
+ return template.replace("__EXT_APPS_SDK__", EXT_APPS_SDK_JS)
+
+
+PREPARED_WORK_HTML: str = _inline_sdk(_PREPARED_WORK_PATH.read_text(encoding="utf-8"))
+"""Full Prepared Work HTML bundle as a string (SDK inlined at import time)."""
ACTIVE_PREDICTIONS_SHA256: str = hashlib.sha256(ACTIVE_PREDICTIONS_HTML.encode("utf-8")).hexdigest()
"""Stable content hash — useful for cache validation + CSP pinning."""
@@ -50,6 +89,7 @@
"ACTIVE_PREDICTIONS_TITLE",
"ACTIVE_PREDICTIONS_URI",
"CSP_RESOURCE_DOMAINS",
+ "EXT_APPS_SDK_JS",
"PREPARED_WORK_DESCRIPTION",
"PREPARED_WORK_HTML",
"PREPARED_WORK_MIME",
diff --git a/src/vaner/mcp/apps/prepared_work.html b/src/vaner/mcp/apps/prepared_work.html
index ecb2ce1b..603e70b3 100644
--- a/src/vaner/mcp/apps/prepared_work.html
+++ b/src/vaner/mcp/apps/prepared_work.html
@@ -4,6 +4,15 @@
Vaner Prepared Work
+