- 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() - 1814
}; - 1815
let nonnegative = |value: &Value| value.as_f64().is_some_and(|n| n.is_finite() && n >= 0.0); - 1816
let valid = match semantic_type { - 1817
"link.preview" => strings(payload, &["url", "title"]), - 1818
"metric" => { - 1819
(strings(payload, &["label"]) - 1820
&& object.contains_key("value") - 1821
&& scalar(&payload["value"])) - 1822
|| (object.len() >= 2 && object.values().any(scalar)) - 1823
} - 1824
"media.image" | "media.video" | "media.audio" => { - 1825
strings(payload, &["source", "media_type", "alt"]) - 1826
} - 1827
"chart" => { - 1828
strings(payload, &["accessible_summary"]) - 1829
&& matches!( - 1830
payload["chart_type"].as_str(), - 1831
Some("line" | "bar" | "area") - 1832
) - 1833
&& array(payload, "series").is_some_and(|series| { - 1834
series.iter().all(|s| { - 1835
strings(s, &["name"]) - 1836
&& array(s, "points").is_some_and(|points| { - 1837
points.iter().all(|p| { - 1838
(p["x"].is_number() || p["x"].is_string()) - 1839
&& p.get("y").is_some_and(|y| { - 1840
y.is_null() || y.as_f64().is_some_and(f64::is_finite) - 1841
}) - 1842
}) - 1843
}) - 1844
}) - 1845
}) - 1846
} - 1847
"research.synthesis" => array(payload, "sources").is_some_and(|sources| { - 1848
sources.iter().all(|s| strings(s, &["title", "url"])) - 1849
&& array(payload, "takeaways").is_some_and(|items| { - 1850
items.iter().all(|item| { - 1851
strings(item, &["text"]) - 1852
&& item.get("citation_indices").is_some_and(|indices| { - 1853
indices.as_array().is_some_and(|indices| { - 1854
!indices.is_empty() - 1855
&& indices.iter().all(|i| { - 1856
i.as_u64() - 1857
.is_some_and(|i| i > 0 && i <= sources.len() as u64) - 1858
}) - 1859
}) - 1860
}) - 1861
}) - 1862
}) - 1863
}), - 1864
"itinerary" => array(payload, "items").is_some_and(|items| { - 1865
items.iter().all(|item| { - 1866
strings(item, &["title"]) && item.get("detail").is_none_or(Value::is_string) - 1867
}) - 1868
}), - 1869
"coding.diff" => array(payload, "files").is_some_and(|files| { - 1870
files.iter().all(|file| { - 1871
strings(file, &["filename", "hunks"]) - 1872
&& file["additions"].is_u64() - 1873
&& file["deletions"].is_u64() - 1874
}) - 1875
}), - 1876
"test.report" => array(payload, "tests").is_some_and(|tests| { - 1877
tests.iter().all(|test| { - 1878
strings(test, &["name"]) - 1879
&& matches!( - 1880
test["status"].as_str(), - 1881
Some("passed" | "failed" | "skipped") - 1882
) - 1883
&& test.get("duration_ms").is_none_or(&nonnegative) - 1884
}) && ["total", "passed", "failed", "skipped"].iter().all(|key| { - 1885
payload.get(*key).is_none_or(|count| { - 1886
count.as_u64().is_some_and(|count| { - 1887
count - 1888
== if *key == "total" { - 1889
tests.len() - 1890
} else { - 1891
tests - 1892
.iter() - 1893
.filter(|test| test["status"].as_str() == Some(*key)) - 1894
.count() - 1895
} as u64 - 1896
}) - 1897
}) - 1898
}) - 1899
}), - 1900
"terminal.view" => { - 1901
strings(payload, &["output"]) - 1902
&& payload.get("command").is_none_or(Value::is_string) - 1903
&& payload.get("exit_code").is_none_or(Value::is_i64) - 1904
&& payload.get("duration_ms").is_none_or(&nonnegative) - 1905
} - 1906
"data.grid" => array(payload, "columns").is_some_and(|columns| { - 1907
let keys: std::collections::BTreeSet<_> = - 1908
columns.iter().filter_map(|c| c["key"].as_str()).collect(); - 1909
keys.len() == columns.len() - 1910
&& columns.iter().all(|c| strings(c, &["key", "label"])) - 1911
&& array(payload, "rows").is_some_and(|rows| { - 1912
rows.iter().all(|row| { - 1913
row.as_object().is_some_and(|row| { - 1914
row.iter() - 1915
.all(|(key, value)| keys.contains(key.as_str()) && scalar(value)) - 1916
}) - 1917
}) - 1918
}) - 1919
}), - 1920
"recipe.card" => { - 1921
strings(payload, &["title"]) - 1922
&& payload - 1923
.get("servings") - 1924
.is_none_or(|v| v.as_u64().is_some_and(|v| v > 0 && v <= 10_000)) - 1925
&& array(payload, "ingredients").is_some_and(|items| { - 1926
items.iter().all(|item| { - 1927
item.is_string() - 1928
|| (strings(item, &["name"]) - 1929
&& item.get("amount").is_none_or(&nonnegative) - 1930
&& item.get("unit").is_none_or(Value::is_string)) - 1931
}) - 1932
}) - 1933
&& array(payload, "steps").is_some_and(|steps| { - 1934
steps.iter().all(|step| { - 1935
step.is_string() - 1936
|| (strings(step, &["text"]) - 1937
&& step.get("timer_seconds").is_none_or(|v| { - 1938
v.as_u64().is_some_and(|v| v > 0 && v <= 86_400) - 1939
})) - 1940
}) - 1941
}) - 1942
} - 1943
// The list renderer only reads an array under one of these keys, and - 1944
// only labels an item from one of these fields. A payload that keys - 1945
// its options as, say, `options: [{name, score, pros, cons}]` is not - 1946
// an error anywhere — it simply renders as a raw JSON dump, which is - 1947
// the failure this validator exists to make loud. - 1948
"decision" | "decision_analysis" => [ - 1949
"choices", - 1950
"items", - 1951
"steps", - 1952
"milestones", - 1953
"slots", - 1954
"agenda", - 1955
"tasks", - 1956
"questions", - 1957
"qa", - 1958
"entries", - 1959
] - 1960
.iter() - 1961
.find_map(|key| array(payload, key)) - 1962
.is_some_and(|items| { - 1963
items.iter().all(|item| { - 1964
item.is_string() - 1965
|| item.is_number() - 1966
|| [ - 1967
"label", "title", "name", "question", "task", "text", "choice", "activity", - 1968
] - 1969
.iter() - 1970
.any(|field| item[*field].is_string()) - 1971
}) - 1972
}), - 1973
"plan.timeline" => { - 1974
strings(payload, &["title"]) - 1975
&& array(payload, "items").is_some_and(|items| { - 1976
items.iter().all(|item| { - 1977
strings(item, &["label"]) - 1978
&& item.get("detail").is_none_or(Value::is_string) - 1979
&& item.get("status").is_none_or(Value::is_string) - 1980
&& item.get("time").is_none_or(Value::is_string) - 1981
&& item.get("options").is_none_or(|options| { - 1982
options.as_array().is_some_and(|options| { - 1983
options.iter().all(|option| { - 1984
strings(option, &["label"]) - 1985
&& option.get("detail").is_none_or(Value::is_string) - 1986
&& option.get("facts").is_none_or(|facts| { - 1987
facts.as_array().is_some_and(|facts| { - 1988
facts.iter().all(Value::is_string) - 1989
}) - 1990
}) - 1991
}) - 1992
}) - 1993
}) - 1994
}) - 1995
}) - 1996
} - 1997
_ => return Ok(()), - 1998
}; - 1999
if valid { - 2000
Ok(())
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.