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.
- package/assets/egregore-launcher.js +118 -13
- package/bin/cli.js +48 -3
- package/lib/connect-runtime.js +4 -2
- package/lib/local.js +15 -1
- package/lib/prime-runtime.js +208 -0
- package/lib/setup.js +45 -3
- package/package.json +5 -4
- package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
- package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
- package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
- package/runtime/codex/.codex/spec-manifest.json +3 -3
- package/runtime/codex/AGENTS.md +11 -4
- package/runtime/codex/bin/activity-data.sh +4 -8
- package/runtime/codex/bin/attendant.sh +13 -0
- package/runtime/codex/bin/autosave.sh +27 -1
- package/runtime/codex/bin/codex-render-spec.mjs +6 -0
- package/runtime/codex/bin/codex-session-start.sh +8 -1
- package/runtime/codex/bin/connect-refresh.sh +33 -0
- package/runtime/codex/bin/graph-batch.sh +25 -6
- package/runtime/codex/bin/graph-op.sh +5 -4
- package/runtime/codex/bin/graph.sh +31 -5
- package/runtime/codex/bin/ingest.py +81 -5
- package/runtime/codex/bin/lib/context.sh +26 -1
- package/runtime/codex/bin/lib/greeting.sh +4 -1
- package/runtime/codex/bin/lib/worktree-links.sh +25 -0
- package/runtime/codex/manifest.json +8 -1
- package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
- package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
- package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
- package/runtime/pi/.codex/spec-manifest.json +3 -3
- package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
- package/runtime/pi/.pi/extensions/egregore.ts +17 -6
- package/runtime/pi/.pi/spec-manifest.json +2 -2
- package/runtime/pi/AGENTS.md +11 -4
- package/runtime/pi/bin/activity-data.sh +4 -8
- package/runtime/pi/bin/attendant.sh +13 -0
- package/runtime/pi/bin/autosave.sh +27 -1
- package/runtime/pi/bin/codex-render-spec.mjs +6 -0
- package/runtime/pi/bin/codex-session-start.sh +8 -1
- package/runtime/pi/bin/connect-refresh.sh +33 -0
- package/runtime/pi/bin/graph-batch.sh +25 -6
- package/runtime/pi/bin/graph-op.sh +5 -4
- package/runtime/pi/bin/graph.sh +31 -5
- package/runtime/pi/bin/ingest.py +81 -5
- package/runtime/pi/bin/lib/context.sh +26 -1
- package/runtime/pi/bin/lib/greeting.sh +4 -1
- package/runtime/pi/bin/lib/worktree-links.sh +25 -0
- package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
- package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
- package/runtime/pi/manifest.json +8 -1
- package/runtime/prime/.claude/context/notification-consent.md +66 -0
- package/runtime/prime/.codex/config.toml +17 -0
- package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
- package/runtime/prime/.codex/hooks/search-hint.js +67 -0
- package/runtime/prime/.codex/hooks.json +30 -0
- package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
- package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
- package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
- package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
- package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
- package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
- package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
- package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
- package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
- package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
- package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
- package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
- package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
- package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
- package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
- package/runtime/prime/.codex/spec-manifest.json +132 -0
- package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
- package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
- package/runtime/prime/.prime/agent/settings.json +4 -0
- package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
- package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
- package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
- package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
- package/runtime/prime/AGENTS.md +489 -0
- package/runtime/prime/bin/activity-data.sh +238 -0
- package/runtime/prime/bin/agent.sh +775 -0
- package/runtime/prime/bin/attendant.sh +707 -0
- package/runtime/prime/bin/autosave.sh +112 -0
- package/runtime/prime/bin/capture-reconcile.sh +69 -0
- package/runtime/prime/bin/capture-run.sh +341 -0
- package/runtime/prime/bin/codex-install-skills.sh +78 -0
- package/runtime/prime/bin/codex-render-spec.mjs +440 -0
- package/runtime/prime/bin/codex-session-start.sh +265 -0
- package/runtime/prime/bin/codex-skill-render.mjs +347 -0
- package/runtime/prime/bin/codex-sync-skills.sh +232 -0
- package/runtime/prime/bin/connect-refresh.sh +33 -0
- package/runtime/prime/bin/dashboard-data.sh +178 -0
- package/runtime/prime/bin/graph-batch.sh +79 -0
- package/runtime/prime/bin/graph-op.sh +1102 -0
- package/runtime/prime/bin/graph-wal.sh +229 -0
- package/runtime/prime/bin/graph.sh +223 -0
- package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
- package/runtime/prime/bin/handoff-run.sh +671 -0
- package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
- package/runtime/prime/bin/index-handoff.sh +322 -0
- package/runtime/prime/bin/ingest-graph.sh +80 -0
- package/runtime/prime/bin/ingest-surface.html +495 -0
- package/runtime/prime/bin/ingest.py +756 -0
- package/runtime/prime/bin/ingest.sh +279 -0
- package/runtime/prime/bin/ingest_graph.py +856 -0
- package/runtime/prime/bin/ingest_surface.py +544 -0
- package/runtime/prime/bin/lib/artifact-id.sh +46 -0
- package/runtime/prime/bin/lib/config.sh +116 -0
- package/runtime/prime/bin/lib/context.sh +621 -0
- package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
- package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
- package/runtime/prime/bin/lib/git-sync.sh +406 -0
- package/runtime/prime/bin/lib/greeting.sh +519 -0
- package/runtime/prime/bin/lib/hash.sh +13 -0
- package/runtime/prime/bin/lib/identity.sh +213 -0
- package/runtime/prime/bin/lib/metrics.sh +374 -0
- package/runtime/prime/bin/lib/noncode.sh +31 -0
- package/runtime/prime/bin/lib/permissions.sh +106 -0
- package/runtime/prime/bin/lib/time.sh +56 -0
- package/runtime/prime/bin/lib/worktree-links.sh +79 -0
- package/runtime/prime/bin/notify.sh +636 -0
- package/runtime/prime/bin/person.py +630 -0
- package/runtime/prime/bin/person.sh +448 -0
- package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
- package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
- package/runtime/prime/bin/prime-render-spec.mjs +123 -0
- package/runtime/prime/bin/prime-session-start.sh +24 -0
- package/runtime/prime/bin/publish-artifact.sh +271 -0
- package/runtime/prime/bin/publish-references.sh +144 -0
- package/runtime/prime/bin/repo-state.sh +96 -0
- package/runtime/prime/bin/search.sh +240 -0
- package/runtime/prime/bin/session-autosave.sh +209 -0
- package/runtime/prime/bin/session-log.sh +199 -0
- package/runtime/prime/bin/settings.sh +304 -0
- package/runtime/prime/bin/telemetry.sh +391 -0
- package/runtime/prime/bin/worktree-create.sh +128 -0
- package/runtime/prime/bin/worktree-remove.sh +50 -0
- package/runtime/prime/bin/worktree.sh +198 -0
- 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
|
+
}
|