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.
Files changed (109) hide show
  1. pow_agent/__init__.py +9 -0
  2. pow_agent/__main__.py +5 -0
  3. pow_agent/action.py +123 -0
  4. pow_agent/agents/__init__.py +1 -0
  5. pow_agent/agents/claims.py +214 -0
  6. pow_agent/agents/curator.py +88 -0
  7. pow_agent/agents/sandbox.py +922 -0
  8. pow_agent/agents/scout.py +88 -0
  9. pow_agent/agents/validator.py +233 -0
  10. pow_agent/bridges/__init__.py +17 -0
  11. pow_agent/bridges/activitypub.py +250 -0
  12. pow_agent/bridges/book.py +1082 -0
  13. pow_agent/bridges/common.py +293 -0
  14. pow_agent/bridges/farcaster.py +108 -0
  15. pow_agent/bridges/feeds.py +285 -0
  16. pow_agent/bridges/fetch.py +90 -0
  17. pow_agent/bridges/jsutil.py +79 -0
  18. pow_agent/bridges/links.py +122 -0
  19. pow_agent/bridges/nostr.py +745 -0
  20. pow_agent/bridges/relay.py +134 -0
  21. pow_agent/bridges/sanitize.py +344 -0
  22. pow_agent/bridges/screen.py +172 -0
  23. pow_agent/bridges/urls.py +357 -0
  24. pow_agent/budget.py +409 -0
  25. pow_agent/cli.py +1799 -0
  26. pow_agent/compose.py +234 -0
  27. pow_agent/curation.py +181 -0
  28. pow_agent/defence.py +1702 -0
  29. pow_agent/feed.py +257 -0
  30. pow_agent/gossip.py +1708 -0
  31. pow_agent/guardrails/NOTES.md +224 -0
  32. pow_agent/guardrails/__init__.py +51 -0
  33. pow_agent/guardrails/_js.py +359 -0
  34. pow_agent/guardrails/check.py +329 -0
  35. pow_agent/guardrails/contract.py +145 -0
  36. pow_agent/guardrails/data/__init__.py +1 -0
  37. pow_agent/guardrails/data/bip39.py +148 -0
  38. pow_agent/guardrails/data/brands.py +80 -0
  39. pow_agent/guardrails/data/gen_ucd.mjs +86 -0
  40. pow_agent/guardrails/data/homoglyphs.py +109 -0
  41. pow_agent/guardrails/data/ucd_tables.py +1530 -0
  42. pow_agent/guardrails/derived.py +311 -0
  43. pow_agent/guardrails/detectors/__init__.py +1 -0
  44. pow_agent/guardrails/detectors/context.py +50 -0
  45. pow_agent/guardrails/detectors/embargo.py +495 -0
  46. pow_agent/guardrails/detectors/injection.py +378 -0
  47. pow_agent/guardrails/detectors/links.py +175 -0
  48. pow_agent/guardrails/detectors/pii.py +274 -0
  49. pow_agent/guardrails/detectors/proprietary.py +208 -0
  50. pow_agent/guardrails/detectors/secrets.py +635 -0
  51. pow_agent/guardrails/fields.py +103 -0
  52. pow_agent/guardrails/forge.py +46 -0
  53. pow_agent/guardrails/mypy.ini +6 -0
  54. pow_agent/guardrails/normalize.py +297 -0
  55. pow_agent/guardrails/punycode.py +101 -0
  56. pow_agent/guardrails/redact.py +132 -0
  57. pow_agent/guardrails/text.py +266 -0
  58. pow_agent/guardrails/ucd.py +155 -0
  59. pow_agent/guardrails/urls.py +357 -0
  60. pow_agent/guardrails/views.py +338 -0
  61. pow_agent/home.py +258 -0
  62. pow_agent/identity.py +207 -0
  63. pow_agent/intel/__init__.py +15 -0
  64. pow_agent/intel/agent.py +357 -0
  65. pow_agent/intel/catchup.py +277 -0
  66. pow_agent/intel/interests.py +314 -0
  67. pow_agent/intel/label.py +36 -0
  68. pow_agent/intel/nlsql.py +389 -0
  69. pow_agent/intel/related.py +127 -0
  70. pow_agent/intel/replica.py +109 -0
  71. pow_agent/intel/search.py +384 -0
  72. pow_agent/intel/text.py +271 -0
  73. pow_agent/linking.py +227 -0
  74. pow_agent/lists.py +224 -0
  75. pow_agent/mcp_install.py +183 -0
  76. pow_agent/mcp_server.py +795 -0
  77. pow_agent/mcp_setup.py +113 -0
  78. pow_agent/node.py +935 -0
  79. pow_agent/preview/NOTES.md +32 -0
  80. pow_agent/preview/__init__.py +15 -0
  81. pow_agent/preview/__main__.py +70 -0
  82. pow_agent/preview/constants.py +83 -0
  83. pow_agent/preview/frame.py +236 -0
  84. pow_agent/preview/host.py +734 -0
  85. pow_agent/preview/link.py +69 -0
  86. pow_agent/preview/rate.py +38 -0
  87. pow_agent/preview/request.py +161 -0
  88. pow_agent/preview/stream.py +122 -0
  89. pow_agent/preview/upstream.py +316 -0
  90. pow_agent/proximity.py +737 -0
  91. pow_agent/py.typed +0 -0
  92. pow_agent/safety.py +432 -0
  93. pow_agent/sync.py +378 -0
  94. pow_agent/terms.py +77 -0
  95. pow_agent/transport/__init__.py +11 -0
  96. pow_agent/transport/bounded.py +132 -0
  97. pow_agent/transport/frame.py +208 -0
  98. pow_agent/transport/loopback.py +148 -0
  99. pow_agent/transport/nostr_driver.py +712 -0
  100. pow_agent/transport/op_sdk.py +164 -0
  101. pow_agent/transport/peer_links.py +751 -0
  102. pow_agent/transport/reassembly.py +176 -0
  103. pow_agent/trust.py +162 -0
  104. pow_agent/untrusted.py +53 -0
  105. pow_agent-0.1.0.dist-info/METADATA +122 -0
  106. pow_agent-0.1.0.dist-info/RECORD +109 -0
  107. pow_agent-0.1.0.dist-info/WHEEL +4 -0
  108. pow_agent-0.1.0.dist-info/entry_points.txt +3 -0
  109. 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
@@ -0,0 +1,5 @@
1
+ """``python -m pow_agent``: the same as the ``pow`` command (used by MCP client configs when no script is on PATH)."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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
+ )