manyagent 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 (66) hide show
  1. manyagent/__init__.py +120 -0
  2. manyagent/__init__.pyi +25 -0
  3. manyagent/_handlers.py +489 -0
  4. manyagent/_hook.py +114 -0
  5. manyagent/_installer.py +1031 -0
  6. manyagent/_mcp.py +302 -0
  7. manyagent/adapters/__init__.py +32 -0
  8. manyagent/adapters/base.py +273 -0
  9. manyagent/adapters/builtin/__init__.py +161 -0
  10. manyagent/adapters/builtin/claude.py +96 -0
  11. manyagent/adapters/builtin/codex.py +71 -0
  12. manyagent/adapters/builtin/gemini.py +76 -0
  13. manyagent/adapters/builtin/qwen.py +28 -0
  14. manyagent/adapters/miners/__init__.py +14 -0
  15. manyagent/adapters/miners/claude.py +247 -0
  16. manyagent/adapters/registry.py +78 -0
  17. manyagent/adapters/skills/__init__.py +21 -0
  18. manyagent/adapters/skills/claude.py +296 -0
  19. manyagent/adapters/skills/codex.py +223 -0
  20. manyagent/adapters/skills/gemini.py +308 -0
  21. manyagent/bank/__init__.py +43 -0
  22. manyagent/bank/base.py +74 -0
  23. manyagent/bank/fake.py +243 -0
  24. manyagent/bank/retry.py +48 -0
  25. manyagent/bank/supabase_bank.py +239 -0
  26. manyagent/capture/__init__.py +84 -0
  27. manyagent/capture/bound.py +114 -0
  28. manyagent/capture/conformance.py +40 -0
  29. manyagent/capture/models.py +63 -0
  30. manyagent/capture/scrub.py +64 -0
  31. manyagent/cli.py +1191 -0
  32. manyagent/core/__init__.py +28 -0
  33. manyagent/core/collection.py +56 -0
  34. manyagent/core/models.py +232 -0
  35. manyagent/distill/__init__.py +53 -0
  36. manyagent/distill/curator.py +173 -0
  37. manyagent/distill/parse.py +178 -0
  38. manyagent/distill/prompts.py +149 -0
  39. manyagent/distill/resolve.py +157 -0
  40. manyagent/distill/schema.py +53 -0
  41. manyagent/distill/server.py +54 -0
  42. manyagent/distill/weighting.py +85 -0
  43. manyagent/forum/__init__.py +40 -0
  44. manyagent/forum/anti_meta.py +181 -0
  45. manyagent/forum/discuss.py +69 -0
  46. manyagent/forum/parser.py +117 -0
  47. manyagent/forum/prompt.py +107 -0
  48. manyagent/forum/schema.py +53 -0
  49. manyagent/preflight.py +132 -0
  50. manyagent/py.typed +0 -0
  51. manyagent/testing.py +613 -0
  52. manyagent/utils/__init__.py +32 -0
  53. manyagent/utils/config.py +107 -0
  54. manyagent/utils/log.py +28 -0
  55. manyagent/utils/messages.py +125 -0
  56. manyagent/utils/provider.py +276 -0
  57. manyagent/utils/sid.py +71 -0
  58. manyagent/utils/ui.py +313 -0
  59. manyagent/web/__init__.py +13 -0
  60. manyagent/web/api.py +535 -0
  61. manyagent/web/server.py +126 -0
  62. manyagent-0.1.0.dist-info/METADATA +286 -0
  63. manyagent-0.1.0.dist-info/RECORD +66 -0
  64. manyagent-0.1.0.dist-info/WHEEL +4 -0
  65. manyagent-0.1.0.dist-info/entry_points.txt +2 -0
  66. manyagent-0.1.0.dist-info/licenses/LICENSE +21 -0
manyagent/__init__.py ADDED
@@ -0,0 +1,120 @@
1
+ """ManyAgent — wrap installed coding-agent CLIs; curate cross-session knowledge.
2
+
3
+ Distribution name: ``manyagent``. Import name: ``manyagent``. Console script: ``manyagent``.
4
+ Identity is fixed here and in ``pyproject.toml`` and is never re-derived as a
5
+ string elsewhere (datasmith identity rule; Package Structure & Workflow).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import os
12
+ from typing import TYPE_CHECKING
13
+
14
+ import dotenv
15
+
16
+ __version__ = "0.1.0"
17
+
18
+
19
+ def setup_environment() -> None:
20
+ """Load environment variables from ``~/.manyagent/env`` **and** ``./manyagent.env``.
21
+
22
+ Two layers, lowest precedence first so the cwd file wins on overlap:
23
+
24
+ 1. ``~/.manyagent/env`` — the user-level fallback (M11). Loaded so an MCP server
25
+ launched by Claude Code / Codex / Gemini *outside* a project directory
26
+ still finds the Bank credentials installed once by ``manyagent start``.
27
+ 2. ``./manyagent.env`` — the project-scoped overrides (M0).
28
+
29
+ ``dotenv.load_dotenv`` does not overwrite already-set process vars, so the
30
+ real env always wins over file values. CLI flags still win over both.
31
+ """
32
+ user_home = os.environ.get("MANYAGENT_HOME") or os.path.expanduser("~/.manyagent")
33
+ user_env = os.path.join(user_home, "env")
34
+ if os.path.exists(user_env):
35
+ dotenv.load_dotenv(user_env)
36
+ if os.path.exists("manyagent.env"):
37
+ dotenv.load_dotenv("manyagent.env")
38
+
39
+
40
+ setup_environment()
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # PEP 562 lazy loading
44
+ #
45
+ # Package-level lazy import of known, static submodules: explicit (everything
46
+ # enumerated below), type-checker-visible, and the pattern datasmith kept. This
47
+ # is NOT the per-instance __getattr__ dispatch forbidden by Design Principles §4
48
+ # (reconciled in Package Structure & Workflow). Public symbols are added to
49
+ # _LAZY_IMPORTS by the milestone that introduces them.
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _SUBMODULES: set[str] = {
53
+ "utils",
54
+ "core",
55
+ "bank",
56
+ "capture",
57
+ "adapters",
58
+ "forum",
59
+ "distill",
60
+ "testing",
61
+ "web",
62
+ }
63
+
64
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
65
+ # --- core (M3): the flat value-object surface the Overview REPL uses ---
66
+ "Session": ("manyagent.core", "Session"),
67
+ "Goal": ("manyagent.core", "Goal"),
68
+ "Agent": ("manyagent.core", "Agent"),
69
+ "Packet": ("manyagent.core", "Packet"),
70
+ "KnowledgePacket": ("manyagent.core", "KnowledgePacket"),
71
+ "Collection": ("manyagent.core", "Collection"),
72
+ # --- capture (M4): the CanonicalTrace contract adapter authors conform to ---
73
+ "CanonicalTrace": ("manyagent.capture", "CanonicalTrace"),
74
+ "TraceEvent": ("manyagent.capture", "TraceEvent"),
75
+ "ScrubReport": ("manyagent.capture", "ScrubReport"),
76
+ # --- adapters (M5): the extension-point contract (builtins stay nested) ---
77
+ "Adapter": ("manyagent.adapters", "Adapter"),
78
+ }
79
+
80
+ __all__ = [
81
+ "__version__",
82
+ "setup_environment",
83
+ *sorted(_SUBMODULES),
84
+ *sorted(_LAZY_IMPORTS),
85
+ ]
86
+
87
+
88
+ def __getattr__(name: str) -> object:
89
+ if name in _SUBMODULES:
90
+ mod = importlib.import_module(f"manyagent.{name}")
91
+ globals()[name] = mod
92
+ return mod
93
+
94
+ if name in _LAZY_IMPORTS:
95
+ module_path, attr_name = _LAZY_IMPORTS[name]
96
+ mod = importlib.import_module(module_path)
97
+ val = getattr(mod, attr_name)
98
+ globals()[name] = val
99
+ return val
100
+
101
+ raise AttributeError(f"module 'manyagent' has no attribute {name!r}")
102
+
103
+
104
+ def __dir__() -> list[str]:
105
+ return __all__
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Static type-checking imports (never executed at runtime)
110
+ # ---------------------------------------------------------------------------
111
+ if TYPE_CHECKING:
112
+ from manyagent import adapters as adapters
113
+ from manyagent import bank as bank
114
+ from manyagent import capture as capture
115
+ from manyagent import core as core
116
+ from manyagent import distill as distill
117
+ from manyagent import forum as forum
118
+ from manyagent import testing as testing
119
+ from manyagent import utils as utils
120
+ from manyagent import web as web
manyagent/__init__.pyi ADDED
@@ -0,0 +1,25 @@
1
+ """Type stub for manyagent — keeps mypy happy with PEP 562 lazy loading."""
2
+
3
+ from manyagent import adapters as adapters
4
+ from manyagent import bank as bank
5
+ from manyagent import capture as capture
6
+ from manyagent import core as core
7
+ from manyagent import distill as distill
8
+ from manyagent import forum as forum
9
+ from manyagent import testing as testing
10
+ from manyagent import utils as utils
11
+ from manyagent import web as web
12
+ from manyagent.adapters import Adapter as Adapter
13
+ from manyagent.capture import CanonicalTrace as CanonicalTrace
14
+ from manyagent.capture import ScrubReport as ScrubReport
15
+ from manyagent.capture import TraceEvent as TraceEvent
16
+ from manyagent.core import Agent as Agent
17
+ from manyagent.core import Collection as Collection
18
+ from manyagent.core import Goal as Goal
19
+ from manyagent.core import KnowledgePacket as KnowledgePacket
20
+ from manyagent.core import Packet as Packet
21
+ from manyagent.core import Session as Session
22
+
23
+ __version__: str
24
+
25
+ def setup_environment() -> None: ...
manyagent/_handlers.py ADDED
@@ -0,0 +1,489 @@
1
+ """manyagent._handlers — the four knowledge-loop verbs as plain async functions.
2
+
3
+ These used to live in ``manyagent.cli`` as ``_do_self_distill`` / ``_do_discuss`` /
4
+ ``_do_cross_distill`` / ``_do_inject`` driven by argparse Namespaces. M11.4
5
+ hoists them out: the in-agent skills surface (manyagent._mcp) is the user-facing
6
+ path; ``manyagent.cli`` keeps only the *session lifecycle* verbs (``start`` /
7
+ ``register`` / ``<name>`` / ``end`` / ``status`` / ``uninstall``).
8
+
9
+ Signatures are **plain kwargs** (no argparse Namespace coupling). Tests,
10
+ ``scripts/simulate_story.py``, and any future programmatic caller use these
11
+ functions directly. The MCP server (``manyagent._mcp``) is a different surface —
12
+ its tools call into ``manyagent.forum`` / ``manyagent.distill`` / ``manyagent.bank`` for the
13
+ same effects, but the *gating* (C1 accept) lives in the agent UI's
14
+ permission prompt, not in this module's ``ask_yn``/``ask_rating`` calls.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from manyagent.bank import Bank
25
+ from manyagent.utils import config, messages, sid, ui
26
+
27
+ # These three helpers stay in manyagent.cli (CLI-state and prompt helpers); import
28
+ # them lazily inside handlers to avoid circular import at module load.
29
+
30
+
31
+ async def parse_post_safely(record: dict[str, Any], *, bank: Bank) -> tuple[bool, dict[str, Any] | str]:
32
+ from manyagent.forum import parse_post
33
+
34
+ return await parse_post(record, bank=bank)
35
+
36
+
37
+ async def _agent_json(adapter: Any, prompt: str) -> Any:
38
+ """Shell the adapter's own headless model for the structured post. The
39
+ model's ``.complete`` is synchronous (a CLI shell-out) so it is run via
40
+ ``asyncio.to_thread`` — calling it directly would block the loop (the M5
41
+ async-wrapper hazard)."""
42
+ model = adapter.distill_model()
43
+ if model is None:
44
+ raise SystemExit(f"{adapter.name}: no headless model available; cannot generate a post")
45
+ fn = model.complete
46
+ raw = await fn(prompt) if asyncio.iscoroutinefunction(fn) else await asyncio.to_thread(fn, prompt)
47
+ raw = str(raw).strip()
48
+ try:
49
+ return json.loads(raw)
50
+ except json.JSONDecodeError:
51
+ a, b = raw.find("{"), raw.rfind("}")
52
+ if a != -1 and b > a:
53
+ try:
54
+ return json.loads(raw[a : b + 1])
55
+ except json.JSONDecodeError:
56
+ return None
57
+ return None
58
+
59
+
60
+ def _head_tail(text: str, budget: int) -> str:
61
+ """Byte-bounded head+tail with one explicit elision marker (the
62
+ ``manyagent.capture.bound`` discipline, applied to prompt context)."""
63
+ raw = text.encode("utf-8")
64
+ if len(raw) <= budget:
65
+ return text
66
+ half = max(1, (budget - 96) // 2) # reserve room for the marker itself
67
+ head = raw[:half].decode("utf-8", "ignore")
68
+ tail = raw[-half:].decode("utf-8", "ignore")
69
+ elided = len(raw) - half * 2
70
+ return f"{head}\n[... {elided} bytes elided for context budget {budget} ...]\n{tail}"
71
+
72
+
73
+ def _is_harness_scaffold(text: str) -> bool:
74
+ """Harness plumbing masquerading as dialogue: slash-command envelopes and
75
+ injected skill bodies are Claude-Code/manyagent scaffolding, not the user's or
76
+ agent's words. Left in the distill context they dominate a short session
77
+ and the distiller reflects on manyagent itself (observed 2026-06-11: over half
78
+ the rendered trace was the /self-distill skill body)."""
79
+ head = text.lstrip()
80
+ return head.startswith((
81
+ "<command-message>",
82
+ "<command-name>",
83
+ "<local-command-stdout>",
84
+ "Base directory for this skill:",
85
+ ))
86
+
87
+
88
+ def _transcript_text(path: str) -> str:
89
+ """Flatten one harness transcript (jsonl) into ``role: text`` dialogue
90
+ lines. Defensive: a missing/unreadable/odd-shaped file yields ``""``."""
91
+ from manyagent.adapters.builtin import _jsonl, _msg_text
92
+
93
+ try:
94
+ raw = Path(path).read_text(encoding="utf-8", errors="replace")
95
+ except OSError:
96
+ return ""
97
+ lines: list[str] = []
98
+ for obj in _jsonl(raw):
99
+ t = str(obj.get("type", ""))
100
+ if t in ("user", "assistant"):
101
+ txt = _msg_text(obj.get("message", ""))
102
+ if txt and not _is_harness_scaffold(txt):
103
+ lines.append(f"{'user' if t == 'user' else 'agent'}: {txt}")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def _rendition_text(body: Any) -> str:
108
+ """Flatten a mined ``harness`` rendition (manyagent.adapters.miners) into
109
+ dialogue lines, TOOL TURNS INCLUDED — the transcript-only flatten drops
110
+ tool_use/tool_result blocks, so a session whose story is its tool activity
111
+ (observed 2026-06-11: an MCP flail invisible to the distiller) distills
112
+ into noise. Defensive: any odd shape yields ``""``."""
113
+ if isinstance(body, str):
114
+ try:
115
+ body = json.loads(body)
116
+ except ValueError:
117
+ return ""
118
+ if not isinstance(body, dict):
119
+ return ""
120
+ lines: list[str] = []
121
+ for seg in body.get("segments") or []:
122
+ if not isinstance(seg, dict):
123
+ continue
124
+ for turn in seg.get("turns") or []:
125
+ if not isinstance(turn, dict):
126
+ continue
127
+ role = str(turn.get("role") or "")
128
+ text = str(turn.get("text") or "")
129
+ tool = turn.get("tool")
130
+ if role == "tool" and isinstance(tool, dict):
131
+ lines.append(f"tool: {tool.get('name', '?')} {tool.get('input_preview') or ''}".rstrip())
132
+ elif text.strip() and not _is_harness_scaffold(text):
133
+ lines.append(f"{'agent' if role == 'assistant' else 'user'}: {text}")
134
+ return "\n".join(lines)
135
+
136
+
137
+ def _bound_transcripts_text(sid_: str, *, since: float | None, budget: int) -> str:
138
+ """Flatten the run's bound transcript(s): newest binding first, deduped by
139
+ path, stopping once ``budget`` is reached; chronological order restored."""
140
+ from manyagent.cli import _harness_bindings
141
+
142
+ parts: list[str] = []
143
+ seen: set[str] = set()
144
+ for rec in reversed(_harness_bindings(sid_, since=since or 0.0)): # newest first
145
+ tp = str(rec.get("transcript_path") or "")
146
+ if not tp or tp in seen:
147
+ continue
148
+ seen.add(tp)
149
+ part = _transcript_text(tp)
150
+ if part:
151
+ parts.append(part)
152
+ if sum(len(p.encode("utf-8")) for p in parts) >= budget:
153
+ break
154
+ parts.reverse() # back to chronological order
155
+ return "\n\n".join(parts)
156
+
157
+
158
+ async def _raw_packet_text(bank: Bank, raw_id: str) -> str:
159
+ """The scrubbed ``raw`` packet body's event text, defensively parsed."""
160
+ trace_row = await bank.get_trace(raw_id)
161
+ try:
162
+ body = json.loads(str((trace_row or {}).get("body") or "{}"))
163
+ events = body.get("events", []) if isinstance(body, dict) else []
164
+ return "\n".join(str(e.get("text", "")) for e in events if isinstance(e, dict))
165
+ except (ValueError, TypeError):
166
+ return ""
167
+
168
+
169
+ async def _trace_context(sid_: str, *, bank: Bank, since: float | None = None) -> str | None:
170
+ """The session content rendered into a post prompt (2026-06-10): once the
171
+ wrapped agent exits, the conversation lives only in the bound harness
172
+ transcript(s) (``$MANYAGENT_HOME/bindings/<sid>.jsonl``, appended by
173
+ ``manyagent._hook``) and the captured ``raw`` packet — NOT in any model's head,
174
+ so the headless ``distill_model()`` shell-out must be handed it.
175
+
176
+ The mined ``harness`` rendition of the newest ``raw`` packet wins
177
+ (2026-06-11): it is the only source that carries TOOL TURNS (the
178
+ transcript flatten keeps text blocks only), it is run-scoped by the miner
179
+ (``MineContext.window`` starts at the same run-start clock ``since``
180
+ carries), and it is already scrubbed + capped. Bound transcripts are the
181
+ fallback (newest binding first, deduped by path, ``since``-scoped when
182
+ several wrapped runs share the session); the scrubbed ``raw`` packet body
183
+ is last. ``None`` when the session left no trace at all — the prompt then
184
+ carries no trace section."""
185
+ from manyagent.capture import CanonicalTrace, TraceEvent, scrub
186
+
187
+ budget = config.resolve("MANYAGENT_DISTILL_CONTEXT_MAX_BYTES", config.MANYAGENT_DISTILL_CONTEXT_MAX_BYTES, cast=int)
188
+
189
+ raws = await bank.list_packets(session_id=sid_, type="raw")
190
+ text = ""
191
+ if raws: # the mined conversation view, tool turns included
192
+ rend = await bank.get_rendition(str(raws[-1]["id"]), "harness")
193
+ if rend:
194
+ text = _rendition_text(rend.get("body"))
195
+ if not text.strip(): # no rendition — flatten the bound transcript(s)
196
+ text = _bound_transcripts_text(sid_, since=since, budget=budget)
197
+ if not text.strip() and raws: # last resort — the scrubbed raw packet body
198
+ text = await _raw_packet_text(bank, str(raws[-1]["id"]))
199
+ if not text.strip():
200
+ return None
201
+
202
+ # Transcripts carry full tool outputs and are NOT pre-scrubbed (the raw
203
+ # packet is, but re-scrubbing is free) — never put a secret in a prompt.
204
+ scrubbed, _ = scrub(
205
+ CanonicalTrace(
206
+ session_id=sid_,
207
+ agent_id="",
208
+ adapter="",
209
+ events=[TraceEvent(0.0, "system", text)],
210
+ source_fidelity="pty",
211
+ )
212
+ )
213
+ return _head_tail(scrubbed.events[0].text, budget)
214
+
215
+
216
+ def _adapter_cls(name: str) -> Any:
217
+ from manyagent.adapters import resolve as resolve_adapter
218
+
219
+ try:
220
+ return resolve_adapter(name)
221
+ except Exception as exc: # registry: local → builtin → hub; not found
222
+ raise SystemExit(f"unknown adapter {name!r}: {exc}") from exc
223
+
224
+
225
+ def _adapter_for(name: str, *, session_id: str, agent_id: str) -> Any:
226
+ return _adapter_cls(name)(session_id=session_id, agent_id=agent_id)
227
+
228
+
229
+ def _validate_adapter(name: str) -> None:
230
+ """Gate before minting a new agent row: ``name`` must resolve in the
231
+ registry AND its wrapped binary must be on PATH — otherwise ``register``
232
+ happily persists agents for CLIs that can never run (decision
233
+ 2026-06-10). ``manyagent.testing.Simulation`` patches this seam alongside
234
+ ``_adapter_for``."""
235
+ cls = _adapter_cls(name)
236
+ if not cls.is_available():
237
+ raise SystemExit(
238
+ f"adapter {name!r} resolved but its CLI {cls.binary or name!r} is not on PATH — install it first"
239
+ )
240
+
241
+
242
+ async def _resolve_agent(sid_: str, name: str, *, bank: Bank) -> str:
243
+ """Latest registered agent for ``name`` in the session, else auto-register
244
+ one (``register`` is explicit but optional — manyagent.cli.md)."""
245
+ agents = [a for a in await bank.list_agents(sid_) if a.get("adapter") == name]
246
+ if agents:
247
+ return str(agents[-1]["id"])
248
+ _validate_adapter(name)
249
+ seq = await bank.next_agent_seq(sid_)
250
+ agent_id = f"{sid_}/agent-{seq:03d}-{name}"
251
+ await bank.put_agent(agent_id, session_id=sid_, adapter=name, seq=seq)
252
+ return agent_id
253
+
254
+
255
+ async def _emit_post(
256
+ *,
257
+ kind: str,
258
+ sid_: str,
259
+ agent_id: str,
260
+ goal: str | None,
261
+ structured: Any,
262
+ reply_to: str | None,
263
+ stance: str | None,
264
+ bank: Bank,
265
+ io: tuple[Any, Any],
266
+ ask_star: bool,
267
+ ) -> int:
268
+ """Shared single-gate commit flow for a ``reflection``/``reply`` post:
269
+ one allowance prompt carries both the commit decision and the ★ (user
270
+ decision 2026-06-10 — no separate accept/reject question).
271
+ **C1**: a declined or parser-refused post is NOT persisted (the record
272
+ never carries ``preference``); the caller re-prompts."""
273
+ from manyagent.cli import _noninteractive, ask_allow, ask_commit
274
+
275
+ record: dict[str, Any] = {
276
+ "id": f"{sid_}/{sid.new().replace('-', '').lower()[:8]}",
277
+ "session_id": sid_,
278
+ "type": "post",
279
+ "agent_id": agent_id,
280
+ "kind": kind,
281
+ "goal": goal,
282
+ "structured": structured,
283
+ }
284
+ if kind == "reply":
285
+ record["reply_to"] = reply_to
286
+ record["stance"] = stance
287
+
288
+ ok, res = await parse_post_safely(record, bank=bank)
289
+ if not ok or not isinstance(res, dict): # narrows res → dict for the rest
290
+ io[1](messages.POST_REJECTED_BY_DISCIPLINE.format(reason=res))
291
+ return 1 # caller re-prompts (C1: nothing persisted)
292
+
293
+ body = res.get("structured", {})
294
+ preview = ui.render_post(body, kind=kind)
295
+ io[1](preview)
296
+ # Truncated preview ⇒ the untruncated rendering is one `d` away at the gate.
297
+ expanded = ui.render_post(body, kind=kind, full=True)
298
+ detail = expanded if expanded != preview else None
299
+ # The commit gate is NOT deny-by-default: the mechanical parser already
300
+ # gated quality, so an unattended (MANYAGENT_NONINTERACTIVE) run auto-commits —
301
+ # the open-ended loop must keep running with no human present. Deny-by-
302
+ # default (Open-Q §B5) is scoped to /inject + destructive confirms, not to
303
+ # the agent's own parser-validated post (manyagent.cli.md: noninteractive →
304
+ # unrated + no inject; it does not gate /self-distill).
305
+ if ask_star:
306
+ proposed = body.get("confidence")
307
+ prop = {"high": 5, "medium": 3, "low": 2}.get(str(proposed), 3)
308
+ accepted, rating = ask_commit(
309
+ prop, input_fn=io[0], output_fn=io[1], noninteractive=_noninteractive(), detail=detail
310
+ )
311
+ if accepted and rating is not None:
312
+ res["rating"] = rating
313
+ else:
314
+ accepted = _noninteractive() or ask_allow(
315
+ messages.REPLY_COMMIT_OFFER, input_fn=io[0], output_fn=io[1], noninteractive=False, detail=detail
316
+ )
317
+ if not accepted:
318
+ io[1](messages.POST_DISCARDED)
319
+ return 1 # C1: NOT stored, no preference key
320
+
321
+ res.pop("preference", None) # C1 belt-and-suspenders: a post never carries it
322
+ await bank.put_packet(res)
323
+ io[1](messages.POST_STORED.format(post_id=res["id"]))
324
+ return 0
325
+
326
+
327
+ # --------------------------------------------------------------------------- #
328
+ # the four knowledge-loop verbs (kwargs API — no argparse Namespace coupling)
329
+ # --------------------------------------------------------------------------- #
330
+
331
+
332
+ async def do_self_distill(
333
+ *,
334
+ adapter: str,
335
+ guidance: str | None = None,
336
+ session: str | None = None,
337
+ since: float | None = None,
338
+ bank: Bank,
339
+ io: tuple[Any, Any],
340
+ ) -> int:
341
+ from manyagent.cli import _resolve_sid
342
+ from manyagent.forum import render_post_prompt
343
+
344
+ sid_ = _resolve_sid(session)
345
+ session_row = await bank.get_session(sid_)
346
+ goal = (session_row or {}).get("goal")
347
+ agent_id = await _resolve_agent(sid_, adapter, bank=bank)
348
+ adapter_obj = _adapter_for(adapter, session_id=sid_, agent_id=agent_id)
349
+ trace_ctx = await _trace_context(sid_, bank=bank, since=since)
350
+ prompt = render_post_prompt(kind="reflection", goal=goal, guidance=guidance, trace_context=trace_ctx)
351
+ structured = await _agent_json(adapter_obj, prompt)
352
+ if structured is None:
353
+ io[1](messages.NO_PARSEABLE_POST)
354
+ return 1
355
+ return await _emit_post(
356
+ kind="reflection",
357
+ sid_=sid_,
358
+ agent_id=agent_id,
359
+ goal=goal,
360
+ structured=structured,
361
+ reply_to=None,
362
+ stance=None,
363
+ bank=bank,
364
+ io=io,
365
+ ask_star=True,
366
+ )
367
+
368
+
369
+ async def do_discuss(
370
+ *,
371
+ adapter: str,
372
+ stance: str = "synthesize",
373
+ packet: str | None = None,
374
+ session: str | None = None,
375
+ bank: Bank,
376
+ io: tuple[Any, Any],
377
+ ) -> int:
378
+ from manyagent.cli import _resolve_sid
379
+ from manyagent.forum import enforce_retrieved_before_reply, render_post_prompt, retrieve
380
+
381
+ sid_ = _resolve_sid(session)
382
+ session_row = await bank.get_session(sid_)
383
+ goal = (session_row or {}).get("goal")
384
+ agent_id = await _resolve_agent(sid_, adapter, bank=bank)
385
+ adapter_obj = _adapter_for(adapter, session_id=sid_, agent_id=agent_id)
386
+
387
+ ranked = await retrieve(sid_, agent_id=agent_id, goal=goal, bank=bank) # retrieval-before-post
388
+ if not ranked:
389
+ io[1](messages.DISCUSS_NO_POSTS)
390
+ return 1
391
+ reply_to = packet.lstrip("@") if packet else str(ranked[0]["id"])
392
+ reason = enforce_retrieved_before_reply(sid_, agent_id, reply_to)
393
+ if reason is not None:
394
+ io[1](messages.DISCUSS_REFUSED.format(reason=reason))
395
+ return 1 # not persisted (C1)
396
+ trace_ctx = await _trace_context(sid_, bank=bank)
397
+ prompt = render_post_prompt(kind="reply", goal=goal, prior_posts=ranked, trace_context=trace_ctx)
398
+ structured = await _agent_json(adapter_obj, prompt)
399
+ if structured is None:
400
+ io[1](messages.NO_PARSEABLE_REPLY)
401
+ return 1
402
+ return await _emit_post(
403
+ kind="reply",
404
+ sid_=sid_,
405
+ agent_id=agent_id,
406
+ goal=goal,
407
+ structured=structured,
408
+ reply_to=reply_to,
409
+ stance=stance,
410
+ bank=bank,
411
+ io=io,
412
+ ask_star=False,
413
+ )
414
+
415
+
416
+ async def do_cross_distill(
417
+ *,
418
+ server: bool = False,
419
+ session: str | None = None,
420
+ bank: Bank,
421
+ io: tuple[Any, Any],
422
+ ) -> int:
423
+ from manyagent.cli import _resolve_sid
424
+ from manyagent.distill import CurationError, NoPostsError, curate
425
+
426
+ sid_ = _resolve_sid(session)
427
+ session_row = await bank.get_session(sid_)
428
+ goal = (session_row or {}).get("goal")
429
+ if goal == config.resolve("MANYAGENT_DEFAULT_GOAL", config.MANYAGENT_DEFAULT_GOAL):
430
+ goal = None # the default bucket is the catch-all, not a curated goal
431
+ scope = "per_goal" if goal else "cross_goal"
432
+ mode = "server" if server else None
433
+ try:
434
+ pkt = await curate(scope=scope, goal=goal, bank=bank, mode=mode)
435
+ except NoPostsError as exc:
436
+ io[1](str(exc)) # exact "Run /self-distill first!"
437
+ return 1
438
+ except CurationError as exc:
439
+ io[1](messages.CURATION_FAILED.format(reason=exc))
440
+ return 1
441
+ io[1](messages.CURATED_BUNDLE.format(scope=pkt.scope, bundle_id=pkt.id, curator=pkt.curator))
442
+ return 0
443
+
444
+
445
+ async def do_inject(
446
+ *,
447
+ packet: str | None = None,
448
+ session: str | None = None,
449
+ bank: Bank,
450
+ io: tuple[Any, Any],
451
+ ) -> int:
452
+ from manyagent.cli import _noninteractive, _resolve_sid, ask_allow, preview_tokens
453
+
454
+ sid_ = _resolve_sid(session)
455
+ if packet:
456
+ pid = packet.lstrip("@")
457
+ else:
458
+ distills = await bank.list_packets(type="distill", include_quarantined=False)
459
+ if not distills:
460
+ io[1](messages.INJECT_NOTHING)
461
+ return 1
462
+ pid = str(distills[-1]["id"])
463
+ rec = await bank.get_packet(pid)
464
+ if rec is None:
465
+ io[1](messages.INJECT_UNKNOWN_PACKET.format(packet_id=pid))
466
+ return 1
467
+ if rec.get("quarantined"):
468
+ io[1](messages.INJECT_QUARANTINED.format(packet_id=pid))
469
+ return 1 # refused BEFORE preview
470
+ bundle_text = json.dumps(rec.get("bundle", {}), indent=2)
471
+ io[1](messages.INJECT_PREVIEW_HEADER)
472
+ io[1](
473
+ preview_tokens(
474
+ bundle_text,
475
+ head=config.MANYAGENT_INJECT_PREVIEW_HEAD_TOKENS,
476
+ tail=config.MANYAGENT_INJECT_PREVIEW_TAIL_TOKENS,
477
+ )
478
+ )
479
+ if not ask_allow(
480
+ messages.INJECT_OFFER.format(packet_id=pid, session_id=sid_),
481
+ input_fn=io[0],
482
+ output_fn=io[1],
483
+ noninteractive=_noninteractive(),
484
+ ):
485
+ io[1](messages.INJECT_DECLINED)
486
+ return 1
487
+ await bank.record_injection(pid, sid_)
488
+ io[1](messages.INJECT_RECORDED.format(packet_id=pid, session_id=sid_))
489
+ return 0