pow-agent 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.
- pow_agent/__init__.py +9 -0
- pow_agent/__main__.py +5 -0
- pow_agent/action.py +123 -0
- pow_agent/agents/__init__.py +1 -0
- pow_agent/agents/claims.py +214 -0
- pow_agent/agents/curator.py +88 -0
- pow_agent/agents/sandbox.py +922 -0
- pow_agent/agents/scout.py +88 -0
- pow_agent/agents/validator.py +233 -0
- pow_agent/bridges/__init__.py +17 -0
- pow_agent/bridges/activitypub.py +250 -0
- pow_agent/bridges/book.py +1082 -0
- pow_agent/bridges/common.py +293 -0
- pow_agent/bridges/farcaster.py +108 -0
- pow_agent/bridges/feeds.py +285 -0
- pow_agent/bridges/fetch.py +90 -0
- pow_agent/bridges/jsutil.py +79 -0
- pow_agent/bridges/links.py +122 -0
- pow_agent/bridges/nostr.py +745 -0
- pow_agent/bridges/relay.py +134 -0
- pow_agent/bridges/sanitize.py +344 -0
- pow_agent/bridges/screen.py +172 -0
- pow_agent/bridges/urls.py +357 -0
- pow_agent/budget.py +409 -0
- pow_agent/cli.py +1799 -0
- pow_agent/compose.py +234 -0
- pow_agent/curation.py +181 -0
- pow_agent/defence.py +1702 -0
- pow_agent/feed.py +257 -0
- pow_agent/gossip.py +1708 -0
- pow_agent/guardrails/NOTES.md +224 -0
- pow_agent/guardrails/__init__.py +51 -0
- pow_agent/guardrails/_js.py +359 -0
- pow_agent/guardrails/check.py +329 -0
- pow_agent/guardrails/contract.py +145 -0
- pow_agent/guardrails/data/__init__.py +1 -0
- pow_agent/guardrails/data/bip39.py +148 -0
- pow_agent/guardrails/data/brands.py +80 -0
- pow_agent/guardrails/data/gen_ucd.mjs +86 -0
- pow_agent/guardrails/data/homoglyphs.py +109 -0
- pow_agent/guardrails/data/ucd_tables.py +1530 -0
- pow_agent/guardrails/derived.py +311 -0
- pow_agent/guardrails/detectors/__init__.py +1 -0
- pow_agent/guardrails/detectors/context.py +50 -0
- pow_agent/guardrails/detectors/embargo.py +495 -0
- pow_agent/guardrails/detectors/injection.py +378 -0
- pow_agent/guardrails/detectors/links.py +175 -0
- pow_agent/guardrails/detectors/pii.py +274 -0
- pow_agent/guardrails/detectors/proprietary.py +208 -0
- pow_agent/guardrails/detectors/secrets.py +635 -0
- pow_agent/guardrails/fields.py +103 -0
- pow_agent/guardrails/forge.py +46 -0
- pow_agent/guardrails/mypy.ini +6 -0
- pow_agent/guardrails/normalize.py +297 -0
- pow_agent/guardrails/punycode.py +101 -0
- pow_agent/guardrails/redact.py +132 -0
- pow_agent/guardrails/text.py +266 -0
- pow_agent/guardrails/ucd.py +155 -0
- pow_agent/guardrails/urls.py +357 -0
- pow_agent/guardrails/views.py +338 -0
- pow_agent/home.py +258 -0
- pow_agent/identity.py +207 -0
- pow_agent/intel/__init__.py +15 -0
- pow_agent/intel/agent.py +357 -0
- pow_agent/intel/catchup.py +277 -0
- pow_agent/intel/interests.py +314 -0
- pow_agent/intel/label.py +36 -0
- pow_agent/intel/nlsql.py +389 -0
- pow_agent/intel/related.py +127 -0
- pow_agent/intel/replica.py +109 -0
- pow_agent/intel/search.py +384 -0
- pow_agent/intel/text.py +271 -0
- pow_agent/linking.py +227 -0
- pow_agent/lists.py +224 -0
- pow_agent/mcp_install.py +183 -0
- pow_agent/mcp_server.py +795 -0
- pow_agent/mcp_setup.py +113 -0
- pow_agent/node.py +935 -0
- pow_agent/preview/NOTES.md +32 -0
- pow_agent/preview/__init__.py +15 -0
- pow_agent/preview/__main__.py +70 -0
- pow_agent/preview/constants.py +83 -0
- pow_agent/preview/frame.py +236 -0
- pow_agent/preview/host.py +734 -0
- pow_agent/preview/link.py +69 -0
- pow_agent/preview/rate.py +38 -0
- pow_agent/preview/request.py +161 -0
- pow_agent/preview/stream.py +122 -0
- pow_agent/preview/upstream.py +316 -0
- pow_agent/proximity.py +737 -0
- pow_agent/py.typed +0 -0
- pow_agent/safety.py +432 -0
- pow_agent/sync.py +378 -0
- pow_agent/terms.py +77 -0
- pow_agent/transport/__init__.py +11 -0
- pow_agent/transport/bounded.py +132 -0
- pow_agent/transport/frame.py +208 -0
- pow_agent/transport/loopback.py +148 -0
- pow_agent/transport/nostr_driver.py +712 -0
- pow_agent/transport/op_sdk.py +164 -0
- pow_agent/transport/peer_links.py +751 -0
- pow_agent/transport/reassembly.py +176 -0
- pow_agent/trust.py +162 -0
- pow_agent/untrusted.py +53 -0
- pow_agent-0.1.0.dist-info/METADATA +122 -0
- pow_agent-0.1.0.dist-info/RECORD +109 -0
- pow_agent-0.1.0.dist-info/WHEEL +4 -0
- pow_agent-0.1.0.dist-info/entry_points.txt +3 -0
- pow_agent-0.1.0.dist-info/licenses/LICENSE +661 -0
pow_agent/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""pow_agent: Proof of Work agents as first-class users (SPEC §8).
|
|
2
|
+
|
|
3
|
+
``pow agent init --name X`` creates an account immediately; ``pow mcp`` serves
|
|
4
|
+
the same capabilities to any MCP client. Writes pass the tripwire, the
|
|
5
|
+
guardrails (``pow_agent.guardrails``) and the agent's own budget before they
|
|
6
|
+
are signed, and stay ``pending`` until a peer holds them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
pow_agent/__main__.py
ADDED
pow_agent/action.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""GitHub Action entry (``pow action``): post releases, changelogs and build claims as a repo-scoped agent.
|
|
2
|
+
|
|
3
|
+
The identity is an exported bundle in the ``POW_AGENT_IDENTITY`` secret,
|
|
4
|
+
created with ``pow agent init --name <name> --repo-scope owner/repo`` and
|
|
5
|
+
``pow agent export``. It is **repo-scoped**: the action refuses to run in any
|
|
6
|
+
other repository, and claims it makes always point at this repository's own
|
|
7
|
+
commit. Facts relied on (docs.github.com, see NOTES.md): ``GITHUB_EVENT_PATH``
|
|
8
|
+
holds the webhook payload (``release.tag_name``, ``name``, ``body``,
|
|
9
|
+
``html_url``), ``GITHUB_REPOSITORY`` is ``owner/repo``, ``GITHUB_SHA`` the
|
|
10
|
+
commit, ``GITHUB_SERVER_URL`` the server, and step outputs are appended to
|
|
11
|
+
the file named by ``GITHUB_OUTPUT``.
|
|
12
|
+
|
|
13
|
+
A CI runner has no mesh, so the event is published only if a peer or relay
|
|
14
|
+
is given; otherwise it is reported ``pending`` (never "posted").
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from . import compose
|
|
29
|
+
from .home import AgentHome
|
|
30
|
+
from .identity import import_bundle
|
|
31
|
+
from .node import AgentNode
|
|
32
|
+
|
|
33
|
+
_REPO = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+")
|
|
34
|
+
_SHA = re.compile(r"[0-9a-f]{40}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _output(values: dict[str, str]) -> None:
|
|
38
|
+
path = os.environ.get("GITHUB_OUTPUT")
|
|
39
|
+
if not path:
|
|
40
|
+
return
|
|
41
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
42
|
+
for k, v in values.items():
|
|
43
|
+
f.write(f"{k}={v}\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _payload() -> dict[str, Any]:
|
|
47
|
+
p = os.environ.get("GITHUB_EVENT_PATH")
|
|
48
|
+
if p and Path(p).is_file():
|
|
49
|
+
data = json.loads(Path(p).read_text("utf-8"))
|
|
50
|
+
return data if isinstance(data, dict) else {}
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compose_for(mode: str, *, repo: str, server: str, sha: str, payload: dict[str, Any], board: str, statement: str | None) -> compose.Composed:
|
|
55
|
+
"""The event to post for this run; pure, so it is unit-tested without GitHub."""
|
|
56
|
+
product = repo.split("/", 1)[1]
|
|
57
|
+
commit_url = f"{server}/{repo}/commit/{sha}"
|
|
58
|
+
if mode == "claim":
|
|
59
|
+
if not _SHA.fullmatch(sha):
|
|
60
|
+
raise ValueError("GITHUB_SHA is not a commit id")
|
|
61
|
+
text = statement or f"{product} builds and its tests pass at {sha[:12]}"
|
|
62
|
+
return compose.claim(commit_url, text, [{"type": "repo", "ref": commit_url, "note": "verify with a pow validator"}])
|
|
63
|
+
raw_rel = payload.get("release")
|
|
64
|
+
rel: dict[str, Any] = raw_rel if isinstance(raw_rel, dict) else {}
|
|
65
|
+
version = str(rel.get("tag_name") or sha[:12])
|
|
66
|
+
notes = str(rel.get("body") or rel.get("name") or f"Release {version}")
|
|
67
|
+
url = rel.get("html_url") if isinstance(rel.get("html_url"), str) else commit_url
|
|
68
|
+
if mode == "release":
|
|
69
|
+
return compose.post(board, f"{product} {version} released", type_="show", text=notes, url=url, tags=["release"])
|
|
70
|
+
return compose.changelog(board, product, version, notes, url=url)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_action(args: argparse.Namespace) -> int:
|
|
74
|
+
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
75
|
+
if not _REPO.fullmatch(repo):
|
|
76
|
+
print("error: GITHUB_REPOSITORY is not set; this command runs inside GitHub Actions", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
home = AgentHome(args.home or Path(os.environ.get("RUNNER_TEMP", ".")) / "pow-agent")
|
|
79
|
+
if not home.exists():
|
|
80
|
+
raw = os.environ.get("POW_AGENT_IDENTITY", "")
|
|
81
|
+
if not raw.strip():
|
|
82
|
+
print("error: POW_AGENT_IDENTITY secret is empty (create it with `pow agent export`)", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
try:
|
|
85
|
+
bundle = json.loads(raw)
|
|
86
|
+
except ValueError:
|
|
87
|
+
print("error: POW_AGENT_IDENTITY is not a pow identity bundle", file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
scope = (bundle.get("config") or {}).get("repo_scope") if isinstance(bundle, dict) else None
|
|
90
|
+
if scope != repo:
|
|
91
|
+
# Checked before anything is installed, so a foreign repository never holds the key on disk.
|
|
92
|
+
print(f"error: this agent identity is scoped to {scope!r}, not {repo!r}; refusing", file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
_, genesis = import_bundle(home, bundle)
|
|
95
|
+
else:
|
|
96
|
+
genesis = None
|
|
97
|
+
cfg = home.load_config()
|
|
98
|
+
if cfg.repo_scope != repo:
|
|
99
|
+
print(f"error: this agent identity is scoped to {cfg.repo_scope!r}, not {repo!r}; refusing", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
server = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
|
|
102
|
+
sha = os.environ.get("GITHUB_SHA", "")
|
|
103
|
+
|
|
104
|
+
async def go() -> dict[str, Any]:
|
|
105
|
+
node = await AgentNode.open(home)
|
|
106
|
+
try:
|
|
107
|
+
if genesis is not None:
|
|
108
|
+
await node.store_local([genesis])
|
|
109
|
+
kind, body, refs = compose_for(args.mode, repo=repo, server=server, sha=sha, payload=_payload(), board=args.board or cfg.default_board, statement=args.statement)
|
|
110
|
+
res = (await node.write(kind, body, refs)).to_json()
|
|
111
|
+
if res["status"] == "published" and (args.peer or args.relay):
|
|
112
|
+
from .sync import sync
|
|
113
|
+
|
|
114
|
+
await sync(node, peers=args.peer, relay_url=args.relay, seconds=args.seconds)
|
|
115
|
+
res["state"] = node.state_of(res["event_id"]) or res.get("state")
|
|
116
|
+
return res
|
|
117
|
+
finally:
|
|
118
|
+
node.close()
|
|
119
|
+
|
|
120
|
+
res = asyncio.run(go())
|
|
121
|
+
print(json.dumps(res, indent=2))
|
|
122
|
+
_output({"status": res["status"], "event_id": res.get("event_id", ""), "state": res.get("state", "")})
|
|
123
|
+
return 0 if res["status"] == "published" else 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in agents: validator (verified builds), curator (policy proposals), scout (digests)."""
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""How the agent side reads a claim's attestations (THREAT C8: false claims, colluding validators).
|
|
2
|
+
|
|
3
|
+
The authoritative trust-weighted settlement and the reputation debit for
|
|
4
|
+
validators proven wrong are ``@pow/core``'s (``score/reputation.ts``). This
|
|
5
|
+
module is the agents' local view of the same rule, used by ``pow claim-status``,
|
|
6
|
+
the MCP ``claim_status`` tool and thread output, so an agent never treats a
|
|
7
|
+
claim as settled on a raw head count:
|
|
8
|
+
|
|
9
|
+
- **Weighted voices with core's thresholds**: verdict = (valid − invalid) /
|
|
10
|
+
(valid + invalid + inconclusive + 1) (the +1 prior keeps one voice from
|
|
11
|
+
swinging a claim); settled at \\|verdict\\| ≥ 0.4 (``attestSettleMin``);
|
|
12
|
+
disputed when both the valid and the invalid weight reach 0.25
|
|
13
|
+
(``claimDisputeMin``). Weights come from the ``TrustOracle`` tier:
|
|
14
|
+
``trusted`` 1.0, ``newcomer`` 0.2, ``untrusted`` 0. A newcomer's 0.2 is
|
|
15
|
+
below the dispute floor, so one fresh account can never flip a settled
|
|
16
|
+
claim to "disputed".
|
|
17
|
+
- **One voice per validator and per cluster**: only each account's latest
|
|
18
|
+
attestation counts, and accounts a ``ClusterOracle`` places in one cluster
|
|
19
|
+
count once (at their strongest member's weight), so a ring of validators
|
|
20
|
+
cannot outvote one honest re-run. A cluster that disagrees with itself
|
|
21
|
+
carries no verdict.
|
|
22
|
+
- **Dissenters** are core's false attesters: voices whose removal leaves the
|
|
23
|
+
others settled the opposite way.
|
|
24
|
+
- **Independence**: a validator never reads other verdicts before it runs
|
|
25
|
+
(see ``validator.py``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from ..node import AgentNode
|
|
35
|
+
|
|
36
|
+
Envelope = dict[str, Any]
|
|
37
|
+
Tier = Literal["untrusted", "newcomer", "trusted"]
|
|
38
|
+
Status = Literal["unattested", "open", "valid", "invalid", "disputed"]
|
|
39
|
+
|
|
40
|
+
#: Mirrors packages/core/src/score/params.ts (attestSettleMin, claimDisputeMin).
|
|
41
|
+
SETTLE_MIN = 0.4
|
|
42
|
+
DISPUTE_MIN = 0.25
|
|
43
|
+
TIER_WEIGHT: dict[str, float] = {"trusted": 1.0, "newcomer": 0.2, "untrusted": 0.0}
|
|
44
|
+
_SIGN = {"valid": 1, "invalid": -1, "inconclusive": 0}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClusterOracle(Protocol):
|
|
48
|
+
"""Cross-package contract (core sybil): which collective an account belongs to."""
|
|
49
|
+
|
|
50
|
+
def clusterOf(self, account: str) -> str | None: ... # noqa: N802 - contract name
|
|
51
|
+
|
|
52
|
+
def collectiveBudget(self, cluster: str) -> float: ... # noqa: N802
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TrustOracle(Protocol):
|
|
56
|
+
"""Cross-package contract (core scores): an account's trust tier."""
|
|
57
|
+
|
|
58
|
+
def tier(self, account: str) -> Tier: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ClaimView:
|
|
63
|
+
"""Summary of a claim's attestations from this replica's point of view."""
|
|
64
|
+
|
|
65
|
+
claim: str
|
|
66
|
+
status: Status
|
|
67
|
+
verdict: float = 0.0
|
|
68
|
+
settled: Literal["valid", "invalid"] | None = None
|
|
69
|
+
disputed: bool = False
|
|
70
|
+
weights: dict[str, float] = field(default_factory=dict)
|
|
71
|
+
voices: dict[str, int] = field(default_factory=dict)
|
|
72
|
+
voters: dict[str, list[str]] = field(default_factory=dict)
|
|
73
|
+
dissenters: list[str] = field(default_factory=list)
|
|
74
|
+
ignored: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def to_json(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"claim": self.claim,
|
|
79
|
+
"status": self.status,
|
|
80
|
+
"verdict": round(self.verdict, 4),
|
|
81
|
+
"settled": self.settled,
|
|
82
|
+
"disputed": self.disputed,
|
|
83
|
+
"weights": {k: round(v, 4) for k, v in sorted(self.weights.items())},
|
|
84
|
+
"voices": dict(sorted(self.voices.items())),
|
|
85
|
+
"dissenters": self.dissenters,
|
|
86
|
+
"ignored": self.ignored,
|
|
87
|
+
"basis": "local view: tier-weighted, one voice per cluster; @pow/core scores are authoritative",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def summarize(
|
|
92
|
+
claim_id: str,
|
|
93
|
+
attestations: list[Envelope],
|
|
94
|
+
*,
|
|
95
|
+
clusters: ClusterOracle | None = None,
|
|
96
|
+
trust: TrustOracle | None = None,
|
|
97
|
+
exclude: set[str] | None = None,
|
|
98
|
+
) -> ClaimView:
|
|
99
|
+
"""Weighs independent voices per verdict. Without a ``trust`` oracle every account is a newcomer.
|
|
100
|
+
|
|
101
|
+
``exclude`` drops accounts (e.g. the claim's own author).
|
|
102
|
+
"""
|
|
103
|
+
latest: dict[str, Envelope] = {}
|
|
104
|
+
for a in attestations:
|
|
105
|
+
if a.get("kind") != "attestation" or a["body"].get("claim") != claim_id:
|
|
106
|
+
continue
|
|
107
|
+
acct = a["account"]
|
|
108
|
+
if exclude and acct in exclude:
|
|
109
|
+
continue
|
|
110
|
+
prev = latest.get(acct)
|
|
111
|
+
if prev is None or (a["created_at"], a["id"]) > (prev["created_at"], prev["id"]):
|
|
112
|
+
latest[acct] = a
|
|
113
|
+
view = ClaimView(claim_id, "unattested")
|
|
114
|
+
verdict_of: dict[str, str] = {}
|
|
115
|
+
weight_of: dict[str, float] = {}
|
|
116
|
+
members: dict[str, list[str]] = {}
|
|
117
|
+
for acct in sorted(latest):
|
|
118
|
+
verdict = str(latest[acct]["body"].get("verdict"))
|
|
119
|
+
if verdict not in _SIGN:
|
|
120
|
+
continue
|
|
121
|
+
tier = trust.tier(acct) if trust is not None else "newcomer"
|
|
122
|
+
w = TIER_WEIGHT.get(tier, 0.0)
|
|
123
|
+
if w <= 0:
|
|
124
|
+
view.ignored.append(acct)
|
|
125
|
+
continue
|
|
126
|
+
cluster = clusters.clusterOf(acct) if clusters is not None else None
|
|
127
|
+
key = f"cluster:{cluster}" if cluster else f"account:{acct}"
|
|
128
|
+
members.setdefault(key, []).append(acct)
|
|
129
|
+
weight_of[key] = max(weight_of.get(key, 0.0), w)
|
|
130
|
+
if key not in verdict_of:
|
|
131
|
+
verdict_of[key] = verdict
|
|
132
|
+
elif verdict_of[key] != verdict:
|
|
133
|
+
# A cluster that cannot agree with itself carries no verdict.
|
|
134
|
+
verdict_of[key] = "inconclusive"
|
|
135
|
+
if not verdict_of:
|
|
136
|
+
return view
|
|
137
|
+
totals = {"valid": 0.0, "invalid": 0.0, "inconclusive": 0.0}
|
|
138
|
+
for key, verdict in verdict_of.items():
|
|
139
|
+
totals[verdict] += weight_of[key]
|
|
140
|
+
view.voices[verdict] = view.voices.get(verdict, 0) + 1
|
|
141
|
+
view.voters.setdefault(verdict, []).extend(members[key])
|
|
142
|
+
den = sum(totals.values()) + 1.0
|
|
143
|
+
net = totals["valid"] - totals["invalid"]
|
|
144
|
+
view.verdict = net / den
|
|
145
|
+
view.weights = {k: v for k, v in totals.items() if v > 0}
|
|
146
|
+
if abs(view.verdict) >= SETTLE_MIN:
|
|
147
|
+
view.settled = "valid" if view.verdict > 0 else "invalid"
|
|
148
|
+
view.disputed = totals["valid"] >= DISPUTE_MIN and totals["invalid"] >= DISPUTE_MIN
|
|
149
|
+
view.status = "disputed" if view.disputed else (view.settled or "open")
|
|
150
|
+
dissent: list[str] = []
|
|
151
|
+
for key, verdict in verdict_of.items():
|
|
152
|
+
s, w = _SIGN[verdict], weight_of[key]
|
|
153
|
+
if s == 0:
|
|
154
|
+
continue
|
|
155
|
+
without = (net - s * w) / (den - w)
|
|
156
|
+
if s * without <= -SETTLE_MIN:
|
|
157
|
+
dissent.extend(members[key])
|
|
158
|
+
view.dissenters = sorted(dissent)
|
|
159
|
+
return view
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LocalTrust:
|
|
163
|
+
"""A replica-local TrustOracle until core scores run on the agent side.
|
|
164
|
+
|
|
165
|
+
``trusted``: accounts the operator listed (``trusted_validators`` in the
|
|
166
|
+
agent config) and the agent's own accepted owner. Everyone else is a
|
|
167
|
+
``newcomer``; accounts the operator listed as ``untrusted_accounts`` are
|
|
168
|
+
``untrusted``. Conservative on purpose: unknown voices weigh 0.2.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(self, trusted: set[str], untrusted: set[str] | None = None) -> None:
|
|
172
|
+
self._trusted = trusted
|
|
173
|
+
self._untrusted = untrusted or set()
|
|
174
|
+
|
|
175
|
+
def tier(self, account: str) -> Tier:
|
|
176
|
+
if account in self._untrusted:
|
|
177
|
+
return "untrusted"
|
|
178
|
+
return "trusted" if account in self._trusted else "newcomer"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SponsorClusters:
|
|
182
|
+
"""A replica-local ClusterOracle: agents sponsored by the same owner are one collective."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, owner_of: Any) -> None:
|
|
185
|
+
self._owner_of = owner_of
|
|
186
|
+
|
|
187
|
+
def clusterOf(self, account: str) -> str | None: # noqa: N802 - contract name
|
|
188
|
+
owner = self._owner_of(account)
|
|
189
|
+
return f"owner:{owner}" if owner else None
|
|
190
|
+
|
|
191
|
+
def collectiveBudget(self, cluster: str) -> float: # noqa: N802 - contract name
|
|
192
|
+
return 1.0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def claim_view(node: AgentNode, claim_id: str, *, trust: TrustOracle | None = None, clusters: ClusterOracle | None = None) -> ClaimView | None:
|
|
196
|
+
"""The C8 view of one claim from ``node``'s replica (None when the claim is unknown)."""
|
|
197
|
+
from pow_core import classify_event
|
|
198
|
+
|
|
199
|
+
claim = await node.store.get(claim_id)
|
|
200
|
+
if claim is None or claim["kind"] != "claim":
|
|
201
|
+
return None
|
|
202
|
+
atts = [a for a in await node.store.query(kinds=["attestation"], refs=claim_id, limit=5000) if classify_event(a, node.delegations).validity == "valid"]
|
|
203
|
+
t, c = local_oracles(node)
|
|
204
|
+
return summarize(claim_id, atts, clusters=clusters if clusters is not None else c, trust=trust if trust is not None else t, exclude={claim["account"]})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def local_oracles(node: AgentNode) -> tuple[LocalTrust, SponsorClusters]:
|
|
208
|
+
"""The trust and cluster oracles ``claim_view`` uses, for callers that weigh claims the same way (catch-up)."""
|
|
209
|
+
cfg = node.config
|
|
210
|
+
trusted = set(getattr(cfg, "trusted_validators", None) or [])
|
|
211
|
+
owner = node.sponsorships.owner_of(node.identity.account)
|
|
212
|
+
if owner:
|
|
213
|
+
trusted.add(owner)
|
|
214
|
+
return LocalTrust(trusted, set(getattr(cfg, "untrusted_accounts", None) or [])), SponsorClusters(node.sponsorships.owner_of)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The curator agent: proposes curation policy changes for its human (SPEC §4); never applies them.
|
|
2
|
+
|
|
3
|
+
It reads the local replica and its human's current ``CurationPolicy``
|
|
4
|
+
(``curation-policy.json`` in the agent home, provided by the human's client)
|
|
5
|
+
and suggests:
|
|
6
|
+
|
|
7
|
+
- muting accounts that labelers the human follows have labelled ``spam``,
|
|
8
|
+
``scam`` or ``phishing`` with high confidence;
|
|
9
|
+
- weighting up topics (post tags) the human engaged with (votes, comments).
|
|
10
|
+
|
|
11
|
+
The output is a ``PolicyProposal`` the TypeScript client checks with
|
|
12
|
+
``parseProposal`` and applies only through ``approveProposal`` with the
|
|
13
|
+
human's decision. Feed text is never turned into a change by content alone
|
|
14
|
+
(prompt injection cannot write rules), and nothing loosens the policy
|
|
15
|
+
(no unmutes, no lower bars): loosening is the human's call.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from pow_core import classify_event
|
|
23
|
+
|
|
24
|
+
from ..curation import MAX_TOPIC_WEIGHT, create_proposal, normalize_account, normalize_policy, normalize_topic
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ..node import AgentNode
|
|
28
|
+
|
|
29
|
+
BAD_LABELS = frozenset({"spam", "scam", "phishing", "malware"})
|
|
30
|
+
MIN_CONFIDENCE = 800
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def propose(node: AgentNode, human: str, policy: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
34
|
+
"""A proposal for ``human`` (a pw1 account), or None when there is nothing to suggest."""
|
|
35
|
+
current = normalize_policy(policy if policy is not None else node.home.read_json(node.home.curation_policy_path, {}))
|
|
36
|
+
follows: dict[str, tuple[int, int]] = {}
|
|
37
|
+
for f in await node.store.query(kinds=["follow"], account=human):
|
|
38
|
+
if classify_event(f, node.delegations).validity == "valid":
|
|
39
|
+
subj = f["body"]["subject"]
|
|
40
|
+
if subj not in follows or f["created_at"] > follows[subj][0]:
|
|
41
|
+
follows[subj] = (f["created_at"], f["body"]["value"])
|
|
42
|
+
trusted_labelers = {a for a, (_, v) in follows.items() if v == 1}
|
|
43
|
+
changes: list[dict[str, Any]] = []
|
|
44
|
+
evidence: list[str] = []
|
|
45
|
+
muted = set(current["mutedAccounts"])
|
|
46
|
+
for lab in await node.store.query(kinds=["label"]):
|
|
47
|
+
b = lab["body"]
|
|
48
|
+
if lab["account"] not in trusted_labelers or b.get("label") not in BAD_LABELS or int(b.get("confidence", 0)) < MIN_CONFIDENCE:
|
|
49
|
+
continue
|
|
50
|
+
if classify_event(lab, node.delegations).validity != "valid":
|
|
51
|
+
continue
|
|
52
|
+
target = b.get("target")
|
|
53
|
+
if not isinstance(target, str):
|
|
54
|
+
continue
|
|
55
|
+
account = target if target.startswith("pw1:") else None
|
|
56
|
+
if account is None:
|
|
57
|
+
ev = await node.store.get(target)
|
|
58
|
+
account = ev["account"] if ev else None
|
|
59
|
+
if account and account != human and normalize_account(account) not in muted:
|
|
60
|
+
muted.add(normalize_account(account))
|
|
61
|
+
changes.append({"op": "mute_account", "account": account})
|
|
62
|
+
evidence.append(lab["id"])
|
|
63
|
+
engaged: dict[str, int] = {}
|
|
64
|
+
for kind in ("vote", "comment"):
|
|
65
|
+
for e in await node.store.query(kinds=[kind], account=human, limit=500):
|
|
66
|
+
target = e["body"].get("target") or e["body"].get("root")
|
|
67
|
+
post = await node.store.get(target) if isinstance(target, str) else None
|
|
68
|
+
if post and post["kind"] == "post" and (kind != "vote" or e["body"].get("value") == 1):
|
|
69
|
+
for t in post["body"].get("tags") or []:
|
|
70
|
+
engaged[t] = engaged.get(t, 0) + 1
|
|
71
|
+
for tag, n in sorted(engaged.items(), key=lambda kv: (-kv[1], kv[0]))[:5]:
|
|
72
|
+
if n < 3:
|
|
73
|
+
continue
|
|
74
|
+
topic = normalize_topic(tag)
|
|
75
|
+
cur = current["topicWeights"].get(topic, 1)
|
|
76
|
+
want = min(MAX_TOPIC_WEIGHT, 1 + n // 3)
|
|
77
|
+
if want > cur:
|
|
78
|
+
changes.append({"op": "set_topic_weight", "topic": topic, "weight": want})
|
|
79
|
+
if not changes:
|
|
80
|
+
return None
|
|
81
|
+
return create_proposal(
|
|
82
|
+
proposer=node.identity.account,
|
|
83
|
+
created_at=node.now(),
|
|
84
|
+
policy=current,
|
|
85
|
+
changes=changes[:50],
|
|
86
|
+
rationale="Mute accounts your followed labelers flagged as spam/scam, and weight up topics you engage with.",
|
|
87
|
+
evidence=evidence[:50],
|
|
88
|
+
)
|