@hraness/wordcell 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +14 -12
  2. package/dist/authoring.js +2 -1
  3. package/dist/capture.js +2 -2
  4. package/dist/cli.js +12 -11
  5. package/dist/clip/cli.js +2 -2
  6. package/dist/clip/defuddle-worker.js +87 -2
  7. package/dist/clip/jobs.js +5 -1
  8. package/dist/evaluation-builder.js +5 -4
  9. package/dist/evaluation-kb.js +5 -4
  10. package/dist/graph-authority.js +2 -1
  11. package/dist/graph-percolation.js +3 -2
  12. package/dist/{index-44kw9tr8.js → index-1wccy765.js} +18 -12
  13. package/dist/{index-1er88ckw.js → index-3gc2yk4k.js} +10 -5
  14. package/dist/{index-0eacgvpv.js → index-47893rgw.js} +22 -8
  15. package/dist/{index-mvs6f7ne.js → index-59bcxqee.js} +1 -1
  16. package/dist/{index-1w0sp1q7.js → index-7drercgk.js} +1 -1
  17. package/dist/{index-tcaq1c7f.js → index-e26mdwxz.js} +1 -1
  18. package/dist/{index-6jcz0m1c.js → index-hxyjer3x.js} +52 -1
  19. package/dist/{index-f184qyh1.js → index-hz2wyggk.js} +1 -1
  20. package/dist/{index-5d1rd5v9.js → index-mcpppwbq.js} +2 -2
  21. package/dist/index-qkesh4c6.js +7 -0
  22. package/dist/{index-b0khj0h5.js → index-qt2zwza8.js} +6 -1
  23. package/dist/{index-2fmnttca.js → index-t3abkwk4.js} +1 -1
  24. package/dist/index.js +6 -5
  25. package/dist/portfolio.js +5 -4
  26. package/dist/sdk.js +4 -3
  27. package/dist/{support-1yhv6vm6.js → support-adj7bbge.js} +11 -10
  28. package/dist/url-intelligence.js +3 -2
  29. package/dist/workflows/decision-context.js +5 -4
  30. package/dist/workflows/index.js +5 -4
  31. package/package.json +13 -8
  32. package/skills/wordcell/SKILL.md +1 -1
  33. package/skills/wordcell/references/publish.md +2 -1
  34. package/src/authoring-platform.ts +3 -0
  35. package/src/cli-program.ts +3 -3
  36. package/src/clip/jobs.ts +2 -1
  37. package/src/clip/metadata-search-tool/Cargo.lock +12 -0
  38. package/src/clip/metadata-search-tool/Cargo.toml +3 -0
  39. package/src/clip/metadata-search-tool/runner.ts +4 -1
  40. package/src/clip/metadata-search-tool/src/bing.rs +539 -0
  41. package/src/clip/metadata-search-tool/src/duckduckgo.rs +562 -0
  42. package/src/clip/metadata-search-tool/src/main.rs +263 -24
  43. package/src/clip/metadata-search-tool/src/openalex.rs +557 -0
  44. package/src/clip/metadata-search.ts +22 -4
  45. package/src/clip/url-intelligence.ts +54 -1
  46. package/src/clip/url-metadata.ts +2 -1
  47. package/src/directory-sync.ts +9 -0
  48. package/src/graph-authority.ts +5 -2
  49. package/src/url-intelligence.ts +2 -0
@@ -1,3 +1,7 @@
1
+ mod bing;
2
+ mod openalex;
3
+ mod duckduckgo;
4
+
1
5
  use std::alloc::{GlobalAlloc, Layout, System};
2
6
  use std::io::{self, Read};
3
7
  use std::net::{IpAddr, SocketAddr};
@@ -8,9 +12,7 @@ use std::time::Duration;
8
12
 
9
13
  use metadata_search_engine_rs::{
10
14
  aggregator::aggregate,
11
- engines::{
12
- BraveEngine, DuckDuckGoEngine, SearchEngine, StartpageEngine, YahooEngine,
13
- },
15
+ engines::{BraveEngine, SearchEngine, StartpageEngine, YahooEngine},
14
16
  models::SearchResponse,
15
17
  };
16
18
  use reqwest::{
@@ -18,7 +20,66 @@ use reqwest::{
18
20
  header::{self, HeaderMap, HeaderValue},
19
21
  redirect::Policy,
20
22
  };
21
- use serde::Deserialize;
23
+ use serde::{Deserialize, Serialize};
24
+
25
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
26
+ #[serde(rename_all = "kebab-case")]
27
+ enum FailureCode {
28
+ Timeout,
29
+ Http,
30
+ HttpBody,
31
+ HttpStatus,
32
+ HttpForbidden,
33
+ HttpRateLimited,
34
+ HttpRedirect,
35
+ Parse,
36
+ UnverifiedEmpty,
37
+ Challenge,
38
+ ContentType,
39
+ ContentEncoding,
40
+ BodyLimit,
41
+ BodyEncoding,
42
+ ResultLimit,
43
+ UnrecognizedHtml,
44
+ QueryMismatch,
45
+ }
46
+
47
+ /// Only bounded categorical diagnostics cross the protocol boundary, never error text.
48
+ #[derive(Serialize)]
49
+ struct EngineFailure {
50
+ engine: String,
51
+ code: FailureCode,
52
+ }
53
+
54
+ #[derive(Serialize)]
55
+ struct EngineUsage { engine: String, reported_cost_usd: f64, billing_status: &'static str }
56
+
57
+ #[derive(Serialize)]
58
+ struct DiagnosticResponse {
59
+ #[serde(flatten)]
60
+ response: SearchResponse,
61
+ #[serde(skip_serializing_if = "Option::is_none")]
62
+ engine_failures: Option<Vec<EngineFailure>>,
63
+ #[serde(skip_serializing_if = "Option::is_none")]
64
+ engine_usage: Option<Vec<EngineUsage>>,
65
+ }
66
+
67
+ fn transport_code(error: &reqwest::Error, otherwise: FailureCode) -> FailureCode {
68
+ if error.is_timeout() {
69
+ FailureCode::Timeout
70
+ } else {
71
+ otherwise
72
+ }
73
+ }
74
+
75
+ fn status_code(status: u16) -> FailureCode {
76
+ match status {
77
+ 403 => FailureCode::HttpForbidden,
78
+ 429 => FailureCode::HttpRateLimited,
79
+ 300..=399 => FailureCode::HttpRedirect,
80
+ _ => FailureCode::HttpStatus,
81
+ }
82
+ }
22
83
 
23
84
  const MAX_INPUT_BYTES: u64 = 16 * 1024;
24
85
  const MAX_QUERY_BYTES: usize = 4 * 1024;
@@ -137,12 +198,27 @@ struct EngineHost {
137
198
  #[serde(deny_unknown_fields)]
138
199
  struct SearchRequest {
139
200
  schema_version: u8,
201
+ #[serde(default)]
202
+ diagnostics: bool,
203
+ #[serde(default)]
204
+ bing_rss: bool,
205
+ #[serde(default)]
206
+ openalex_discovery: bool,
140
207
  query: String,
141
208
  max_results: usize,
142
209
  timeout_ms: u64,
143
210
  engine_hosts: Vec<EngineHost>,
144
211
  }
145
212
 
213
+ fn expected_hosts(bing_rss: bool, openalex_discovery: bool) -> Vec<&'static str> {
214
+ let mut hosts = ENGINE_HOSTS.to_vec();
215
+ if bing_rss {
216
+ hosts.push("www.bing.com");
217
+ }
218
+ if openalex_discovery { hosts.push("api.openalex.org"); }
219
+ hosts
220
+ }
221
+
146
222
  fn read_request() -> Result<SearchRequest, &'static str> {
147
223
  let mut bytes = Vec::new();
148
224
  io::stdin()
@@ -165,10 +241,16 @@ fn read_request() -> Result<SearchRequest, &'static str> {
165
241
  if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&request.timeout_ms) {
166
242
  return Err("invalid-timeout");
167
243
  }
168
- if request.engine_hosts.len() != ENGINE_HOSTS.len() {
244
+ validate_engine_hosts(&request)?;
245
+ Ok(request)
246
+ }
247
+
248
+ fn validate_engine_hosts(request: &SearchRequest) -> Result<(), &'static str> {
249
+ let expected_hosts = expected_hosts(request.bing_rss, request.openalex_discovery);
250
+ if request.engine_hosts.len() != expected_hosts.len() {
169
251
  return Err("invalid-engine-hosts");
170
252
  }
171
- for (host, expected) in request.engine_hosts.iter().zip(ENGINE_HOSTS) {
253
+ for (host, expected) in request.engine_hosts.iter().zip(expected_hosts) {
172
254
  if host.hostname != expected
173
255
  || host.addresses.is_empty()
174
256
  || host.addresses.len() > MAX_ENGINE_ADDRESSES
@@ -176,7 +258,7 @@ fn read_request() -> Result<SearchRequest, &'static str> {
176
258
  return Err("invalid-engine-hosts");
177
259
  }
178
260
  }
179
- Ok(request)
261
+ Ok(())
180
262
  }
181
263
 
182
264
  fn fail(code: &'static str) -> ! {
@@ -225,8 +307,7 @@ fn is_private_or_reserved(address: IpAddr) -> bool {
225
307
  IpAddr::V6(address) => {
226
308
  let groups = address.segments();
227
309
  let ipv4_compatible = groups[..6].iter().all(|group| *group == 0);
228
- let ipv4_mapped = groups[..5].iter().all(|group| *group == 0)
229
- && groups[5] == 0xffff;
310
+ let ipv4_mapped = groups[..5].iter().all(|group| *group == 0) && groups[5] == 0xffff;
230
311
  if ipv4_compatible || ipv4_mapped {
231
312
  let high = groups[6];
232
313
  let low = groups[7];
@@ -291,18 +372,12 @@ fn build_pinned_http_client(
291
372
  );
292
373
  headers.insert(
293
374
  header::ACCEPT,
294
- HeaderValue::from_static(
295
- "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
296
- ),
375
+ HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
297
376
  );
298
377
  headers.insert(
299
378
  header::ACCEPT_LANGUAGE,
300
379
  HeaderValue::from_static("en-US,en;q=0.9"),
301
380
  );
302
- headers.insert(
303
- header::ACCEPT_ENCODING,
304
- HeaderValue::from_static("gzip, deflate, br"),
305
- );
306
381
  headers.insert("DNT", HeaderValue::from_static("1"));
307
382
  headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
308
383
  headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
@@ -339,23 +414,60 @@ async fn run() {
339
414
  Ok(request) => request,
340
415
  Err(code) => fail(code),
341
416
  };
342
- let timeout = Duration::from_millis(request.timeout_ms / ENGINE_HOSTS.len() as u64);
417
+ let timeout =
418
+ Duration::from_millis(request.timeout_ms / expected_hosts(request.bing_rss, request.openalex_discovery).len() as u64);
343
419
  let client = Arc::new(
344
- build_pinned_http_client(&request.engine_hosts, timeout)
345
- .unwrap_or_else(|code| fail(code)),
420
+ build_pinned_http_client(&request.engine_hosts, timeout).unwrap_or_else(|code| fail(code)),
346
421
  );
347
422
  let engines: Vec<Arc<dyn SearchEngine>> = vec![
348
- Arc::new(DuckDuckGoEngine::with_timeout(Arc::clone(&client), timeout)),
349
423
  Arc::new(BraveEngine::with_timeout(Arc::clone(&client), timeout)),
350
424
  Arc::new(StartpageEngine::with_timeout(Arc::clone(&client), timeout)),
351
425
  Arc::new(YahooEngine::with_timeout(Arc::clone(&client), timeout)),
352
426
  ];
353
- let engines_queried = engines
427
+ let mut engines_queried = engines
354
428
  .iter()
355
429
  .map(|engine| engine.name().to_string())
356
430
  .collect::<Vec<_>>();
431
+ engines_queried.insert(0, "duckduckgo".to_string());
357
432
  let mut successes = Vec::new();
358
433
  let mut engines_failed = Vec::new();
434
+ let mut engine_failures = Vec::new();
435
+ let mut engine_usage = Vec::new();
436
+ // Optional independent backend runs first within the existing total time budget.
437
+ if request.openalex_discovery {
438
+ engines_queried.insert(0, "openalex".to_string());
439
+ match openalex::search(&client, request.query.trim(), request.max_results, timeout).await {
440
+ Ok(discovery) => {
441
+ if let Some(cost)=discovery.reported_cost_usd { engine_usage.push(EngineUsage {engine:"openalex".into(),reported_cost_usd:cost,billing_status:"not-established"}); }
442
+ successes.push(("openalex".to_string(), discovery.results));
443
+ },
444
+ Err(code) => { engines_failed.push("openalex".to_string()); engine_failures.push(EngineFailure {engine:"openalex".to_string(),code}); }
445
+ }
446
+ }
447
+ if request.bing_rss {
448
+ engines_queried.insert(0, "bing".to_string());
449
+ match bing::search(&client, request.query.trim(), request.max_results, timeout).await {
450
+ Ok(results) => successes.push(("bing".to_string(), results)),
451
+ Err(code) => {
452
+ engines_failed.push("bing".to_string());
453
+ engine_failures.push(EngineFailure {
454
+ engine: "bing".to_string(),
455
+ code,
456
+ });
457
+ }
458
+ }
459
+ }
460
+ match duckduckgo::search(&client, request.query.trim(), request.max_results, timeout).await {
461
+ Ok(results) => successes.push(("duckduckgo".to_string(), results)),
462
+ Err(code) => {
463
+ eprintln!("metadata search engine duckduckgo: {code:?}");
464
+ engines_failed.push("duckduckgo".to_string());
465
+ engine_failures.push(EngineFailure {
466
+ engine: "duckduckgo".to_string(),
467
+ code,
468
+ });
469
+ }
470
+ }
359
471
  // Run one upstream engine at a time so its unbounded internal `text()`
360
472
  // allocation is contained by the process memory ceiling rather than
361
473
  // multiplied across four concurrent responses.
@@ -365,8 +477,30 @@ async fn run() {
365
477
  .search(request.query.trim(), request.max_results)
366
478
  .await
367
479
  {
368
- Ok(results) => successes.push((name, results)),
369
- Err(_) => engines_failed.push(name),
480
+ Ok(results) if !results.is_empty() => successes.push((name, results)),
481
+ Ok(_) => {
482
+ eprintln!("metadata search engine {name}: unverified-empty");
483
+ engine_failures.push(EngineFailure {
484
+ engine: name.clone(),
485
+ code: FailureCode::UnverifiedEmpty,
486
+ });
487
+ engines_failed.push(name);
488
+ }
489
+ Err(error) => {
490
+ use metadata_search_engine_rs::error::EngineError;
491
+ let code = match error {
492
+ EngineError::Timeout { .. } => FailureCode::Timeout,
493
+ EngineError::Http { source, .. } => transport_code(&source, FailureCode::Http),
494
+ EngineError::BadStatus { status, .. } => status_code(status),
495
+ EngineError::ParseFailed { .. } => FailureCode::Parse,
496
+ };
497
+ eprintln!("metadata search engine {name}: {code:?}");
498
+ engine_failures.push(EngineFailure {
499
+ engine: name.clone(),
500
+ code,
501
+ });
502
+ engines_failed.push(name);
503
+ }
370
504
  }
371
505
  }
372
506
  let mut results = aggregate(successes, request.max_results);
@@ -383,7 +517,112 @@ async fn run() {
383
517
  engines_queried,
384
518
  engines_failed,
385
519
  };
386
- if serde_json::to_writer(io::stdout().lock(), &response).is_err() {
520
+ if serde_json::to_writer(
521
+ io::stdout().lock(),
522
+ &DiagnosticResponse {
523
+ response,
524
+ engine_failures: request.diagnostics.then_some(engine_failures),
525
+ engine_usage: (request.diagnostics && !engine_usage.is_empty()).then_some(engine_usage),
526
+ },
527
+ )
528
+ .is_err()
529
+ {
387
530
  fail("stdout-write");
388
531
  }
389
532
  }
533
+
534
+ #[cfg(test)]
535
+ mod diagnostic_tests {
536
+ use super::*;
537
+ #[test]
538
+ fn emits_categorical_diagnostics_inside_search_protocol() {
539
+ let response = DiagnosticResponse {
540
+ response: SearchResponse {
541
+ query: "query".into(),
542
+ results: vec![],
543
+ engines_queried: vec!["brave".into()],
544
+ engines_failed: vec!["brave".into()],
545
+ },
546
+ engine_usage: None,
547
+ engine_failures: Some(vec![EngineFailure {
548
+ engine: "brave".into(),
549
+ code: FailureCode::HttpForbidden,
550
+ }]),
551
+ };
552
+ let value = serde_json::to_value(response).unwrap();
553
+ assert_eq!(
554
+ value["engine_failures"],
555
+ serde_json::json!([{ "engine": "brave", "code": "http-forbidden" }])
556
+ );
557
+ assert_eq!(value.as_object().unwrap().len(), 5);
558
+ assert_eq!(status_code(429), FailureCode::HttpRateLimited);
559
+ assert_eq!(status_code(302), FailureCode::HttpRedirect);
560
+ assert_eq!(status_code(503), FailureCode::HttpStatus);
561
+ }
562
+ #[test]
563
+ fn diagnostics_are_an_explicit_boolean_opt_in() {
564
+ let legacy = serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,"engine_hosts":[]});
565
+ let old: SearchRequest = serde_json::from_value(legacy.clone()).unwrap();
566
+ assert!(!old.diagnostics);
567
+ for requested in [false, true] {
568
+ let mut input = legacy.clone();
569
+ input["diagnostics"] = serde_json::json!(requested);
570
+ let parsed: SearchRequest = serde_json::from_value(input).unwrap();
571
+ let output = DiagnosticResponse {
572
+ response: SearchResponse {
573
+ query: "query".into(),
574
+ results: vec![],
575
+ engines_queried: vec![],
576
+ engines_failed: vec![],
577
+ },
578
+ engine_failures: parsed.diagnostics.then_some(vec![]),
579
+ engine_usage: None,
580
+ };
581
+ let encoded = serde_json::to_value(output).unwrap();
582
+ assert_eq!(
583
+ encoded.as_object().unwrap().len(),
584
+ if requested { 5 } else { 4 }
585
+ );
586
+ assert_eq!(encoded.get("engine_failures").is_some(), requested);
587
+ }
588
+ for invalid in [
589
+ serde_json::json!(null),
590
+ serde_json::json!(1),
591
+ serde_json::json!("true"),
592
+ ] {
593
+ let mut input = legacy.clone();
594
+ input["diagnostics"] = invalid;
595
+ assert!(serde_json::from_value::<SearchRequest>(input).is_err());
596
+ }
597
+ }
598
+ #[test]
599
+ fn optional_bing_preserves_exact_legacy_host_contract() {
600
+ let mut value = serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,
601
+ "engine_hosts": expected_hosts(false, false).into_iter().map(|hostname| serde_json::json!({"hostname":hostname,"addresses":[{"address":"93.184.216.34","family":4}]})).collect::<Vec<_>>() });
602
+ let legacy: SearchRequest = serde_json::from_value(value.clone()).unwrap();
603
+ assert!(!legacy.bing_rss);
604
+ assert!(validate_engine_hosts(&legacy).is_ok());
605
+ value["bing_rss"] = serde_json::json!(true);
606
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
607
+ value["engine_hosts"].as_array_mut().unwrap().push(serde_json::json!({"hostname":"www.bing.com","addresses":[{"address":"93.184.216.34","family":4}]}));
608
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_ok());
609
+ value["bing_rss"] = serde_json::json!(false);
610
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
611
+ value["bing_rss"] = serde_json::json!("true");
612
+ assert!(serde_json::from_value::<SearchRequest>(value).is_err());
613
+ }
614
+ #[test]
615
+ fn optional_openalex_preserves_legacy_hosts_and_boolean_contract() {
616
+ let mut value=serde_json::json!({"schema_version":1,"query":"query","max_results":3,"timeout_ms":15000,
617
+ "engine_hosts":expected_hosts(false,false).into_iter().map(|hostname|serde_json::json!({"hostname":hostname,"addresses":[{"address":"93.184.216.34","family":4}]})).collect::<Vec<_>>()});
618
+ let legacy:SearchRequest=serde_json::from_value(value.clone()).unwrap();assert!(!legacy.openalex_discovery);
619
+ value["openalex_discovery"]=serde_json::json!(true);
620
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
621
+ value["engine_hosts"].as_array_mut().unwrap().push(serde_json::json!({"hostname":"api.openalex.org","addresses":[{"address":"93.184.216.34","family":4}]}));
622
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_ok());
623
+ value["openalex_discovery"]=serde_json::json!(false);
624
+ assert!(validate_engine_hosts(&serde_json::from_value(value.clone()).unwrap()).is_err());
625
+ value["openalex_discovery"]=serde_json::json!("true");assert!(serde_json::from_value::<SearchRequest>(value).is_err());
626
+ }
627
+
628
+ }