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,544 @@
1
+ #!/usr/bin/env python3
2
+ """Local, one-shot browser surface for selecting Egregore intake files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import re
12
+ import secrets
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.parse
18
+ import webbrowser
19
+ from datetime import datetime, timezone
20
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
+ from pathlib import Path, PurePosixPath
22
+ from threading import Event, Lock
23
+
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ HTML_PATH = Path(__file__).with_name("ingest-surface.html")
27
+ DESIGN_CSS_PATH = ROOT / "packages" / "design-system" / "dist" / "egregore-design.css"
28
+ SELECTION_SCHEMA = "egregore-ingest-selection/v1"
29
+ STALE_AFTER_SECONDS = 24 * 60 * 60
30
+ MAX_JSON_BYTES = 64 * 1024
31
+ RECEIPT_GRACE_SECONDS = 20 * 60
32
+ VALID_THEME_PAIRS = {"meridian", "agronomic", "sovereign"}
33
+ VALID_THEME_MODES = {"light", "auto", "dark"}
34
+
35
+
36
+ def ingest_root() -> Path:
37
+ override = os.environ.get("EGREGORE_INGEST_ROOT")
38
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest").resolve()
39
+
40
+
41
+ def staging_root() -> Path:
42
+ override = os.environ.get("EGREGORE_INGEST_STAGING_ROOT")
43
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest-staging").resolve()
44
+
45
+
46
+ def preferences_path() -> Path:
47
+ override = os.environ.get("EGREGORE_INGEST_UI_PREFERENCES")
48
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest-ui-preferences.json").resolve()
49
+
50
+
51
+ def load_preferences(path: Path) -> dict[str, str]:
52
+ try:
53
+ value = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError):
55
+ value = {}
56
+ pair = value.get("pair") if value.get("pair") in VALID_THEME_PAIRS else "sovereign"
57
+ mode = value.get("mode") if value.get("mode") in VALID_THEME_MODES else "auto"
58
+ return {"pair": pair, "mode": mode}
59
+
60
+
61
+ def save_preferences(path: Path, value: dict) -> dict[str, str]:
62
+ pair = value.get("pair")
63
+ mode = value.get("mode")
64
+ if pair not in VALID_THEME_PAIRS or mode not in VALID_THEME_MODES:
65
+ raise ValueError("invalid theme preference")
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ temporary = path.with_suffix(path.suffix + ".tmp")
68
+ result = {"pair": pair, "mode": mode}
69
+ temporary.write_text(json.dumps(result, sort_keys=True) + "\n", encoding="utf-8")
70
+ temporary.replace(path)
71
+ return result
72
+
73
+
74
+ def slug(value: str) -> str:
75
+ result = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
76
+ if not result:
77
+ raise ValueError("source id must contain at least one letter or number")
78
+ return result
79
+
80
+
81
+ def safe_relative_path(raw: str) -> str:
82
+ value = urllib.parse.unquote(raw).strip()
83
+ if not value or "\x00" in value or "\\" in value:
84
+ raise ValueError("invalid relative path")
85
+ path = PurePosixPath(value)
86
+ if path.is_absolute() or any(part in {"", ".", ".."} or part.startswith(".") for part in path.parts):
87
+ raise ValueError("path must be a visible relative path without traversal")
88
+ if len(value) > 2048:
89
+ raise ValueError("relative path is too long")
90
+ return path.as_posix()
91
+
92
+
93
+ def clean_stale_sessions(base: Path, now: float | None = None) -> None:
94
+ if not base.exists():
95
+ return
96
+ cutoff = (now if now is not None else time.time()) - STALE_AFTER_SECONDS
97
+ for child in base.iterdir():
98
+ try:
99
+ if child.is_dir() and child.stat().st_mtime < cutoff:
100
+ shutil.rmtree(child)
101
+ except OSError:
102
+ continue
103
+
104
+
105
+ class SelectionState:
106
+ def __init__(self, staging_base: Path, session_id: str | None = None) -> None:
107
+ self.session_id = session_id or secrets.token_hex(12)
108
+ self.session_root = (staging_base / self.session_id).resolve()
109
+ self.files_root = self.session_root / "files"
110
+ self.files_root.mkdir(parents=True, exist_ok=False)
111
+ self.items: dict[str, dict] = {}
112
+ self.done = Event()
113
+ self.cancelled = False
114
+ self.result: dict | None = None
115
+ self.receipt: dict = {"status": "selecting"}
116
+ self.receipt_done = Event()
117
+ self.receipt_ack = Event()
118
+ self.receipt_url = ""
119
+ self.receipt_token = ""
120
+ self.lock = Lock()
121
+
122
+ def upload(self, relative_path: str, mime: str, stream, size: int) -> dict:
123
+ relative = safe_relative_path(relative_path)
124
+ if size < 0:
125
+ raise ValueError("invalid content length")
126
+ free = shutil.disk_usage(self.session_root).free
127
+ if size > max(0, free - 16 * 1024 * 1024):
128
+ raise ValueError("not enough free disk space for this file")
129
+
130
+ with self.lock:
131
+ if self.done.is_set():
132
+ raise ValueError("selection is already closed")
133
+ if relative in self.items:
134
+ existing = self.items[relative]
135
+ hasher = hashlib.sha256()
136
+ remaining = size
137
+ while remaining:
138
+ block = stream.read(min(1024 * 1024, remaining))
139
+ if not block:
140
+ raise ValueError("upload ended before content length")
141
+ hasher.update(block)
142
+ remaining -= len(block)
143
+ if size == existing["bytes"] and hasher.hexdigest() == existing["sha256"]:
144
+ return existing
145
+ raise FileExistsError(f"duplicate relative path has different bytes: {relative}")
146
+ destination = (self.files_root / relative).resolve()
147
+ if self.files_root not in destination.parents:
148
+ raise ValueError("staged path escaped the selection root")
149
+ destination.parent.mkdir(parents=True, exist_ok=True)
150
+ temporary = destination.with_name(destination.name + ".upload")
151
+ hasher = hashlib.sha256()
152
+ remaining = size
153
+ try:
154
+ with temporary.open("xb") as handle:
155
+ while remaining:
156
+ block = stream.read(min(1024 * 1024, remaining))
157
+ if not block:
158
+ raise ValueError("upload ended before content length")
159
+ handle.write(block)
160
+ hasher.update(block)
161
+ remaining -= len(block)
162
+ temporary.replace(destination)
163
+ except Exception:
164
+ temporary.unlink(missing_ok=True)
165
+ raise
166
+
167
+ item = {
168
+ "relative_path": relative,
169
+ "local_path": str(destination),
170
+ "mime": mime or mimetypes.guess_type(relative)[0] or "application/octet-stream",
171
+ "bytes": size,
172
+ "sha256": hasher.hexdigest(),
173
+ }
174
+ self.items[relative] = item
175
+ return item
176
+
177
+ def confirm(self, payload: dict) -> dict:
178
+ with self.lock:
179
+ if self.done.is_set():
180
+ raise ValueError("selection is already closed")
181
+ source = payload.get("source")
182
+ if not isinstance(source, dict):
183
+ raise ValueError("source contract is required")
184
+ if "org" in payload or "org" in source:
185
+ raise ValueError("org is derived from egregore.json and cannot be supplied")
186
+ source_id = slug(str(source.get("id", "")))
187
+ name = str(source.get("name", "")).strip() or source_id
188
+ if len(name) > 200:
189
+ raise ValueError("source name is too long")
190
+ boundaries_raw = source.get("boundaries", {})
191
+ if not isinstance(boundaries_raw, dict):
192
+ raise ValueError("boundaries must be an object")
193
+ boundaries: dict[str, str] = {}
194
+ for raw_key, raw_value in boundaries_raw.items():
195
+ key = slug(str(raw_key))
196
+ value = str(raw_value).strip()
197
+ if not value:
198
+ raise ValueError(f"boundary value is empty: {raw_key}")
199
+ boundaries[key] = value
200
+ if not self.items:
201
+ raise ValueError("select at least one file")
202
+
203
+ created_at = datetime.now(timezone.utc).isoformat()
204
+ manifest = {
205
+ "schema": SELECTION_SCHEMA,
206
+ "session_id": self.session_id,
207
+ "created_at": created_at,
208
+ "source": {
209
+ "id": source_id,
210
+ "name": name,
211
+ "kind": "files",
212
+ "boundaries": dict(sorted(boundaries.items())),
213
+ "authoritative_snapshot": bool(source.get("authoritative_snapshot", False)),
214
+ },
215
+ "staging_root": str(self.session_root),
216
+ "items": [self.items[key] for key in sorted(self.items)],
217
+ "receipt": {"url": self.receipt_url, "token": self.receipt_token},
218
+ }
219
+ manifest_path = self.session_root / "selection.json"
220
+ temporary = manifest_path.with_suffix(".json.tmp")
221
+ temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
222
+ temporary.replace(manifest_path)
223
+ self.result = {
224
+ "schema": SELECTION_SCHEMA,
225
+ "selection_path": str(manifest_path),
226
+ "source_id": source_id,
227
+ "files": len(self.items),
228
+ "bytes": sum(item["bytes"] for item in self.items.values()),
229
+ "receipt_url": self.receipt_url,
230
+ }
231
+ self.receipt = {**self.result, "status": "processing", "message": "The agent is validating and indexing this selection."}
232
+ self.done.set()
233
+ return self.result
234
+
235
+ def choose_connector(self, payload: dict) -> dict:
236
+ # A connector choice ends the local-selection flow. When the CLI
237
+ # pre-started an OAuth listener, the browser response carries its
238
+ # consent URL so THIS tab continues straight into Google's sign-in;
239
+ # otherwise the agent guides authorization from the terminal. No bytes
240
+ # are staged and no source contract is collected here.
241
+ with self.lock:
242
+ if self.done.is_set():
243
+ raise ValueError("selection already finished")
244
+ provider = str(payload.get("provider", "")).strip().lower()
245
+ if provider not in available_connectors():
246
+ raise ValueError(f"unknown connector: {provider or '(empty)'}")
247
+ redirect = connector_auth_urls().get(provider, "")
248
+ account = connector_accounts().get(provider, "")
249
+ self.result = {
250
+ "connector": provider,
251
+ "receipt_url": self.receipt_url,
252
+ }
253
+ if redirect:
254
+ self.result["auth"] = "in-browser"
255
+ elif account:
256
+ self.result["account"] = account
257
+ self.receipt = {
258
+ **self.result,
259
+ "status": "processing",
260
+ "message": "The agent is walking you through connecting this source in the terminal.",
261
+ }
262
+ self.done.set()
263
+ response = dict(self.result)
264
+ if redirect:
265
+ response["redirect"] = redirect
266
+ return response
267
+
268
+ def finish_receipt(self, payload: dict) -> dict:
269
+ with self.lock:
270
+ if not self.done.is_set() or self.cancelled:
271
+ raise ValueError("selection has not been confirmed")
272
+ status = payload.get("status")
273
+ if status not in {"complete", "attention", "failed"}:
274
+ raise ValueError("invalid receipt status")
275
+ self.receipt = payload
276
+ self.receipt_done.set()
277
+ return self.receipt
278
+
279
+ def cancel(self) -> None:
280
+ with self.lock:
281
+ self.cancelled = True
282
+ self.done.set()
283
+
284
+ def cleanup(self) -> None:
285
+ shutil.rmtree(self.session_root, ignore_errors=True)
286
+
287
+
288
+ def available_connectors() -> list[str]:
289
+ # A connector is offered when its runtime shim ships with this instance.
290
+ # Authorization state is NOT checked here — the button's job is to route
291
+ # the user into the guided setup, which handles both fresh and connected.
292
+ here = Path(__file__).parent
293
+ providers = []
294
+ if (here / "connector-google.sh").exists():
295
+ providers.append("google")
296
+ if (here / "connector-notion.sh").exists():
297
+ providers.append("notion")
298
+ return providers
299
+
300
+
301
+ def _env_json_map(name: str) -> dict[str, str]:
302
+ try:
303
+ value = json.loads(os.environ.get(name, "") or "{}")
304
+ except json.JSONDecodeError:
305
+ return {}
306
+ if not isinstance(value, dict):
307
+ return {}
308
+ return {str(k): str(v) for k, v in value.items() if isinstance(v, (str, int))}
309
+
310
+
311
+ def connector_auth_urls() -> dict[str, str]:
312
+ # Consent URLs pre-fetched by the CLI (listener already running) so a
313
+ # connector click can redirect this same tab straight into sign-in.
314
+ # Only loopback/HTTPS URLs are accepted.
315
+ urls = {}
316
+ for provider, url in _env_json_map("EGREGORE_CONNECTOR_AUTH_URLS").items():
317
+ if url.startswith("https://") or url.startswith("http://127.0.0.1") or url.startswith("http://localhost"):
318
+ urls[provider] = url
319
+ return urls
320
+
321
+
322
+ def connector_accounts() -> dict[str, str]:
323
+ # Already-connected account per provider, for honest completion copy.
324
+ return _env_json_map("EGREGORE_CONNECTOR_ACCOUNTS")
325
+
326
+
327
+ def handler_class(state: SelectionState, token: str, expected_origin: str, preference_file: Path | None = None):
328
+ html_template = HTML_PATH.read_text(encoding="utf-8")
329
+ design_css = DESIGN_CSS_PATH.read_text(encoding="utf-8")
330
+ capabilities = {
331
+ "pdftotext": bool(shutil.which("pdftotext")),
332
+ "connectors": available_connectors(),
333
+ "connector_accounts": connector_accounts(),
334
+ }
335
+ preferences = load_preferences(preference_file or preferences_path())
336
+ page = (
337
+ html_template.replace("__EGREGORE_DESIGN_CSS__", design_css)
338
+ .replace("__EGREGORE_TOKEN_JSON__", json.dumps(token))
339
+ .replace("__EGREGORE_CAPABILITIES_JSON__", json.dumps(capabilities))
340
+ .replace("__EGREGORE_PREFERENCES_JSON__", json.dumps(preferences))
341
+ .encode("utf-8")
342
+ )
343
+ entry_path = f"/{token}/"
344
+
345
+ class Handler(BaseHTTPRequestHandler):
346
+ server_version = "EgregoreIngest/1"
347
+
348
+ def log_message(self, _format: str, *_args) -> None:
349
+ return
350
+
351
+ def send_headers(self, status: int, content_type: str, length: int) -> None:
352
+ self.send_response(status)
353
+ self.send_header("Content-Type", content_type)
354
+ self.send_header("Content-Length", str(length))
355
+ self.send_header("Cache-Control", "no-store")
356
+ self.send_header("X-Content-Type-Options", "nosniff")
357
+ self.send_header("X-Frame-Options", "DENY")
358
+ self.send_header("Referrer-Policy", "no-referrer")
359
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
360
+ self.send_header(
361
+ "Content-Security-Policy",
362
+ "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
363
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
364
+ )
365
+ self.end_headers()
366
+
367
+ def send_json(self, status: int, value: object) -> None:
368
+ body = json.dumps(value, ensure_ascii=False).encode("utf-8")
369
+ self.send_headers(status, "application/json; charset=utf-8", len(body))
370
+ self.wfile.write(body)
371
+
372
+ def do_GET(self) -> None:
373
+ if urllib.parse.urlsplit(self.path).path != entry_path:
374
+ self.send_json(404, {"error": "not found"})
375
+ return
376
+ self.send_headers(200, "text/html; charset=utf-8", len(page))
377
+ self.wfile.write(page)
378
+
379
+ def authorized(self) -> bool:
380
+ return secrets.compare_digest(self.headers.get("X-Egregore-Token", ""), token) and self.headers.get("Origin") == expected_origin
381
+
382
+ def token_authorized(self) -> bool:
383
+ return secrets.compare_digest(self.headers.get("X-Egregore-Token", ""), token)
384
+
385
+ def read_json(self) -> dict:
386
+ try:
387
+ length = int(self.headers.get("Content-Length", "-1"))
388
+ except ValueError as exc:
389
+ raise ValueError("invalid content length") from exc
390
+ if length < 0 or length > MAX_JSON_BYTES:
391
+ raise ValueError("invalid JSON body size")
392
+ value = json.loads(self.rfile.read(length))
393
+ if not isinstance(value, dict):
394
+ raise ValueError("JSON body must be an object")
395
+ return value
396
+
397
+ def do_POST(self) -> None:
398
+ path = urllib.parse.urlsplit(self.path).path
399
+ if path == "/api/result":
400
+ if not self.token_authorized():
401
+ self.send_json(403, {"error": "invalid receipt token"})
402
+ return
403
+ try:
404
+ self.send_json(200, state.finish_receipt(self.read_json()))
405
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
406
+ self.send_json(400, {"error": str(exc)})
407
+ return
408
+ if not self.authorized():
409
+ self.send_json(403, {"error": "invalid selection token or origin"})
410
+ return
411
+ try:
412
+ if path == "/api/upload":
413
+ raw_length = self.headers.get("Content-Length")
414
+ if raw_length is None:
415
+ raise ValueError("content length is required")
416
+ item = state.upload(
417
+ self.headers.get("X-Egregore-Path", ""),
418
+ self.headers.get("Content-Type", ""),
419
+ self.rfile,
420
+ int(raw_length),
421
+ )
422
+ self.send_json(201, item)
423
+ return
424
+ if path == "/api/confirm":
425
+ result = state.confirm(self.read_json())
426
+ self.send_json(200, result)
427
+ return
428
+ if path == "/api/connector":
429
+ result = state.choose_connector(self.read_json())
430
+ self.send_json(200, result)
431
+ return
432
+ if path == "/api/cancel":
433
+ state.cancel()
434
+ self.send_json(200, {"cancelled": True})
435
+ return
436
+ if path == "/api/status":
437
+ self.send_json(200, state.receipt)
438
+ return
439
+ if path == "/api/ack":
440
+ state.receipt_ack.set()
441
+ self.send_json(200, {"acknowledged": True})
442
+ return
443
+ if path == "/api/preferences":
444
+ saved = save_preferences(preference_file or preferences_path(), self.read_json())
445
+ self.send_json(200, saved)
446
+ return
447
+ self.send_json(404, {"error": "not found"})
448
+ except FileExistsError as exc:
449
+ self.send_json(409, {"error": str(exc)})
450
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
451
+ self.send_json(400, {"error": str(exc)})
452
+
453
+ return Handler
454
+
455
+
456
+ def build_server(state: SelectionState, token: str, host: str = "127.0.0.1", port: int = 0, preference_file: Path | None = None) -> ThreadingHTTPServer:
457
+ server = ThreadingHTTPServer((host, port), BaseHTTPRequestHandler)
458
+ actual_host, actual_port = server.server_address[:2]
459
+ origin = f"http://{actual_host}:{actual_port}"
460
+ state.receipt_url = origin
461
+ state.receipt_token = token
462
+ server.RequestHandlerClass = handler_class(state, token, origin, preference_file)
463
+ server.daemon_threads = True
464
+ return server
465
+
466
+
467
+ def serve_picker(args: argparse.Namespace) -> int:
468
+ staging_base = Path(args.staging_root).resolve() if args.staging_root else staging_root()
469
+ staging_base.mkdir(parents=True, exist_ok=True)
470
+ clean_stale_sessions(staging_base)
471
+ state = SelectionState(staging_base)
472
+ token = secrets.token_urlsafe(24)
473
+ server = build_server(state, token, port=args.port)
474
+ host, port = server.server_address[:2]
475
+ url = f"http://{host}:{port}/{token}/"
476
+ server.timeout = 0.25
477
+ print(json.dumps({"event": "ready", "url": url}), flush=True)
478
+ deadline = time.monotonic() + args.timeout
479
+ try:
480
+ while not state.done.is_set() and time.monotonic() < deadline:
481
+ server.handle_request()
482
+ except KeyboardInterrupt:
483
+ state.cancel()
484
+ if not state.done.is_set():
485
+ server.server_close()
486
+ state.cleanup()
487
+ print("ingest picker timed out", file=sys.stderr)
488
+ return 2
489
+ if state.cancelled:
490
+ server.server_close()
491
+ state.cleanup()
492
+ print(json.dumps({"schema": SELECTION_SCHEMA, "cancelled": True}))
493
+ return 0
494
+ print(json.dumps({"event": "selected", **state.result}, ensure_ascii=False), flush=True)
495
+ receipt_deadline = time.monotonic() + args.receipt_timeout
496
+ while not state.receipt_ack.is_set() and time.monotonic() < receipt_deadline:
497
+ server.handle_request()
498
+ if state.receipt_done.is_set() and time.monotonic() + 5 >= receipt_deadline:
499
+ break
500
+ server.server_close()
501
+ return 0
502
+
503
+
504
+ def run_picker(args: argparse.Namespace) -> int:
505
+ command = [sys.executable, str(Path(__file__).resolve()), "--serve", "--port", str(args.port), "--timeout", str(args.timeout), "--receipt-timeout", str(args.receipt_timeout)]
506
+ if args.staging_root:
507
+ command.extend(["--staging-root", args.staging_root])
508
+ child = subprocess.Popen(
509
+ command,
510
+ cwd=ROOT,
511
+ text=True,
512
+ stdout=subprocess.PIPE,
513
+ stderr=subprocess.DEVNULL,
514
+ start_new_session=True,
515
+ )
516
+ assert child.stdout
517
+ ready = json.loads(child.stdout.readline())
518
+ url = ready["url"]
519
+ if not args.no_open:
520
+ webbrowser.open(url, new=1)
521
+ print(f"ingest picker: {url}", file=sys.stderr, flush=True)
522
+ selected = json.loads(child.stdout.readline())
523
+ if selected.get("event") == "selected":
524
+ selected.pop("event", None)
525
+ print(json.dumps(selected, ensure_ascii=False), flush=True)
526
+ return 0
527
+ print(json.dumps({"schema": SELECTION_SCHEMA, "cancelled": True}))
528
+ return 0
529
+
530
+
531
+ def parser() -> argparse.ArgumentParser:
532
+ result = argparse.ArgumentParser(description=__doc__)
533
+ result.add_argument("--port", type=int, default=0)
534
+ result.add_argument("--timeout", type=int, default=30 * 60)
535
+ result.add_argument("--receipt-timeout", type=int, default=RECEIPT_GRACE_SECONDS)
536
+ result.add_argument("--staging-root")
537
+ result.add_argument("--no-open", action="store_true")
538
+ result.add_argument("--serve", action="store_true", help=argparse.SUPPRESS)
539
+ return result
540
+
541
+
542
+ if __name__ == "__main__":
543
+ options = parser().parse_args()
544
+ raise SystemExit(serve_picker(options) if options.serve else run_picker(options))
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # artifact-id.sh — Deterministic artifact IDs for memory files.
3
+ #
4
+ # Emits `{prefix}-{12 hex}` where prefix is derived from the file extension
5
+ # (m = .md, h = .html) and the hex is the first 12 chars of sha256(canonical_path).
6
+ #
7
+ # Kept in parity with packages/egregore-artifacts/lib/artifact-id.js — both MUST
8
+ # produce identical output for the same input. Any change here requires an
9
+ # equivalent change there (and a passing parity test in test/artifact-id.test.js).
10
+
11
+ _artifact_id_sha256() {
12
+ local input="$1"
13
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -d' ' -f1 \
14
+ || printf '%s' "$input" | sha256sum 2>/dev/null | cut -d' ' -f1
15
+ }
16
+
17
+ # artifact_id_from_path <path>
18
+ # Returns the deterministic artifact id for a repo-relative memory path, or
19
+ # empty (exit 1) if the extension is unsupported.
20
+ artifact_id_from_path() {
21
+ local raw="$1"
22
+ [ -n "$raw" ] || return 1
23
+
24
+ local canonical
25
+ canonical="${raw#./}"
26
+ canonical="$(printf '%s' "$canonical" | sed 's|/\{2,\}|/|g')"
27
+
28
+ # Extension match is case-insensitive to keep parity with the JS renderer
29
+ # (`extension()` in artifact-id.js lowercases before lookup). The canonical
30
+ # path itself is hashed verbatim — only the extension lookup is case-folded.
31
+ local ext_lower
32
+ ext_lower="$(printf '%s' "${canonical##*.}" | tr '[:upper:]' '[:lower:]')"
33
+
34
+ local prefix=""
35
+ case "$ext_lower" in
36
+ md) prefix="m" ;;
37
+ html) prefix="h" ;;
38
+ *) return 1 ;;
39
+ esac
40
+
41
+ local hash
42
+ hash="$(_artifact_id_sha256 "$canonical")"
43
+ [ -n "$hash" ] || return 1
44
+
45
+ printf '%s-%s' "$prefix" "${hash:0:12}"
46
+ }