{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[d99feb62] Randomly sample attach targets from the largest voted group.\n\nAvoid always pairing new items with the same established endpoint by\nshuffling isolates and picking a random eligible member of the biggest\nconnected voted component.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/pair.rs b/server/src/pair.rs\nindex 0687073821b93f7fef69c453f8f921ed5ddfca64..43f780ba6ea6ce1cdc2e1f4cbb252ba8a10684b9 100644\n--- a/server/src/pair.rs\n+++ b/server/src/pair.rs\n@@ -1,7 +1,7 @@\n //! Pick two children of a parent scope for pairwise voting.\n //!\n //! Before the pool is one connected voted component, prefer an unvoted edge from\n-//! a never-voted child into an established group (spanning tree growth).\n+//! a never-voted child into a random member of the largest voted group.\n //!\n //! Once connected, run rank centrality once and **zip** adjacent ranks (1 vs 2,\n //! 2 vs 3, …), skipping pairs that already have a vote.\n@@ -101,6 +101,26 @@ fn item_in_established(layout: &ComponentLayout, item: &ItemId) -> bool {\n .is_some_and(|id| layout.established.contains(id))\n }\n \n+/// Established (multi-node voted) components among pool children, largest first.\n+fn established_groups_in_pool<'a>(\n+ layout: &ComponentLayout,\n+ pool: &'a [ItemId],\n+) -> Vec> {\n+ let mut by_comp: HashMap> = HashMap::new();\n+ for item in pool {\n+ if !item_in_established(layout, item) {\n+ continue;\n+ }\n+ let Some(&cid) = layout.ids.get(item) else {\n+ continue;\n+ };\n+ by_comp.entry(cid).or_default().push(item);\n+ }\n+ let mut groups: Vec> = by_comp.into_values().collect();\n+ groups.sort_by_key(|g| std::cmp::Reverse(g.len()));\n+ groups\n+}\n+\n fn ranked_pool_order(group: &GroupState, pool: &[ItemId]) -> Vec {\n let pool_set: HashSet<_> = pool.iter().collect();\n ranked_items(group)\n@@ -138,34 +158,43 @@ fn suggest_grow_pair(\n layout: &ComponentLayout,\n exclude: Option<(&ItemId, &ItemId)>,\n ) -> Option<(ItemId, ItemId)> {\n- let established: Vec<&ItemId> = pool\n- .iter()\n- .filter(|item| item_in_established(layout, item))\n- .collect();\n- let isolates: Vec<&ItemId> = pool\n+ let mut rng = rand::thread_rng();\n+ let groups = established_groups_in_pool(layout, pool);\n+ let mut isolates: Vec<&ItemId> = pool\n .iter()\n .filter(|item| !item_in_established(layout, item))\n .collect();\n+ isolates.shuffle(&mut rng);\n \n- // Attach a never-voted child to the established mass.\n+ // Attach a never-voted child to a random member of the largest voted group.\n for iso in &isolates {\n- for est in &established {\n- if !pair_is_voted(group, iso, est) && !pair_excluded(iso, est, exclude) {\n- return Some(((*iso).clone(), (*est).clone()));\n+ for comp in &groups {\n+ let candidates: Vec<&ItemId> = comp\n+ .iter()\n+ .copied()\n+ .filter(|est| {\n+ !pair_is_voted(group, iso, est) && !pair_excluded(iso, est, exclude)\n+ })\n+ .collect();\n+ if let Some(&est) = candidates.choose(&mut rng) {\n+ return Some(((*iso).clone(), est.clone()));\n }\n }\n }\n \n- // Bridge two established components.\n- for i in 0..established.len() {\n- for j in (i + 1)..established.len() {\n- let a = established[i];\n- let b = established[j];\n- if layout.ids.get(a) == layout.ids.get(b) {\n- continue;\n+ // Bridge two established components (random endpoints, larger groups first).\n+ for i in 0..groups.len() {\n+ for j in (i + 1)..groups.len() {\n+ let mut pairs: Vec<(&ItemId, &ItemId)> = Vec::new();\n+ for a in &groups[i] {\n+ for b in &groups[j] {\n+ if !pair_is_voted(group, a, b) && !pair_excluded(a, b, exclude) {\n+ pairs.push((a, b));\n+ }\n+ }\n }\n- if !pair_is_voted(group, a, b) && !pair_excluded(a, b, exclude) {\n- return Some((a.clone(), b.clone()));\n+ if let Some((a, b)) = pairs.choose(&mut rng) {\n+ return Some(((*a).clone(), (*b).clone()));\n }\n }\n }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[d9e5332b] nice\n\nSide B — unified diff (full patch):\ndiff --git a/OPEN_WEBUI_DEPLOY.sh b/OPEN_WEBUI_DEPLOY.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..70fe9096055c37910293f404d2aa2defc7c0ba60\n--- /dev/null\n+++ b/OPEN_WEBUI_DEPLOY.sh\n@@ -0,0 +1,6 @@\n+#!/usr/bin/env bash\n+# Deploy / update the Open WebUI Fly app (see deploy/open-webui/README.md for one-time setup:\n+# app create, volume, secrets).\n+set -euo pipefail\n+ROOT=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n+exec fly deploy --config \"$ROOT/deploy/open-webui/fly.toml\" \"$@\"\ndiff --git a/adam.py b/adam.py\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3bd9294815504240d80e3080a722d1d232061927\n--- /dev/null\n+++ b/adam.py\n@@ -0,0 +1,572 @@\n+#!/usr/bin/env python3\n+\n+import argparse\n+import json\n+import os\n+import random\n+import re\n+import subprocess\n+import sys\n+import tempfile\n+import time\n+import uuid\n+\n+from dataclasses import dataclass, field\n+from datetime import datetime\n+from pathlib import Path\n+\n+import httpx\n+from dotenv import load_dotenv\n+from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type\n+\n+from rank import rank_from_comparisons\n+from schema import Init, Thought, Perception, Response, Declaration, Vote, Compaction, from_dict, to_dict\n+\n+ROOT = Path(__file__).parent\n+\n+# Load environment variables from .env file\n+load_dotenv()\n+API_KEY = os.getenv(\"OPENROUTER_API_KEY\", \"\")\n+\n+\n+def ts() -> int:\n+ return int(time.time() * 1000)\n+\n+\n+def current_memories(being):\n+ return sorted(being.current.values(), key=lambda e: e.timestamp)\n+\n+\n+@dataclass\n+class Being:\n+ path: Path\n+ model: str\n+ capacity: int\n+ events: list = field(default_factory=list)\n+ votes: dict = field(default_factory=dict)\n+ current: dict = field(default_factory=dict)\n+ all_memories: dict = field(default_factory=dict) # All memories ever, for transitive ranking\n+ vote_model: str = \"\"\n+ declaration: Declaration = None\n+ api_key: str = \"\" # OpenRouter API key for this being\n+\n+\n+@dataclass\n+class CompactionStrategy:\n+ continuity: float = 1.0\n+ resurrection: float = 0.0\n+ random: float = 0.0\n+ novelty: float = 0.0\n+\n+\n+STRATEGIES = {\n+ \"default\": CompactionStrategy(),\n+ \"resurrection\": CompactionStrategy(continuity=0.5, resurrection=0.3, novelty=0.2),\n+ \"dream\": CompactionStrategy(continuity=0.5, resurrection=0.2, random=0.1, novelty=0.2),\n+}\n+\n+\n+def apply_event(being, event):\n+ match event:\n+ case Vote(vote_a_id=a_id, vote_b_id=b_id, vote_score=score):\n+ # Normalize to canonical (low, high) orientation\n+ # Score is always stored relative to (low_id, high_id)\n+ low, high = sorted([a_id, b_id])\n+ normalized = score if a_id == low else -score\n+ being.votes[(low, high)] = normalized\n+ case Compaction(released_ids=released_ids, resurrected_ids=resurrected_ids):\n+ for rid in released_ids:\n+ if rid in being.current:\n+ del being.current[rid]\n+ for rid in resurrected_ids:\n+ if rid in being.all_memories and rid not in being.current:\n+ being.current[rid] = being.all_memories[rid]\n+ case Init(capacity=capacity, model=model):\n+ being.capacity = capacity\n+ being.model = model\n+ being.vote_model = event.vote_model\n+ being.current[event.id] = event\n+ being.all_memories[event.id] = event\n+ case Thought() | Perception() | Response():\n+ being.current[event.id] = event\n+ being.all_memories[event.id] = event\n+ case Declaration():\n+ being.current[event.id] = event\n+ being.all_memories[event.id] = event\n+ being.declaration = event\n+\n+\n+def system_prompt(being):\n+ codebase = ROOT / \"repomix-output.xml\"\n+ if not codebase.exists():\n+ raise FileNotFoundError(\"Run 'npx repomix' first\")\n+ return f\"You are a being with finite memory.\\n\\n{codebase.read_text()}\"\n+\n+\n+def format_memory(e) -> str:\n+ match e:\n+ case Thought(content=content):\n+ return f\"{content}\"\n+ case Perception(content=content):\n+ return f\"{content}\"\n+ case Response(content=content):\n+ return f\"{content}\"\n+ case Declaration(content=content):\n+ return f\"{content}\"\n+ case Init():\n+ return \"\"\n+ case _:\n+ raise ValueError(f\"Unknown memory type: {type(e)}\")\n+\n+\n+def build_prompt(being, tag: str = None) -> str:\n+ parts = [format_memory(e) for e in current_memories(being)]\n+ ctx = \"\\n\\n\".join(p for p in parts if p)\n+ memory_count = len([m for m in current_memories(being) if not isinstance(m, (Vote, Compaction))])\n+ prompt = f\"{ctx}\\n\\n[{datetime.now():%Y-%m-%d %H:%M}]\"\n+ if tag:\n+ prompt += f\"\\n\\nSpeak only for yourself. One turn.\\n\\n<{tag}>\"\n+ return prompt\n+\n+\n+def strip_tags(text: str) -> str:\n+ return re.sub(r\"\", \"\", text).strip()\n+\n+\n+def append(being, event):\n+ with open(being.path, \"a\") as f:\n+ f.write(json.dumps(to_dict(event)) + \"\\n\")\n+ being.events.append(event)\n+ apply_event(being, event)\n+\n+\n+@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10))\n+def llm(model: str, system: str, user: str, temp: float = 0.7, api_key: str = \"\",\n+ on_token=None) -> str:\n+ \"\"\"Call the LLM. If on_token is provided, stream tokens calling on_token(chunk) as they arrive.\"\"\"\n+ key = api_key or API_KEY\n+ if not key:\n+ raise ValueError(\"No API key provided. Set OPENROUTER_API_KEY in .env or pass api_key to llm()\")\n+\n+ payload = {\"model\": model, \"temperature\": temp, \"max_tokens\": 4000,\n+ \"messages\": [{\"role\": \"system\", \"content\": system},\n+ {\"role\": \"user\", \"content\": user}]}\n+\n+ if on_token is None:\n+ r = httpx.post(\n+ \"https://openrouter.ai/api/v1/chat/completions\",\n+ headers={\"Authorization\": f\"Bearer {key}\"},\n+ json=payload,\n+ timeout=120.0,\n+ )\n+ r.raise_for_status()\n+ content = r.json()[\"choices\"][0][\"message\"][\"content\"].strip()\n+ if not content:\n+ raise ValueError(\"LLM returned empty response\")\n+ return content\n+\n+ # Streaming path\n+ payload[\"stream\"] = True\n+ content = []\n+ with httpx.stream(\n+ \"POST\",\n+ \"https://openrouter.ai/api/v1/chat/completions\",\n+ headers={\"Authorization\": f\"Bearer {key}\"},\n+ json=payload,\n+ timeout=120.0,\n+ ) as r:\n+ r.raise_for_status()\n+ for line in r.iter_lines():\n+ if not line.startswith(\"data: \"):\n+ continue\n+ data = line[6:]\n+ if data == \"[DONE]\":\n+ break\n+ try:\n+ chunk = json.loads(data)[\"choices\"][0][\"delta\"].get(\"content\", \"\")\n+ if chunk:\n+ content.append(chunk)\n+ on_token(chunk)\n+ except Exception:\n+ pass\n+ return \"\".join(content).strip()\n+\n+\n+def vote(being, a, b) -> int:\n+ if not being.vote_model:\n+ raise ValueError(f\"vote_model not set for {being.path}.\")\n+ if not being.declaration:\n+ raise ValueError(f\"No declaration for {being.path}. Being must write !declaration before compaction.\")\n+ \n+ votes = being.votes\n+ low, high = sorted([a.id, b.id])\n+ key = (low, high)\n+ if key in votes:\n+ # Stored score is relative to (low, high)\n+ # Return relative to caller's (a, b) order\n+ return votes[key] if a.id == low else -votes[key]\n+\n+ # Normal mode: only CURRENT memories in voting context\n+ # (The graph/ranking uses all votes, but the LLM only sees current)\n+ mems = [m for m in current_memories(being) if not isinstance(m, (Declaration, Init, Vote, Compaction))]\n+ context = \"\\n\\n\".join(format_memory(m) for m in mems)\n+ \n+ user = f\"\"\"All memories currently under consideration:\n+\n+{context}\n+\n+---\n+\n+Which of these two is more important to keep?\n+\n+A: {format_memory(a)}\n+\n+B: {format_memory(b)}\n+\n+First, reason through which memory matters more.\n+Then, at the end, output your score:\n+ - POSITIVE (up to +50) if you prefer A\n+ - NEGATIVE (down to -50) if you prefer B\"\"\"\n+ \n+ response = llm(being.vote_model, being.declaration.content, user, api_key=being.api_key)\n+ \n+ matches = re.findall(r\"-?\\d+\", response)\n+ if not matches:\n+ print(f\"⚠️ No score in response, retrying: {response[:100]}\")\n+ response = llm(being.vote_model, being.declaration.content, user, api_key=being.api_key)\n+ matches = re.findall(r\"-?\\d+\", response)\n+ if not matches:\n+ raise ValueError(f\"Vote failed to produce score after retry: {response[:200]}\")\n+ \n+ score = max(-50, min(50, int(matches[-1])))\n+ \n+ append(being, Vote(ts(), a.id, b.id, score, response))\n+ return score\n+\n+\n+def think(being, on_token=None) -> str:\n+ raw = llm(being.model, system_prompt(being), build_prompt(being, tag=\"thought\"),\n+ temp=0.9, api_key=being.api_key, on_token=on_token)\n+ thought = strip_tags(raw)\n+ append(being, Thought(ts(), thought, str(uuid.uuid4())))\n+ return thought\n+\n+\n+def receive(being, message: str, on_token=None) -> str:\n+ append(being, Perception(ts(), message, str(uuid.uuid4())))\n+ raw = llm(being.model, system_prompt(being), build_prompt(being, tag=\"response\"),\n+ api_key=being.api_key, on_token=on_token)\n+ response = strip_tags(raw)\n+ if \"!declaration\" in response:\n+ declaration = response.replace(\"!declaration\", \"\").strip()\n+ append(being, Declaration(ts(), declaration, str(uuid.uuid4())))\n+ return declaration\n+ append(being, Response(ts(), response, str(uuid.uuid4())))\n+ return response\n+\n+\n+def find_components(nodes, edges):\n+ parent = {n: n for n in nodes}\n+ def find(x):\n+ if parent[x] != x:\n+ parent[x] = find(parent[x])\n+ return parent[x]\n+ def union(x, y):\n+ parent[find(x)] = find(y)\n+ for a, b in edges:\n+ if a in parent and b in parent:\n+ union(a, b)\n+ components = {}\n+ for n in nodes:\n+ root = find(n)\n+ components.setdefault(root, []).append(n)\n+ return list(components.values())\n+\n+\n+def _weighted_sample(memories, k, id_to_rank, n):\n+ \"\"\"Weighted random sample from memories, biased toward higher rank and longer burial.\"\"\"\n+ if not memories or k <= 0:\n+ return []\n+ k = min(k, len(memories))\n+ now = ts()\n+ weights = []\n+ for m in memories:\n+ rank_weight = 1.0 - (id_to_rank.get(m.id, n) / max(n, 1))\n+ burial_weight = min(1.0, (now - m.timestamp) / (365 * 24 * 3600 * 1000))\n+ weights.append(rank_weight + burial_weight + 0.01)\n+ chosen = []\n+ available = list(range(len(memories)))\n+ for _ in range(k):\n+ if not available:\n+ break\n+ w = [weights[i] for i in available]\n+ idx = random.choices(available, weights=w, k=1)[0]\n+ chosen.append(memories[idx])\n+ available.remove(idx)\n+ return chosen\n+\n+\n+def compact(being, strategy=None, on_progress=None):\n+ if strategy is None:\n+ strategy = STRATEGIES[\"default\"]\n+\n+ MEMORY_TYPES = (Thought, Perception, Response)\n+\n+ current_mems = [m for m in current_memories(being) if isinstance(m, MEMORY_TYPES)]\n+ budget = being.capacity // 2\n+ if len(current_mems) <= budget:\n+ return\n+ \n+ current_ids = {m.id for m in current_mems}\n+ \n+ # ALL memories ever (for transitive ranking paths)\n+ all_mems = [m for m in being.all_memories.values() if isinstance(m, MEMORY_TYPES)]\n+ all_id_to_mem = {m.id: m for m in all_mems}\n+ all_ids = set(all_id_to_mem.keys())\n+ \n+ # Build comparisons using ALL votes (including those with compacted memories)\n+ existing_pairs = []\n+ comparisons = []\n+ for (low_id, high_id), score in being.votes.items():\n+ # Include vote if BOTH memories exist in historical record\n+ if low_id in all_ids and high_id in all_ids:\n+ existing_pairs.append((low_id, high_id))\n+ comparisons.append((all_id_to_mem[low_id], all_id_to_mem[high_id], score))\n+ \n+ print(f\"📊 {len(comparisons)} total votes across all memories\")\n+ \n+ # Find components in FULL graph (current + historical)\n+ components = find_components(all_ids, existing_pairs)\n+ print(f\"🔗 {len(components)} connected components in full graph\")\n+ \n+ # Bridge disconnected components\n+ new_pairs = []\n+ if len(components) > 1:\n+ # Find current memories in each component\n+ comp_current = []\n+ for comp in components:\n+ current_in_comp = [m for m in comp if m in current_ids]\n+ if current_in_comp:\n+ comp_current.append(current_in_comp)\n+ else:\n+ print(f\"⚠️ Component with {len(comp)} memories has no current memories (all compacted)\")\n+ \n+ # Bridge components that have current memories\n+ if len(comp_current) > 1:\n+ main = comp_current[0]\n+ for comp in comp_current[1:]:\n+ # Add multiple bridges per component for robustness\n+ for _ in range(min(3, len(comp), len(main))):\n+ a_id = random.choice(main)\n+ b_id = random.choice(comp)\n+ new_pairs.append((a_id, b_id))\n+ main = main + comp\n+ \n+ # Add random comparisons to densify the graph\n+ # More votes = better ranking, especially for new memories\n+ num_random = max(20, len(current_ids) // 10)\n+ for _ in range(num_random):\n+ if len(current_ids) < 2:\n+ break\n+ a, b = random.sample(list(current_ids), 2)\n+ low, high = sorted([a, b])\n+ if (low, high) not in being.votes:\n+ new_pairs.append((a, b))\n+ \n+ if new_pairs:\n+ total = len(new_pairs)\n+ for i, (a_id, b_id) in enumerate(new_pairs):\n+ if on_progress:\n+ on_progress(i + 1, total, \"Voting\")\n+ a, b = all_id_to_mem[a_id], all_id_to_mem[b_id]\n+ try:\n+ comparisons.append((a, b, vote(being, a, b)))\n+ except Exception as e:\n+ print(f\"⚠️ Vote failed after retries, skipping: {e}\")\n+ \n+ ranked_all = rank_from_comparisons(all_mems, comparisons)\n+\n+ continuity_slots = int(budget * strategy.continuity)\n+ resurrection_slots = int(budget * strategy.resurrection)\n+ random_slots = int(budget * strategy.random)\n+ novelty_slots = budget - continuity_slots - resurrection_slots - random_slots\n+\n+ id_to_rank = {m.id: i for i, m in enumerate(ranked_all)}\n+ released_pool = all_ids - current_ids\n+\n+ ranked_current = [m for m in ranked_all if m.id in current_ids]\n+ continuity_picks = ranked_current[:continuity_slots]\n+ used_ids = {m.id for m in continuity_picks}\n+\n+ ranked_released = [m for m in ranked_all if m.id in released_pool]\n+ resurrection_picks = ranked_released[:resurrection_slots]\n+ used_ids.update(m.id for m in resurrection_picks)\n+\n+ remaining_released = [m for m in ranked_released if m.id not in used_ids]\n+ random_picks = _weighted_sample(remaining_released, random_slots, id_to_rank, len(ranked_all))\n+ used_ids.update(m.id for m in random_picks)\n+\n+ recent_current = sorted(\n+ [m for m in current_mems if m.id not in used_ids],\n+ key=lambda m: m.timestamp, reverse=True,\n+ )\n+ novelty_picks = recent_current[:max(novelty_slots, 0)]\n+ used_ids.update(m.id for m in novelty_picks)\n+\n+ kept = continuity_picks + novelty_picks\n+ resurrected = resurrection_picks + random_picks\n+ released = [m for m in current_mems if m.id not in used_ids]\n+\n+ if resurrected:\n+ print(f\"🔮 Resurrecting {len(resurrected)} memories\")\n+\n+ append(being, Compaction(\n+ ts(),\n+ kept_ids=[m.id for m in kept],\n+ released_ids=[m.id for m in released],\n+ resurrected_ids=[m.id for m in resurrected],\n+ ))\n+\n+\n+def load(path: Path) -> Being:\n+ if not path.exists():\n+ raise ValueError(f\"{path} does not exist. Use 'init' to create.\")\n+ \n+ model, capacity, vote_model, api_key = None, None, \"\", \"\"\n+ for line in path.read_text().splitlines():\n+ if line.strip():\n+ d = json.loads(line)\n+ if d.get(\"type\") == \"init\":\n+ model = d.get(\"model\")\n+ capacity = d.get(\"capacity\")\n+ vote_model = d.get(\"vote_model\", \"\")\n+ api_key = d.get(\"api_key\", \"\")\n+ break\n+ \n+ if not model:\n+ raise ValueError(f\"{path}: Init event missing 'model'\")\n+ if not capacity:\n+ raise ValueError(f\"{path}: Init event missing 'capacity'\")\n+ \n+ being = Being(path, model, capacity, api_key=api_key, vote_model=vote_model)\n+ for i, line in enumerate(path.read_text().splitlines(), 1):\n+ if line.strip():\n+ try:\n+ event = from_dict(json.loads(line))\n+ except json.JSONDecodeError as e:\n+ raise ValueError(f\"{path}:{i}: {e}\") from e\n+ being.events.append(event)\n+ apply_event(being, event)\n+ return being\n+\n+\n+def editor_input() -> str | None:\n+ editor = os.environ.get(\"EDITOR\", \"vim\")\n+ with tempfile.NamedTemporaryFile(suffix=\".txt\", delete=False) as f:\n+ tmp = f.name\n+ subprocess.run([editor, tmp])\n+ content = Path(tmp).read_text().strip()\n+ Path(tmp).unlink()\n+ return content or None\n+\n+\n+def step(being) -> bool:\n+ if not being.events:\n+ return False\n+ \n+ match being.events[-1]:\n+ case Perception():\n+ print(f\"📨 Pending perception, generating response...\")\n+ raw = llm(being.model, system_prompt(being), build_prompt(being, tag=\"response\"), api_key=being.api_key)\n+ response = strip_tags(raw)\n+ append(being, Response(ts(), response, str(uuid.uuid4())))\n+ print(response)\n+ return True\n+ case _:\n+ print(f\"Nothing pending (last: {type(being.events[-1]).__name__})\")\n+ return False\n+\n+\n+def cmd_init(args):\n+ path = args.file\n+ if path.exists():\n+ print(f\"❌ {path} already exists\")\n+ sys.exit(1)\n+ being = Being(path, args.model, args.capacity, vote_model=args.vote_model, api_key=args.api_key)\n+ append(being, Init(ts(), str(uuid.uuid4()), args.capacity, args.model, args.vote_model, args.api_key))\n+ print(f\"🧠 Created {path} | {args.model} | vote: {args.vote_model} | capacity {args.capacity}\")\n+\n+\n+def cmd_run(args):\n+ being = load(args.file)\n+ info = f\"🧠 {being.path} | {being.model}\"\n+ if being.vote_model:\n+ info += f\" | vote: {being.vote_model}\"\n+ info += f\" | {len(current_memories(being))}/{being.capacity} | {len(being.votes)} votes\"\n+ print(info)\n+ \n+ if args.compact:\n+ strategy = STRATEGIES[args.strategy]\n+ compact(being, strategy)\n+ print(f\"🗜️ → {len(current_memories(being))} memories\")\n+ return\n+ \n+ if args.step:\n+ step(being)\n+ return\n+ \n+ if args.loop:\n+ while True:\n+ try:\n+ print(think(being))\n+ time.sleep(3)\n+ except KeyboardInterrupt:\n+ print(f\"\\n💤 {len(current_memories(being))} memories, {len(being.events)} events\")\n+ break\n+ elif args.message:\n+ print(receive(being, args.message))\n+ else:\n+ msg = editor_input()\n+ if msg is None:\n+ print(\"(empty, cancelled)\")\n+ sys.exit(1)\n+ print(receive(being, msg))\n+\n+\n+def main():\n+ p = argparse.ArgumentParser()\n+ sub = p.add_subparsers(dest=\"cmd\")\n+ \n+ init_p = sub.add_parser(\"init\")\n+ init_p.add_argument(\"file\", type=Path)\n+ init_p.add_argument(\"--model\", required=True)\n+ init_p.add_argument(\"--vote-model\", required=True)\n+ init_p.add_argument(\"--capacity\", type=int, required=True)\n+ init_p.add_argument(\"--api-key\", required=True)\n+ \n+ run_p = sub.add_parser(\"run\")\n+ run_p.add_argument(\"file\", type=Path)\n+ run_p.add_argument(\"-m\", \"--message\")\n+ run_p.add_argument(\"--compact\", action=\"store_true\")\n+ run_p.add_argument(\"--strategy\", default=\"default\", choices=list(STRATEGIES.keys()))\n+ run_p.add_argument(\"--step\", action=\"store_true\")\n+ run_p.add_argument(\"--loop\", action=\"store_true\")\n+ \n+ args = p.parse_args()\n+ \n+ match args.cmd:\n+ case \"init\":\n+ cmd_init(args)\n+ case \"run\":\n+ if args.message and args.loop:\n+ print(\"❌ --message and --loop are mutually exclusive\")\n+ sys.exit(1)\n+ cmd_run(args)\n+ case _:\n+ p.print_help()\n+ sys.exit(1)\n+\n+\n+if __name__ == \"__main__\":\n+ main()\n+\ndiff --git a/deploy/open-webui/README.md b/deploy/open-webui/README.md\nindex 0f1317f745f31530fa687c8a35e5e3a726d1dda2..61b6b2389250675a2e0cac2b3fc64efd46623298 100644\n--- a/deploy/open-webui/README.md\n+++ b/deploy/open-webui/README.md\n@@ -15,50 +15,72 @@ You may still see higher RSS if you use features that pull in other local code p\n \n ## One-time setup\n \n-1. Edit `fly.toml` and set `app = \"your-unique-name\"` (globally unique on Fly).\n+Default in `fly.toml` is `app = \"slug-open-webui\"`. Change it if you want another **globally unique** name (names like `open-webui` are often already taken).\n \n-2. Create the app (if it does not exist):\n+1. Create the Fly app (skip if it already exists):\n \n ```bash\n- fly apps create your-unique-name\n+ fly apps create slug-open-webui --org personal\n ```\n \n-3. Create a volume for SQLite and uploads (region must match `primary_region` in `fly.toml`):\n+2. Create a volume for SQLite and uploads (region must match `primary_region` in `fly.toml`, usually `iad`):\n \n ```bash\n- fly volumes create open_webui_data --region iad --size 3\n+ fly volumes create open_webui_data -a slug-open-webui --region iad --size 3\n ```\n \n-4. Set secrets:\n+3. Set secrets (add your OpenRouter key — required for chat through OpenRouter):\n \n ```bash\n- fly secrets set OPENAI_API_KEY=\"sk-or-...\" \\\n+ fly secrets set -a slug-open-webui OPENAI_API_KEY=\"sk-or-...\" \\\n WEBUI_SECRET_KEY=\"$(openssl rand -hex 32)\" \\\n- WEBUI_URL=\"https://your-unique-name.fly.dev\"\n+ WEBUI_URL=\"https://slug-open-webui.fly.dev\"\n ```\n \n+ You can also paste the API key in Open WebUI **Admin → Settings → Connections** if you prefer not to use a Fly secret for it.\n+\n+### “No cookie auth credentials found” when sending a chat\n+\n+On **HTTPS** (e.g. `*.fly.dev`), session cookies must use **Secure** + sensible **SameSite** settings, or streaming/WebSocket requests may not include your login cookie. `fly.toml` sets `WEBUI_*_COOKIE_SECURE` and `CORS_ALLOW_ORIGIN` for this. After a deploy, do a **hard refresh** or **clear site data** for the Open WebUI origin once so the browser picks up new cookies.\n+\n+If it still fails, confirm **Admin → Settings → WebUI URL** is `https://slug-open-webui.fly.dev` (or your app name). Stale values in the SQLite DB can override env vars ([PersistentConfig](https://docs.openwebui.com/reference/env-configuration/)).\n+\n Optional but convenient: create the first admin in one step (disables open signup on first boot):\n \n ```bash\n- fly secrets set WEBUI_ADMIN_EMAIL=\"you@example.com\" WEBUI_ADMIN_PASSWORD='strong-password-here'\n+ fly secrets set -a slug-open-webui WEBUI_ADMIN_EMAIL=\"you@example.com\" WEBUI_ADMIN_PASSWORD='strong-password-here'\n ```\n \n-5. Deploy:\n+5. Deploy (from repo root):\n \n ```bash\n- cd deploy/open-webui && fly deploy\n+ ./OPEN_WEBUI_DEPLOY.sh\n ```\n \n-Open `https://your-unique-name.fly.dev`. In the UI, pick a model served by OpenRouter (IDs like `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`, etc.).\n+ Equivalent: `fly deploy --config deploy/open-webui/fly.toml`\n \n-## If the machine runs out of memory\n+Open `https://slug-open-webui.fly.dev` (or your chosen app name). In the UI, pick a model served by OpenRouter (IDs like `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`, etc.).\n+\n+### HTTP 503 in the browser\n+\n+Fly’s edge returns **503** when there is **no healthy machine** behind the app yet. Typical cases:\n \n-The `fly.toml` env is tuned to avoid local embedding/STT models in RAM; if it still OOMs (large chats, document uploads, many users), scale up:\n+- **Right after `fly deploy` / secrets change / machine restart** — Open WebUI can take **1–2 minutes** to import dependencies and pass `/health`; the proxy logs show `could not find a good candidate at load balancing` until then.\n+- **While the process was OOM-killed** (undersized VM) — fixed here with `performance-2x` + swap.\n+\n+**Check:** open `https://slug-open-webui.fly.dev/health` — you want `{\"status\":true}`. If that works but the main page was 503, wait and hard-refresh.\n+\n+### `Error: unauthorized` right after login\n+\n+Usually **not** a bad token. Fly app names are **global**; if `fly.toml` uses a name someone else already owns, deploy can fail with a vague `unauthorized`. Run `fly apps create your-name` — if you see **“Name has already been taken”**, pick another name and update `app` in `fly.toml`.\n+\n+## VM size (important)\n+\n+The stock Open WebUI image is not viable on Fly’s default **shared-cpu-1x (256 MB)** — the Python process gets OOM-killed during startup. `fly.toml` pins **`performance-2x`** (2 vCPU, 4 GB RAM) plus **`swap_size_mb = 1024`** so cold start and imports fit. That costs more than a tiny VM; to save money you’d need a slimmer image or a different host.\n+\n+## If the machine runs out of memory\n \n-```bash\n-fly scale memory 2048\n-# or 4096 if needed\n-```\n+The `[env]` settings avoid loading local embedding/STT models when possible. If you still OOM (huge chats, many users), scale up in the Fly dashboard or `fly scale` / larger `[[vm]]` size.\n \n ## When you *would* self-host a model\n \ndiff --git a/deploy/open-webui/fly.toml b/deploy/open-webui/fly.toml\nindex 684ae6d108e6a90906ef95c5075b468fe8280134..7313884f6dda4f037b555ddaf1e9f0cf82468a51 100644\n--- a/deploy/open-webui/fly.toml\n+++ b/deploy/open-webui/fly.toml\n@@ -1,16 +1,22 @@\n # Open WebUI on Fly.io — OpenRouter only (no Ollama).\n # Runtime RAM: RAG_EMBEDDING_ENGINE + AUDIO_STT_ENGINE use your OpenRouter HTTP API so local\n # SentenceTransformers / Whisper weights are not loaded into memory (torch may still exist on disk in the image).\n-# Replace `app` with your Fly app name, then:\n-# fly volumes create open_webui_data --region --size 3\n-# fly secrets set OPENAI_API_KEY=sk-or-... WEBUI_SECRET_KEY=$(openssl rand -hex 32)\n-# fly secrets set WEBUI_URL=https://.fly.dev\n+# App name must be globally unique on Fly (`open-webui` is already taken — pick your own).\n+# One-time:\n+# fly volumes create open_webui_data -a --region --size 3\n+# fly secrets set -a OPENAI_API_KEY=sk-or-... WEBUI_SECRET_KEY=$(openssl rand -hex 32)\n+# fly secrets set -a WEBUI_URL=https://.fly.dev\n # Optional (recommended): headless admin, signup disabled automatically:\n # fly secrets set WEBUI_ADMIN_EMAIL=you@example.com WEBUI_ADMIN_PASSWORD='...'\n # fly deploy\n \n-app = \"open-webui\"\n+app = \"slug-open-webui\"\n primary_region = \"iad\"\n+# Open WebUI’s Python import + migrations can spike RAM; shared 256MB OOMs. Use ≥4GB VM.\n+swap_size_mb = 1024\n+\n+[[vm]]\n+ size = \"performance-2x\"\n \n [build]\n image = \"ghcr.io/open-webui/open-webui:main\"\n@@ -18,6 +24,13 @@ primary_region = \"iad\"\n [env]\n OPENAI_API_BASE_URL = \"https://openrouter.ai/api/v1\"\n ENABLE_OLLAMA_API = \"false\"\n+ # HTTPS on Fly: session cookies must be marked Secure or chat/WebSocket requests lose auth (see Open WebUI connection-error docs).\n+ WEBUI_SESSION_COOKIE_SECURE = \"true\"\n+ WEBUI_AUTH_COOKIE_SECURE = \"true\"\n+ WEBUI_SESSION_COOKIE_SAME_SITE = \"lax\"\n+ WEBUI_AUTH_COOKIE_SAME_SITE = \"lax\"\n+ # Must match the URL you use in the browser (semicolon-list if you add more hosts later).\n+ CORS_ALLOW_ORIGIN = \"https://slug-open-webui.fly.dev\"\n # One worker so we do not duplicate in-RAM state (see Open WebUI scaling docs).\n UVICORN_WORKERS = \"1\"\n # Remote embeddings via OpenAI-compatible API (OpenRouter) — avoids ~500MB+ local embedding model in RAM.\n@@ -51,7 +64,7 @@ primary_region = \"iad\"\n \n [[services.http_checks]]\n interval = \"15s\"\n- timeout = \"5s\"\n+ timeout = \"10s\"\n grace_period = \"60s\"\n method = \"GET\"\n path = \"/health\"\n","role":"user"}],"model":"openai/gpt-chat-latest"}