- 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
] - 1475
.into_iter() - 1476
.filter(|part| !part.is_empty()) - 1477
.collect::<Vec<_>>() - 1478
.join(" · "); - 1479
if text.is_empty() { - 1480
lines.push(format!(" - Option: {name}")); - 1481
} else { - 1482
lines.push(format!(" - Option: {name}: {text}")); - 1483
} - 1484
} - 1485
} - 1486
} - 1487
} - 1488
"ui.preview" => lines.push(format!( - 1489
"Preview: {}\nFile: {}", - 1490
title, - 1491
p["artifact_path"].as_str().unwrap_or_default() - 1492
)), - 1493
_ => {} - 1494
} - 1495
let json = serde_json::to_string_pretty(p).unwrap_or_else(|_| p.to_string()); - 1496
let fence = "`".repeat( - 1497
json.split(|c| c != '`') - 1498
.map(str::len) - 1499
.max() - 1500
.unwrap_or(0) - 1501
.max(2) - 1502
+ 1, - 1503
); - 1504
lines.push(format!("{fence}json\n{json}\n{fence}")); - 1505
lines.join("\n\n") - 1506
} - 1507
- 1508
/// Replace validated rich fences in-place with their deterministic text - 1509
/// projection. Ordinary Markdown and invalid/incomplete fences stay exact. - 1510
/// - 1511
/// Uses the built-in skill registry. For plugin-contributed semantic types, - 1512
/// use [`project_structured_fences_with`]. - 1513
pub fn project_structured_fences(source: &str) -> String { - 1514
project_structured_fences_with(source, &built_in_skill_registry()) - 1515
} - 1516
- 1517
/// Like [`project_structured_fences`] but validates against an arbitrary - 1518
/// (possibly plugin-extended) skill registry. Called by `render_content` - 1519
/// with the `DeliveryJob.skill_registry` field (the Core's merged registry) - 1520
/// when available, falling back to builtins in the worker. - 1521
pub fn project_structured_fences_with(source: &str, skills: &SkillRegistry) -> String { - 1522
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; - 1523
let mut replacements = Vec::new(); - 1524
let mut pending: Option<(usize, String)> = None; - 1525
for (event, range) in Parser::new(source).into_offset_iter() { - 1526
match event { - 1527
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(language))) - 1528
if language.as_ref() == "vak" => - 1529
{ - 1530
pending = Some((range.start, String::new())); - 1531
} - 1532
Event::Text(text) => { - 1533
if let Some((_, content)) = &mut pending { - 1534
content.push_str(&text); - 1535
} - 1536
} - 1537
Event::End(TagEnd::CodeBlock) => { - 1538
if let Some((start, content)) = pending.take() - 1539
&& let Ok(output) = parse_fragment_with(&content, skills) - 1540
{ - 1541
replacements.push((start..range.end, structured_markdown(&output))); - 1542
} - 1543
} - 1544
_ => {} - 1545
} - 1546
} - 1547
let mut result = source.to_string(); - 1548
for (range, replacement) in replacements.into_iter().rev() { - 1549
result.replace_range(range, &replacement); - 1550
} - 1551
result - 1552
} - 1553
- 1554
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1555
pub struct LinkPreview { - 1556
pub url: String, - 1557
pub canonical_url: Option<String>, - 1558
pub title: String, - 1559
pub description: Option<String>, - 1560
pub site_name: Option<String>, - 1561
pub image_url: Option<String>, - 1562
pub icon_url: Option<String>, - 1563
pub media_type: Option<String>, - 1564
pub published_at: Option<String>, - 1565
} - 1566
- 1567
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1568
pub struct Metric { - 1569
pub label: String, - 1570
pub value: Value, - 1571
pub unit: Option<String>, - 1572
pub change: Option<f64>, - 1573
pub trend: Option<Vec<f64>>, - 1574
} - 1575
- 1576
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1577
pub struct MediaOutput { - 1578
pub source: String, - 1579
pub media_type: String, - 1580
pub alt: String, - 1581
pub thumbnail: Option<String>, - 1582
pub width: Option<u32>, - 1583
pub height: Option<u32>, - 1584
pub duration_ms: Option<u64>, - 1585
} - 1586
- 1587
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1588
pub struct ChartOutput { - 1589
pub chart_type: String, - 1590
pub title: Option<String>, - 1591
pub x_label: Option<String>, - 1592
pub y_label: Option<String>, - 1593
pub series: Vec<ChartSeries>, - 1594
pub accessible_summary: String, - 1595
} - 1596
- 1597
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1598
pub struct ChartSeries { - 1599
pub name: String, - 1600
pub points: Vec<ChartPoint>, - 1601
} - 1602
- 1603
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 1604
pub struct ChartPoint { - 1605
pub x: Value, - 1606
pub y: f64, - 1607
} - 1608
- 1609
#[derive(Debug, Clone, PartialEq, Eq)] - 1610
pub enum SkillError { - 1611
InvalidManifest(String), - 1612
UnknownType(String), - 1613
InvalidPayload(String), - 1614
MissingCapability(String), - 1615
} - 1616
- 1617
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] - 1618
pub struct SkillRegistry { - 1619
pub skills: BTreeMap<String, PresentationSkillManifest>, - 1620
} - 1621
- 1622
impl SkillRegistry { - 1623
pub fn register(&mut self, manifest: PresentationSkillManifest) -> Result<(), SkillError> { - 1624
if manifest.id.trim().is_empty() || manifest.version.trim().is_empty() { - 1625
return Err(SkillError::InvalidManifest( - 1626
"id and version are required".into(), - 1627
)); - 1628
} - 1629
if manifest.api != PRESENTATION_SKILL_API { - 1630
return Err(SkillError::InvalidManifest(format!( - 1631
"unsupported API {}", - 1632
manifest.api - 1633
))); - 1634
} - 1635
if manifest.provides.iter().any(|kind| kind.trim().is_empty()) { - 1636
return Err(SkillError::InvalidManifest("empty semantic type".into())); - 1637
} - 1638
if manifest.outcome_requirements.iter().any(|requirement| { - 1639
requirement.id.trim().is_empty() - 1640
|| requirement.kind.trim().is_empty() - 1641
|| requirement.description.trim().is_empty() - 1642
}) { - 1643
return Err(SkillError::InvalidManifest( - 1644
"outcome requirements need id, kind, and description".into(), - 1645
)); - 1646
} - 1647
if self.skills.iter().any(|(id, existing)| { - 1648
id != &manifest.id - 1649
&& manifest - 1650
.provides - 1651
.iter() - 1652
.any(|kind| existing.provides.iter().any(|owned| owned == kind)) - 1653
}) { - 1654
return Err(SkillError::InvalidManifest( - 1655
"semantic type is already owned by another skill".into(), - 1656
)); - 1657
} - 1658
self.skills.insert(manifest.id.clone(), manifest); - 1659
Ok(()) - 1660
} - 1661
- 1662
/// Return plugin-declared requirements for a validated semantic type. - 1663
/// Declarations are data only; the runtime still evaluates them and - 1664
/// applies its own permission and evidence rules. - 1665
pub fn outcome_requirements_for_type( - 1666
&self, - 1667
semantic_type: &str, - 1668
) -> Vec<&OutcomeRequirementDeclaration> { - 1669
self.skills - 1670
.values() - 1671
.filter(|skill| skill.provides.iter().any(|kind| kind == semantic_type)) - 1672
.flat_map(|skill| skill.outcome_requirements.iter()) - 1673
.collect() - 1674
} - 1675
- 1676
/// Merge plugin-contributed skill manifests into this registry. - 1677
/// Plugins declare presentation files in their manifest `components.presentation` - 1678
/// list. Each file is a JSON-encoded `PresentationSkillManifest` or - 1679
/// `PresentationRecipe`. Unknown files are silently skipped (a plugin - 1680
/// manifest can declare files for other systems). - 1681
pub fn merge_plugin_files(&mut self, files: &[(String, Vec<u8>)]) { - 1682
for (_path, bytes) in files { - 1683
if let Ok(json) = std::str::from_utf8(bytes) - 1684
&& let Ok(manifest) = serde_json::from_str::<PresentationSkillManifest>(json) - 1685
{ - 1686
let _ = self.register(manifest); - 1687
} - 1688
} - 1689
} - 1690
- 1691
pub fn get(&self, id: &str) -> Option<&PresentationSkillManifest> { - 1692
self.skills.get(id) - 1693
} - 1694
- 1695
/// Find the skill that declares a given semantic type. Returns the skill's - 1696
/// `(id, version)` so `parse_fragment_with` can attribute an output to - 1697
/// its real owner rather than the hardcoded `"core"` fallback. - 1698
pub fn find_by_type(&self, semantic_type: &str) -> Option<(&String, &String)> { - 1699
self.skills - 1700
.values() - 1701
.find(|skill| skill.provides.iter().any(|kind| kind == semantic_type)) - 1702
.map(|skill| (&skill.id, &skill.version)) - 1703
} - 1704
- 1705
pub fn validate( - 1706
&self, - 1707
output: &StructuredOutput, - 1708
surface: &str, - 1709
capabilities: &[String], - 1710
) -> Result<&RendererBinding, SkillError> { - 1711
let skill = self - 1712
.get(&output.skill_id) - 1713
.ok_or_else(|| SkillError::UnknownType(output.skill_id.clone()))?; - 1714
if output.schema_version != crate::PRESENTATION_SCHEMA_VERSION - 1715
|| output.skill_version != skill.version - 1716
{ - 1717
return Err(SkillError::InvalidPayload( - 1718
"unsupported structured output version".into(), - 1719
)); - 1720
} - 1721
if !skill - 1722
.provides - 1723
.iter() - 1724
.any(|kind| kind == &output.semantic_type) - 1725
{ - 1726
return Err(SkillError::UnknownType(output.semantic_type.clone())); - 1727
} - 1728
let renderer = skill - 1729
.renderers - 1730
.get(surface) - 1731
.ok_or_else(|| SkillError::MissingCapability(surface.into()))?; - 1732
for required in &renderer.requires { - 1733
if !capabilities.iter().any(|capability| capability == required) { - 1734
return Err(SkillError::MissingCapability(required.clone())); - 1735
} - 1736
} - 1737
validate_payload( - 1738
&output.semantic_type, - 1739
&output.payload, - 1740
skill.schema.as_ref(), - 1741
)?; - 1742
Ok(renderer) - 1743
} - 1744
} - 1745
- 1746
/// Validate a payload value against a minimal JSON Schema subset - 1747
/// (`type`, `required`). This avoids pulling in a schema crate; plugin - 1748
/// manifests that need richer validation declare their own renderer. - 1749
fn validate_against_schema(payload: &Value, schema: &Value) -> Result<(), SkillError> { - 1750
let Some(schema_obj) = schema.as_object() else { - 1751
return Err(SkillError::InvalidPayload( - 1752
"plugin schema must be a JSON object".into(), - 1753
)); - 1754
}; - 1755
if let Some(expected) = schema_obj.get("type").and_then(|t| t.as_str()) { - 1756
let matches = match expected { - 1757
"object" => payload.is_object(), - 1758
"array" => payload.is_array(), - 1759
"string" => payload.is_string(), - 1760
"number" => payload.is_number(), - 1761
"boolean" => payload.is_boolean(), - 1762
"null" => payload.is_null(), - 1763
_ => true, - 1764
}; - 1765
if !matches { - 1766
return Err(SkillError::InvalidPayload(format!( - 1767
"payload is not a {expected}" - 1768
))); - 1769
} - 1770
} - 1771
if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) { - 1772
let Some(obj) = payload.as_object() else { - 1773
return Err(SkillError::InvalidPayload( - 1774
"payload must be an object for required check".into(), - 1775
)); - 1776
}; - 1777
for key in required.iter().flat_map(|k| k.as_str()) { - 1778
if !obj.contains_key(key) { - 1779
return Err(SkillError::InvalidPayload(format!( - 1780
"missing required field: {key}" - 1781
))); - 1782
} - 1783
} - 1784
} - 1785
Ok(()) - 1786
} - 1787
- 1788
fn validate_payload( - 1789
semantic_type: &str, - 1790
payload: &Value, - 1791
schema: Option<&Value>, - 1792
) -> Result<(), SkillError> { - 1793
if payload.to_string().len() > 1_000_000 { - 1794
return Err(SkillError::InvalidPayload("payload exceeds 1 MB".into())); - 1795
} - 1796
let object = payload.as_object(); - 1797
if let Some(object) = object { - 1798
validate_context(object)?; - 1799
} - 1800
// Plugin-declared schema takes precedence: if the skill ships a JSON - 1801
// Schema, validate against it. If not, fall through to the built-in - 1802
// type-by-type validators for known core types. - 1803
if let Some(schema) = schema { - 1804
return validate_against_schema(payload, schema); - 1805
} - 1806
let object = - 1807
object.ok_or_else(|| SkillError::InvalidPayload("payload must be an object".into()))?; - 1808
let strings = |value: &Value, keys: &[&str]| keys.iter().all(|key| value[*key].is_string()); - 1809
fn array<'a>(value: &'a Value, key: &str) -> Option<&'a Vec<Value>> { - 1810
value[key].as_array().filter(|items| items.len() <= 10_000) - 1811
} - 1812
let scalar = |value: &Value| { - 1813
value.is_null() || value.is_string() || value.is_number() || value.is_boolean()
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.