passiveworkers 0.1.0__py3-none-any.whl

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.
council/serve.py ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/serve.py — the local research desk (single-user UI for council.local)
4
+ ==============================================================================
5
+ python -m council.serve # → http://127.0.0.1:8770
6
+ pw serve
7
+
8
+ One process, one user, no auth, no accounts, no map, no telemetry: a brief box, live
9
+ progress while your models research, the rendered report, and the history of every
10
+ report in ./reports/. The network app (council/net) is the separate multiplayer mode.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import pathlib
17
+ import threading
18
+ import uuid
19
+
20
+ from fastapi import FastAPI, HTTPException
21
+ from fastapi.responses import HTMLResponse, PlainTextResponse
22
+ from pydantic import BaseModel, Field
23
+
24
+ from council.local import run as run_research
25
+
26
+ app = FastAPI(title="Passive Workers — local research desk")
27
+ REPORTS = pathlib.Path("reports")
28
+ _jobs: dict = {} # id → {"log": [..], "done": bool, "file": str|None, "error": str|None}
29
+ _lock = threading.Lock()
30
+
31
+
32
+ class Brief(BaseModel):
33
+ brief: str = Field(..., max_length=4000)
34
+ depth: str = Field(default="standard", pattern="^(quick|standard|deep)$")
35
+ analysts: int = Field(default=3, ge=1, le=4)
36
+
37
+
38
+ def _work(job_id: str, b: Brief) -> None:
39
+ j = _jobs[job_id]
40
+
41
+ def progress(msg: str) -> None:
42
+ with _lock:
43
+ j["log"].append(msg)
44
+
45
+ try:
46
+ path = run_research(b.brief, depth=b.depth, n_analysts=b.analysts,
47
+ on_progress=progress)
48
+ with _lock:
49
+ j["file"], j["done"] = path.name, True
50
+ except Exception as e:
51
+ with _lock:
52
+ j["error"], j["done"] = f"{type(e).__name__}: {e}", True
53
+
54
+
55
+ @app.post("/research")
56
+ def research(b: Brief):
57
+ job_id = uuid.uuid4().hex[:12]
58
+ _jobs[job_id] = {"log": [], "done": False, "file": None, "error": None}
59
+ threading.Thread(target=_work, args=(job_id, b), daemon=True).start()
60
+ return {"job_id": job_id}
61
+
62
+
63
+ @app.get("/progress/{job_id}")
64
+ def progress(job_id: str):
65
+ j = _jobs.get(job_id)
66
+ if not j:
67
+ raise HTTPException(404)
68
+ with _lock:
69
+ return {"log": list(j["log"]), "done": j["done"], "file": j["file"], "error": j["error"]}
70
+
71
+
72
+ @app.get("/reports")
73
+ def reports():
74
+ if not REPORTS.exists():
75
+ return []
76
+ return sorted((p.name for p in REPORTS.glob("*.md")), reverse=True)
77
+
78
+
79
+ @app.get("/report/{name}", response_class=PlainTextResponse)
80
+ def report(name: str):
81
+ p = REPORTS / pathlib.Path(name).name # basename only — no traversal
82
+ if not p.exists() or p.suffix != ".md":
83
+ raise HTTPException(404)
84
+ return p.read_text()
85
+
86
+
87
+ @app.get("/", response_class=HTMLResponse)
88
+ def home():
89
+ return SERVE_HTML
90
+
91
+
92
+ SERVE_HTML = """<!doctype html>
93
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
94
+ <title>Research desk — Passive Workers</title>
95
+ <style>
96
+ :root{--bg:#0b1020;--ink:#e8ecff;--muted:#93a0c8;--edge:#222b4d;--card:#101736}
97
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 -apple-system,Segoe UI,Inter,sans-serif}
98
+ .wrap{max-width:860px;margin:0 auto;padding:28px 18px}
99
+ h1{font-size:20px;margin:0 0 2px} .sub{color:var(--muted);font-size:13px;margin-bottom:18px}
100
+ textarea{width:100%;box-sizing:border-box;min-height:84px;background:#0c1430;color:var(--ink);
101
+ border:1px solid var(--edge);border-radius:12px;padding:11px;font:inherit;resize:vertical}
102
+ select,button{background:#0c1430;color:var(--ink);border:1px solid var(--edge);border-radius:10px;
103
+ padding:8px 12px;font:inherit;cursor:pointer}
104
+ button.go{background:#2447b2;border-color:#2e57d6;font-weight:600}
105
+ .row{display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap}
106
+ .card{background:var(--card);border:1px solid var(--edge);border-radius:14px;padding:14px 16px;margin-top:16px}
107
+ .muted{color:var(--muted)} .log div{font-size:12.5px;color:var(--muted);padding:1px 0}
108
+ .report h1,.report h3{margin:14px 0 6px}.report h4{margin:10px 0 4px}
109
+ .report a{color:#6ea8ff;word-break:break-all}
110
+ .hist div{cursor:pointer;padding:6px 2px;border-top:1px dashed #1b2750}
111
+ .hist div:hover{color:#fff}
112
+ </style></head><body><div class="wrap">
113
+ <h1>🔬 Research desk</h1>
114
+ <div class="sub">Your models · your connection · your disk. Nothing leaves this machine but the web searches.</div>
115
+ <textarea id="brief" placeholder="What should be researched?"></textarea>
116
+ <div class="row">
117
+ <select id="depth"><option value="quick">quick (~2–5 min)</option>
118
+ <option value="standard" selected>standard (~5–15 min)</option>
119
+ <option value="deep">deep (~15–30 min)</option></select>
120
+ <select id="analysts"><option>1</option><option>2</option><option selected>3</option><option>4</option></select>
121
+ <span class="muted">local models as independent analysts</span>
122
+ <button class="go" id="go" style="margin-left:auto">Research →</button>
123
+ </div>
124
+ <div class="card" id="live" style="display:none"><b id="status">working…</b><div class="log" id="log"></div></div>
125
+ <div class="card report" id="out" style="display:none"></div>
126
+ <div class="card hist"><b>📄 Past reports</b><div id="hist" class="muted">none yet</div></div>
127
+ <script>
128
+ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
129
+ function md(t){
130
+ let h=esc(t||'');
131
+ h=h.replace(/^### (.*)$/gm,'<h4>$1</h4>').replace(/^## (.*)$/gm,'<h3>$1</h3>').replace(/^# (.*)$/gm,'<h1>$1</h1>');
132
+ h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<b>$1</b>');
133
+ h=h.replace(/(https?:\\/\\/[^\\s<)\\]]+)/g,'<a href="$1" target="_blank" rel="noopener">$1</a>');
134
+ h=h.replace(/\\n{2,}/g,'</p><p>').replace(/\\n/g,'<br>');
135
+ return '<p>'+h+'</p>';
136
+ }
137
+ async function refreshHist(){
138
+ try{const l=await (await fetch('/reports')).json();
139
+ const el=document.getElementById('hist');
140
+ if(!l.length){el.textContent='none yet';return}
141
+ el.innerHTML=l.map(n=>'<div onclick="openReport(\\''+esc(n)+'\\')">'+esc(n)+'</div>').join('');
142
+ }catch(e){}
143
+ }
144
+ async function openReport(name){
145
+ const t=await (await fetch('/report/'+encodeURIComponent(name))).text();
146
+ const o=document.getElementById('out');o.style.display='';o.innerHTML=md(t);
147
+ o.scrollIntoView({behavior:'smooth'});
148
+ }
149
+ let timer=null;
150
+ document.getElementById('go').onclick=async()=>{
151
+ const brief=document.getElementById('brief').value.trim();if(!brief)return;
152
+ const body={brief:brief,depth:document.getElementById('depth').value,
153
+ analysts:+document.getElementById('analysts').value};
154
+ const r=await fetch('/research',{method:'POST',headers:{'Content-Type':'application/json'},
155
+ body:JSON.stringify(body)});
156
+ const j=await r.json();
157
+ document.getElementById('live').style.display='';document.getElementById('out').style.display='none';
158
+ document.getElementById('log').innerHTML='';document.getElementById('status').textContent='working…';
159
+ if(timer)clearInterval(timer);
160
+ timer=setInterval(async()=>{
161
+ const p=await (await fetch('/progress/'+j.job_id)).json();
162
+ document.getElementById('log').innerHTML=p.log.map(l=>'<div>'+esc(l)+'</div>').join('');
163
+ if(p.done){clearInterval(timer);timer=null;
164
+ document.getElementById('status').textContent=p.error?('✗ '+p.error):'done ✓';
165
+ if(p.file){openReport(p.file)}
166
+ refreshHist();
167
+ }
168
+ },1500);
169
+ };
170
+ refreshHist();
171
+ </script></div></body></html>
172
+ """
173
+
174
+
175
+ def main() -> None:
176
+ import uvicorn
177
+ host = os.environ.get("PW_SERVE_HOST", "127.0.0.1") # 0.0.0.0 only inside containers
178
+ print(f"🔬 Research desk → http://{host}:8770 (Ctrl-C to stop)", flush=True)
179
+ uvicorn.run(app, host=host, port=8770, log_level="warning")
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
council/trust.py ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/trust.py — out-of-band operator key trust (D25, FEDERATION_V2)
4
+ =====================================================================
5
+ Closes the honest limitation documented in D23. Signed deliverables were verified against the
6
+ signing key the COORDINATOR reports for an operator (`registered_sign_pub`). That defeats a
7
+ passive or honest-but-curious coordinator, but NOT a fully hostile one: a coordinator that
8
+ controls the node record can swap the content, swap the key, and re-sign — the asker would
9
+ still "verify" successfully against the rewritten key.
10
+
11
+ The fix is an out-of-band root of trust the coordinator does not control: the asker PINS an
12
+ operator's signing key and verifies every later deliverable against the PINNED key.
13
+
14
+ • Trust On First Use (TOFU) — like SSH `known_hosts`: on the first delivery from an operator,
15
+ pin whatever key the coordinator reports and warn that this first contact is unverified
16
+ (compare the printed fingerprint with the operator over a side channel to upgrade trust).
17
+ • Explicit pin — `pw trust add <operator> <pubkey>`: the operator shares their PUBLIC signing
18
+ key (safe to share) over a trusted channel; the asker pins it and the fingerprint is shown
19
+ to confirm. From then on a coordinator cannot forge a delivery for that operator.
20
+ • Mismatch — if a pinned operator's delivery presents a different key, fetch REFUSES and shows
21
+ both fingerprints (key rotation, a second machine, or coordinator tampering — verify before
22
+ trusting).
23
+
24
+ Pure standard library: managing pins never requires the [crypto] extra (verifying a signature
25
+ still does). Pins live in ~/.passiveworkers/trust.json, owner-only.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import pathlib
35
+ import sys
36
+
37
+ # status codes returned by classify()
38
+ PINNED_MATCH = "pinned-match" # key matches an existing out-of-band/TOFU pin → trusted
39
+ UNPINNED = "unpinned" # first contact — no pin yet (caller TOFU-pins after verifying)
40
+ MISMATCH = "mismatch" # key differs from the pin → REFUSE (rotation or tampering)
41
+ NO_KEY = "no-key" # nothing to pin/verify (unsigned delivery)
42
+
43
+
44
+ def _store_path() -> pathlib.Path:
45
+ return pathlib.Path(os.environ.get("PW_LIBRARY_DIR",
46
+ str(pathlib.Path.home() / ".passiveworkers"))) / "trust.json"
47
+
48
+
49
+ def _key_bytes(pub_b64: str) -> bytes:
50
+ """Canonical key material for fingerprinting — decode the base64 so the fingerprint is
51
+ independent of incidental string formatting; fall back to the raw bytes if it isn't b64."""
52
+ try:
53
+ return base64.b64decode(pub_b64.encode(), validate=True)
54
+ except Exception:
55
+ return pub_b64.encode()
56
+
57
+
58
+ def fingerprint(pub_b64: str) -> str:
59
+ """A short, human-comparable fingerprint of a public key (80-bit truncated SHA-256, base32).
60
+ Formatted `PW-XXXX-XXXX-XXXX-XXXX` so two people can read it to each other out of band."""
61
+ if not pub_b64:
62
+ return ""
63
+ digest = hashlib.sha256(_key_bytes(pub_b64)).digest()[:10] # 80 bits
64
+ b32 = base64.b32encode(digest).decode().rstrip("=") # 16 chars, no padding
65
+ groups = [b32[i:i + 4] for i in range(0, len(b32), 4)]
66
+ return "PW-" + "-".join(groups)
67
+
68
+
69
+ def _load() -> dict:
70
+ path = _store_path()
71
+ if not path.exists():
72
+ return {}
73
+ try:
74
+ d = json.loads(path.read_text())
75
+ if not isinstance(d, dict):
76
+ raise ValueError("trust store is not a JSON object")
77
+ return d
78
+ except Exception as e:
79
+ # Never silently drop pins: preserve the unreadable file (so a later _save doesn't blindly
80
+ # overwrite recoverable data) and tell the user, then start fresh.
81
+ try:
82
+ backup = path.parent / (path.name + ".corrupt")
83
+ os.replace(str(path), str(backup))
84
+ sys.stderr.write(f"⚠ trust store {path} was unreadable ({e}); moved to {backup}. "
85
+ f"Starting fresh — re-pin operators or restore from that file.\n")
86
+ except Exception:
87
+ pass
88
+ return {}
89
+
90
+
91
+ def _save(d: dict) -> None:
92
+ path = _store_path()
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ blob = json.dumps(d, indent=2, sort_keys=True).encode()
95
+ # Atomic + owner-only: write a temp file in the same dir (0600 from creation), then replace.
96
+ # No torn writes and no world-readable window. Single-user store; last writer wins on a truly
97
+ # concurrent write (the lost pin simply re-establishes via TOFU on the next fetch).
98
+ tmp = path.parent / (path.name + ".tmp")
99
+ try:
100
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
101
+ with os.fdopen(fd, "wb") as f:
102
+ f.write(blob)
103
+ os.chmod(tmp, 0o600)
104
+ os.replace(str(tmp), str(path))
105
+ except Exception:
106
+ # last-resort fallback: still force owner-only perms (a trust anchor must never be world-readable)
107
+ path.write_text(json.dumps(d, indent=2, sort_keys=True))
108
+ try:
109
+ os.chmod(path, 0o600)
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ def get(operator: str) -> dict | None:
115
+ """The pin for an operator handle, or None. {pub, fp, source}."""
116
+ return _load().get((operator or "").strip()) or None
117
+
118
+
119
+ def list_pins() -> dict:
120
+ return _load()
121
+
122
+
123
+ def pin(operator: str, pub_b64: str, source: str = "manual") -> dict:
124
+ """Pin (or re-pin) an operator's signing key. Returns the stored record. Caller decides
125
+ when re-pinning is appropriate (e.g. after verifying a rotated key out of band)."""
126
+ operator = (operator or "").strip()
127
+ if not operator:
128
+ raise ValueError("operator handle required")
129
+ if not pub_b64:
130
+ raise ValueError("public key required")
131
+ d = _load()
132
+ rec = {"pub": pub_b64, "fp": fingerprint(pub_b64), "source": source}
133
+ d[operator] = rec
134
+ _save(d)
135
+ return rec
136
+
137
+
138
+ def remove(operator: str) -> bool:
139
+ d = _load()
140
+ if (operator or "").strip() in d:
141
+ del d[(operator or "").strip()]
142
+ _save(d)
143
+ return True
144
+ return False
145
+
146
+
147
+ def classify(operator: str, presented_pub: str) -> tuple[str, dict | None]:
148
+ """PURE trust-state decision (no side effects) for a delivery's presented signing key.
149
+
150
+ Returns (status, existing_pin_or_None):
151
+ • NO_KEY — unsigned delivery, nothing to decide.
152
+ • MISMATCH — operator is pinned to a DIFFERENT key → caller must refuse.
153
+ • PINNED_MATCH — presented key equals the pin → verify against the pinned key.
154
+ • UNPINNED — first contact → caller verifies, then TOFU-pins only if the signature is
155
+ valid (we never pin a key taken from an invalid signature).
156
+
157
+ Pinning a rotated key is always an explicit, human-verified action (`pw trust add`), never
158
+ automatic — so a coordinator can't silently rotate a pinned operator's key.
159
+ """
160
+ operator = (operator or "").strip()
161
+ if not presented_pub:
162
+ return NO_KEY, None
163
+ existing = get(operator) if operator else None
164
+ if existing is None:
165
+ return UNPINNED, None
166
+ if existing["pub"] == presented_pub:
167
+ return PINNED_MATCH, existing
168
+ return MISMATCH, existing
council/worker.py ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/worker.py — A "perspective" agent
4
+ ==========================================
5
+ One worker = one contributor's machine running ONE model, optionally with a
6
+ distinct LENS (angle of attack) and a COUNTRY tag. The diversity of the council
7
+ comes from three stacked axes:
8
+
9
+ • different MODELS (gemma vs qwen vs mistral — genuinely different training)
10
+ • different LENSES (optimist vs skeptic vs first-principles — different prompts)
11
+ • different COUNTRIES (the real moat: a worker that researches the live web from
12
+ inside Brazil sees different sources than one in Japan)
13
+
14
+ A worker returns an OWNED DELIVERABLE — an answer it produced — never a tunnel for
15
+ someone else's traffic. That bright line is what keeps Passive Workers clear of the
16
+ residential-proxy / exit-node legal trap. The optional `web_context` is retrieved by
17
+ the worker's OWN agent and handed in as text; wiring real per-country search is the
18
+ next step (and the reason we need a second machine abroad).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import time
25
+ from dataclasses import dataclass
26
+ from typing import Callable, Optional
27
+
28
+ import requests
29
+
30
+ OLLAMA_BASE = "http://localhost:11434"
31
+
32
+ # A small library of lenses — different prompts pull different ideas out of the same model.
33
+ LENSES: dict[str, str] = {
34
+ "neutral": "Answer thoroughly and accurately.",
35
+ "opportunity": "Focus on the strongest opportunities, upsides, and what could go right.",
36
+ "skeptic": "Focus on risks, failure modes, hidden costs, and what could go wrong.",
37
+ "first_principles": "Reason from first principles; question assumptions others would take for granted.",
38
+ "practical": "Be concrete and actionable; prefer specific steps over abstractions.",
39
+ }
40
+
41
+
42
+ @dataclass
43
+ class Answer:
44
+ worker_id: str
45
+ model: str
46
+ lens: str
47
+ country: str
48
+ text: str
49
+ tokens: int
50
+ elapsed_s: float
51
+
52
+
53
+ @dataclass
54
+ class PerspectiveWorker:
55
+ worker_id: str
56
+ model: str
57
+ lens: str = "neutral"
58
+ country: str = "local"
59
+ temperature: float = 0.7 # natural divergence between workers
60
+ num_predict: int = 500
61
+ ollama_base: str = OLLAMA_BASE
62
+ # Optional hook: a function (question) -> retrieved web context string, run by
63
+ # THIS worker's own agent. Left None in the MVP; this is where per-country
64
+ # search plugs in once a second machine abroad joins.
65
+ web_search: Optional[Callable[[str], str]] = None
66
+
67
+ def answer(self, question: str) -> Answer:
68
+ lens_instruction = LENSES.get(self.lens, LENSES["neutral"])
69
+ context_block = ""
70
+ if self.web_search is not None:
71
+ try:
72
+ ctx = self.web_search(question)
73
+ if ctx:
74
+ from council.sanitize import spotlight
75
+ context_block = (
76
+ f"\n\nResearch you gathered from your local ({self.country}) web sources:\n"
77
+ f"{spotlight(ctx)}\n"
78
+ "Use it where relevant and cite what you used.\n"
79
+ )
80
+ except Exception:
81
+ context_block = "" # web is best-effort; never block the answer
82
+
83
+ prompt = (
84
+ f"{lens_instruction}\n\n"
85
+ f"Question:\n{question}\n"
86
+ f"{context_block}\n"
87
+ "Give your best standalone answer."
88
+ )
89
+
90
+ t0 = time.monotonic()
91
+ resp = requests.post(
92
+ f"{self.ollama_base}/api/generate",
93
+ json={
94
+ "model": self.model,
95
+ "prompt": prompt,
96
+ "stream": False,
97
+ "options": {"temperature": self.temperature, "num_predict": self.num_predict},
98
+ # keep the model warm across a worker's calls / the session (R17): kills the
99
+ # 5-30s reload stalls; PW_OLLAMA_KEEP_ALIVE="0" to unload immediately.
100
+ "keep_alive": os.environ.get("PW_OLLAMA_KEEP_ALIVE", "30m"),
101
+ },
102
+ # On a shared CPU host a concurrent heavy generation (e.g. another job's
103
+ # baseline) can starve this one; a hard 300s turned brief contention into
104
+ # empty answers (score 0, perspective lost). Configurable per node.
105
+ timeout=float(os.environ.get("PW_OLLAMA_TIMEOUT", "300")),
106
+ )
107
+ resp.raise_for_status()
108
+ data = resp.json()
109
+ elapsed = time.monotonic() - t0
110
+ text = (data.get("response") or "").strip()
111
+ tokens = data.get("eval_count") or len(text.split())
112
+ return Answer(
113
+ worker_id=self.worker_id,
114
+ model=self.model,
115
+ lens=self.lens,
116
+ country=self.country,
117
+ text=text,
118
+ tokens=tokens,
119
+ elapsed_s=elapsed,
120
+ )
121
+
122
+ def label(self) -> str:
123
+ return f"{self.worker_id} [{self.model}/{self.lens}/{self.country}]"