- 298
/// produced), so it cannot be used to notice a missing card. - 299
pub fn intended_outputs( - 300
&self, - 301
signals: &[String], - 302
surface: &str, - 303
) -> Option<IntendedPresentation> { - 304
self.recipes - 305
.iter() - 306
.filter(|recipe| !recipe.default_recipe && !recipe.match_signals.is_empty()) - 307
.filter(|recipe| { - 308
recipe - 309
.match_signals - 310
.iter() - 311
.all(|signal| signals.iter().any(|candidate| candidate == signal)) - 312
}) - 313
.filter(|recipe| recipe.surfaces.iter().any(|item| item == surface)) - 314
.max_by(|left, right| { - 315
(left.priority, left.match_signals.len(), left.id.as_str()).cmp(&( - 316
right.priority, - 317
right.match_signals.len(), - 318
right.id.as_str(), - 319
)) - 320
}) - 321
.map(|recipe| IntendedPresentation { - 322
recipe_id: recipe.id.clone(), - 323
matched_signals: recipe.match_signals.clone(), - 324
primary_types: recipe.primary.clone(), - 325
}) - 326
} - 327
- 328
pub fn choose(&self, signals: &[String], surface: &str) -> Option<PresentationDecision> { - 329
self.choose_for_types(signals, surface, &[]) - 330
} - 331
- 332
pub fn choose_for_types( - 333
&self, - 334
signals: &[String], - 335
surface: &str, - 336
available_types: &[String], - 337
) -> Option<PresentationDecision> { - 338
self.recipes - 339
.iter() - 340
.filter_map(|recipe| { - 341
let matched_signals: Vec<String> = recipe - 342
.match_signals - 343
.iter() - 344
.filter(|signal| signals.iter().any(|candidate| candidate == *signal)) - 345
.cloned() - 346
.collect(); - 347
if matched_signals.len() != recipe.match_signals.len() - 348
&& !recipe.match_signals.is_empty() - 349
{ - 350
return None; - 351
} - 352
let supported_surface = recipe.surfaces.iter().any(|item| item == surface) - 353
|| (recipe.surfaces.is_empty() - 354
&& recipe.fallback.keys().any(|item| item == surface)); - 355
if !supported_surface { - 356
return None; - 357
} - 358
if recipe.requires_typed_output - 359
&& !recipe.typed_output_types.iter().any(|required| { - 360
available_types.iter().any(|candidate| { - 361
type_matches(required, std::slice::from_ref(candidate)) - 362
}) - 363
}) - 364
{ - 365
return None; - 366
} - 367
if !recipe.default_recipe - 368
&& !available_types.is_empty() - 369
&& !recipe - 370
.primary - 371
.iter() - 372
.all(|required| type_matches(required, available_types)) - 373
{ - 374
return None; - 375
} - 376
Some(PresentationDecision { - 377
recipe_id: recipe.id.clone(), - 378
recipe_version: recipe.version.clone(), - 379
priority: recipe.priority, - 380
matched_signals, - 381
// Renderer selection is performed by PresentationPlanner - 382
// from validated skill bindings. Recipes only select - 383
// composition intent. - 384
renderer: "unresolved".into(), - 385
disposition: DecisionDisposition::Unresolved, - 386
requires_typed_output: recipe.requires_typed_output, - 387
typed_output_types: recipe.typed_output_types.clone(), - 388
}) - 389
}) - 390
.max_by(|left, right| { - 391
( - 392
left.priority, - 393
left.matched_signals.len(), - 394
left.recipe_id.as_str(), - 395
left.recipe_version.as_str(), - 396
) - 397
.cmp(&( - 398
right.priority, - 399
right.matched_signals.len(), - 400
right.recipe_id.as_str(), - 401
right.recipe_version.as_str(), - 402
)) - 403
}) - 404
.filter(|decision| { - 405
!(decision.recipe_id == "answer.basic" - 406
&& decision.matched_signals.is_empty() - 407
&& !signals.is_empty()) - 408
}) - 409
} - 410
} - 411
- 412
fn type_matches(required: &str, available: &[String]) -> bool { - 413
let base = required.split('.').next().unwrap_or(required); - 414
available.iter().any(|candidate| { - 415
candidate == required - 416
|| candidate == base - 417
|| (required == "source.card" && candidate == "link.preview") - 418
|| (required == "outcome" && candidate == "research.synthesis") - 419
}) - 420
} - 421
- 422
/// Built-in recipes are deliberately small and composable. Domain skills can - 423
/// register more specific recipes; the generic answer recipe remains the - 424
/// deterministic last-resort composition. - 425
pub fn built_in_recipes() -> RecipeCatalog { - 426
let mut catalog = RecipeCatalog::default(); - 427
for recipe in [ - 428
( - 429
"answer.basic", - 430
vec![], - 431
vec!["outcome"], - 432
vec!["desktop", "terminal", "telegram"], - 433
), - 434
( - 435
"answer.research", - 436
vec!["citations", "multiple_sources"], - 437
vec!["research.synthesis"], - 438
vec!["desktop", "terminal"], - 439
), - 440
( - 441
"research.synthesis", - 442
vec!["research", "synthesis", "takeaways"], - 443
vec!["research.synthesis"], - 444
vec!["desktop", "terminal"], - 445
), - 446
( - 447
"weather.forecast", - 448
vec!["temperature", "forecast"], - 449
vec!["metric"], - 450
vec!["desktop", "terminal", "telegram"], - 451
), - 452
( - 453
"travel.itinerary", - 454
vec!["travel"], - 455
vec!["itinerary"], - 456
vec!["desktop", "terminal", "telegram"], - 457
), - 458
( - 459
"news.synthesis", - 460
vec!["news", "research"], - 461
vec!["research.synthesis"], - 462
vec!["desktop", "terminal", "telegram"], - 463
), - 464
( - 465
"coding.change_summary", - 466
vec!["files_changed"], - 467
vec!["outcome"], - 468
vec!["desktop", "terminal"], - 469
), - 470
( - 471
"coding.diff_inspector", - 472
vec!["diff", "files_changed"], - 473
vec!["coding.diff"], - 474
vec!["desktop", "terminal"], - 475
), - 476
( - 477
"coding.test_report", - 478
vec!["tests", "pass_fail"], - 479
vec!["test.report"], - 480
vec!["desktop", "terminal", "telegram"], - 481
), - 482
( - 483
"terminal.session", - 484
vec!["terminal", "command_exec"], - 485
vec!["terminal.view"], - 486
vec!["desktop", "terminal"], - 487
), - 488
( - 489
"data.multi_chart", - 490
vec!["chart", "telemetry"], - 491
vec!["chart"], - 492
vec!["desktop", "terminal"], - 493
), - 494
( - 495
"data.spreadsheet_grid", - 496
// A markdown table is structurally unambiguous on its own. The - 497
// former second signal (`table_data`, keywords like "dataset" or - 498
// "MRR") made a plain table of index levels or prices — no such - 499
// word — match nothing, so it was never recognised as a grid. - 500
vec!["tabular"], - 501
vec!["data.grid"], - 502
vec!["desktop", "terminal"], - 503
), - 504
( - 505
"lifestyle.culinary_recipe", - 506
vec!["recipe", "ingredients"], - 507
vec!["recipe.card"], - 508
vec!["desktop"], - 509
), - 510
( - 511
"workflow.approval", - 512
vec!["approval", "action"], - 513
vec!["approval"], - 514
vec!["desktop", "telegram"], - 515
), - 516
( - 517
"artifact.collection", - 518
vec!["artifact"], - 519
vec!["artifact.collection"], - 520
vec!["desktop", "terminal", "telegram"], - 521
), - 522
( - 523
"ui.preview", - 524
vec![], - 525
vec!["ui.preview"], - 526
vec!["desktop", "terminal", "telegram"], - 527
), - 528
( - 529
"plan.timeline", - 530
vec!["plan", "timeline"], - 531
vec!["plan.timeline"], - 532
vec!["desktop", "terminal", "telegram"], - 533
), - 534
( - 535
"universal.map", - 536
vec!["location", "coordinates", "route"], - 537
vec!["map"], - 538
vec!["desktop", "terminal", "telegram"], - 539
), - 540
( - 541
"universal.calendar", - 542
vec!["event", "meeting", "availability"], - 543
vec!["calendar"], - 544
vec!["desktop", "terminal", "telegram"], - 545
), - 546
( - 547
"universal.board", - 548
vec!["board", "kanban", "column"], - 549
vec!["board"], - 550
vec!["desktop", "terminal"], - 551
), - 552
( - 553
"universal.entity", - 554
vec!["person", "company", "place", "product"], - 555
vec!["entity"], - 556
vec!["desktop", "terminal", "telegram"], - 557
), - 558
( - 559
"universal.evidence", - 560
vec!["source", "citation", "provenance"], - 561
vec!["evidence"], - 562
vec!["desktop", "terminal", "telegram"], - 563
), - 564
( - 565
"universal.document", - 566
vec!["document", "pdf", "report"], - 567
vec!["document"], - 568
vec!["desktop", "terminal", "telegram"], - 569
), - 570
( - 571
"universal.graph", - 572
vec!["graph", "network", "dependency"], - 573
vec!["graph"], - 574
vec!["desktop", "terminal"], - 575
), - 576
( - 577
"universal.form", - 578
vec!["form", "input", "survey"], - 579
vec!["form"], - 580
vec!["desktop", "terminal"], - 581
), - 582
( - 583
"universal.transaction", - 584
vec!["invoice", "payment", "booking"], - 585
vec!["transaction"], - 586
vec!["desktop", "terminal", "telegram"], - 587
), - 588
( - 589
"universal.alert", - 590
vec!["alert", "warning", "incident"], - 591
vec!["alert"], - 592
vec!["desktop", "terminal", "telegram"], - 593
), - 594
( - 595
"universal.conversation", - 596
vec!["conversation", "thread", "message"], - 597
vec!["conversation"], - 598
vec!["desktop", "terminal", "telegram"], - 599
), - 600
( - 601
"universal.simulation", - 602
vec!["forecast", "scenario", "what_if"], - 603
vec!["simulation"], - 604
vec!["desktop", "terminal"], - 605
), - 606
] { - 607
let requires_typed_output = matches!(recipe.0, "coding.test_report" | "ui.preview"); - 608
let typed_output_types: Vec<String> = match recipe.0 { - 609
"coding.test_report" => vec!["test.report".into()], - 610
"ui.preview" => vec!["ui.preview".into()], - 611
_ => Vec::new(), - 612
}; - 613
let _ = catalog.register(PresentationRecipe { - 614
id: recipe.0.into(), - 615
version: "1.0.0".into(), - 616
owner_skill: None, - 617
priority: 0, - 618
match_signals: recipe.1.into_iter().map(String::from).collect(), - 619
primary: recipe.2.into_iter().map(String::from).collect(), - 620
optional: Vec::new(), - 621
fallback: BTreeMap::new(), - 622
surfaces: recipe.3.into_iter().map(String::from).collect(), - 623
default_recipe: recipe.0 == "answer.basic", - 624
requires_typed_output, - 625
typed_output_types, - 626
}); - 627
} - 628
catalog - 629
} - 630
- 631
pub fn built_in_skill_registry() -> SkillRegistry { - 632
let mut registry = SkillRegistry::default(); - 633
let mut renderers = BTreeMap::new(); - 634
renderers.insert( - 635
"desktop".into(), - 636
RendererBinding { - 637
renderer: "native:structured".into(), - 638
interactive: false, - 639
requires: vec![], - 640
}, - 641
); - 642
renderers.insert( - 643
"terminal".into(), - 644
RendererBinding { - 645
renderer: "builtin:generic".into(), - 646
interactive: false, - 647
requires: vec![], - 648
}, - 649
); - 650
renderers.insert( - 651
"telegram".into(), - 652
RendererBinding { - 653
renderer: "builtin:compact".into(), - 654
interactive: false, - 655
requires: vec![], - 656
}, - 657
); - 658
let _ = registry.register(PresentationSkillManifest { - 659
id: "core".into(), - 660
version: "1.0.0".into(), - 661
api: PRESENTATION_SKILL_API.into(), - 662
provides: vec![ - 663
"link.preview", - 664
"metric", - 665
"chart", - 666
// `chart` synonyms `emit_chart_card` accepts; the client has - 667
// renderers for all three (PresentationRenderer.tsx), and a - 668
// type the registry rejects degrades a valid card to fallback - 669
// text. - 670
"trend", - 671
"timeseries", - 672
"bar_chart", - 673
// Same gap, same shape: the client (PresentationRenderer.tsx - 674
// STRUCTURED_RENDERERS) has had renderers for these ten since - 675
// commits 85408ebc and 1d34cd6b ("support all 62+ outcome - 676
// render types") but this registry was never updated alongside - 677
// them, so every one of them was rejected by - 678
// SkillRegistry::validate() with UnknownType before ever - 679
// reaching that client code — confirmed by diffing - 680
// built_in_semantic_types() against STRUCTURED_RENDERERS' keys - 681
// while building the emit_*_card tool-calling path (2026-09-18, - 682
// see docs/design/67-presentation-renderer-guide.md Step 0). - 683
// None of them need a dedicated validate_payload() arm: each - 684
// shares its shape with an already-registered sibling - 685
// (metric_chart/comparison_chart/telemetry.chart with chart; - 686
// telemetry.metric/weather with metric; decision_matrix/ - 687
// criteria_matrix/tradeoff_analysis with table; - 688
// lifestyle.recipe/lifestyle.culinary_recipe with recipe.card), - 689
// so the shared, permissive fallthrough in validate_payload - 690
// already covers them. - 691
"metric_chart", - 692
"comparison_chart", - 693
"telemetry.chart", - 694
"telemetry.metric", - 695
"weather", - 696
"decision_matrix", - 697
"criteria_matrix", - 698
"tradeoff_analysis", - 699
"lifestyle.recipe", - 700
"lifestyle.culinary_recipe", - 701
"media.image", - 702
"media.video", - 703
"media.audio", - 704
"research.synthesis", - 705
"itinerary", - 706
"coding.diff", - 707
"test.report", - 708
"terminal.view", - 709
"data.grid", - 710
"recipe.card", - 711
"ui.preview", - 712
"plan.timeline", - 713
"overview", - 714
"checklist", - 715
"schedule", - 716
"comparison", - 717
"decision", - 718
"budget", - 719
"collection", - 720
"detail", - 721
"steps", - 722
"summary", - 723
"notes", - 724
"agenda", - 725
"follow_up", - 726
"reminder", - 727
"comparison_table", - 728
"pros_cons", - 729
"scorecard", - 730
"milestones", - 731
"progress", - 732
"status", - 733
"inventory", - 734
"shopping_list", - 735
"meal_plan", - 736
"lesson", - 737
"reading_list", - 738
"habit_plan", - 739
"project_plan", - 740
"meeting_notes", - 741
"contact_log", - 742
"finance_summary", - 743
"invoice_summary", - 744
"recipe_summary", - 745
"travel_options", - 746
"home_project", - 747
"care_plan", - 748
"event_plan", - 749
"media_list", - 750
"research_brief", - 751
"faq", - 752
"timeline", - 753
"table", - 754
"dataframe", - 755
"recipe", - 756
"news", - 757
"coding.deployment", - 758
"coding.incident", - 759
"coding.benchmark", - 760
"coding.architecture", - 761
"coding.dependencies", - 762
"coding.release", - 763
"coding.search", - 764
"map", - 765
"route_map", - 766
"calendar", - 767
"availability", - 768
"board", - 769
"entity", - 770
"search_results", - 771
"evidence", - 772
"decision_analysis", - 773
"document", - 774
"graph", - 775
"form", - 776
"action", - 777
"transaction", - 778
"alert", - 779
"conversation", - 780
"progress_dashboard", - 781
"simulation", - 782
] - 783
.into_iter() - 784
.map(String::from) - 785
.collect(), - 786
renderers, - 787
schema: None, - 788
outcome_requirements: Vec::new(), - 789
}); - 790
registry - 791
} - 792
- 793
/// Every `semantic_type` the built-in registry will accept, sorted and - 794
/// de-duplicated. This is the compiled-in vocabulary a model may legally - 795
/// emit in a ```vak block; prompt building reads it so the advertised - 796
/// catalogue cannot drift from what the server actually validates. - 797
pub fn built_in_semantic_types() -> Vec<String> { - 798
let mut types: Vec<String> = built_in_skill_registry() - 799
.skills - 800
.values() - 801
.flat_map(|skill| skill.provides.iter().cloned()) - 802
.collect(); - 803
types.sort(); - 804
types.dedup(); - 805
types - 806
} - 807
- 808
pub fn signals_from_text(text: &str) -> Vec<String> { - 809
let lower = text.to_ascii_lowercase(); - 810
let checks = [ - 811
( - 812
"citations", - 813
&["http://", "https://", "source:", "sources:"][..], - 814
), - 815
( - 816
"multiple_sources", - 817
&["sources:", "according to", "references"][..], - 818
), - 819
( - 820
"research", - 821
&[ - 822
"research", - 823
"key takeaways", - 824
"findings", - 825
"research synthesis", - 826
"verified sources", - 827
][..], - 828
), - 829
( - 830
"synthesis", - 831
&["synthesis", "key takeaways", "findings", "verified sources"][..], - 832
), - 833
( - 834
"takeaways", - 835
&[ - 836
"key takeaways", - 837
"takeaways", - 838
"findings", - 839
"research synthesis", - 840
][..], - 841
), - 842
("temperature", &["temperature", "°c", "°f"][..]), - 843
("forecast", &["forecast", "humidity", "wind speed"][..]), - 844
( - 845
"travel", - 846
&["travel", "trip", "itinerary", "flight", "hotel", "vacation"][..], - 847
), - 848
( - 849
"news", - 850
&[ - 851
"news", - 852
"latest", - 853
"headlines", - 854
"what happened", - 855
"current events", - 856
][..], - 857
), - 858
( - 859
"files_changed", - 860
&["files changed", "modified:", "created:"][..], - 861
), - 862
("tests", &["tests", "test suite", "test report"][..]), - 863
("pass_fail", &["passed", "failed", "failures"][..]), - 864
( - 865
"benchmark", - 866
&[ - 867
"benchmark", - 868
"req/sec", - 869
"req/s", - 870
"p99", - 871
"throughput", - 872
"concurrency", - 873
][..], - 874
), - 875
( - 876
"chart", - 877
&["chart", "plot", "graph", "trend", "time series"][..], - 878
), - 879
( - 880
"telemetry", - 881
&[ - 882
"telemetry", - 883
"metrics", - 884
"kpi", - 885
"latency", - 886
"req/sec", - 887
"req/s", - 888
"p99", - 889
"throughput", - 890
][..], - 891
), - 892
( - 893
"table_data", - 894
&[ - 895
"table", - 896
"dataset", - 897
"mrr", - 898
"active orgs", - 899
"revenue breakdown", - 900
][..], - 901
), - 902
( - 903
"recipe", - 904
&["recipe", "servings", "cook time", "prep time", "baste"][..], - 905
), - 906
( - 907
"ingredients", - 908
&[ - 909
"ingredients", - 910
"tbsp", - 911
"tsp", - 912
"fillet", - 913
"tablespoon", - 914
"teaspoon", - 915
"cups", - 916
][..], - 917
), - 918
("artifact", &["artifact", "download", "generated file"][..]), - 919
("approval", &["approval", "approve", "permission"][..]), - 920
("action", &["allow once", "deny", "run this"][..]), - 921
( - 922
"terminal", - 923
&["docker ps", "kubectl", "exit 0", "exit 1", "command line"][..], - 924
), - 925
( - 926
"command_exec", - 927
&["$ ", "user@", "exit 0", "exit 1", "stdout:", "stderr:"][..], - 928
), - 929
( - 930
"docker", - 931
&["docker", "container", "microservices", "ports"][..], - 932
), - 933
]; - 934
let mut signals: Vec<String> = checks - 935
.iter() - 936
.filter_map(|(signal, needles)| { - 937
needles - 938
.iter() - 939
.any(|needle| signal_text_hit(&lower, needle)) - 940
.then_some((*signal).into()) - 941
}) - 942
.collect(); - 943
signals.extend(structural_signals(text).into_iter().map(String::from)); - 944
signals - 945
} - 946
- 947
/// Signals about the *structure* of a piece of Markdown, read from the parsed - 948
/// document rather than from characters in the raw text: a table is a table - 949
/// block, a diff is a code block in diff form. (These used to be substring - 950
/// hits on `| ---`, a fenced ```` ```diff ```` marker and a literal tab, which - 951
/// miss an indented table, match a table inside a code sample, and cannot tell - 952
/// a real tab-separated block from one stray tab.) - 953
fn structural_signals(text: &str) -> Vec<&'static str> { - 954
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; - 955
let mut signals = Vec::new(); - 956
let mut code: Option<(bool, String)> = None; // (declared diff/patch, body) - 957
for event in Parser::new_ext(text, Options::ENABLE_TABLES) { - 958
match event { - 959
Event::Start(Tag::Table(_)) => signals.push("tabular"), - 960
Event::Start(Tag::CodeBlock(kind)) => { - 961
let declared_diff = matches!(&kind, CodeBlockKind::Fenced(info) - 962
if matches!(info.split_whitespace().next(), Some("diff" | "patch"))); - 963
code = Some((declared_diff, String::new())); - 964
} - 965
Event::Text(chunk) => { - 966
if let Some((_, body)) = code.as_mut() { - 967
body.push_str(&chunk); - 968
} - 969
} - 970
Event::End(TagEnd::CodeBlock) => { - 971
if let Some((declared_diff, body)) = code.take() { - 972
let first = body.lines().next().unwrap_or_default(); - 973
if declared_diff || first.starts_with("diff --git") || first.starts_with("@@ ") - 974
{ - 975
signals.push("diff"); - 976
} - 977
if is_tab_separated(&body) { - 978
signals.push("tabular"); - 979
} - 980
} - 981
} - 982
_ => {} - 983
} - 984
} - 985
if is_tab_separated(text) { - 986
signals.push("tabular"); - 987
} - 988
// An unfenced unified diff is still a diff: a `diff --git` header line or a - 989
// hunk header (`@@ -1,2 +1,3 @@`) at the start of a line. - 990
if text - 991
.lines() - 992
.any(|line| line.starts_with("diff --git ") || is_hunk_header(line)) - 993
{ - 994
signals.push("diff"); - 995
} - 996
signals - 997
} - 998
- 999
/// `@@ -a[,b] +c[,d] @@` at the start of a line. - 1000
fn is_hunk_header(line: &str) -> bool { - 1001
let Some(rest) = line.strip_prefix("@@ -") else { - 1002
return false; - 1003
}; - 1004
let mut parts = rest.splitn(2, " +"); - 1005
let (old, new) = ( - 1006
parts.next().unwrap_or_default(), - 1007
parts.next().unwrap_or_default(), - 1008
); - 1009
let range = |text: &str| { - 1010
let text = text.split(" @@").next().unwrap_or_default(); - 1011
!text.is_empty() - 1012
&& text - 1013
.split(',') - 1014
.all(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) - 1015
}; - 1016
range(old) && range(new) && line.contains(" @@") - 1017
} - 1018
- 1019
/// Two or more consecutive lines that each split into the same number (at - 1020
/// least two) of tab-separated fields — a pasted spreadsheet range — as - 1021
/// opposed to a stray tab in prose. - 1022
fn is_tab_separated(text: &str) -> bool { - 1023
let mut previous: Option<usize> = None; - 1024
let mut run = 0; - 1025
for line in text.lines() { - 1026
let fields = line.split('\t').count(); - 1027
if fields >= 2 && previous.is_none_or(|p| p == fields) { - 1028
run += 1; - 1029
previous = Some(fields); - 1030
if run >= 2 { - 1031
return true; - 1032
} - 1033
} else { - 1034
run = usize::from(fields >= 2); - 1035
previous = (fields >= 2).then_some(fields); - 1036
} - 1037
} - 1038
false - 1039
} - 1040
- 1041
/// Match standalone lexical terms without allowing incidental substrings in - 1042
/// ordinary prose. Compound phrases retain substring matching because their - 1043
/// spaces provide the boundary. Needles that themselves contain non-word - 1044
/// boundary characters (e.g. `https://`, `source:`, `°c`) fall back to - 1045
/// substring matching, since tokenization would strip those characters and - 1046
/// make a match impossible. Presentation signals are hints only; typed - 1047
/// result provenance remains the authority for specialized recipes. - 1048
fn signal_text_hit(lower: &str, needle: &str) -> bool { - 1049
if needle.chars().any(char::is_whitespace) - 1050
|| needle.chars().any(|c| !c.is_alphanumeric() && c != '_') - 1051
{ - 1052
return lower.contains(needle); - 1053
} - 1054
lower - 1055
.split(|character: char| !character.is_alphanumeric() && character != '_') - 1056
.any(|token| token == needle) - 1057
} - 1058
- 1059
#[derive(Debug, Clone, Default)] - 1060
pub struct SignalContext<'a> { - 1061
pub text: &'a str, - 1062
pub tool_name: Option<&'a str>, - 1063
pub tool_input: Option<&'a serde_json::Value>, - 1064
pub tool_output: Option<&'a str>, - 1065
pub is_error: bool, - 1066
/// Domain names the calling capability declares it serves (e.g. `"web"`, - 1067
/// `"live-data"`), as recorded by `vak_core::capability::domain::Domain`. - 1068
/// Drives presentation signals that depend on external retrieval; a - 1069
/// tool's name or identity is never matched for this. - 1070
pub domains: &'a [&'a str], - 1071
} - 1072
- 1073
pub fn signals_from_context(ctx: &SignalContext<'_>) -> Vec<String> { - 1074
let mut signals = signals_from_text(ctx.text); - 1075
if let Some(tool) = ctx.tool_name { - 1076
match tool { - 1077
"bash" => { - 1078
signals.push("terminal".into()); - 1079
signals.push("command_exec".into()); - 1080
if let Some(input) = ctx.tool_input { - 1081
let cmd = input - 1082
.get("command") - 1083
.or_else(|| input.get("cmd")) - 1084
.and_then(|v| v.as_str()) - 1085
.unwrap_or(""); - 1086
let lower = cmd.to_ascii_lowercase(); - 1087
if lower.contains("test") - 1088
|| lower.contains("pytest") - 1089
|| lower.contains("cargo test") - 1090
|| lower.contains("npm test") - 1091
{ - 1092
signals.push("tests".into()); - 1093
signals.push("pass_fail".into()); - 1094
} - 1095
if lower.contains("docker") || lower.contains("kubectl") { - 1096
signals.push("docker".into()); - 1097
signals.push("terminal".into()); - 1098
} - 1099
if lower.contains("diff") || lower.contains("git diff") { - 1100
signals.push("diff".into()); - 1101
signals.push("files_changed".into()); - 1102
} - 1103
} - 1104
} - 1105
"edit" | "write" | "apply_patch" => { - 1106
signals.push("diff".into()); - 1107
signals.push("files_changed".into()); - 1108
} - 1109
_ => {} - 1110
} - 1111
} - 1112
// Retrieval-flavoured presentation signals come from what the calling - 1113
// capability declares it serves, never from the tool's name: a vendor - 1114
// renamed or replaced still serves `web`/`live-data`, and an - 1115
// undeclared capability contributes nothing here rather than being - 1116
// guessed at. - 1117
if ctx - 1118
.domains - 1119
.iter() - 1120
.any(|domain| matches!(*domain, "web" | "live-data")) - 1121
{ - 1122
signals.push("citations".into()); - 1123
signals.push("multiple_sources".into()); - 1124
signals.push("research".into()); - 1125
signals.push("synthesis".into()); - 1126
signals.push("takeaways".into()); - 1127
} - 1128
if let Some(output) = ctx.tool_output { - 1129
let lower = output.to_ascii_lowercase(); - 1130
if lower.contains("test result:") - 1131
|| lower.contains("tests passed") - 1132
|| lower.contains("failures:") - 1133
{ - 1134
signals.push("tests".into()); - 1135
signals.push("pass_fail".into()); - 1136
} - 1137
if lower.contains("diff --git") || lower.contains("@@ ") { - 1138
signals.push("diff".into()); - 1139
signals.push("files_changed".into()); - 1140
} - 1141
} - 1142
signals.sort(); - 1143
signals.dedup(); - 1144
signals - 1145
} - 1146
- 1147
pub fn link_previews_from_text(text: &str) -> Vec<StructuredOutput> { - 1148
let mut seen = std::collections::BTreeSet::new(); - 1149
text.split_whitespace() - 1150
.filter_map(|token| { - 1151
let url = token.trim_matches(|character: char| "()[]{}<>.,;\"'".contains(character)); - 1152
(url.starts_with("https://") || url.starts_with("http://")).then(|| url.to_string()) - 1153
}) - 1154
.filter(|url| seen.insert(url.clone())) - 1155
.take(12) - 1156
.map(|url| StructuredOutput { - 1157
semantic_type: "link.preview".into(), - 1158
schema_version: crate::PRESENTATION_SCHEMA_VERSION, - 1159
skill_id: "core".into(), - 1160
skill_version: "1.0.0".into(), - 1161
payload: serde_json::json!({"url": url, "title": "Open source link"}), - 1162
}) - 1163
.collect() - 1164
} - 1165
- 1166
/// Extract explicitly typed `vak` fragments from model/tool text. This is - 1167
/// intentionally domain-neutral: the registry, not this parser, decides what - 1168
/// semantic types exist and whether a surface may render them. - 1169
/// - 1170
/// A tool result is usually not Markdown at all — it's just the JSON its - 1171
/// implementation returned. Requiring a ```vak fence around it would force - 1172
/// every tool wrapper to know about Markdown just to be found here, which is - 1173
/// its own kind of special-casing. So the *whole* text is tried as one bare - 1174
/// `{"semantic_type": ..., "payload": ...}` envelope first — the identical - 1175
/// two-field contract a fenced fragment uses, just without the fence. Any - 1176
/// tool, or any future one, opts in the same way a model does: declare what - 1177
/// it produced. Nothing here inspects a tool's name or its payload's field - 1178
/// names to guess a type. - 1179
pub fn structured_outputs_from_text(text: &str) -> Vec<StructuredOutput> { - 1180
structured_outputs_from_text_with(text, &built_in_skill_registry()) - 1181
} - 1182
- 1183
/// Locate byte spans `(start, end)` of candidate JSON objects containing - 1184
/// `"semantic_type"` within arbitrary text. - 1185
pub(crate) fn find_semantic_json_spans(text: &str) -> Vec<(usize, usize)> { - 1186
let mut spans = Vec::new(); - 1187
let bytes = text.as_bytes(); - 1188
let len = bytes.len(); - 1189
let marker = b"\"semantic_type\""; - 1190
let marker_len = marker.len(); - 1191
- 1192
let mut search_start = 0; - 1193
while search_start + marker_len <= len { - 1194
let Some(rel_pos) = text[search_start..].find("\"semantic_type\"") else { - 1195
break; - 1196
}; - 1197
let marker_pos = search_start + rel_pos; - 1198
- 1199
let mut candidate = None; - 1200
for i in (0..=marker_pos).rev() { - 1201
if bytes[i] == b'{' { - 1202
let mut depth = 0; - 1203
let mut in_str = false; - 1204
let mut escape = false; - 1205
let mut closed_at = None; - 1206
#[allow(clippy::needless_range_loop)] - 1207
for j in i..len { - 1208
let b = bytes[j]; - 1209
if escape { - 1210
escape = false; - 1211
continue; - 1212
} - 1213
if b == b'\\' && in_str { - 1214
escape = true; - 1215
continue; - 1216
} - 1217
if b == b'"' { - 1218
in_str = !in_str; - 1219
continue; - 1220
} - 1221
if !in_str { - 1222
if b == b'{' { - 1223
depth += 1; - 1224
} else if b == b'}' { - 1225
depth -= 1; - 1226
if depth == 0 { - 1227
closed_at = Some(j); - 1228
break; - 1229
} - 1230
} - 1231
} - 1232
} - 1233
if let Some(end_idx) = closed_at - 1234
&& end_idx >= marker_pos - 1235
{ - 1236
candidate = Some((i, end_idx + 1)); - 1237
} - 1238
} - 1239
} - 1240
- 1241
if let Some((start, end)) = candidate { - 1242
if !spans.iter().any(|(s, e)| *s == start && *e == end) { - 1243
spans.push((start, end)); - 1244
} - 1245
search_start = end; - 1246
} else { - 1247
search_start = marker_pos + marker_len; - 1248
} - 1249
} - 1250
spans - 1251
} - 1252
- 1253
/// Like [`structured_outputs_from_text`] but validates against a - 1254
/// plugin-extended skill registry. The Core passes its merged registry - 1255
/// (builtins + plugin skills) so that plugin-declared semantic types are - 1256
/// recognized in model/tool text. - 1257
pub fn structured_outputs_from_text_with( - 1258
text: &str, - 1259
skills: &SkillRegistry, - 1260
) -> Vec<StructuredOutput> { - 1261
if let Ok(output) = parse_fragment_with(text.trim(), skills) { - 1262
return vec![output]; - 1263
} - 1264
let mut outputs = Vec::new(); - 1265
let mut remainder = text; - 1266
while let Some(start) = remainder.find("```vak") { - 1267
let after = &remainder[start + 6..]; - 1268
let body = if let Some(nl) = after.find('\n') { - 1269
&after[nl + 1..] - 1270
} else { - 1271
after - 1272
}; - 1273
let Some(end) = body.find("```") else { break }; - 1274
if let Ok(output) = parse_fragment_with(&body[..end], skills) - 1275
&& !outputs - 1276
.iter() - 1277
.any(|existing: &StructuredOutput| existing == &output) - 1278
{ - 1279
outputs.push(output); - 1280
} - 1281
remainder = &body[end + 3..]; - 1282
} - 1283
for (start, end) in find_semantic_json_spans(text) { - 1284
let candidate = &text[start..end]; - 1285
if let Ok(output) = parse_fragment_with(candidate, skills) - 1286
&& !outputs - 1287
.iter() - 1288
.any(|existing: &StructuredOutput| existing == &output) - 1289
{ - 1290
outputs.push(output); - 1291
} - 1292
} - 1293
outputs - 1294
} - 1295
- 1296
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1297
pub struct PresentationSkillManifest {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.