create-egregore 0.19.32 → 0.20.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 (173) hide show
  1. package/assets/egregore-launcher.js +118 -13
  2. package/bin/cli.js +48 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +45 -3
  7. package/package.json +5 -4
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +8 -1
  18. package/runtime/codex/bin/connect-refresh.sh +33 -0
  19. package/runtime/codex/bin/graph-batch.sh +25 -6
  20. package/runtime/codex/bin/graph-op.sh +5 -4
  21. package/runtime/codex/bin/graph.sh +31 -5
  22. package/runtime/codex/bin/ingest.py +81 -5
  23. package/runtime/codex/bin/lib/context.sh +26 -1
  24. package/runtime/codex/bin/lib/greeting.sh +4 -1
  25. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  26. package/runtime/codex/manifest.json +8 -1
  27. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  30. package/runtime/pi/.codex/spec-manifest.json +3 -3
  31. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  32. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  33. package/runtime/pi/.pi/spec-manifest.json +2 -2
  34. package/runtime/pi/AGENTS.md +11 -4
  35. package/runtime/pi/bin/activity-data.sh +4 -8
  36. package/runtime/pi/bin/attendant.sh +13 -0
  37. package/runtime/pi/bin/autosave.sh +27 -1
  38. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  39. package/runtime/pi/bin/codex-session-start.sh +8 -1
  40. package/runtime/pi/bin/connect-refresh.sh +33 -0
  41. package/runtime/pi/bin/graph-batch.sh +25 -6
  42. package/runtime/pi/bin/graph-op.sh +5 -4
  43. package/runtime/pi/bin/graph.sh +31 -5
  44. package/runtime/pi/bin/ingest.py +81 -5
  45. package/runtime/pi/bin/lib/context.sh +26 -1
  46. package/runtime/pi/bin/lib/greeting.sh +4 -1
  47. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  48. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  49. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  50. package/runtime/pi/manifest.json +8 -1
  51. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  52. package/runtime/prime/.codex/config.toml +17 -0
  53. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  54. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  55. package/runtime/prime/.codex/hooks.json +30 -0
  56. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  57. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  58. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  61. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  64. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  65. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  66. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  67. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  68. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  69. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  70. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  71. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  72. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  73. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  74. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  76. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  79. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  80. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  82. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  84. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  85. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  86. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  87. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  88. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  89. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  91. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  94. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  95. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  97. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  98. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  99. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  100. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  101. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  102. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  103. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  104. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  105. package/runtime/prime/.codex/spec-manifest.json +132 -0
  106. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  107. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  108. package/runtime/prime/.prime/agent/settings.json +4 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  110. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  111. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  112. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  113. package/runtime/prime/AGENTS.md +489 -0
  114. package/runtime/prime/bin/activity-data.sh +238 -0
  115. package/runtime/prime/bin/agent.sh +775 -0
  116. package/runtime/prime/bin/attendant.sh +707 -0
  117. package/runtime/prime/bin/autosave.sh +112 -0
  118. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  119. package/runtime/prime/bin/capture-run.sh +341 -0
  120. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  121. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  122. package/runtime/prime/bin/codex-session-start.sh +265 -0
  123. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  124. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  125. package/runtime/prime/bin/connect-refresh.sh +33 -0
  126. package/runtime/prime/bin/dashboard-data.sh +178 -0
  127. package/runtime/prime/bin/graph-batch.sh +79 -0
  128. package/runtime/prime/bin/graph-op.sh +1102 -0
  129. package/runtime/prime/bin/graph-wal.sh +229 -0
  130. package/runtime/prime/bin/graph.sh +223 -0
  131. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  132. package/runtime/prime/bin/handoff-run.sh +671 -0
  133. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  134. package/runtime/prime/bin/index-handoff.sh +322 -0
  135. package/runtime/prime/bin/ingest-graph.sh +80 -0
  136. package/runtime/prime/bin/ingest-surface.html +495 -0
  137. package/runtime/prime/bin/ingest.py +756 -0
  138. package/runtime/prime/bin/ingest.sh +279 -0
  139. package/runtime/prime/bin/ingest_graph.py +856 -0
  140. package/runtime/prime/bin/ingest_surface.py +544 -0
  141. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  142. package/runtime/prime/bin/lib/config.sh +116 -0
  143. package/runtime/prime/bin/lib/context.sh +621 -0
  144. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  145. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  146. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  147. package/runtime/prime/bin/lib/greeting.sh +519 -0
  148. package/runtime/prime/bin/lib/hash.sh +13 -0
  149. package/runtime/prime/bin/lib/identity.sh +213 -0
  150. package/runtime/prime/bin/lib/metrics.sh +374 -0
  151. package/runtime/prime/bin/lib/noncode.sh +31 -0
  152. package/runtime/prime/bin/lib/permissions.sh +106 -0
  153. package/runtime/prime/bin/lib/time.sh +56 -0
  154. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  155. package/runtime/prime/bin/notify.sh +636 -0
  156. package/runtime/prime/bin/person.py +630 -0
  157. package/runtime/prime/bin/person.sh +448 -0
  158. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  159. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  160. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  161. package/runtime/prime/bin/prime-session-start.sh +24 -0
  162. package/runtime/prime/bin/publish-artifact.sh +271 -0
  163. package/runtime/prime/bin/publish-references.sh +144 -0
  164. package/runtime/prime/bin/repo-state.sh +96 -0
  165. package/runtime/prime/bin/search.sh +240 -0
  166. package/runtime/prime/bin/session-autosave.sh +209 -0
  167. package/runtime/prime/bin/session-log.sh +199 -0
  168. package/runtime/prime/bin/settings.sh +304 -0
  169. package/runtime/prime/bin/telemetry.sh +391 -0
  170. package/runtime/prime/bin/worktree-create.sh +128 -0
  171. package/runtime/prime/bin/worktree-remove.sh +50 -0
  172. package/runtime/prime/bin/worktree.sh +198 -0
  173. package/runtime/prime/manifest.json +178 -0
@@ -0,0 +1,756 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic, org-scoped intake substrate for Egregore.
3
+
4
+ The agent skill owns interpretation and curation. This program owns the parts
5
+ that must not depend on model judgment: stable identities, provenance,
6
+ deterministic chunks, boundary metadata, manifests, and retrieval filtering.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import html
14
+ import json
15
+ import mimetypes
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import zipfile
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from xml.etree import ElementTree
25
+
26
+ sys.path.append(str(Path(__file__).resolve().parent))
27
+ import catalogue as catalogue_module # noqa: E402
28
+ import doc_segment # noqa: E402 (same-directory module, path set above)
29
+ import pdf_extract # noqa: E402
30
+ import source_profile # noqa: E402
31
+ import text_repair # noqa: E402
32
+
33
+
34
+ ROOT = Path(__file__).resolve().parent.parent
35
+ CONFIG = ROOT / "egregore.json"
36
+ TEXT_EXTS = {".txt", ".md", ".markdown", ".rst", ".csv", ".tsv", ".json", ".jsonl", ".yaml", ".yml", ".xml"}
37
+ HTML_EXTS = {".html", ".htm"}
38
+ SELECTION_SCHEMA = "egregore-ingest-selection/v1"
39
+ EXTRACTION_SCHEMA = "egregore-ingest-extraction/v1"
40
+ CORPUS_SCHEMA = "egregore-ingest-corpus/v1"
41
+
42
+
43
+ def load_config() -> dict:
44
+ with CONFIG.open(encoding="utf-8") as handle:
45
+ return json.load(handle)
46
+
47
+
48
+ def metadata_root() -> Path:
49
+ override = os.environ.get("EGREGORE_INGEST_META_ROOT")
50
+ return Path(override).resolve() if override else (ROOT / "memory" / "ingest").resolve()
51
+
52
+
53
+ def data_root() -> Path:
54
+ override = os.environ.get("EGREGORE_INGEST_ROOT")
55
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest").resolve()
56
+
57
+
58
+ def staging_root() -> Path:
59
+ override = os.environ.get("EGREGORE_INGEST_STAGING_ROOT")
60
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest-staging").resolve()
61
+
62
+
63
+ def producer_session_id() -> str:
64
+ trusted = os.environ.get("EGREGORE_SESSION_ID", "").strip()
65
+ if trusted:
66
+ return trusted
67
+ session_file = ROOT / ".egregore-session-id"
68
+ if session_file.is_file():
69
+ return session_file.read_text(encoding="utf-8").strip()
70
+ return ""
71
+
72
+
73
+ def slug(value: str) -> str:
74
+ result = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
75
+ if not result:
76
+ raise ValueError("value must contain at least one letter or number")
77
+ return result
78
+
79
+
80
+ def digest(value: bytes | str, length: int = 24) -> str:
81
+ raw = value.encode("utf-8") if isinstance(value, str) else value
82
+ return hashlib.sha256(raw).hexdigest()[:length]
83
+
84
+
85
+ def hash_file(path: Path) -> tuple[str, int]:
86
+ hasher = hashlib.sha256()
87
+ size = 0
88
+ with path.open("rb") as handle:
89
+ while block := handle.read(1024 * 1024):
90
+ hasher.update(block)
91
+ size += len(block)
92
+ return hasher.hexdigest(), size
93
+
94
+
95
+ def parse_boundaries(items: list[str]) -> dict[str, str]:
96
+ result: dict[str, str] = {}
97
+ for item in items:
98
+ if "=" not in item:
99
+ raise ValueError(f"boundary must be key=value: {item}")
100
+ key, value = item.split("=", 1)
101
+ key = slug(key)
102
+ value = value.strip()
103
+ if not value:
104
+ raise ValueError(f"boundary value is empty: {item}")
105
+ result[key] = value
106
+ return dict(sorted(result.items()))
107
+
108
+
109
+ def read_text(path: Path) -> tuple[str | None, str | None, str | None, dict | None]:
110
+ """Extract text. Returns (text, error, extractor, detail).
111
+
112
+ `detail` carries per-page extraction facts for formats that have pages, so the
113
+ manifest records which pages needed OCR and which remain sparse or blank.
114
+ """
115
+ ext = path.suffix.lower()
116
+ try:
117
+ if ext in TEXT_EXTS:
118
+ return path.read_text(encoding="utf-8", errors="replace"), None, "builtin-text", None
119
+ if ext in HTML_EXTS:
120
+ raw = path.read_text(encoding="utf-8", errors="replace")
121
+ raw = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", raw)
122
+ return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw)), None, "builtin-html", None
123
+ if ext == ".docx":
124
+ with zipfile.ZipFile(path) as archive:
125
+ raw = archive.read("word/document.xml")
126
+ root = ElementTree.fromstring(raw)
127
+ text = "\n".join(t.text or "" for t in root.iter() if t.tag.endswith("}t"))
128
+ return text, None, "builtin-docx", None
129
+ if ext == ".pdf":
130
+ result = pdf_extract.extract(path)
131
+ return result.text, result.error, result.extractor, (result.report or None)
132
+ except (OSError, ValueError, zipfile.BadZipFile, ElementTree.ParseError) as exc:
133
+ return None, str(exc), None, None
134
+ return None, f"unsupported extension {ext or '(none)'}", None, None
135
+
136
+
137
+ def chunks(text: str, limit: int = 7000) -> list[str]:
138
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
139
+ if not paragraphs and text.strip():
140
+ paragraphs = [text.strip()]
141
+ result: list[str] = []
142
+ current = ""
143
+ for paragraph in paragraphs:
144
+ pieces = [paragraph[i : i + limit] for i in range(0, len(paragraph), limit)] or [""]
145
+ for piece in pieces:
146
+ candidate = f"{current}\n\n{piece}".strip() if current else piece
147
+ if current and len(candidate) > limit:
148
+ result.append(current)
149
+ current = piece
150
+ else:
151
+ current = candidate
152
+ if current:
153
+ result.append(current)
154
+ return result
155
+
156
+
157
+ def find_publisher_profile() -> Path | None:
158
+ """Where this instance's publisher profile is, if it has one.
159
+
160
+ The profile is what assigns a zone and a trust tier to each document, and
161
+ zone is a hard retrieval boundary. Ingesting without it produces a corpus
162
+ where every document is unplaced, so every later question is refused — a
163
+ failure that looks like an empty archive rather than a missing setting.
164
+
165
+ So this looks in the places a profile actually lives rather than in one
166
+ fixed path. An instance keeps its ontology under memory/knowledge/tools/,
167
+ not beside its intake metadata, and requiring an environment variable to
168
+ bridge that gap means the common case is the broken one.
169
+ """
170
+ override = os.environ.get("EGREGORE_PUBLISHER_PROFILE")
171
+ if override:
172
+ path = Path(override)
173
+ return path if path.is_file() else None
174
+
175
+ candidates = [metadata_root() / "publisher-profile.json"]
176
+ memory = Path("memory")
177
+ if memory.is_dir():
178
+ candidates.extend(sorted(memory.glob("knowledge/tools/*/publisher-profile.json")))
179
+ candidates.append(memory / "publisher-profile.json")
180
+ for path in candidates:
181
+ if path.is_file():
182
+ return path
183
+ return None
184
+
185
+
186
+ def publisher_profile() -> dict | None:
187
+ """Load the instance's publisher profile, if one is configured.
188
+
189
+ Resolution is recorded as evidence only. It never overrides a boundary the
190
+ operator set: deriving a region is an inference, and an inference must not
191
+ silently replace a stated fact.
192
+ """
193
+ path = find_publisher_profile()
194
+ if path is None:
195
+ return None
196
+ try:
197
+ return json.loads(path.read_text(encoding="utf-8"))
198
+ except (OSError, json.JSONDecodeError):
199
+ return None
200
+
201
+
202
+ def source_dir(source_id: str) -> Path:
203
+ return metadata_root() / "sources" / slug(source_id)
204
+
205
+
206
+ def load_manifest(directory: Path) -> dict:
207
+ path = directory / "manifest.json"
208
+ if not path.exists():
209
+ return {"documents": []}
210
+ return json.loads(path.read_text(encoding="utf-8"))
211
+
212
+
213
+ def atomic_json(path: Path, value: object) -> None:
214
+ path.parent.mkdir(parents=True, exist_ok=True)
215
+ temporary = path.with_suffix(path.suffix + ".tmp")
216
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
217
+ temporary.replace(path)
218
+
219
+
220
+ def atomic_text(path: Path, value: str) -> None:
221
+ path.parent.mkdir(parents=True, exist_ok=True)
222
+ temporary = path.with_suffix(path.suffix + ".tmp")
223
+ temporary.write_text(value, encoding="utf-8")
224
+ temporary.replace(path)
225
+
226
+
227
+ def candidate_files(path: Path) -> tuple[Path, list[Path]]:
228
+ resolved = path.resolve()
229
+ if resolved.is_file():
230
+ return resolved.parent, [resolved]
231
+ if not resolved.is_dir():
232
+ raise ValueError(f"source path does not exist: {path}")
233
+ files = [
234
+ p
235
+ for p in resolved.rglob("*")
236
+ if p.is_file()
237
+ and not p.is_symlink()
238
+ and not any(part.startswith(".") for part in p.relative_to(resolved).parts)
239
+ ]
240
+ return resolved, sorted(files, key=lambda p: p.as_posix())
241
+
242
+
243
+ def add_path(args: argparse.Namespace) -> dict:
244
+ config = load_config()
245
+ org = slug(config.get("slug") or "egregore")
246
+ source_id = slug(args.source)
247
+ boundaries = parse_boundaries(args.boundary)
248
+ input_path = Path(args.path).resolve()
249
+ base, files = candidate_files(input_path)
250
+ directory = source_dir(source_id)
251
+ docs_dir = data_root() / "sources" / source_id / "documents"
252
+ docs_dir.mkdir(parents=True, exist_ok=True)
253
+ existing_manifest = load_manifest(directory)
254
+ previous = {d["id"]: d for d in existing_manifest.get("documents", [])}
255
+ now = datetime.now(timezone.utc).isoformat()
256
+ profile_path = find_publisher_profile()
257
+ profile = publisher_profile()
258
+ if profile is None:
259
+ # Said once, plainly, because the alternative is a corpus that ingests
260
+ # cleanly and then refuses every question with no indication why.
261
+ print("ingest: no publisher-profile.json found — documents will carry no zone "
262
+ "or trust tier, and a zoneless document can never be served. Put one "
263
+ "under memory/knowledge/tools/<ontology>/ or set "
264
+ "EGREGORE_PUBLISHER_PROFILE.", file=sys.stderr)
265
+ catalogue = catalogue_module.load(
266
+ profile or {}, base=profile_path.parent if profile_path else None
267
+ )
268
+ repair_map = text_repair.load_mapping(profile or {})
269
+ ingested = unchanged = metadata_updated = skipped = pruned = 0
270
+ errors: list[dict] = []
271
+ seen_paths: set[str] = set()
272
+
273
+ for path in files:
274
+ relative = path.relative_to(base).as_posix() if path != base else path.name
275
+ seen_paths.add(relative)
276
+ doc_id = digest(f"{org}\0{source_id}\0{relative}")
277
+ content_hash, size_bytes = hash_file(path)
278
+ existing = previous.get(doc_id)
279
+ registered = getattr(args, "extractions", {}).get(relative)
280
+ if (
281
+ existing
282
+ and existing.get("content_hash") == content_hash
283
+ and existing.get("boundaries", {}) == boundaries
284
+ and not registered
285
+ and existing.get("status") == "unverified"
286
+ ):
287
+ unchanged += 1
288
+ continue
289
+ if registered:
290
+ text, error = registered["text"], None
291
+ extraction = {"kind": "runtime", "extractor": registered["extractor"]}
292
+ else:
293
+ text, error, extractor, detail = read_text(path)
294
+ extraction = {"kind": "local", "extractor": extractor} if extractor else None
295
+ if extraction and detail:
296
+ extraction["pages"] = detail
297
+ # Fix characters the document's own font encoding got wrong, before
298
+ # anything downstream indexes or quotes them.
299
+ repaired = None
300
+ if text and repair_map:
301
+ outcome = text_repair.repair(text, repair_map)
302
+ if outcome.replaced:
303
+ text = outcome.text
304
+ repaired = outcome.as_report()
305
+
306
+ if error or text is None or not text.strip():
307
+ skipped += 1
308
+ errors.append({"path": relative, "error": error or "empty extracted text"})
309
+ if existing:
310
+ normalized = data_root() / existing["file_path"]
311
+ normalized.unlink(missing_ok=True)
312
+ previous[doc_id] = {
313
+ **existing,
314
+ "content_hash": content_hash,
315
+ "revision": content_hash[:16],
316
+ "mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
317
+ "bytes": size_bytes,
318
+ "chunks": [],
319
+ "extraction": None,
320
+ "boundaries": boundaries,
321
+ "status": "extraction_failed",
322
+ "ingested_at": now,
323
+ }
324
+ continue
325
+ normalized_hash = hashlib.sha256(text.encode()).hexdigest()
326
+ if (
327
+ existing
328
+ and existing.get("content_hash") == content_hash
329
+ and existing.get("normalized_content_hash") == normalized_hash
330
+ and existing.get("boundaries", {}) == boundaries
331
+ and existing.get("status") == "unverified"
332
+ ):
333
+ unchanged += 1
334
+ continue
335
+ # Segment before chunking so no passage straddles two works. A document
336
+ # holding a single work yields one segment, and chunking is unchanged.
337
+ if repaired and extraction:
338
+ extraction["repair"] = repaired
339
+
340
+ segments = doc_segment.segment(text)
341
+ document_chunks: list[tuple[str, doc_segment.Segment]] = []
342
+ for seg in segments:
343
+ for piece in chunks(seg.text(text)):
344
+ document_chunks.append((piece, seg))
345
+
346
+ chunk_rows = []
347
+ body = []
348
+ for index, (content, seg) in enumerate(document_chunks, 1):
349
+ chunk_id = f"{doc_id}-c{index:04d}-{digest(content, 8)}"
350
+ row = {"id": chunk_id, "index": index, "sha256": hashlib.sha256(content.encode()).hexdigest()}
351
+ if seg.kind != "article" or seg.label:
352
+ row["segment"] = {"kind": seg.kind, **({"label": seg.label} if seg.label else {})}
353
+ chunk_rows.append(row)
354
+ body.append(f"## Chunk {index:04d}\n\n<!-- ingest-chunk: {chunk_id} -->\n\n{content}")
355
+
356
+ structure = {
357
+ "segments": len([s for s in segments if s.kind == "article"]),
358
+ "summary": doc_segment.summarise(segments),
359
+ }
360
+ derived_year = doc_segment.publication_year(segments)
361
+ if derived_year:
362
+ structure["publication_year"] = derived_year
363
+ structure["year_source"] = "running_head"
364
+
365
+ # Fill in boundaries the operator left blank, when the instance supplies a
366
+ # profile that can work them out from where the document came from. People
367
+ # dumping a folder of documents will not type these in, and a boundary that
368
+ # is absent filters nothing. A derived value is marked as derived: anything
369
+ # downstream can tell it apart from one a person stated, and weigh it by the
370
+ # confidence recorded alongside. An operator's value is never overwritten.
371
+ # Evidence strongest first: what the operator stated, then what the
372
+ # organisation's own catalogue records, then what can be inferred from
373
+ # the document's origin. A weaker source never overwrites a stronger one.
374
+ provenance = None
375
+ doc_boundaries = dict(boundaries)
376
+ catalogued = catalogue_module.lookup(catalogue, relative)
377
+
378
+ if catalogued:
379
+ provenance = {"from_catalogue": {k: v for k, v in catalogued.items()}}
380
+ for field_name, value in catalogued.items():
381
+ if field_name in ("zone", "crop", "topic", "customer", "jurisdiction", "project"):
382
+ if field_name not in doc_boundaries:
383
+ doc_boundaries[field_name] = value
384
+ provenance["applied_boundary"] = field_name
385
+ if catalogued.get("year"):
386
+ structure.setdefault("publication_year", catalogued["year"])
387
+ structure["year_source"] = structure.get("year_source") or "catalogue"
388
+ if catalogued.get("tier"):
389
+ provenance["tier"] = catalogued["tier"]
390
+ provenance["confidence"] = "high"
391
+
392
+ if profile:
393
+ resolved = source_profile.resolve(profile, filename=relative)
394
+ if resolved.publisher_class or resolved.notes:
395
+ inferred = resolved.as_dict()
396
+ # Keep the inference visible even when the catalogue answered,
397
+ # so a disagreement between the two is inspectable.
398
+ provenance = {**inferred, **(provenance or {})} if provenance else inferred
399
+ key = source_profile.boundary_key(profile)
400
+ if key and resolved.zone and key not in doc_boundaries:
401
+ doc_boundaries[key] = resolved.zone
402
+ provenance = {**(provenance or {}), "applied_boundary": key, "derived": True}
403
+ out_path = docs_dir / f"{doc_id}.md"
404
+ frontmatter = {
405
+ "id": doc_id,
406
+ "type": "ingest-document",
407
+ "org": org,
408
+ "source_id": source_id,
409
+ "source_path": relative,
410
+ "title": path.stem,
411
+ "content_hash": content_hash,
412
+ "normalized_content_hash": normalized_hash,
413
+ "revision": content_hash[:16],
414
+ "status": "unverified",
415
+ "boundaries": doc_boundaries,
416
+ "extraction": extraction,
417
+ "structure": structure,
418
+ "provenance": provenance,
419
+ "ingested_at": now,
420
+ }
421
+ yaml_lines = ["---"] + [f"{key}: {json.dumps(value, ensure_ascii=False)}" for key, value in frontmatter.items()] + ["---", ""]
422
+ atomic_text(out_path, "\n".join(yaml_lines) + f"# {path.stem}\n\n" + "\n\n".join(body) + "\n")
423
+ previous[doc_id] = {
424
+ "id": doc_id,
425
+ "title": path.stem,
426
+ "source_path": relative,
427
+ "file_path": out_path.relative_to(data_root()).as_posix(),
428
+ "content_hash": content_hash,
429
+ "normalized_content_hash": normalized_hash,
430
+ "revision": content_hash[:16],
431
+ "mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
432
+ "bytes": size_bytes,
433
+ "chunks": chunk_rows,
434
+ "extraction": extraction,
435
+ "structure": structure,
436
+ "provenance": provenance,
437
+ "boundaries": doc_boundaries,
438
+ "status": "unverified",
439
+ "ingested_at": now,
440
+ }
441
+ if existing and existing.get("content_hash") == content_hash:
442
+ metadata_updated += 1
443
+ else:
444
+ ingested += 1
445
+
446
+ tombstones = existing_manifest.get("tombstones", [])
447
+ if args.prune:
448
+ if not input_path.is_dir():
449
+ raise ValueError("--prune requires a directory snapshot")
450
+ for doc_id, document in list(previous.items()):
451
+ if document["source_path"] in seen_paths:
452
+ continue
453
+ normalized = data_root() / document["file_path"]
454
+ if normalized.is_file():
455
+ normalized.unlink()
456
+ tombstones.append({
457
+ "id": doc_id,
458
+ "source_path": document["source_path"],
459
+ "content_hash": document["content_hash"],
460
+ "deleted_at": now,
461
+ })
462
+ del previous[doc_id]
463
+ pruned += 1
464
+ # A path can disappear in one authoritative snapshot and return later.
465
+ # Current documents always win over historical deletion records.
466
+ tombstones = [row for row in tombstones if row["id"] not in previous]
467
+
468
+ source_record = {
469
+ "id": source_id,
470
+ "org": org,
471
+ "name": args.name or source_id,
472
+ "kind": args.kind,
473
+ "boundaries": boundaries,
474
+ "root_hint": base.name,
475
+ "updated_at": now,
476
+ }
477
+ atomic_json(directory / "source.json", source_record)
478
+ atomic_json(
479
+ directory / "manifest.json",
480
+ {
481
+ "schema": CORPUS_SCHEMA,
482
+ "producer_session": producer_session_id(),
483
+ "source": source_record,
484
+ "documents": sorted(previous.values(), key=lambda d: d["id"]),
485
+ "tombstones": tombstones,
486
+ "errors": errors,
487
+ },
488
+ )
489
+ return {"org": org, "source": source_id, "ingested": ingested, "unchanged": unchanged, "metadata_updated": metadata_updated, "skipped": skipped, "pruned": pruned, "documents": len(previous), "metadata_root": str(directory), "data_root": str(docs_dir)}
490
+
491
+
492
+ def cmd_add(args: argparse.Namespace) -> int:
493
+ print(json.dumps(add_path(args), indent=2))
494
+ return 0
495
+
496
+
497
+ def selection_relative_path(value: object) -> str:
498
+ if not isinstance(value, str) or not value or "\x00" in value or "\\" in value:
499
+ raise ValueError("selection contains an invalid relative path")
500
+ candidate = Path(value)
501
+ if candidate.is_absolute() or any(part in {"", ".", ".."} or part.startswith(".") for part in candidate.parts):
502
+ raise ValueError(f"selection path must be visible and relative: {value}")
503
+ return candidate.as_posix()
504
+
505
+
506
+ def validate_selection(path: Path) -> tuple[dict, Path]:
507
+ manifest_path = path.resolve()
508
+ staging_base = staging_root()
509
+ if manifest_path.name != "selection.json" or manifest_path.parent.parent != staging_base:
510
+ raise ValueError("selection manifest must be inside the local ingest staging root")
511
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
512
+ if manifest.get("schema") != SELECTION_SCHEMA:
513
+ raise ValueError("unsupported selection manifest schema")
514
+ if "org" in manifest:
515
+ raise ValueError("selection manifest cannot supply an org")
516
+ if manifest.get("session_id") != manifest_path.parent.name:
517
+ raise ValueError("selection session id does not match its staging directory")
518
+ if Path(str(manifest.get("staging_root", ""))).resolve() != manifest_path.parent:
519
+ raise ValueError("selection staging root does not match the manifest location")
520
+
521
+ source = manifest.get("source")
522
+ if not isinstance(source, dict) or "org" in source:
523
+ raise ValueError("selection source contract is invalid")
524
+ source_id = slug(str(source.get("id", "")))
525
+ if source_id != source.get("id"):
526
+ raise ValueError("selection source id must already be a normalized slug")
527
+ name = str(source.get("name", "")).strip()
528
+ if not name or len(name) > 200:
529
+ raise ValueError("selection source name is invalid")
530
+ if source.get("kind") != "files":
531
+ raise ValueError("local selections must use source kind files")
532
+ boundaries = source.get("boundaries", {})
533
+ if not isinstance(boundaries, dict):
534
+ raise ValueError("selection boundaries must be an object")
535
+ normalized_boundaries = parse_boundaries([f"{key}={value}" for key, value in boundaries.items()])
536
+ if normalized_boundaries != boundaries:
537
+ raise ValueError("selection boundary keys must already be normalized slugs")
538
+
539
+ files_root = manifest_path.parent / "files"
540
+ items = manifest.get("items")
541
+ if not isinstance(items, list) or not items:
542
+ raise ValueError("selection must contain at least one item")
543
+ declared: set[str] = set()
544
+ for item in items:
545
+ if not isinstance(item, dict):
546
+ raise ValueError("selection item must be an object")
547
+ relative = selection_relative_path(item.get("relative_path"))
548
+ if relative in declared:
549
+ raise ValueError(f"duplicate selection path: {relative}")
550
+ declared.add(relative)
551
+ expected = (files_root / relative).resolve()
552
+ if Path(str(item.get("local_path", ""))).resolve() != expected or files_root.resolve() not in expected.parents:
553
+ raise ValueError(f"selection item escaped its staging root: {relative}")
554
+ if not expected.is_file() or expected.is_symlink():
555
+ raise ValueError(f"selection item is missing or unsafe: {relative}")
556
+ content_hash, size_bytes = hash_file(expected)
557
+ if item.get("sha256") != content_hash or item.get("bytes") != size_bytes:
558
+ raise ValueError(f"selection item changed after confirmation: {relative}")
559
+
560
+ actual = {
561
+ candidate.relative_to(files_root).as_posix()
562
+ for candidate in files_root.rglob("*")
563
+ if candidate.is_file() and not candidate.is_symlink()
564
+ }
565
+ if actual != declared:
566
+ raise ValueError("staging directory does not match the confirmed selection")
567
+ return manifest, files_root
568
+
569
+
570
+ def registered_extractions(manifest: dict) -> dict[str, dict]:
571
+ session_root = Path(manifest["staging_root"])
572
+ directory = session_root / "extractions"
573
+ if not directory.exists():
574
+ return {}
575
+ declared = {item["relative_path"]: item for item in manifest["items"]}
576
+ result: dict[str, dict] = {}
577
+ for sidecar_path in sorted(directory.glob("*.json")):
578
+ sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
579
+ if sidecar.get("schema") != EXTRACTION_SCHEMA:
580
+ raise ValueError(f"unsupported extraction sidecar: {sidecar_path.name}")
581
+ relative = selection_relative_path(sidecar.get("relative_path"))
582
+ item = declared.get(relative)
583
+ if not item:
584
+ raise ValueError(f"extraction does not belong to this selection: {relative}")
585
+ if sidecar_path.name != f"{digest(relative)}.json":
586
+ raise ValueError(f"extraction sidecar has an invalid name: {relative}")
587
+ if sidecar.get("source_sha256") != item["sha256"]:
588
+ raise ValueError(f"extraction source hash does not match: {relative}")
589
+ extractor = str(sidecar.get("extractor", "")).strip()
590
+ text = sidecar.get("text")
591
+ if not extractor or len(extractor) > 100 or not isinstance(text, str) or not text.strip():
592
+ raise ValueError(f"extraction sidecar is incomplete: {relative}")
593
+ result[relative] = {"extractor": extractor, "text": text}
594
+ return result
595
+
596
+
597
+ def cmd_register_extraction(args: argparse.Namespace) -> int:
598
+ manifest, _ = validate_selection(Path(args.manifest))
599
+ relative = selection_relative_path(args.relative_path)
600
+ item = next((row for row in manifest["items"] if row["relative_path"] == relative), None)
601
+ if not item:
602
+ raise ValueError(f"file is not part of this selection: {relative}")
603
+ extractor = args.extractor.strip()
604
+ if not extractor or len(extractor) > 100:
605
+ raise ValueError("extractor name is invalid")
606
+ text = sys.stdin.read()
607
+ if not text.strip():
608
+ raise ValueError("registered extraction is empty")
609
+ directory = Path(manifest["staging_root"]) / "extractions"
610
+ path = directory / f"{digest(relative)}.json"
611
+ atomic_json(path, {
612
+ "schema": EXTRACTION_SCHEMA,
613
+ "relative_path": relative,
614
+ "source_sha256": item["sha256"],
615
+ "extractor": extractor,
616
+ "text": text,
617
+ })
618
+ print(json.dumps({"registered": relative, "extractor": extractor, "sidecar": str(path)}))
619
+ return 0
620
+
621
+
622
+ def cmd_add_selection(args: argparse.Namespace) -> int:
623
+ manifest, files_root = validate_selection(Path(args.manifest))
624
+ source = manifest["source"]
625
+ add_args = argparse.Namespace(
626
+ path=str(files_root),
627
+ source=source["id"],
628
+ name=source["name"],
629
+ kind="files",
630
+ boundary=[f"{key}={value}" for key, value in source.get("boundaries", {}).items()],
631
+ prune=bool(source.get("authoritative_snapshot", False)),
632
+ extractions=registered_extractions(manifest),
633
+ )
634
+ summary = add_path(add_args)
635
+ cleaned = summary["skipped"] == 0
636
+ if cleaned:
637
+ shutil.rmtree(Path(manifest["staging_root"]), ignore_errors=True)
638
+ summary["selection_files"] = len(manifest["items"])
639
+ summary["staging_cleaned"] = cleaned
640
+ if not cleaned:
641
+ summary["selection_path"] = str(Path(args.manifest).resolve())
642
+ print(json.dumps(summary, indent=2))
643
+ return 0
644
+
645
+
646
+ def all_sources() -> list[tuple[dict, dict]]:
647
+ base = metadata_root() / "sources"
648
+ if not base.exists():
649
+ return []
650
+ result = []
651
+ for manifest_path in sorted(base.glob("*/manifest.json")):
652
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
653
+ result.append((manifest.get("source", {}), manifest))
654
+ return result
655
+
656
+
657
+ def boundaries_match(actual: dict, required: dict) -> bool:
658
+ return all(str(actual.get(key, "")) == str(value) for key, value in required.items())
659
+
660
+
661
+ def cmd_filter_results(args: argparse.Namespace) -> int:
662
+ required = parse_boundaries(args.boundary)
663
+ allowed: set[str] = set()
664
+ for source, manifest in all_sources():
665
+ if args.source and source.get("id") != slug(args.source):
666
+ continue
667
+ if not boundaries_match(source.get("boundaries", {}), required):
668
+ continue
669
+ allowed.update(d["file_path"] for d in manifest.get("documents", []) if d.get("status") != "extraction_failed")
670
+ results = json.load(sys.stdin)
671
+ filtered = []
672
+ for item in results if isinstance(results, list) else []:
673
+ file_name = re.sub(r"^qmd://[^/]+/", "", item.get("file", ""))
674
+ if file_name in allowed:
675
+ filtered.append(item)
676
+ if len(filtered) >= args.limit:
677
+ break
678
+ print(json.dumps(filtered, ensure_ascii=False))
679
+ return 0
680
+
681
+
682
+ def cmd_status(_: argparse.Namespace) -> int:
683
+ sources = all_sources()
684
+ documents = sum(len(m.get("documents", [])) for _, m in sources)
685
+ chunks_count = sum(len(d.get("chunks", [])) for _, m in sources for d in m.get("documents", []))
686
+ errors = sum(len(m.get("errors", [])) for _, m in sources)
687
+ cached = sum(1 for _, m in sources for d in m.get("documents", []) if (data_root() / d["file_path"]).is_file())
688
+ state_dir = data_root() / ".state"
689
+ pending = []
690
+ if state_dir.exists():
691
+ pending = [json.loads(path.read_text(encoding="utf-8")) for path in sorted(state_dir.glob("*.json"))]
692
+ print(json.dumps({"org": slug(load_config().get("slug") or "egregore"), "metadata_root": str(metadata_root()), "data_root": str(data_root()), "staging_root": str(staging_root()), "sources": len(sources), "documents": documents, "cached_documents": cached, "chunks": chunks_count, "errors": errors, "pending_recovery": pending}, indent=2))
693
+ return 0
694
+
695
+
696
+ def cmd_journal(args: argparse.Namespace) -> int:
697
+ path = data_root() / ".state" / f"{slug(args.source)}.json"
698
+ if args.clear:
699
+ path.unlink(missing_ok=True)
700
+ return 0
701
+ detail = json.loads(args.detail) if args.detail else {}
702
+ atomic_json(path, {
703
+ "source": slug(args.source),
704
+ "phase": args.phase,
705
+ "detail": detail,
706
+ "updated_at": datetime.now(timezone.utc).isoformat(),
707
+ })
708
+ return 0
709
+
710
+
711
+ def parser() -> argparse.ArgumentParser:
712
+ result = argparse.ArgumentParser(description=__doc__)
713
+ sub = result.add_subparsers(dest="command", required=True)
714
+ add = sub.add_parser("add")
715
+ add.add_argument("path")
716
+ add.add_argument("--source", required=True, help="stable connector/dataset id")
717
+ add.add_argument("--name")
718
+ add.add_argument("--kind", default="files")
719
+ add.add_argument("--boundary", action="append", default=[])
720
+ add.add_argument("--prune", action="store_true", help="tombstone files absent from this authoritative directory snapshot")
721
+ add.set_defaults(func=cmd_add)
722
+ add_selection = sub.add_parser("add-selection")
723
+ add_selection.add_argument("manifest")
724
+ add_selection.set_defaults(func=cmd_add_selection)
725
+ extraction = sub.add_parser("register-extraction")
726
+ extraction.add_argument("manifest")
727
+ extraction.add_argument("relative_path")
728
+ extraction.add_argument("--extractor", required=True)
729
+ extraction.set_defaults(func=cmd_register_extraction)
730
+ filt = sub.add_parser("filter-results")
731
+ filt.add_argument("--source")
732
+ filt.add_argument("--boundary", action="append", default=[])
733
+ filt.add_argument("--limit", type=int, default=8)
734
+ filt.set_defaults(func=cmd_filter_results)
735
+ status = sub.add_parser("status")
736
+ status.set_defaults(func=cmd_status)
737
+ journal = sub.add_parser("journal")
738
+ journal.add_argument("--source", required=True)
739
+ journal.add_argument("--phase", default="pending")
740
+ journal.add_argument("--detail")
741
+ journal.add_argument("--clear", action="store_true")
742
+ journal.set_defaults(func=cmd_journal)
743
+ return result
744
+
745
+
746
+ def main() -> int:
747
+ try:
748
+ args = parser().parse_args()
749
+ return args.func(args)
750
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
751
+ print(f"ingest: {exc}", file=sys.stderr)
752
+ return 2
753
+
754
+
755
+ if __name__ == "__main__":
756
+ raise SystemExit(main())