create-egregore 0.19.8 → 0.19.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/local.js +11 -1
- package/lib/setup.js +33 -0
- package/package.json +1 -1
- package/runtime/codex/.codex/hooks/branch-guard.js +242 -34
- package/runtime/codex/.codex/skills/activity/SKILL.md +18 -6
- package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +90 -55
- package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +25 -0
- package/runtime/codex/.codex/skills/handoff/SKILL.md +6 -2
- package/runtime/codex/.codex/skills/save/SKILL.md +21 -3
- package/runtime/codex/.codex/skills/scroll/SKILL.md +54 -3
- package/runtime/codex/.codex/skills/task/SKILL.md +25 -0
- package/runtime/codex/.codex/skills/tui-design/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/wrap/SKILL.md +4 -1
- package/runtime/codex/.codex/spec-manifest.json +15 -3
- package/runtime/codex/AGENTS.md +45 -9
- package/runtime/codex/bin/activity-data.sh +1 -1
- package/runtime/codex/bin/agent.sh +66 -23
- package/runtime/codex/bin/attendant.sh +525 -0
- package/runtime/codex/bin/capture-reconcile.sh +67 -0
- package/runtime/codex/bin/capture-run.sh +341 -0
- package/runtime/codex/bin/codex-render-spec.mjs +15 -4
- package/runtime/codex/bin/codex-session-start.sh +45 -3
- package/runtime/codex/bin/codex-skill-render.mjs +23 -4
- package/runtime/codex/bin/graph-op.sh +719 -49
- package/runtime/codex/bin/graph-wal.sh +3 -2
- package/runtime/codex/bin/handoff-run.sh +72 -0
- package/runtime/codex/bin/handoff-save-egregore.sh +51 -11
- package/runtime/codex/bin/index-handoff.sh +26 -17
- package/runtime/codex/bin/ingest-graph.sh +80 -0
- package/runtime/codex/bin/ingest-surface.html +429 -0
- package/runtime/codex/bin/ingest.py +614 -0
- package/runtime/codex/bin/ingest.sh +231 -0
- package/runtime/codex/bin/ingest_graph.py +776 -0
- package/runtime/codex/bin/ingest_surface.py +464 -0
- package/runtime/codex/bin/lib/git-sync.sh +2 -6
- package/runtime/codex/bin/lib/greeting.sh +157 -22
- package/runtime/codex/bin/lib/identity.sh +50 -29
- package/runtime/codex/bin/lib/noncode.sh +31 -0
- package/runtime/codex/bin/person.py +610 -0
- package/runtime/codex/bin/person.sh +448 -0
- package/runtime/codex/bin/session-autosave.sh +184 -0
- package/runtime/codex/bin/session-log.sh +199 -0
- package/runtime/codex/bin/settings.sh +1 -1
- package/runtime/codex/manifest.json +19 -1
- package/runtime/pi/.codex/hooks/branch-guard.js +242 -34
- package/runtime/pi/.codex/skills/activity/SKILL.md +18 -6
- package/runtime/pi/.codex/skills/deep-reflect/SKILL.md +90 -55
- package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +25 -0
- package/runtime/pi/.codex/skills/handoff/SKILL.md +6 -2
- package/runtime/pi/.codex/skills/save/SKILL.md +21 -3
- package/runtime/pi/.codex/skills/scroll/SKILL.md +54 -3
- package/runtime/pi/.codex/skills/task/SKILL.md +25 -0
- package/runtime/pi/.codex/skills/tui-design/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/wrap/SKILL.md +4 -1
- package/runtime/pi/.codex/spec-manifest.json +15 -3
- package/runtime/pi/.pi/APPEND_SYSTEM.md +41 -5
- package/runtime/pi/.pi/extensions/egregore.ts +98 -8
- package/runtime/pi/.pi/spec-manifest.json +2 -2
- package/runtime/pi/AGENTS.md +45 -9
- package/runtime/pi/bin/activity-data.sh +1 -1
- package/runtime/pi/bin/agent.sh +66 -23
- package/runtime/pi/bin/attendant.sh +525 -0
- package/runtime/pi/bin/capture-reconcile.sh +67 -0
- package/runtime/pi/bin/capture-run.sh +341 -0
- package/runtime/pi/bin/codex-render-spec.mjs +15 -4
- package/runtime/pi/bin/codex-session-start.sh +45 -3
- package/runtime/pi/bin/codex-skill-render.mjs +23 -4
- package/runtime/pi/bin/graph-op.sh +719 -49
- package/runtime/pi/bin/graph-wal.sh +3 -2
- package/runtime/pi/bin/handoff-run.sh +72 -0
- package/runtime/pi/bin/handoff-save-egregore.sh +51 -11
- package/runtime/pi/bin/index-handoff.sh +26 -17
- package/runtime/pi/bin/ingest-graph.sh +80 -0
- package/runtime/pi/bin/ingest-surface.html +429 -0
- package/runtime/pi/bin/ingest.py +614 -0
- package/runtime/pi/bin/ingest.sh +231 -0
- package/runtime/pi/bin/ingest_graph.py +776 -0
- package/runtime/pi/bin/ingest_surface.py +464 -0
- package/runtime/pi/bin/lib/git-sync.sh +2 -6
- package/runtime/pi/bin/lib/greeting.sh +157 -22
- package/runtime/pi/bin/lib/identity.sh +50 -29
- package/runtime/pi/bin/lib/noncode.sh +31 -0
- package/runtime/pi/bin/person.py +610 -0
- package/runtime/pi/bin/person.sh +448 -0
- package/runtime/pi/bin/pi-branch-policy.mjs +164 -4
- package/runtime/pi/bin/pi-product-workflows.mjs +31 -2
- package/runtime/pi/bin/publish-artifact.sh +14 -0
- package/runtime/pi/bin/session-autosave.sh +184 -0
- package/runtime/pi/bin/session-log.sh +199 -0
- package/runtime/pi/bin/settings.sh +274 -0
- package/runtime/pi/manifest.json +20 -1
|
@@ -0,0 +1,464 @@
|
|
|
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 finish_receipt(self, payload: dict) -> dict:
|
|
236
|
+
with self.lock:
|
|
237
|
+
if not self.done.is_set() or self.cancelled:
|
|
238
|
+
raise ValueError("selection has not been confirmed")
|
|
239
|
+
status = payload.get("status")
|
|
240
|
+
if status not in {"complete", "attention", "failed"}:
|
|
241
|
+
raise ValueError("invalid receipt status")
|
|
242
|
+
self.receipt = payload
|
|
243
|
+
self.receipt_done.set()
|
|
244
|
+
return self.receipt
|
|
245
|
+
|
|
246
|
+
def cancel(self) -> None:
|
|
247
|
+
with self.lock:
|
|
248
|
+
self.cancelled = True
|
|
249
|
+
self.done.set()
|
|
250
|
+
|
|
251
|
+
def cleanup(self) -> None:
|
|
252
|
+
shutil.rmtree(self.session_root, ignore_errors=True)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def handler_class(state: SelectionState, token: str, expected_origin: str, preference_file: Path | None = None):
|
|
256
|
+
html_template = HTML_PATH.read_text(encoding="utf-8")
|
|
257
|
+
design_css = DESIGN_CSS_PATH.read_text(encoding="utf-8")
|
|
258
|
+
capabilities = {"pdftotext": bool(shutil.which("pdftotext"))}
|
|
259
|
+
preferences = load_preferences(preference_file or preferences_path())
|
|
260
|
+
page = (
|
|
261
|
+
html_template.replace("__EGREGORE_DESIGN_CSS__", design_css)
|
|
262
|
+
.replace("__EGREGORE_TOKEN_JSON__", json.dumps(token))
|
|
263
|
+
.replace("__EGREGORE_CAPABILITIES_JSON__", json.dumps(capabilities))
|
|
264
|
+
.replace("__EGREGORE_PREFERENCES_JSON__", json.dumps(preferences))
|
|
265
|
+
.encode("utf-8")
|
|
266
|
+
)
|
|
267
|
+
entry_path = f"/{token}/"
|
|
268
|
+
|
|
269
|
+
class Handler(BaseHTTPRequestHandler):
|
|
270
|
+
server_version = "EgregoreIngest/1"
|
|
271
|
+
|
|
272
|
+
def log_message(self, _format: str, *_args) -> None:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
def send_headers(self, status: int, content_type: str, length: int) -> None:
|
|
276
|
+
self.send_response(status)
|
|
277
|
+
self.send_header("Content-Type", content_type)
|
|
278
|
+
self.send_header("Content-Length", str(length))
|
|
279
|
+
self.send_header("Cache-Control", "no-store")
|
|
280
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
281
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
282
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
283
|
+
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
|
284
|
+
self.send_header(
|
|
285
|
+
"Content-Security-Policy",
|
|
286
|
+
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
|
287
|
+
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
288
|
+
)
|
|
289
|
+
self.end_headers()
|
|
290
|
+
|
|
291
|
+
def send_json(self, status: int, value: object) -> None:
|
|
292
|
+
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
|
|
293
|
+
self.send_headers(status, "application/json; charset=utf-8", len(body))
|
|
294
|
+
self.wfile.write(body)
|
|
295
|
+
|
|
296
|
+
def do_GET(self) -> None:
|
|
297
|
+
if urllib.parse.urlsplit(self.path).path != entry_path:
|
|
298
|
+
self.send_json(404, {"error": "not found"})
|
|
299
|
+
return
|
|
300
|
+
self.send_headers(200, "text/html; charset=utf-8", len(page))
|
|
301
|
+
self.wfile.write(page)
|
|
302
|
+
|
|
303
|
+
def authorized(self) -> bool:
|
|
304
|
+
return secrets.compare_digest(self.headers.get("X-Egregore-Token", ""), token) and self.headers.get("Origin") == expected_origin
|
|
305
|
+
|
|
306
|
+
def token_authorized(self) -> bool:
|
|
307
|
+
return secrets.compare_digest(self.headers.get("X-Egregore-Token", ""), token)
|
|
308
|
+
|
|
309
|
+
def read_json(self) -> dict:
|
|
310
|
+
try:
|
|
311
|
+
length = int(self.headers.get("Content-Length", "-1"))
|
|
312
|
+
except ValueError as exc:
|
|
313
|
+
raise ValueError("invalid content length") from exc
|
|
314
|
+
if length < 0 or length > MAX_JSON_BYTES:
|
|
315
|
+
raise ValueError("invalid JSON body size")
|
|
316
|
+
value = json.loads(self.rfile.read(length))
|
|
317
|
+
if not isinstance(value, dict):
|
|
318
|
+
raise ValueError("JSON body must be an object")
|
|
319
|
+
return value
|
|
320
|
+
|
|
321
|
+
def do_POST(self) -> None:
|
|
322
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
323
|
+
if path == "/api/result":
|
|
324
|
+
if not self.token_authorized():
|
|
325
|
+
self.send_json(403, {"error": "invalid receipt token"})
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
self.send_json(200, state.finish_receipt(self.read_json()))
|
|
329
|
+
except (ValueError, OSError, json.JSONDecodeError) as exc:
|
|
330
|
+
self.send_json(400, {"error": str(exc)})
|
|
331
|
+
return
|
|
332
|
+
if not self.authorized():
|
|
333
|
+
self.send_json(403, {"error": "invalid selection token or origin"})
|
|
334
|
+
return
|
|
335
|
+
try:
|
|
336
|
+
if path == "/api/upload":
|
|
337
|
+
raw_length = self.headers.get("Content-Length")
|
|
338
|
+
if raw_length is None:
|
|
339
|
+
raise ValueError("content length is required")
|
|
340
|
+
item = state.upload(
|
|
341
|
+
self.headers.get("X-Egregore-Path", ""),
|
|
342
|
+
self.headers.get("Content-Type", ""),
|
|
343
|
+
self.rfile,
|
|
344
|
+
int(raw_length),
|
|
345
|
+
)
|
|
346
|
+
self.send_json(201, item)
|
|
347
|
+
return
|
|
348
|
+
if path == "/api/confirm":
|
|
349
|
+
result = state.confirm(self.read_json())
|
|
350
|
+
self.send_json(200, result)
|
|
351
|
+
return
|
|
352
|
+
if path == "/api/cancel":
|
|
353
|
+
state.cancel()
|
|
354
|
+
self.send_json(200, {"cancelled": True})
|
|
355
|
+
return
|
|
356
|
+
if path == "/api/status":
|
|
357
|
+
self.send_json(200, state.receipt)
|
|
358
|
+
return
|
|
359
|
+
if path == "/api/ack":
|
|
360
|
+
state.receipt_ack.set()
|
|
361
|
+
self.send_json(200, {"acknowledged": True})
|
|
362
|
+
return
|
|
363
|
+
if path == "/api/preferences":
|
|
364
|
+
saved = save_preferences(preference_file or preferences_path(), self.read_json())
|
|
365
|
+
self.send_json(200, saved)
|
|
366
|
+
return
|
|
367
|
+
self.send_json(404, {"error": "not found"})
|
|
368
|
+
except FileExistsError as exc:
|
|
369
|
+
self.send_json(409, {"error": str(exc)})
|
|
370
|
+
except (ValueError, OSError, json.JSONDecodeError) as exc:
|
|
371
|
+
self.send_json(400, {"error": str(exc)})
|
|
372
|
+
|
|
373
|
+
return Handler
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_server(state: SelectionState, token: str, host: str = "127.0.0.1", port: int = 0, preference_file: Path | None = None) -> ThreadingHTTPServer:
|
|
377
|
+
server = ThreadingHTTPServer((host, port), BaseHTTPRequestHandler)
|
|
378
|
+
actual_host, actual_port = server.server_address[:2]
|
|
379
|
+
origin = f"http://{actual_host}:{actual_port}"
|
|
380
|
+
state.receipt_url = origin
|
|
381
|
+
state.receipt_token = token
|
|
382
|
+
server.RequestHandlerClass = handler_class(state, token, origin, preference_file)
|
|
383
|
+
server.daemon_threads = True
|
|
384
|
+
return server
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def serve_picker(args: argparse.Namespace) -> int:
|
|
388
|
+
staging_base = Path(args.staging_root).resolve() if args.staging_root else staging_root()
|
|
389
|
+
staging_base.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
clean_stale_sessions(staging_base)
|
|
391
|
+
state = SelectionState(staging_base)
|
|
392
|
+
token = secrets.token_urlsafe(24)
|
|
393
|
+
server = build_server(state, token, port=args.port)
|
|
394
|
+
host, port = server.server_address[:2]
|
|
395
|
+
url = f"http://{host}:{port}/{token}/"
|
|
396
|
+
server.timeout = 0.25
|
|
397
|
+
print(json.dumps({"event": "ready", "url": url}), flush=True)
|
|
398
|
+
deadline = time.monotonic() + args.timeout
|
|
399
|
+
try:
|
|
400
|
+
while not state.done.is_set() and time.monotonic() < deadline:
|
|
401
|
+
server.handle_request()
|
|
402
|
+
except KeyboardInterrupt:
|
|
403
|
+
state.cancel()
|
|
404
|
+
if not state.done.is_set():
|
|
405
|
+
server.server_close()
|
|
406
|
+
state.cleanup()
|
|
407
|
+
print("ingest picker timed out", file=sys.stderr)
|
|
408
|
+
return 2
|
|
409
|
+
if state.cancelled:
|
|
410
|
+
server.server_close()
|
|
411
|
+
state.cleanup()
|
|
412
|
+
print(json.dumps({"schema": SELECTION_SCHEMA, "cancelled": True}))
|
|
413
|
+
return 0
|
|
414
|
+
print(json.dumps({"event": "selected", **state.result}, ensure_ascii=False), flush=True)
|
|
415
|
+
receipt_deadline = time.monotonic() + args.receipt_timeout
|
|
416
|
+
while not state.receipt_ack.is_set() and time.monotonic() < receipt_deadline:
|
|
417
|
+
server.handle_request()
|
|
418
|
+
if state.receipt_done.is_set() and time.monotonic() + 5 >= receipt_deadline:
|
|
419
|
+
break
|
|
420
|
+
server.server_close()
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def run_picker(args: argparse.Namespace) -> int:
|
|
425
|
+
command = [sys.executable, str(Path(__file__).resolve()), "--serve", "--port", str(args.port), "--timeout", str(args.timeout), "--receipt-timeout", str(args.receipt_timeout)]
|
|
426
|
+
if args.staging_root:
|
|
427
|
+
command.extend(["--staging-root", args.staging_root])
|
|
428
|
+
child = subprocess.Popen(
|
|
429
|
+
command,
|
|
430
|
+
cwd=ROOT,
|
|
431
|
+
text=True,
|
|
432
|
+
stdout=subprocess.PIPE,
|
|
433
|
+
stderr=subprocess.DEVNULL,
|
|
434
|
+
start_new_session=True,
|
|
435
|
+
)
|
|
436
|
+
assert child.stdout
|
|
437
|
+
ready = json.loads(child.stdout.readline())
|
|
438
|
+
url = ready["url"]
|
|
439
|
+
if not args.no_open:
|
|
440
|
+
webbrowser.open(url, new=1)
|
|
441
|
+
print(f"ingest picker: {url}", file=sys.stderr, flush=True)
|
|
442
|
+
selected = json.loads(child.stdout.readline())
|
|
443
|
+
if selected.get("event") == "selected":
|
|
444
|
+
selected.pop("event", None)
|
|
445
|
+
print(json.dumps(selected, ensure_ascii=False), flush=True)
|
|
446
|
+
return 0
|
|
447
|
+
print(json.dumps({"schema": SELECTION_SCHEMA, "cancelled": True}))
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def parser() -> argparse.ArgumentParser:
|
|
452
|
+
result = argparse.ArgumentParser(description=__doc__)
|
|
453
|
+
result.add_argument("--port", type=int, default=0)
|
|
454
|
+
result.add_argument("--timeout", type=int, default=30 * 60)
|
|
455
|
+
result.add_argument("--receipt-timeout", type=int, default=RECEIPT_GRACE_SECONDS)
|
|
456
|
+
result.add_argument("--staging-root")
|
|
457
|
+
result.add_argument("--no-open", action="store_true")
|
|
458
|
+
result.add_argument("--serve", action="store_true", help=argparse.SUPPRESS)
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
options = parser().parse_args()
|
|
464
|
+
raise SystemExit(serve_picker(options) if options.serve else run_picker(options))
|
|
@@ -265,6 +265,8 @@ if [ -d "$SCRIPT_DIR/memory/.git" ]; then
|
|
|
265
265
|
fi
|
|
266
266
|
|
|
267
267
|
# --- Sync managed repos ---
|
|
268
|
+
# Managed repos sync silently — they are a CLI concern and never render in the
|
|
269
|
+
# greeting. REPOS_STATUS stays exported (empty) for consumers of this lib.
|
|
268
270
|
REPOS_STATUS=""
|
|
269
271
|
for REPO in $MANAGED_REPOS; do
|
|
270
272
|
REPO_DIR="$SCRIPT_DIR/../$REPO"
|
|
@@ -278,11 +280,5 @@ for REPO in $MANAGED_REPOS; do
|
|
|
278
280
|
else
|
|
279
281
|
git -C "$REPO_DIR" merge --ff-only "origin/$REPO_BASE" --quiet 2>/dev/null || true
|
|
280
282
|
fi
|
|
281
|
-
# Collect status
|
|
282
|
-
R_DIRTY=""
|
|
283
|
-
if [ -n "$(git -C "$REPO_DIR" status --porcelain 2>/dev/null | head -1)" ]; then
|
|
284
|
-
R_DIRTY=" *"
|
|
285
|
-
fi
|
|
286
|
-
REPOS_STATUS="${REPOS_STATUS} ◇ ${REPO}: ${R_BRANCH}${R_DIRTY}\n"
|
|
287
283
|
fi
|
|
288
284
|
done
|