@hraness/wordcell 0.25.0 → 0.25.1
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.
package/README.md
CHANGED
|
@@ -54,14 +54,14 @@ Wordcell keeps the record in Markdown files you own, rebuilds every index from t
|
|
|
54
54
|
The CLI and TypeScript SDK run with Bun. Install the versioned GitHub archive:
|
|
55
55
|
|
|
56
56
|
```sh
|
|
57
|
-
bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.
|
|
57
|
+
bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.1/hraness-wordcell-0.25.1.tgz
|
|
58
58
|
wordcell --help
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Prefer npm? The same release is [mirrored there](https://www.npmjs.com/package/@hraness/wordcell):
|
|
62
62
|
|
|
63
63
|
```sh
|
|
64
|
-
npm install --global --ignore-scripts @hraness/wordcell@0.25.
|
|
64
|
+
npm install --global --ignore-scripts @hraness/wordcell@0.25.1
|
|
65
65
|
wordcell --help
|
|
66
66
|
```
|
|
67
67
|
|
|
@@ -103,7 +103,7 @@ After trying the CLI, install the public Agent Skill into a compatible agent,
|
|
|
103
103
|
such as Claude Code, Codex, Cursor, or GitHub Copilot:
|
|
104
104
|
|
|
105
105
|
```sh
|
|
106
|
-
bunx skills add hraness/wordcell#v0.25.
|
|
106
|
+
bunx skills add hraness/wordcell#v0.25.1 --skill wordcell
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
Then ask:
|
|
@@ -334,7 +334,7 @@ exist. [Graph queries and proof limits](https://github.com/hraness/wordcell/blob
|
|
|
334
334
|
Add the same immutable release to a Bun project:
|
|
335
335
|
|
|
336
336
|
```sh
|
|
337
|
-
bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.
|
|
337
|
+
bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.1/hraness-wordcell-0.25.1.tgz
|
|
338
338
|
```
|
|
339
339
|
|
|
340
340
|
The SDK provides read-only vault sessions, metadata queries, search, graph
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hraness/wordcell",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Wordcell keeps decisions, plans, and sources as Markdown beside your code, so coding agents can find them from the file they are about to change.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/skills/wordcell/SKILL.md
CHANGED
|
@@ -58,7 +58,7 @@ missing:
|
|
|
58
58
|
```sh
|
|
59
59
|
command -v wordcell >/dev/null 2>&1 || {
|
|
60
60
|
command -v bun >/dev/null 2>&1 || exit 1
|
|
61
|
-
bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.
|
|
61
|
+
bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.25.1/hraness-wordcell-0.25.1.tgz
|
|
62
62
|
}
|
|
63
63
|
wordcell --help
|
|
64
64
|
```
|
|
@@ -170,8 +170,8 @@ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
|
|
|
170
170
|
Some(cost)
|
|
171
171
|
}
|
|
172
172
|
};
|
|
173
|
-
let mut
|
|
174
|
-
let mut
|
|
173
|
+
let mut known_retracted_ids = std::collections::HashSet::new();
|
|
174
|
+
let mut observed_ids = Vec::with_capacity(rows.len());
|
|
175
175
|
let mut results = Vec::new();
|
|
176
176
|
for row in rows {
|
|
177
177
|
let id = row
|
|
@@ -191,6 +191,18 @@ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
|
|
|
191
191
|
if title.trim().is_empty() || title.len() > MAX_TEXT {
|
|
192
192
|
return Err(FailureCode::Parse);
|
|
193
193
|
}
|
|
194
|
+
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
195
|
+
// Absence is unknown; only an explicit flag or title marker excludes a work.
|
|
196
|
+
let retracted = match row.get("is_retracted") {
|
|
197
|
+
None => false,
|
|
198
|
+
Some(value) => value.as_bool().ok_or(FailureCode::Parse)?,
|
|
199
|
+
};
|
|
200
|
+
const RETRACTED_PREFIX: &str = "RETRACTED ARTICLE:";
|
|
201
|
+
let retracted = retracted
|
|
202
|
+
|| title
|
|
203
|
+
.get(..RETRACTED_PREFIX.len())
|
|
204
|
+
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(RETRACTED_PREFIX));
|
|
205
|
+
observed_ids.push(id);
|
|
194
206
|
let mut candidates = Vec::new();
|
|
195
207
|
let mut locations = Vec::new();
|
|
196
208
|
for field in ["best_oa_location", "primary_location"] {
|
|
@@ -270,21 +282,41 @@ fn parse(body: &str, limit: usize, query: &str) -> Outcome {
|
|
|
270
282
|
}
|
|
271
283
|
_ => return Err(FailureCode::Parse),
|
|
272
284
|
}
|
|
285
|
+
// Validate every row's bounded fields even when its work will be omitted.
|
|
286
|
+
if retracted {
|
|
287
|
+
known_retracted_ids.insert(id);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
273
290
|
// Prefer observed direct destinations over DOI redirectors; preserve stable location order.
|
|
274
291
|
candidates.sort_by_key(|(priority, _)| *priority);
|
|
275
292
|
let Some((_, url)) = candidates.into_iter().next() else {
|
|
276
293
|
continue;
|
|
277
294
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
295
|
+
results.push((
|
|
296
|
+
id,
|
|
297
|
+
SearchResult {
|
|
298
|
+
title,
|
|
281
299
|
url,
|
|
282
300
|
snippet: Some("Scholarly index metadata; full text not fetched.".into()),
|
|
283
301
|
source_engine: "openalex".into(),
|
|
284
|
-
}
|
|
285
|
-
|
|
302
|
+
},
|
|
303
|
+
));
|
|
286
304
|
}
|
|
287
|
-
|
|
305
|
+
// A duplicate row cannot reintroduce an identity explicitly marked retracted.
|
|
306
|
+
// Deduplicate only admitted works so excluded identities cannot hide usable rows.
|
|
307
|
+
let mut seen = std::collections::HashSet::new();
|
|
308
|
+
let mut seen_ids = std::collections::HashSet::new();
|
|
309
|
+
let results: Vec<_> = results
|
|
310
|
+
.into_iter()
|
|
311
|
+
.filter(|(id, _)| !known_retracted_ids.contains(id))
|
|
312
|
+
.filter(|(id, result)| seen_ids.insert(*id) && seen.insert(result.url.clone()))
|
|
313
|
+
.map(|(_, result)| result)
|
|
314
|
+
.collect();
|
|
315
|
+
if results.is_empty()
|
|
316
|
+
&& observed_ids
|
|
317
|
+
.iter()
|
|
318
|
+
.any(|id| !known_retracted_ids.contains(id))
|
|
319
|
+
{
|
|
288
320
|
return Err(FailureCode::UnverifiedEmpty);
|
|
289
321
|
}
|
|
290
322
|
Ok(Discovery {
|
|
@@ -307,6 +339,122 @@ mod tests {
|
|
|
307
339
|
parse(&value.to_string(), 3, "microalgae")
|
|
308
340
|
}
|
|
309
341
|
#[test]
|
|
342
|
+
fn omits_known_retractions_and_preserves_cost_for_policy_empty_results() {
|
|
343
|
+
let mut retracted = row();
|
|
344
|
+
retracted["is_retracted"] = json!(true);
|
|
345
|
+
let mut value = response(vec![retracted.clone()]);
|
|
346
|
+
value["meta"]["cost_usd"] = json!(0.0001);
|
|
347
|
+
let result = check(value).unwrap();
|
|
348
|
+
assert!(result.results.is_empty());
|
|
349
|
+
assert_eq!(result.reported_cost_usd, Some(0.0001));
|
|
350
|
+
|
|
351
|
+
let mut good = row();
|
|
352
|
+
good["id"] = json!("https://openalex.org/W456");
|
|
353
|
+
good["is_retracted"] = json!(false);
|
|
354
|
+
let result = check(response(vec![retracted, good])).unwrap();
|
|
355
|
+
assert_eq!(result.results.len(), 1);
|
|
356
|
+
assert_eq!(
|
|
357
|
+
result.results[0].title,
|
|
358
|
+
"Microalgae harvesting energy efficiency"
|
|
359
|
+
);
|
|
360
|
+
// Missing status remains admissible metadata, without a verified-status claim.
|
|
361
|
+
let result = check(response(vec![row()])).unwrap();
|
|
362
|
+
assert_eq!(result.results.len(), 1);
|
|
363
|
+
assert_eq!(
|
|
364
|
+
result.results[0].snippet.as_deref(),
|
|
365
|
+
Some("Scholarly index metadata; full text not fetched.")
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
#[test]
|
|
369
|
+
fn retraction_marker_is_anchored_and_does_not_exclude_scholarship_about_retractions() {
|
|
370
|
+
for title in [
|
|
371
|
+
"RETRACTED ARTICLE: Microalgae",
|
|
372
|
+
" retracted article:Microalgae",
|
|
373
|
+
"RETRACTED\n ARTICLE: Microalgae",
|
|
374
|
+
] {
|
|
375
|
+
let mut r = row();
|
|
376
|
+
r["title"] = json!(title);
|
|
377
|
+
r["is_retracted"] = json!(false);
|
|
378
|
+
assert!(
|
|
379
|
+
check(response(vec![r])).unwrap().results.is_empty(),
|
|
380
|
+
"{title}"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
for title in [
|
|
384
|
+
"A study of retractions",
|
|
385
|
+
"Analysis of RETRACTED ARTICLE: labels",
|
|
386
|
+
"RETRACTED ARTICLES: a review",
|
|
387
|
+
"Retraction notices in science",
|
|
388
|
+
"論文について",
|
|
389
|
+
] {
|
|
390
|
+
let mut r = row();
|
|
391
|
+
r["title"] = json!(title);
|
|
392
|
+
assert_eq!(
|
|
393
|
+
check(response(vec![r])).unwrap().results.len(),
|
|
394
|
+
1,
|
|
395
|
+
"{title}"
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
#[test]
|
|
400
|
+
fn rejects_malformed_status_even_with_an_explicit_title_marker() {
|
|
401
|
+
for status in [Value::Null, json!("true"), json!(1), json!([]), json!({})] {
|
|
402
|
+
for title in ["Microalgae harvesting", "RETRACTED ARTICLE: Microalgae"] {
|
|
403
|
+
let mut r = row();
|
|
404
|
+
r["title"] = json!(title);
|
|
405
|
+
r["is_retracted"] = status.clone();
|
|
406
|
+
assert_eq!(check(response(vec![r])).unwrap_err(), FailureCode::Parse);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
#[test]
|
|
411
|
+
fn policy_filter_preserves_unusable_link_failures_and_row_validation() {
|
|
412
|
+
let mut retracted = row();
|
|
413
|
+
retracted["is_retracted"] = json!(true);
|
|
414
|
+
let mut unusable = row();
|
|
415
|
+
unusable["id"] = json!("https://openalex.org/W456");
|
|
416
|
+
unusable["best_oa_location"] = Value::Null;
|
|
417
|
+
unusable["doi"] = Value::Null;
|
|
418
|
+
assert_eq!(
|
|
419
|
+
check(response(vec![retracted.clone(), unusable])).unwrap_err(),
|
|
420
|
+
FailureCode::UnverifiedEmpty
|
|
421
|
+
);
|
|
422
|
+
retracted["doi"] = json!(42);
|
|
423
|
+
assert_eq!(
|
|
424
|
+
check(response(vec![retracted.clone()])).unwrap_err(),
|
|
425
|
+
FailureCode::Parse
|
|
426
|
+
);
|
|
427
|
+
retracted["doi"] = Value::Null;
|
|
428
|
+
retracted["locations"] = json!([{"is_oa":true,"landing_page_url":"x".repeat(8193)}]);
|
|
429
|
+
assert_eq!(
|
|
430
|
+
check(response(vec![retracted])).unwrap_err(),
|
|
431
|
+
FailureCode::BodyLimit
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
#[test]
|
|
435
|
+
fn duplicate_work_cannot_reintroduce_a_known_retraction_in_either_order() {
|
|
436
|
+
let mut retracted = row();
|
|
437
|
+
retracted["is_retracted"] = json!(true);
|
|
438
|
+
for rows in [vec![row(), retracted.clone()], vec![retracted, row()]] {
|
|
439
|
+
assert!(check(response(rows)).unwrap().results.is_empty());
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
#[test]
|
|
443
|
+
fn excluded_identity_cannot_consume_a_valid_works_url_deduplication_slot() {
|
|
444
|
+
let mut retracted = row();
|
|
445
|
+
retracted["is_retracted"] = json!(true);
|
|
446
|
+
let mut good = row();
|
|
447
|
+
good["id"] = json!("https://openalex.org/W456");
|
|
448
|
+
good["title"] = json!("Allowed work at the shared destination");
|
|
449
|
+
let result = check(response(vec![row(), good, retracted])).unwrap();
|
|
450
|
+
assert_eq!(result.results.len(), 1);
|
|
451
|
+
assert_eq!(
|
|
452
|
+
result.results[0].title,
|
|
453
|
+
"Allowed work at the shared destination"
|
|
454
|
+
);
|
|
455
|
+
assert_eq!(result.results[0].url, "https://repository.example/paper");
|
|
456
|
+
}
|
|
457
|
+
#[test]
|
|
310
458
|
fn discovery_prefers_observed_oa_landing_and_deduplicates() {
|
|
311
459
|
let results = check(response(vec![row(), row()])).unwrap().results;
|
|
312
460
|
assert_eq!(results.len(), 1);
|