scriptoria 0.5.2__tar.gz → 0.6.1__tar.gz

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 (50) hide show
  1. {scriptoria-0.5.2 → scriptoria-0.6.1}/PKG-INFO +17 -3
  2. scriptoria-0.6.1/README.md +54 -0
  3. {scriptoria-0.5.2 → scriptoria-0.6.1}/pyproject.toml +1 -1
  4. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/__init__.py +1 -1
  5. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/blocks.py +9 -11
  6. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/cli.py +88 -4
  7. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/facts.py +6 -1
  8. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/frontmatter.py +7 -2
  9. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/graph.py +7 -2
  10. scriptoria-0.6.1/src/scrip/pageindex_adapter.py +371 -0
  11. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/retrieval.py +6 -2
  12. scriptoria-0.6.1/tests/test_pageindex_cmd.py +186 -0
  13. {scriptoria-0.5.2 → scriptoria-0.6.1}/uv.lock +1 -1
  14. scriptoria-0.5.2/README.md +0 -40
  15. {scriptoria-0.5.2 → scriptoria-0.6.1}/.gitignore +0 -0
  16. {scriptoria-0.5.2 → scriptoria-0.6.1}/pyrightconfig.json +0 -0
  17. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/anchors.py +0 -0
  18. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/embeddings.py +0 -0
  19. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/errors.py +0 -0
  20. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/hashing.py +0 -0
  21. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/ingest.py +0 -0
  22. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/lock.py +0 -0
  23. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/manifest.py +0 -0
  24. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/query.py +0 -0
  25. {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/similar.py +0 -0
  26. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/conftest.py +0 -0
  27. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_anchor_cmd.py +0 -0
  28. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_anchors.py +0 -0
  29. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_blocks.py +0 -0
  30. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_embeddings.py +0 -0
  31. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_fact_cmd.py +0 -0
  32. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_frontmatter.py +0 -0
  33. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_graph_status.py +0 -0
  34. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_hashing.py +0 -0
  35. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_index_cmd.py +0 -0
  36. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_ingest.py +0 -0
  37. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_json_shapes.py +0 -0
  38. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_lock.py +0 -0
  39. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_manifest.py +0 -0
  40. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_new_cmd.py +0 -0
  41. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_query.py +0 -0
  42. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_retrieval.py +0 -0
  43. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_similar_cmd.py +0 -0
  44. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_span_cmd.py +0 -0
  45. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_stamp.py +0 -0
  46. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_status_cmd.py +0 -0
  47. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_unlock_cmd.py +0 -0
  48. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_verify.py +0 -0
  49. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_version.py +0 -0
  50. {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptoria
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base
5
5
  Project-URL: Homepage, https://github.com/coredipper/scriptorium
6
6
  Project-URL: Changelog, https://github.com/coredipper/scriptorium/blob/main/CHANGELOG.md
@@ -53,11 +53,25 @@ uv run --project scrip scrip --help
53
53
  |---|---|
54
54
  | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
55
55
  | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
56
- | `scrip query [claims\|entities\|edges\|contradictions]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
57
- | `scrip index` | v0 stub for the embeddings retrieval rung. |
56
+ | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
57
+ | `scrip ingest <url\|file>` | Fetch/read a source and write canonical `vault/raw/<slug>.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. |
58
+ | `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
59
+ | `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
60
+ | `scrip fact add --table claims\|entities\|edges\|reconciliations` | Validate and append facts under the write lock; claims mint verified anchors, reconciliations mint ids/timestamps. |
61
+ | `scrip span --claim <id>` | Resolve a claim anchor and print the cited span. |
62
+ | `scrip similar --title ... --from ...` | Score overlap with existing wiki pages before PROMOTE. |
63
+ | `scrip search "<question>"` | Retrieve source blocks for a miss; uses embeddings if an index exists, otherwise lexical grep. Add `--long-docs pageindex` to try the PageIndex cache first. |
64
+ | `scrip index` | Build the optional embeddings index over `vault/raw/` when `scriptoria[embeddings]` is installed; otherwise exits cleanly and `search` falls back to grep. |
65
+ | `scrip pageindex build raw/<slug>` | Build the optional PageIndex cache for one raw source when a compatible backend is importable; otherwise exits cleanly. |
66
+ | `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
67
+ | `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
58
68
 
59
69
  Every command accepts `--root DIR` and `--json`.
60
70
 
71
+ The optional `scrip-harness` package adds the model-bearing commands, including
72
+ `scrip-harness answer "<question>"`, which gathers evidence with these primitives
73
+ and verifies every citation before printing or saving an answer.
74
+
61
75
  ## Exit codes
62
76
 
63
77
  | Code | Meaning |
@@ -0,0 +1,54 @@
1
+ # scrip
2
+
3
+ The deterministic keeper of a [scriptorium](../README.md) knowledge base.
4
+
5
+ The agent owns judgment (synthesis, fact extraction, reconciliation, promotion).
6
+ `scrip` owns only what LLMs are unreliable or expensive at: **content hashing**,
7
+ **staleness** over a dependency graph, **provenance-anchor integrity**, and
8
+ **structured queries** over the facts layer. Files are the source of truth;
9
+ `.kb/manifest.json` is only a regenerable speed cache.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ uv tool install ./scrip # installs the `scrip` command
15
+ # or, without installing:
16
+ uv run --project scrip scrip --help
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ | Command | What it does |
22
+ |---|---|
23
+ | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
24
+ | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
25
+ | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
26
+ | `scrip ingest <url\|file>` | Fetch/read a source and write canonical `vault/raw/<slug>.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. |
27
+ | `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
28
+ | `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
29
+ | `scrip fact add --table claims\|entities\|edges\|reconciliations` | Validate and append facts under the write lock; claims mint verified anchors, reconciliations mint ids/timestamps. |
30
+ | `scrip span --claim <id>` | Resolve a claim anchor and print the cited span. |
31
+ | `scrip similar --title ... --from ...` | Score overlap with existing wiki pages before PROMOTE. |
32
+ | `scrip search "<question>"` | Retrieve source blocks for a miss; uses embeddings if an index exists, otherwise lexical grep. Add `--long-docs pageindex` to try the PageIndex cache first. |
33
+ | `scrip index` | Build the optional embeddings index over `vault/raw/` when `scriptoria[embeddings]` is installed; otherwise exits cleanly and `search` falls back to grep. |
34
+ | `scrip pageindex build raw/<slug>` | Build the optional PageIndex cache for one raw source when a compatible backend is importable; otherwise exits cleanly. |
35
+ | `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
36
+ | `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
37
+
38
+ Every command accepts `--root DIR` and `--json`.
39
+
40
+ The optional `scrip-harness` package adds the model-bearing commands, including
41
+ `scrip-harness answer "<question>"`, which gathers evidence with these primitives
42
+ and verifies every citation before printing or saving an answer.
43
+
44
+ ## Exit codes
45
+
46
+ | Code | Meaning |
47
+ |---|---|
48
+ | `0` | clean / success |
49
+ | `1` | actionable finding (stale artifacts; broken citations) |
50
+ | `2` | usage error |
51
+ | `3` | data error (malformed frontmatter / NDJSON; missing source; duplicate id) |
52
+ | `4` | internal error |
53
+
54
+ Code `1` is an *expected* outcome the agent branches on, not a crash.
@@ -2,7 +2,7 @@
2
2
  # Distribution name on PyPI is `scriptoria` (scrip/scriptorium were taken); the
3
3
  # CLI command and the import package both remain `scrip`.
4
4
  name = "scriptoria"
5
- version = "0.5.2"
5
+ version = "0.6.1"
6
6
  description = "Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from pathlib import Path
15
15
 
16
- __version__ = "0.5.2"
16
+ __version__ = "0.6.1"
17
17
 
18
18
  # --- canonical vault layout ------------------------------------------------
19
19
  # ``root`` is the repo/instance root: the directory containing ``vault/``.
@@ -35,17 +35,13 @@ def _is_blank(line: str) -> bool:
35
35
 
36
36
  def split_blocks(text: str) -> list[dict]:
37
37
  """Return a list of ``{"block_id", "span": [start, end], "hash"}``."""
38
- # Index every line with its [start, end) char offsets.
39
- spans: list[tuple[int, int, str]] = []
38
+ # ⚡ Bolt: Single pass block segmentation to avoid intermediate O(N) allocation
39
+ ranges: list[list[int]] = [] # [start, end] per block
40
+ cur: list[int] | None = None
40
41
  start = 0
42
+
41
43
  for line in text.splitlines(keepends=True):
42
44
  end = start + len(line)
43
- spans.append((start, end, line))
44
- start = end
45
-
46
- ranges: list[list[int]] = [] # [start, end] per block
47
- cur: list[int] | None = None
48
- for s, e, line in spans:
49
45
  if _is_blank(line):
50
46
  if cur is not None:
51
47
  ranges.append(cur)
@@ -54,12 +50,14 @@ def split_blocks(text: str) -> list[dict]:
54
50
  if cur is not None:
55
51
  ranges.append(cur)
56
52
  cur = None
57
- ranges.append([s, e]) # a heading is its own block
53
+ ranges.append([start, end]) # a heading is its own block
58
54
  else:
59
55
  if cur is None:
60
- cur = [s, e]
56
+ cur = [start, end]
61
57
  else:
62
- cur[1] = e
58
+ cur[1] = end
59
+ start = end
60
+
63
61
  if cur is not None:
64
62
  ranges.append(cur)
65
63
 
@@ -224,15 +224,72 @@ def cmd_search(args: argparse.Namespace) -> int:
224
224
  from . import retrieval
225
225
 
226
226
  root = resolve_root(args.root)
227
- out = retrieval.search(root, args.query, k=args.k)
227
+ out = retrieval.search(root, args.query, k=args.k, long_docs=args.long_docs)
228
228
  if args.json:
229
229
  _emit(out)
230
230
  else:
231
- tag = out["method"] + (" — INDEX STALE, rebuild with `scrip index`" if out["stale_index"] else "")
231
+ stale_hint = "INDEX STALE, rebuild with `scrip index`"
232
+ if out["method"] == "pageindex":
233
+ stale_hint = "INDEX STALE, rebuild with `scrip pageindex build raw/<slug>`"
234
+ tag = out["method"] + (f" - {stale_hint}" if out["stale_index"] else "")
232
235
  print(f"[{tag}] top {len(out['results'])} for: {args.query!r}")
233
236
  for r in out["results"]:
234
- score = f"{r['score']:.3f}" if isinstance(r["score"], float) else str(r["score"])
235
- print(f" {r['source_id']}#{r['block_id']} ({score})")
237
+ score_value = r.get("score", "")
238
+ score = f"{score_value:.3f}" if isinstance(score_value, float) else str(score_value)
239
+ locator = r.get("block_id") or r.get("section_id") or "?"
240
+ print(f" {r['source_id']}#{locator} ({score})")
241
+ print(f" {r.get('snippet', '')}")
242
+ if not out["results"]:
243
+ print(" (no matches)")
244
+ return 0
245
+
246
+
247
+ def cmd_pageindex_build(args: argparse.Namespace) -> int:
248
+ from . import pageindex_adapter
249
+
250
+ root = resolve_root(args.root)
251
+ out = pageindex_adapter.build_index(root, args.source)
252
+ if args.json:
253
+ _emit(out)
254
+ else:
255
+ if out["status"] == "built":
256
+ print(f"indexed {out['sections_indexed']} section(s) for {out['source_id']} into .kb/pageindex/")
257
+ else:
258
+ print(out["message"])
259
+ return 0
260
+
261
+
262
+ def cmd_pageindex_search(args: argparse.Namespace) -> int:
263
+ from . import pageindex_adapter
264
+
265
+ root = resolve_root(args.root)
266
+ source_id = args.source
267
+ if source_id is not None and not source_id.startswith("raw/"):
268
+ source_id = f"raw/{source_id}"
269
+ out = pageindex_adapter.search(root, args.query, k=args.k, source_id=source_id)
270
+ if out is None:
271
+ payload = {
272
+ "status": "missing",
273
+ "message": "no usable PageIndex cache; run `scrip pageindex build raw/<slug>` first",
274
+ "method": "pageindex",
275
+ "stale_index": False,
276
+ "results": [],
277
+ }
278
+ if args.json:
279
+ _emit(payload)
280
+ else:
281
+ print(payload["message"])
282
+ return 0
283
+ if args.json:
284
+ _emit(out)
285
+ else:
286
+ stale_hint = " - INDEX STALE, rebuild with `scrip pageindex build raw/<slug>`"
287
+ tag = out["method"] + (stale_hint if out["stale_index"] else "")
288
+ print(f"[{tag}] top {len(out['results'])} for: {args.query!r}")
289
+ for r in out["results"]:
290
+ score_value = r.get("score", "")
291
+ score = f"{score_value:.3f}" if isinstance(score_value, float) else str(score_value)
292
+ print(f" {r['source_id']}#{r.get('section_id', '?')} ({score})")
236
293
  print(f" {r['snippet']}")
237
294
  if not out["results"]:
238
295
  print(" (no matches)")
@@ -569,8 +626,35 @@ def build_parser() -> argparse.ArgumentParser:
569
626
  )
570
627
  psr.add_argument("query", help="the question / search text")
571
628
  psr.add_argument("-k", type=int, default=5, help="number of results (default 5)")
629
+ psr.add_argument(
630
+ "--long-docs",
631
+ choices=["pageindex"],
632
+ help="try a long-document adapter cache before embeddings/grep",
633
+ )
572
634
  psr.set_defaults(func=cmd_search)
573
635
 
636
+ ppidx = sub.add_parser(
637
+ "pageindex",
638
+ help="optional PageIndex long-document cache (regenerable, not source of truth)",
639
+ )
640
+ pidx_sub = ppidx.add_subparsers(dest="pageindex_command", required=True, metavar="<action>")
641
+ ppb = pidx_sub.add_parser(
642
+ "build",
643
+ parents=[common],
644
+ help="build a PageIndex cache for one raw source",
645
+ )
646
+ ppb.add_argument("source", metavar="raw/<slug>", help="raw source to index")
647
+ ppb.set_defaults(func=cmd_pageindex_build)
648
+ pps = pidx_sub.add_parser(
649
+ "search",
650
+ parents=[common],
651
+ help="search the PageIndex cache for verbatim raw-source snippets",
652
+ )
653
+ pps.add_argument("query", help="the question / search text")
654
+ pps.add_argument("--source", metavar="raw/<slug>", help="restrict to one cached source")
655
+ pps.add_argument("-k", type=int, default=5, help="number of results (default 5)")
656
+ pps.set_defaults(func=cmd_pageindex_search)
657
+
574
658
  pul = sub.add_parser(
575
659
  "unlock",
576
660
  parents=[common],
@@ -26,6 +26,11 @@ from pathlib import Path
26
26
 
27
27
  import yaml
28
28
 
29
+ try:
30
+ from yaml import CSafeLoader as SafeLoader
31
+ except ImportError:
32
+ from yaml import SafeLoader
33
+
29
34
  from . import anchors, facts_dir, lock
30
35
  from .errors import DataError, UsageError
31
36
 
@@ -337,7 +342,7 @@ def _load_meta(root: Path) -> dict:
337
342
  "confidence": 0.0,
338
343
  }
339
344
  try:
340
- data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
345
+ data = yaml.load(p.read_text(encoding="utf-8"), Loader=SafeLoader) or {}
341
346
  except yaml.YAMLError as e:
342
347
  raise DataError(f"invalid facts/_meta.yaml: {e}") from e
343
348
  if not isinstance(data, dict):
@@ -12,6 +12,11 @@ from pathlib import Path
12
12
 
13
13
  import yaml
14
14
 
15
+ try:
16
+ from yaml import CSafeLoader as SafeLoader
17
+ except ImportError:
18
+ from yaml import SafeLoader
19
+
15
20
  from .errors import DataError
16
21
 
17
22
  FENCE = "---"
@@ -28,7 +33,7 @@ def parse(text: str) -> tuple[dict, str]:
28
33
  fm_text = "".join(lines[1:i])
29
34
  body = "".join(lines[i + 1 :])
30
35
  try:
31
- meta = yaml.safe_load(fm_text)
36
+ meta = yaml.load(fm_text, Loader=SafeLoader)
32
37
  except yaml.YAMLError as e:
33
38
  raise DataError(f"invalid YAML frontmatter: {e}") from e
34
39
  if meta is None:
@@ -53,7 +58,7 @@ def _read_frontmatter(f) -> tuple[bool, dict]:
53
58
  raise DataError("unterminated frontmatter (missing closing '---')")
54
59
 
55
60
  try:
56
- meta = yaml.safe_load("".join(fm_lines))
61
+ meta = yaml.load("".join(fm_lines), Loader=SafeLoader)
57
62
  except yaml.YAMLError as e:
58
63
  raise DataError(f"invalid YAML frontmatter: {e}") from e
59
64
  if meta is None:
@@ -13,6 +13,11 @@ from pathlib import Path
13
13
 
14
14
  import yaml
15
15
 
16
+ try:
17
+ from yaml import CSafeLoader as SafeLoader
18
+ except ImportError:
19
+ from yaml import SafeLoader
20
+
16
21
  from . import blocks as blocks_mod
17
22
  from . import facts_dir, frontmatter, hashing, raw_dir, wiki_dir
18
23
  from . import manifest as manifest_mod
@@ -92,7 +97,7 @@ def scan_derived(root: Path) -> dict:
92
97
  fmeta = facts_dir(root) / "_meta.yaml"
93
98
  if fmeta.exists():
94
99
  try:
95
- data = yaml.safe_load(fmeta.read_text(encoding="utf-8"))
100
+ data = yaml.load(fmeta.read_text(encoding="utf-8"), Loader=SafeLoader)
96
101
  except yaml.YAMLError as e:
97
102
  raise DataError(f"invalid facts/_meta.yaml: {e}") from e
98
103
  if data is None:
@@ -246,7 +251,7 @@ def stamp_artifacts(root: Path, paths: list[str] | None = None) -> list[dict]:
246
251
  ih = hashing.input_hash(deps)
247
252
  path = root / d["path"]
248
253
  if path.name == "_meta.yaml":
249
- data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
254
+ data = yaml.load(path.read_text(encoding="utf-8"), Loader=SafeLoader) or {}
250
255
  data["input-hash"] = ih
251
256
  data["last-compiled"] = now
252
257
  path.write_text(
@@ -0,0 +1,371 @@
1
+ """Optional PageIndex cache adapter for long-document retrieval.
2
+
3
+ PageIndex is treated like embeddings: a regenerable cache under ``.kb/``, never
4
+ the source of truth. The only durable text remains ``vault/raw/<slug>.md``; every
5
+ search result snippet persisted here must be copied verbatim from that raw text
6
+ so a later answer can still cite it with ``scrip anchor``.
7
+
8
+ The real backend is deliberately optional. Tests monkeypatch ``_get_backend``;
9
+ production can provide a module exposing ``build``/``build_index`` and optionally
10
+ ``search``. Without a backend, the CLI reports an unavailable adapter and normal
11
+ ``scrip search`` falls back to embeddings/grep.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import json
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from . import hashing, raw_dir
22
+ from .errors import DataError
23
+
24
+ INDEX_SCHEMA = 1
25
+
26
+ _backend = None
27
+ _backend_tried = False
28
+
29
+
30
+ def _pageindex_dir(root: Path) -> Path:
31
+ return root / ".kb" / "pageindex"
32
+
33
+
34
+ def _source_id(source: str) -> str:
35
+ sid = source if source.startswith("raw/") else f"raw/{source}"
36
+ slug = sid[len("raw/") :]
37
+ if "/" in slug or "\\" in slug or slug.startswith(".") or ".." in slug:
38
+ raise DataError(f"unsafe source id: {source}")
39
+ return sid
40
+
41
+
42
+ def _source_path(root: Path, source_id: str) -> Path:
43
+ p = raw_dir(root) / f"{source_id[len('raw/'):]}.md"
44
+ if not p.exists():
45
+ raise DataError(f"source does not exist: {source_id}")
46
+ return p
47
+
48
+
49
+ def _cache_dir(root: Path, source_id: str) -> Path:
50
+ return _pageindex_dir(root) / source_id[len("raw/") :]
51
+
52
+
53
+ def _now() -> str:
54
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+
56
+
57
+ def _get_backend():
58
+ """Return an optional PageIndex backend module/object.
59
+
60
+ The adapter accepts either ``pageindex`` or ``page_index`` as import names.
61
+ A usable backend exposes ``build`` or ``build_index``; ``search`` is optional
62
+ because cached records can still be ranked lexically.
63
+ """
64
+ global _backend, _backend_tried
65
+ if _backend_tried:
66
+ return _backend
67
+ _backend_tried = True
68
+ for name in ("pageindex", "page_index"):
69
+ try:
70
+ mod = importlib.import_module(name)
71
+ except Exception:
72
+ continue
73
+ if any(hasattr(mod, attr) for attr in ("build", "build_index")):
74
+ _backend = mod
75
+ break
76
+ return _backend
77
+
78
+
79
+ def available() -> bool:
80
+ return _get_backend() is not None
81
+
82
+
83
+ def _backend_name(backend) -> str:
84
+ return getattr(backend, "__name__", backend.__class__.__name__)
85
+
86
+
87
+ def _backend_version(backend) -> str:
88
+ return str(getattr(backend, "__version__", "unknown"))
89
+
90
+
91
+ def _call_build(backend, *, source_id: str, text: str):
92
+ if hasattr(backend, "build_index"):
93
+ return backend.build_index(source_id=source_id, text=text)
94
+ if hasattr(backend, "build"):
95
+ return backend.build(source_id=source_id, text=text)
96
+ if callable(backend):
97
+ return backend(source_id=source_id, text=text)
98
+ raise RuntimeError("PageIndex backend does not expose build/build_index")
99
+
100
+
101
+ def _call_search(backend, *, query: str, items: list[dict], k: int):
102
+ if backend is not None and hasattr(backend, "search"):
103
+ return backend.search(query=query, items=items, k=k)
104
+ return _lexical_rank(query, items, k)
105
+
106
+
107
+ def _candidate_records(raw):
108
+ if raw is None:
109
+ return []
110
+ if isinstance(raw, list):
111
+ return raw
112
+ if isinstance(raw, dict):
113
+ for key in ("items", "sections", "nodes", "results"):
114
+ val = raw.get(key)
115
+ if isinstance(val, list):
116
+ return val
117
+ return []
118
+
119
+
120
+ def _coerce_items(raw, *, source_id: str, text: str) -> list[dict]:
121
+ """Normalize backend records and keep only snippets present in raw text."""
122
+ items: list[dict] = []
123
+ for i, rec in enumerate(_candidate_records(raw)):
124
+ if not isinstance(rec, dict):
125
+ continue
126
+ snippet = str(rec.get("snippet") or rec.get("text") or rec.get("content") or "")
127
+ span = rec.get("span_hint") or rec.get("span")
128
+ if (
129
+ isinstance(span, list)
130
+ and len(span) == 2
131
+ and all(isinstance(n, int) for n in span)
132
+ and 0 <= span[0] < span[1] <= len(text)
133
+ ):
134
+ # Prefer the canonical raw slice. Backend summaries are not citable.
135
+ snippet = text[span[0] : span[1]].strip()
136
+ if not snippet:
137
+ continue
138
+ start = text.find(snippet)
139
+ if start < 0:
140
+ # The contract requires verbatim evidence; discard summaries.
141
+ continue
142
+ span_hint = [start, start + len(snippet)]
143
+ items.append(
144
+ {
145
+ "source_id": source_id,
146
+ "section_id": str(rec.get("section_id") or rec.get("id") or i),
147
+ "snippet": snippet,
148
+ "span_hint": span_hint,
149
+ "score": float(rec.get("score") or 0.0),
150
+ "method": "pageindex",
151
+ }
152
+ )
153
+ return items
154
+
155
+
156
+ def _cached_item_maps(items: list[dict]) -> tuple[dict[tuple[str, str], dict], dict[str, list[dict]]]:
157
+ by_section: dict[tuple[str, str], dict] = {}
158
+ by_snippet: dict[str, list[dict]] = {}
159
+ for item in items:
160
+ source_id = item.get("source_id")
161
+ section_id = item.get("section_id")
162
+ snippet = item.get("snippet")
163
+ if isinstance(source_id, str) and section_id is not None:
164
+ by_section[(source_id, str(section_id))] = item
165
+ if isinstance(snippet, str):
166
+ by_snippet.setdefault(snippet, []).append(item)
167
+ return by_section, by_snippet
168
+
169
+
170
+ def _float_score(value, fallback: float) -> float:
171
+ try:
172
+ return float(value)
173
+ except (TypeError, ValueError):
174
+ return fallback
175
+
176
+
177
+ def _match_cached_item(
178
+ rec: dict, by_section: dict[tuple[str, str], dict], by_snippet: dict[str, list[dict]]
179
+ ) -> dict | None:
180
+ source_id = rec.get("source_id")
181
+ if isinstance(source_id, str):
182
+ try:
183
+ source_id = _source_id(source_id)
184
+ except DataError:
185
+ source_id = None
186
+ else:
187
+ source_id = None
188
+ section_id = rec.get("section_id") or rec.get("id")
189
+ if source_id is not None and section_id is not None:
190
+ item = by_section.get((source_id, str(section_id)))
191
+ if item is not None:
192
+ return item
193
+ snippet = rec.get("snippet") or rec.get("text") or rec.get("content")
194
+ if isinstance(snippet, str):
195
+ for item in by_snippet.get(snippet, []):
196
+ if source_id is None or item.get("source_id") == source_id:
197
+ if section_id is None or str(item.get("section_id")) == str(section_id):
198
+ return item
199
+ return None
200
+
201
+
202
+ def _canonical_search_results(raw, items: list[dict], k: int) -> list[dict]:
203
+ by_section, by_snippet = _cached_item_maps(items)
204
+ results: list[dict] = []
205
+ seen: set[tuple[str, str, tuple[int, ...]]] = set()
206
+ for rec in _candidate_records(raw):
207
+ if not isinstance(rec, dict):
208
+ continue
209
+ item = _match_cached_item(rec, by_section, by_snippet)
210
+ if item is None:
211
+ continue
212
+ span = item.get("span_hint")
213
+ span_key = tuple(span) if isinstance(span, list) and all(isinstance(n, int) for n in span) else ()
214
+ key = (str(item.get("source_id")), str(item.get("section_id")), span_key)
215
+ if key in seen:
216
+ continue
217
+ seen.add(key)
218
+ out = dict(item)
219
+ out["score"] = _float_score(rec.get("score"), _float_score(item.get("score"), 0.0))
220
+ out["method"] = "pageindex"
221
+ results.append(out)
222
+ if len(results) >= k:
223
+ break
224
+ return results
225
+
226
+
227
+ def build_index(root: Path, source: str) -> dict:
228
+ """Build a PageIndex cache for one raw source.
229
+
230
+ Returns ``{"status": "built"|"unavailable", ...}`` rather than raising for
231
+ a missing backend so the CLI can fail soft like ``scrip index``.
232
+ """
233
+ backend = _get_backend()
234
+ if backend is None:
235
+ return {
236
+ "status": "unavailable",
237
+ "message": (
238
+ "PageIndex backend not installed; see docs/pageindex-adapter.md. "
239
+ "Normal `scrip search` still falls back to embeddings/grep."
240
+ ),
241
+ }
242
+ source_id = _source_id(source)
243
+ path = _source_path(root, source_id)
244
+ text = path.read_text(encoding="utf-8")
245
+ raw = _call_build(backend, source_id=source_id, text=text)
246
+ items = _coerce_items(raw, source_id=source_id, text=text)
247
+ d = _cache_dir(root, source_id)
248
+ d.mkdir(parents=True, exist_ok=True)
249
+ raw_hash = hashing.content_hash_file(path)
250
+ (d / "tree.json").write_text(
251
+ json.dumps({"source_id": source_id, "items": items}, indent=2, ensure_ascii=False)
252
+ + "\n",
253
+ encoding="utf-8",
254
+ )
255
+ (d / "meta.json").write_text(
256
+ json.dumps(
257
+ {
258
+ "source_id": source_id,
259
+ "raw_content_hash": raw_hash,
260
+ "backend": _backend_name(backend),
261
+ "backend_version": _backend_version(backend),
262
+ "schema": INDEX_SCHEMA,
263
+ "created_at": _now(),
264
+ },
265
+ indent=2,
266
+ ensure_ascii=False,
267
+ )
268
+ + "\n",
269
+ encoding="utf-8",
270
+ )
271
+ return {"status": "built", "source_id": source_id, "sections_indexed": len(items)}
272
+
273
+
274
+ def _load_cached(root: Path, source_id: str | None = None) -> tuple[list[dict], bool]:
275
+ base = _pageindex_dir(root)
276
+ if not base.is_dir():
277
+ return [], False
278
+ dirs = (
279
+ [_cache_dir(root, _source_id(source_id))]
280
+ if source_id
281
+ else sorted(p for p in base.iterdir() if p.is_dir())
282
+ )
283
+ items: list[dict] = []
284
+ stale = False
285
+ for d in dirs:
286
+ tree_p, meta_p = d / "tree.json", d / "meta.json"
287
+ if not tree_p.exists() or not meta_p.exists():
288
+ continue
289
+ try:
290
+ tree = json.loads(tree_p.read_text(encoding="utf-8"))
291
+ meta = json.loads(meta_p.read_text(encoding="utf-8"))
292
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
293
+ continue
294
+ sid = meta.get("source_id") or tree.get("source_id")
295
+ if not isinstance(sid, str):
296
+ continue
297
+ try:
298
+ source_path = _source_path(root, sid)
299
+ current = hashing.content_hash_file(source_path)
300
+ text = source_path.read_text(encoding="utf-8")
301
+ except DataError:
302
+ stale = True
303
+ continue
304
+ if meta.get("schema") != INDEX_SCHEMA:
305
+ stale = True
306
+ continue
307
+ if meta.get("raw_content_hash") != current:
308
+ stale = True
309
+ vals = tree.get("items") if isinstance(tree, dict) else None
310
+ if isinstance(vals, list):
311
+ items.extend(_revalidate_cached_items(vals, source_id=sid, text=text))
312
+ return items, stale
313
+
314
+
315
+ def _revalidate_cached_items(raw_items: list, *, source_id: str, text: str) -> list[dict]:
316
+ items: list[dict] = []
317
+ for item in raw_items:
318
+ if not isinstance(item, dict):
319
+ continue
320
+ cached_source = item.get("source_id")
321
+ if isinstance(cached_source, str):
322
+ try:
323
+ if _source_id(cached_source) != source_id:
324
+ continue
325
+ except DataError:
326
+ continue
327
+ snippet = item.get("snippet")
328
+ if not isinstance(snippet, str) or not snippet:
329
+ continue
330
+ start = text.find(snippet)
331
+ if start < 0:
332
+ continue
333
+ out = dict(item)
334
+ out["source_id"] = source_id
335
+ out["snippet"] = snippet
336
+ out["span_hint"] = [start, start + len(snippet)]
337
+ out["score"] = _float_score(item.get("score"), 0.0)
338
+ out["method"] = "pageindex"
339
+ items.append(out)
340
+ return items
341
+
342
+
343
+ def _terms(query: str) -> list[str]:
344
+ return [t for t in hashing.normalize(query).split() if t]
345
+
346
+
347
+ def _lexical_rank(query: str, items: list[dict], k: int) -> list[dict]:
348
+ terms = _terms(query)
349
+ ranked: list[dict] = []
350
+ for item in items:
351
+ low = str(item.get("snippet") or "").lower()
352
+ score = sum(low.count(t) for t in terms)
353
+ if score <= 0:
354
+ continue
355
+ out = dict(item)
356
+ out["score"] = float(score)
357
+ out["method"] = "pageindex"
358
+ ranked.append(out)
359
+ ranked.sort(key=lambda r: (-float(r["score"]), str(r.get("source_id")), str(r.get("section_id"))))
360
+ return ranked[:k]
361
+
362
+
363
+ def search(root: Path, query: str, k: int = 5, source_id: str | None = None) -> dict | None:
364
+ """Return ``{method, stale_index, results}``, or ``None`` with no cache."""
365
+ items, stale = _load_cached(root, source_id)
366
+ if not items:
367
+ return None
368
+ backend = _get_backend()
369
+ raw_results = _call_search(backend, query=query, items=items, k=k)
370
+ results = _canonical_search_results(raw_results, items, k)
371
+ return {"method": "pageindex", "stale_index": stale, "results": results}
@@ -11,7 +11,7 @@ import re
11
11
  from pathlib import Path
12
12
 
13
13
  from . import blocks as blocks_mod
14
- from . import embeddings, raw_dir
14
+ from . import embeddings, pageindex_adapter, raw_dir
15
15
 
16
16
  _WORD = re.compile(r"\w+")
17
17
 
@@ -41,8 +41,12 @@ def grep_search(root: Path, query: str, k: int = 5) -> list[dict]:
41
41
  return hits[:k]
42
42
 
43
43
 
44
- def search(root: Path, query: str, k: int = 5) -> dict:
44
+ def search(root: Path, query: str, k: int = 5, long_docs: str | None = None) -> dict:
45
45
  """Return ``{method, stale_index, results}``."""
46
+ if long_docs == "pageindex":
47
+ p = pageindex_adapter.search(root, query, k)
48
+ if p is not None:
49
+ return p
46
50
  v = embeddings.vector_search(root, query, k)
47
51
  if v is not None:
48
52
  results, stale = v
@@ -0,0 +1,186 @@
1
+ """PageIndex adapter tests. Hermetic: no real PageIndex dependency is installed;
2
+ the backend is monkeypatched with a deterministic tiny object."""
3
+
4
+ import json
5
+
6
+ from scrip import cli, pageindex_adapter, retrieval
7
+
8
+
9
+ class FakePageIndex:
10
+ __version__ = "test"
11
+
12
+ def build_index(self, *, source_id, text):
13
+ start = text.index("Alpha section explains caching.")
14
+ end = start + len("Alpha section explains caching.")
15
+ return {
16
+ "sections": [
17
+ {"section_id": "alpha", "span_hint": [start, end], "score": 0.9},
18
+ # Summaries that are not verbatim raw text must not enter cache.
19
+ {"section_id": "summary", "snippet": "a generated summary absent from raw"},
20
+ ]
21
+ }
22
+
23
+ def search(self, *, query, items, k):
24
+ return [it for it in items if query.lower() in it["snippet"].lower()][:k]
25
+
26
+
27
+ def _fake_backend(monkeypatch):
28
+ monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: FakePageIndex())
29
+
30
+
31
+ def test_pageindex_build_unavailable_exits_cleanly(kb, capsys, monkeypatch):
32
+ monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: None)
33
+ kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
34
+ rc = cli.main(["pageindex", "build", "raw/paper", "--json", "--root", str(kb.root)])
35
+ assert rc == 0
36
+ data = json.loads(capsys.readouterr().out)
37
+ assert data["status"] == "unavailable"
38
+
39
+
40
+ def test_pageindex_build_persists_only_verbatim_raw_snippets(kb, capsys, monkeypatch):
41
+ _fake_backend(monkeypatch)
42
+ kb.add_raw(
43
+ "paper",
44
+ "# Paper\n\nAlpha section explains caching.\n\nBeta section covers indexing.\n",
45
+ )
46
+ rc = cli.main(["pageindex", "build", "raw/paper", "--json", "--root", str(kb.root)])
47
+ assert rc == 0
48
+ data = json.loads(capsys.readouterr().out)
49
+ assert data["status"] == "built"
50
+ assert data["sections_indexed"] == 1
51
+
52
+ tree = json.loads(
53
+ (kb.root / ".kb" / "pageindex" / "paper" / "tree.json").read_text(encoding="utf-8")
54
+ )
55
+ [item] = tree["items"]
56
+ assert item["source_id"] == "raw/paper"
57
+ assert item["section_id"] == "alpha"
58
+ assert item["snippet"] == "Alpha section explains caching."
59
+ assert "summary absent" not in json.dumps(tree)
60
+
61
+
62
+ def test_pageindex_search_cli_and_retrieval_integration(kb, capsys, monkeypatch):
63
+ _fake_backend(monkeypatch)
64
+ kb.add_raw(
65
+ "paper",
66
+ "# Paper\n\nAlpha section explains caching.\n\nBeta section covers indexing.\n",
67
+ )
68
+ assert cli.main(["pageindex", "build", "paper", "--root", str(kb.root)]) == 0
69
+ capsys.readouterr()
70
+
71
+ assert cli.main(["pageindex", "search", "Alpha", "--json", "--root", str(kb.root)]) == 0
72
+ out = json.loads(capsys.readouterr().out)
73
+ assert out["method"] == "pageindex"
74
+ assert out["results"][0]["snippet"] == "Alpha section explains caching."
75
+
76
+ routed = retrieval.search(kb.root, "Alpha", long_docs="pageindex")
77
+ assert routed["method"] == "pageindex"
78
+ assert routed["results"][0]["source_id"] == "raw/paper"
79
+
80
+
81
+ def test_search_long_docs_pageindex_cli_prints_section_results(kb, capsys, monkeypatch):
82
+ _fake_backend(monkeypatch)
83
+ kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
84
+ assert cli.main(["pageindex", "build", "paper", "--root", str(kb.root)]) == 0
85
+ capsys.readouterr()
86
+
87
+ rc = cli.main(["search", "Alpha", "--long-docs", "pageindex", "--root", str(kb.root)])
88
+ assert rc == 0
89
+ out = capsys.readouterr().out
90
+ assert "[pageindex]" in out
91
+ assert "raw/paper#alpha" in out
92
+ assert "Alpha section explains caching." in out
93
+
94
+
95
+ def test_pageindex_search_reuses_cached_verbatim_snippet(kb, monkeypatch):
96
+ class SummarySearchBackend(FakePageIndex):
97
+ def search(self, *, query, items, k):
98
+ return [
99
+ {
100
+ "source_id": "raw/paper",
101
+ "section_id": "alpha",
102
+ "snippet": "fabricated summary absent from raw",
103
+ "score": 99,
104
+ }
105
+ ]
106
+
107
+ monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: SummarySearchBackend())
108
+ kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
109
+ pageindex_adapter.build_index(kb.root, "paper")
110
+
111
+ out = pageindex_adapter.search(kb.root, "Alpha")
112
+ assert out is not None
113
+ assert out["results"][0]["snippet"] == "Alpha section explains caching."
114
+ assert "fabricated summary" not in json.dumps(out)
115
+
116
+
117
+ def test_pageindex_cache_revalidates_snippets_after_raw_change(kb, monkeypatch):
118
+ _fake_backend(monkeypatch)
119
+ kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
120
+ pageindex_adapter.build_index(kb.root, "paper")
121
+ kb.mutate_raw("paper", "# Paper\n\nNew intro.\n\nAlpha section explains caching.\n")
122
+
123
+ out = pageindex_adapter.search(kb.root, "Alpha")
124
+ assert out is not None
125
+ assert out["stale_index"] is True
126
+ assert out["results"][0]["snippet"] == "Alpha section explains caching."
127
+
128
+
129
+ def test_pageindex_cache_drops_stale_snippets_absent_from_raw(kb, monkeypatch):
130
+ _fake_backend(monkeypatch)
131
+ kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
132
+ pageindex_adapter.build_index(kb.root, "paper")
133
+ kb.mutate_raw("paper", "# Paper\n\nAlpha section changed completely.\n")
134
+
135
+ assert pageindex_adapter.search(kb.root, "Alpha") is None
136
+ out = retrieval.search(kb.root, "Alpha", long_docs="pageindex")
137
+ assert out["method"] == "grep"
138
+ assert "changed completely" in out["results"][0]["snippet"]
139
+
140
+
141
+ def test_pageindex_lexical_fallback_uses_query_time_scores(kb, monkeypatch):
142
+ class RankedBuildBackend:
143
+ __version__ = "test"
144
+
145
+ def build_index(self, *, source_id, text):
146
+ alpha_start = text.index("Alpha Alpha section.")
147
+ beta_start = text.index("Beta section.")
148
+ return {
149
+ "sections": [
150
+ {
151
+ "section_id": "alpha",
152
+ "span_hint": [alpha_start, alpha_start + len("Alpha Alpha section.")],
153
+ "score": 1,
154
+ },
155
+ {
156
+ "section_id": "beta",
157
+ "span_hint": [beta_start, beta_start + len("Beta section.")],
158
+ "score": 100,
159
+ },
160
+ ]
161
+ }
162
+
163
+ monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: RankedBuildBackend())
164
+ kb.add_raw("paper", "# Paper\n\nAlpha Alpha section.\n\nBeta section.\n")
165
+ pageindex_adapter.build_index(kb.root, "paper")
166
+ monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: None)
167
+
168
+ out = pageindex_adapter.search(kb.root, "Alpha section")
169
+ assert out is not None
170
+ assert [r["section_id"] for r in out["results"]] == ["alpha", "beta"]
171
+ assert [r["score"] for r in out["results"]] == [3.0, 1.0]
172
+
173
+
174
+ def test_search_long_docs_pageindex_falls_back_without_cache(kb):
175
+ kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
176
+ out = retrieval.search(kb.root, "Alpha", long_docs="pageindex")
177
+ assert out["method"] == "grep"
178
+ assert out["results"][0]["source_id"] == "raw/paper"
179
+
180
+
181
+ def test_pageindex_search_missing_cache_json(kb, capsys):
182
+ rc = cli.main(["pageindex", "search", "anything", "--json", "--root", str(kb.root)])
183
+ assert rc == 0
184
+ out = json.loads(capsys.readouterr().out)
185
+ assert out["status"] == "missing"
186
+ assert out["results"] == []
@@ -1141,7 +1141,7 @@ wheels = [
1141
1141
 
1142
1142
  [[package]]
1143
1143
  name = "scriptoria"
1144
- version = "0.5.2"
1144
+ version = "0.6.1"
1145
1145
  source = { editable = "." }
1146
1146
  dependencies = [
1147
1147
  { name = "duckdb" },
@@ -1,40 +0,0 @@
1
- # scrip
2
-
3
- The deterministic keeper of a [scriptorium](../README.md) knowledge base.
4
-
5
- The agent owns judgment (synthesis, fact extraction, reconciliation, promotion).
6
- `scrip` owns only what LLMs are unreliable or expensive at: **content hashing**,
7
- **staleness** over a dependency graph, **provenance-anchor integrity**, and
8
- **structured queries** over the facts layer. Files are the source of truth;
9
- `.kb/manifest.json` is only a regenerable speed cache.
10
-
11
- ## Install
12
-
13
- ```sh
14
- uv tool install ./scrip # installs the `scrip` command
15
- # or, without installing:
16
- uv run --project scrip scrip --help
17
- ```
18
-
19
- ## Commands
20
-
21
- | Command | What it does |
22
- |---|---|
23
- | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
24
- | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
25
- | `scrip query [claims\|entities\|edges\|contradictions]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
26
- | `scrip index` | v0 stub for the embeddings retrieval rung. |
27
-
28
- Every command accepts `--root DIR` and `--json`.
29
-
30
- ## Exit codes
31
-
32
- | Code | Meaning |
33
- |---|---|
34
- | `0` | clean / success |
35
- | `1` | actionable finding (stale artifacts; broken citations) |
36
- | `2` | usage error |
37
- | `3` | data error (malformed frontmatter / NDJSON; missing source; duplicate id) |
38
- | `4` | internal error |
39
-
40
- Code `1` is an *expected* outcome the agent branches on, not a crash.
File without changes
File without changes
File without changes