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/__init__.py +1 -0
- council/artifacts.py +161 -0
- council/batch.py +84 -0
- council/cli.py +54 -0
- council/coordinator.py +133 -0
- council/crypto.py +133 -0
- council/fidelity.py +197 -0
- council/judge.py +393 -0
- council/ledger.py +230 -0
- council/library.py +431 -0
- council/local.py +228 -0
- council/mcp_server.py +87 -0
- council/net/__init__.py +1 -0
- council/net/agent.py +231 -0
- council/net/app.py +390 -0
- council/net/baseline.py +86 -0
- council/net/config.py +79 -0
- council/net/coordinator_app.py +370 -0
- council/net/dashboard.py +111 -0
- council/net/store.py +964 -0
- council/net/submit.py +102 -0
- council/operator.py +412 -0
- council/research.py +520 -0
- council/researcher.py +300 -0
- council/retrieval.py +80 -0
- council/run_demo.py +175 -0
- council/sanitize.py +78 -0
- council/serve.py +183 -0
- council/trust.py +168 -0
- council/worker.py +123 -0
- passiveworkers-0.1.0.dist-info/METADATA +269 -0
- passiveworkers-0.1.0.dist-info/RECORD +36 -0
- passiveworkers-0.1.0.dist-info/WHEEL +5 -0
- passiveworkers-0.1.0.dist-info/entry_points.txt +2 -0
- passiveworkers-0.1.0.dist-info/licenses/LICENSE +21 -0
- passiveworkers-0.1.0.dist-info/top_level.txt +1 -0
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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}]"
|