- 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 { - 1298
pub id: String, - 1299
pub version: String, - 1300
pub api: String, - 1301
#[serde(default)] - 1302
pub provides: Vec<String>, - 1303
#[serde(default)] - 1304
pub renderers: BTreeMap<String, RendererBinding>, - 1305
/// Optional JSON Schema for payload validation. When present, payloads - 1306
/// are validated against it. When absent (plugin skills that don't - 1307
/// declare one), the payload shape is accepted as-is — the plugin's own - 1308
/// renderer owns correctness of its data. - 1309
#[serde(default, skip_serializing_if = "Option::is_none")] - 1310
pub schema: Option<serde_json::Value>, - 1311
/// Declarative requirements that a consumer may merge into its shared - 1312
/// outcome contract when this skill owns the produced semantic type. - 1313
#[serde(default)] - 1314
pub outcome_requirements: Vec<OutcomeRequirementDeclaration>, - 1315
} - 1316
- 1317
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1318
pub struct OutcomeRequirementDeclaration { - 1319
pub id: String, - 1320
pub kind: String, - 1321
pub description: String, - 1322
#[serde(default)] - 1323
pub importance: String, - 1324
} - 1325
- 1326
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1327
pub struct RendererBinding { - 1328
pub renderer: String, - 1329
#[serde(default)] - 1330
pub interactive: bool, - 1331
#[serde(default)] - 1332
pub requires: Vec<String>, - 1333
} - 1334
- 1335
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1336
pub struct StructuredOutput { - 1337
pub semantic_type: String, - 1338
pub schema_version: u16, - 1339
pub skill_id: String, - 1340
pub skill_version: String, - 1341
pub payload: Value, - 1342
} - 1343
- 1344
/// The only model-authored rich block envelope. The host supplies versions - 1345
/// and renderer identity; models supply data, never executable UI or receipts. - 1346
/// - 1347
/// Uses the built-in skill registry for validation. For plugin-contributed - 1348
/// semantic types, use [`parse_fragment_with`]. - 1349
pub fn parse_fragment(source: &str) -> Result<StructuredOutput, SkillError> { - 1350
parse_fragment_with(source, &built_in_skill_registry()) - 1351
} - 1352
- 1353
/// Like [`parse_fragment`] but validates against an arbitrary (possibly - 1354
/// plugin-extended) skill registry. The worker receives the merged registry - 1355
/// through the `DeliveryJob.skill_registry` field. - 1356
pub fn parse_fragment_with( - 1357
source: &str, - 1358
skills: &SkillRegistry, - 1359
) -> Result<StructuredOutput, SkillError> { - 1360
#[derive(Deserialize)] - 1361
#[serde(deny_unknown_fields)] - 1362
struct Fragment { - 1363
semantic_type: String, - 1364
payload: Value, - 1365
} - 1366
let fragment: Fragment = match serde_json::from_str(source) { - 1367
Ok(f) => f, - 1368
Err(err) => { - 1369
if let Ok(value) = toml::from_str::<serde_json::Value>(source) { - 1370
if let (Some(st), Some(p)) = (value.get("semantic_type"), value.get("payload")) { - 1371
Fragment { - 1372
semantic_type: st.as_str().unwrap_or("metric").to_string(), - 1373
payload: p.clone(), - 1374
} - 1375
} else { - 1376
return Err(SkillError::InvalidPayload(err.to_string())); - 1377
} - 1378
} else { - 1379
return Err(SkillError::InvalidPayload(err.to_string())); - 1380
} - 1381
} - 1382
}; - 1383
// Resolve the owning skill by the semantic type it declares, rather than - 1384
// assuming "core" — plugin-contributed types are registered under their - 1385
// own skill id and must validate against their own manifest. - 1386
let (skill_id, skill_version) = skills - 1387
.find_by_type(&fragment.semantic_type) - 1388
.map(|(id, version)| (id.to_string(), version.to_string())) - 1389
.unwrap_or_else(|| ("core".to_string(), "1.0.0".to_string())); - 1390
let output = StructuredOutput { - 1391
semantic_type: fragment.semantic_type, - 1392
payload: fragment.payload, - 1393
schema_version: crate::PRESENTATION_SCHEMA_VERSION, - 1394
skill_id, - 1395
skill_version, - 1396
}; - 1397
skills.validate(&output, "desktop", &[])?; - 1398
Ok(output) - 1399
} - 1400
- 1401
/// Deterministic text projection of validated data. It preserves every supplied - 1402
/// field in an inspectable data appendix rather than trusting a second summary. - 1403
pub fn structured_markdown(output: &StructuredOutput) -> String { - 1404
let p = &output.payload; - 1405
let title = p["title"].as_str().unwrap_or(&output.semantic_type); - 1406
let mut lines = vec![format!("### {title}")]; - 1407
match output.semantic_type.as_str() { - 1408
"metric" => { - 1409
if p.get("label").is_some() && p.get("value").is_some() { - 1410
lines.push(format!( - 1411
"{}: {} {}", - 1412
p["label"].as_str().unwrap_or_default(), - 1413
p["value"] - 1414
.as_str() - 1415
.map(String::from) - 1416
.unwrap_or_else(|| p["value"].to_string()), - 1417
p["unit"].as_str().unwrap_or_default() - 1418
)); - 1419
} else if let Some(obj) = p.as_object() { - 1420
for (k, v) in obj { - 1421
if k == "title" { - 1422
continue; - 1423
} - 1424
let val_str = v - 1425
.as_str() - 1426
.map(String::from) - 1427
.unwrap_or_else(|| v.to_string()); - 1428
lines.push(format!("{k}: {val_str}")); - 1429
} - 1430
} - 1431
} - 1432
"link.preview" => lines.push(format!( - 1433
"{}\n{}", - 1434
title, - 1435
p["url"].as_str().unwrap_or_default() - 1436
)), - 1437
"chart" => lines.push(p["accessible_summary"].as_str().unwrap_or_default().into()), - 1438
"terminal.view" => lines.push(format!( - 1439
"Command: {}\nExit: {}", - 1440
p["command"].as_str().unwrap_or("not supplied"), - 1441
p.get("exit_code") - 1442
.map(Value::to_string) - 1443
.unwrap_or_else(|| "not supplied".into()) - 1444
)), - 1445
"plan.timeline" => { - 1446
if let Some(items) = p["items"].as_array() { - 1447
for item in items.iter().take(100) { - 1448
let label = item["label"].as_str().unwrap_or("Step"); - 1449
let label = match item["time"].as_str() { - 1450
Some(time) => format!("{label} ({time})"), - 1451
None => label.to_string(), - 1452
}; - 1453
let detail = item["detail"].as_str().unwrap_or_default(); - 1454
if detail.is_empty() { - 1455
lines.push(format!("- {label}")); - 1456
} else { - 1457
lines.push(format!("- {label}: {detail}")); - 1458
} - 1459
for option in item["options"].as_array().into_iter().flatten().take(20) { - 1460
let name = option["label"].as_str().unwrap_or("Option"); - 1461
let facts = option["facts"] - 1462
.as_array() - 1463
.map(|facts| { - 1464
facts - 1465
.iter() - 1466
.filter_map(Value::as_str) - 1467
.collect::<Vec<_>>() - 1468
.join(", ") - 1469
}) - 1470
.unwrap_or_default(); - 1471
let text = [ - 1472
option["detail"].as_str().unwrap_or_default(), - 1473
facts.as_str(), - 1474
]
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.