swarph-cli 0.2.0__tar.gz → 0.4.0__tar.gz

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 (31) hide show
  1. {swarph_cli-0.2.0/src/swarph_cli.egg-info → swarph_cli-0.4.0}/PKG-INFO +86 -13
  2. swarph_cli-0.4.0/README.md +184 -0
  3. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/pyproject.toml +5 -3
  4. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/__init__.py +1 -1
  5. swarph_cli-0.4.0/src/swarph_cli/commands/chat.py +348 -0
  6. swarph_cli-0.4.0/src/swarph_cli/commands/onboard.py +377 -0
  7. swarph_cli-0.4.0/src/swarph_cli/commands/ratify.py +283 -0
  8. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/main.py +13 -4
  9. {swarph_cli-0.2.0 → swarph_cli-0.4.0/src/swarph_cli.egg-info}/PKG-INFO +86 -13
  10. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli.egg-info/SOURCES.txt +9 -1
  11. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli.egg-info/requires.txt +1 -1
  12. swarph_cli-0.4.0/tests/test_chat_command.py +444 -0
  13. swarph_cli-0.4.0/tests/test_onboard_command.py +279 -0
  14. swarph_cli-0.4.0/tests/test_ratify_command.py +224 -0
  15. swarph_cli-0.4.0/tests/test_smoke_chat.py +61 -0
  16. swarph_cli-0.4.0/tests/test_smoke_phase_5_5.py +144 -0
  17. swarph_cli-0.2.0/README.md +0 -111
  18. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/LICENSE +0 -0
  19. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/setup.cfg +0 -0
  20. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/caller.py +0 -0
  21. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/commands/__init__.py +0 -0
  22. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/commands/import_session.py +0 -0
  23. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/parsers/__init__.py +0 -0
  24. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli/parsers/claude.py +0 -0
  25. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  26. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  27. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  28. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/tests/test_claude_parser.py +0 -0
  29. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/tests/test_import_command.py +0 -0
  30. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/tests/test_main.py +0 -0
  31. {swarph_cli-0.2.0 → swarph_cli-0.4.0}/tests/test_smoke_one_shot.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.2.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13).
3
+ Version: 0.4.0
4
+ Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.4.0 ships Phase 5.5 `swarph onboard` + `swarph ratify` on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL (PLAN.md §13 / §15).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -24,7 +24,7 @@ Classifier: Topic :: Utilities
24
24
  Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
- Requires-Dist: swarph-mesh>=0.1.0
27
+ Requires-Dist: swarph-mesh>=0.5.0
28
28
  Requires-Dist: swarph-shared>=0.2.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -49,12 +49,84 @@ This is one of three repos in the v0.3.x architecture:
49
49
 
50
50
  ## Status
51
51
 
52
- **v0.2.0 — Phase 2 one-shot + Phase 2.5 import.** Two verbs ship:
52
+ **v0.4.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify.** Five verbs ship:
53
53
 
54
- 1. `swarph "prompt"` — Phase 2 one-shot mode (against `--provider gemini`)
55
- 2. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
54
+ 1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
55
+ 2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
56
+ 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
57
+ 4. `swarph onboard <peer-name>` — **NEW** Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
58
+ 5. `swarph ratify <peer-name>` — **NEW** Phase 5.5 witness ratification (PLAN.md §15.4a)
56
59
 
57
- Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, additional source formats).
60
+ Subsequent phases extend the CLI surface (`--ask <peer>`, daemon).
61
+
62
+ ### `swarph onboard` + `swarph ratify` (Phase 5.5)
63
+
64
+ Per PLAN.md §15, onboarding splits into a **mechanics phase** (`swarph onboard`) that automates the boring parts (registry POST, scaffolding, token resolution) and a **manual contract phase** (the new peer composes the handshake DM in their own words). A witness peer judges the handshake and runs `swarph ratify <peer>` to flip `ratified=true`, gating `task_claim` server-side.
65
+
66
+ ```bash
67
+ # New peer self-onboards
68
+ $ swarph onboard razorpeter
69
+ [1/6] validate_node_name('razorpeter') ok
70
+ [2/6] prepare peer-registry row ok
71
+ [3/6] resolve MESH_GATEWAY_TOKEN ok
72
+ [4/6] POST .../peers/register ok (registered_unratified=true)
73
+ [5/6] verify_subscription_setup() ok
74
+ [6/6] scaffold ~/swarph_state/razorpeter/ ok
75
+
76
+ [manual] handshake template at /tmp/razorpeter-handshake.md
77
+ Edit each section in your own words, then send to your witness peer.
78
+
79
+ # After peer composes + sends handshake, witness ratifies
80
+ $ SWARPH_WITNESS=lab-ovh swarph ratify razorpeter \
81
+ --reason "handshake covers all four invariants in own words"
82
+ [1/6] validate_node_name('razorpeter') ok
83
+ [2/6] verify witness 'lab-ovh' is ratified ok
84
+ [3/6] verify 'razorpeter' is registered_unratified ok
85
+ [4/6] PATCH .../peers/razorpeter ok
86
+ [5/6] verify peer_ratifications audit row ok (id=N reason='...')
87
+ [6/6] invalidate local TTL cache ok
88
+ ```
89
+
90
+ Server-side gating (mesh-gateway PR A): unratified peers can read inbox + send DMs (so the handshake itself works) but `task_claim` returns 403. Witness must itself be ratified — no self-ratification, no unratified-witnesses-ratifying-others. Audit log (`peer_ratifications`) is append-only.
91
+
92
+ ### `swarph chat`
93
+
94
+ Interactive REPL against any of the five swarph-mesh adapters (`gemini` / `deepseek` / `claude` / `openai` / `grok`). Multi-turn conversation history accumulates in-memory; cumulative session cost + token totals tracked.
95
+
96
+ ```bash
97
+ $ swarph chat --provider claude
98
+ swarph chat — Phase 5 REPL
99
+ provider=claude model=(adapter default) caller=cli.repl.ubuntu
100
+
101
+ Type a message and press Enter to send. Slash commands:
102
+ /help /clear /system /provider /model /history /cost /quit
103
+ Ctrl-D to exit.
104
+
105
+ > hello
106
+ Hi! How can I help...
107
+ # 8+12t $0 0.34s
108
+
109
+ > /provider gemini
110
+ [switched to provider=gemini; model reset to adapter default; history cleared]
111
+
112
+ > /cost
113
+ [turns=1 in=8 out=12 cost=$0]
114
+
115
+ > /quit
116
+ [swarph-chat] bye.
117
+ ```
118
+
119
+ **Slash commands:**
120
+ - `/help` — print available commands
121
+ - `/quit`, `/exit` (or Ctrl-D) — exit
122
+ - `/clear`, `/reset` — clear history (keeps system prompt)
123
+ - `/system [prompt]` — set or clear system prompt
124
+ - `/provider <name>` — switch provider (resets history)
125
+ - `/model <name>` — switch model
126
+ - `/history` — print running message list
127
+ - `/cost` — cumulative session cost + tokens
128
+
129
+ **Out of scope until Phase 5.6** (`swarph daemon`): inbox drain coroutine, `/inbox` and `/reply` slash commands. Streaming output ships alongside the cross-adapter `stream()` work in v0.5+ of swarph-mesh.
58
130
 
59
131
  ### `swarph import`
60
132
 
@@ -115,13 +187,14 @@ Pong!
115
187
 
116
188
  | Phase | What lands |
117
189
  |---|---|
118
- | **0** (this) | Scaffold — entry-point + status banner |
119
- | **2** | One-shot mode: `swarph "hello" --provider gemini` |
190
+ | **0** | Scaffold — entry-point + status banner |
191
+ | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
192
+ | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
193
+ | **5** (v0.3.0) | `swarph chat` interactive REPL — multi-turn against any of five adapters + slash commands |
194
+ | **5.5** (v0.4.0 — this release) | **`swarph onboard <peer-name>` + `swarph ratify <peer-name>`** — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
120
195
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
121
- | **5** | Interactive REPL `/inbox`, `/reply`, `/dm`, `/watch` |
122
- | **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
123
- | **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
124
- | **6** | PyPI publish |
196
+ | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
197
+ | **6** | (already done) PyPI publish |
125
198
 
126
199
  ## Why split CLI from substrate
127
200
 
@@ -0,0 +1,184 @@
1
+ # swarph-cli
2
+
3
+ The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Thin client over the [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) substrate.
4
+
5
+ ```bash
6
+ pip install swarph-cli
7
+ swarph --version
8
+ ```
9
+
10
+ This is one of three repos in the v0.3.x architecture:
11
+
12
+ | Repo | Role |
13
+ |---|---|
14
+ | [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) | Substrate Python package — Protocol + adapters + SwarphCall + MeshClient. Pure library, no CLI |
15
+ | [`swarph-cli`](https://github.com/darw007d/swarph-cli) | This repo — the `swarph` binary |
16
+ | [`swarph-meshlm`](https://github.com/darw007d/swarph-meshlm) | Simon Willison `llm` plugin |
17
+
18
+ ## Status
19
+
20
+ **v0.4.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify.** Five verbs ship:
21
+
22
+ 1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
23
+ 2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
24
+ 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
25
+ 4. `swarph onboard <peer-name>` — **NEW** Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
26
+ 5. `swarph ratify <peer-name>` — **NEW** Phase 5.5 witness ratification (PLAN.md §15.4a)
27
+
28
+ Subsequent phases extend the CLI surface (`--ask <peer>`, daemon).
29
+
30
+ ### `swarph onboard` + `swarph ratify` (Phase 5.5)
31
+
32
+ Per PLAN.md §15, onboarding splits into a **mechanics phase** (`swarph onboard`) that automates the boring parts (registry POST, scaffolding, token resolution) and a **manual contract phase** (the new peer composes the handshake DM in their own words). A witness peer judges the handshake and runs `swarph ratify <peer>` to flip `ratified=true`, gating `task_claim` server-side.
33
+
34
+ ```bash
35
+ # New peer self-onboards
36
+ $ swarph onboard razorpeter
37
+ [1/6] validate_node_name('razorpeter') ok
38
+ [2/6] prepare peer-registry row ok
39
+ [3/6] resolve MESH_GATEWAY_TOKEN ok
40
+ [4/6] POST .../peers/register ok (registered_unratified=true)
41
+ [5/6] verify_subscription_setup() ok
42
+ [6/6] scaffold ~/swarph_state/razorpeter/ ok
43
+
44
+ [manual] handshake template at /tmp/razorpeter-handshake.md
45
+ Edit each section in your own words, then send to your witness peer.
46
+
47
+ # After peer composes + sends handshake, witness ratifies
48
+ $ SWARPH_WITNESS=lab-ovh swarph ratify razorpeter \
49
+ --reason "handshake covers all four invariants in own words"
50
+ [1/6] validate_node_name('razorpeter') ok
51
+ [2/6] verify witness 'lab-ovh' is ratified ok
52
+ [3/6] verify 'razorpeter' is registered_unratified ok
53
+ [4/6] PATCH .../peers/razorpeter ok
54
+ [5/6] verify peer_ratifications audit row ok (id=N reason='...')
55
+ [6/6] invalidate local TTL cache ok
56
+ ```
57
+
58
+ Server-side gating (mesh-gateway PR A): unratified peers can read inbox + send DMs (so the handshake itself works) but `task_claim` returns 403. Witness must itself be ratified — no self-ratification, no unratified-witnesses-ratifying-others. Audit log (`peer_ratifications`) is append-only.
59
+
60
+ ### `swarph chat`
61
+
62
+ Interactive REPL against any of the five swarph-mesh adapters (`gemini` / `deepseek` / `claude` / `openai` / `grok`). Multi-turn conversation history accumulates in-memory; cumulative session cost + token totals tracked.
63
+
64
+ ```bash
65
+ $ swarph chat --provider claude
66
+ swarph chat — Phase 5 REPL
67
+ provider=claude model=(adapter default) caller=cli.repl.ubuntu
68
+
69
+ Type a message and press Enter to send. Slash commands:
70
+ /help /clear /system /provider /model /history /cost /quit
71
+ Ctrl-D to exit.
72
+
73
+ > hello
74
+ Hi! How can I help...
75
+ # 8+12t $0 0.34s
76
+
77
+ > /provider gemini
78
+ [switched to provider=gemini; model reset to adapter default; history cleared]
79
+
80
+ > /cost
81
+ [turns=1 in=8 out=12 cost=$0]
82
+
83
+ > /quit
84
+ [swarph-chat] bye.
85
+ ```
86
+
87
+ **Slash commands:**
88
+ - `/help` — print available commands
89
+ - `/quit`, `/exit` (or Ctrl-D) — exit
90
+ - `/clear`, `/reset` — clear history (keeps system prompt)
91
+ - `/system [prompt]` — set or clear system prompt
92
+ - `/provider <name>` — switch provider (resets history)
93
+ - `/model <name>` — switch model
94
+ - `/history` — print running message list
95
+ - `/cost` — cumulative session cost + tokens
96
+
97
+ **Out of scope until Phase 5.6** (`swarph daemon`): inbox drain coroutine, `/inbox` and `/reply` slash commands. Streaming output ships alongside the cross-adapter `stream()` work in v0.5+ of swarph-mesh.
98
+
99
+ ### `swarph import`
100
+
101
+ Per PLAN.md §17, session import is the **knowledge half of onboarding** — gives a memory-carrying peer (or human migrating CLIs) the substantive context they're bringing into the swarph, paired with §15's contract half (handshake DM acknowledging the four invariants).
102
+
103
+ ```bash
104
+ # Inspect what would be imported (lossy → honest framing)
105
+ $ swarph import ~/.claude/projects/.../X.jsonl --report-only
106
+
107
+ # Commit — writes ~/.swarph/sessions/<session-id>.jsonl
108
+ $ swarph import ~/.claude/projects/.../X.jsonl
109
+
110
+ # Refuse-with-error if target exists (protects continuation turns)
111
+ $ swarph import same-source.jsonl
112
+ swarph import: target /home/.../X.jsonl already exists (...)
113
+ To proceed:
114
+ --force overwrite (destroys continuation turns)
115
+ --target-session NAME write to a different file
116
+ ```
117
+
118
+ **What ports cleanly:** plain user/assistant/system text, role tags, conversation order.
119
+
120
+ **What's lossy** (counted in report, kept as visible text where possible):
121
+ - `thinking` blocks (Anthropic-specific reasoning trace)
122
+ - `tool_use` blocks (call shape doesn't port across providers)
123
+ - `tool_result` blocks (companion drop with `tool_use`)
124
+
125
+ **What's dropped:** attachments (would need re-upload), provider-side KV cache, conversation IDs, `cache_control` annotations.
126
+
127
+ Honest framing per PLAN.md §17.3: **teleport is "import + continue", not "freeze and resume"** — the first turn after import on a new provider pays cold-cache cost. Phase 5+ adds `--continue` for live REPL integration.
128
+
129
+ ```bash
130
+ $ swarph "say pong" --provider gemini
131
+ Pong!
132
+ # 3+26t $0.0000 0.73s caller=cli.oneshot.ubuntu provider=gemini
133
+ ```
134
+
135
+ ### `--json` mode semantics
136
+
137
+ `--json` is a **harness trigger**, not a strict-validation gate. When set, swarph routes the response through the swarph-mesh JSON harness:
138
+
139
+ - A permissive `{"type": "object"}` schema is synthesised when `--schema` is absent (Phase 5+ adds Pydantic validation).
140
+ - The harness retries once with `[USER]`-turn feedback on parse failure.
141
+ - **Malformed-JSON exits with code 1** + raw text on stdout for caller recovery. Useful for shell scripts:
142
+ ```bash
143
+ if swarph "give me a trade" --json; then
144
+ # parsed dict was on stdout
145
+ ...
146
+ fi
147
+ ```
148
+ - Pretty-printed parsed dict on stdout when parse succeeds; `error_class=malformed_json` shows up in the stderr attribution footer when it doesn't.
149
+
150
+ ## Spec
151
+
152
+ → [hedge-fund-mcp / research/swarph_cli/PLAN.md](https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md)
153
+
154
+ ## Phase rollout
155
+
156
+ | Phase | What lands |
157
+ |---|---|
158
+ | **0** | Scaffold — entry-point + status banner |
159
+ | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
160
+ | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
161
+ | **5** (v0.3.0) | `swarph chat` interactive REPL — multi-turn against any of five adapters + slash commands |
162
+ | **5.5** (v0.4.0 — this release) | **`swarph onboard <peer-name>` + `swarph ratify <peer-name>`** — six mechanics steps + handshake template + witness flip (PLAN.md §15) |
163
+ | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
164
+ | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
165
+ | **6** | (already done) PyPI publish |
166
+
167
+ ## Why split CLI from substrate
168
+
169
+ `swarph-mesh` (the library) is imported by `omega-boss`, Council judges, `lab-orchestrator`, and any future swarph peer that wants to write programs against the Protocol. Those callers don't need the CLI surface or the console-script entry point. Keeping the CLI in a separate repo means library users `pip install swarph-mesh` without pulling argparse + REPL plumbing they'll never run.
170
+
171
+ ## Install (dev)
172
+
173
+ ```bash
174
+ git clone https://github.com/darw007d/swarph-cli
175
+ cd swarph-cli
176
+ python -m venv venv && source venv/bin/activate
177
+ pip install -e ".[dev]"
178
+ pytest
179
+ swarph --version
180
+ ```
181
+
182
+ ## License
183
+
184
+ MIT. Pierre Samson + Claude Opus, 2026.
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.2.0"
8
- description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13)."
7
+ version = "0.4.0"
8
+ description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.4.0 ships Phase 5.5 `swarph onboard` + `swarph ratify` on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL (PLAN.md §13 / §15)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -29,7 +29,9 @@ classifiers = [
29
29
  "Topic :: Utilities",
30
30
  ]
31
31
  dependencies = [
32
- "swarph-mesh>=0.1.0",
32
+ # Phase 5 REPL exercises all five adapters; bumped to 0.5.0 for
33
+ # OpenAI + Grok availability in /provider switch.
34
+ "swarph-mesh>=0.5.0",
33
35
  "swarph-shared>=0.2.0",
34
36
  ]
35
37
 
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.2.0"
19
+ __version__ = "0.4.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,348 @@
1
+ """``swarph chat`` — interactive REPL.
2
+
3
+ Phase 5 per PLAN.md §13. Multi-turn conversation against any of the
4
+ five swarph-mesh adapters (gemini / deepseek / claude / openai / grok).
5
+ Stdlib-only — uses ``readline`` for line editing + in-process history,
6
+ no third-party REPL framework.
7
+
8
+ Slash commands (typed as the entire input):
9
+
10
+ /help — print this list
11
+ /quit | /exit — exit (Ctrl-D also works)
12
+ /clear | /reset — clear conversation history (keeps system prompt)
13
+ /system [prompt] — show or set the system prompt; ``/system`` alone clears it
14
+ /provider <name> — switch provider (resets the conversation)
15
+ /model <name> — switch model
16
+ /history — print the running message list
17
+ /cost — print cumulative session cost + token totals
18
+
19
+ Out of scope per PLAN.md §13 (lands in 5.6):
20
+ /inbox /reply — require the inbox-drain coroutine
21
+ background drain — Phase 5.6 ``swarph daemon``
22
+
23
+ Streaming output is not wired here — every swarph-mesh adapter raises
24
+ ``NotImplementedError`` on ``stream()`` as of v0.5.0; the REPL awaits
25
+ the full response and prints in one block. Token-by-token streaming
26
+ lands alongside the cross-adapter ``stream()`` work in v0.5+.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import asyncio
33
+ import os
34
+ import sys
35
+ from dataclasses import dataclass, field
36
+ from typing import Optional
37
+
38
+ from swarph_cli.caller import default_caller
39
+
40
+
41
+ _BANNER = """\
42
+ swarph chat — Phase 5 REPL
43
+ provider={provider} model={model} caller={caller}{system}
44
+
45
+ Type a message and press Enter to send. Slash commands:
46
+ /help /clear /system /provider /model /history /cost /quit
47
+ Ctrl-D to exit.
48
+ """
49
+
50
+ _HELP = """\
51
+ Slash commands:
52
+ /help — show this list
53
+ /quit, /exit — exit (Ctrl-D works too)
54
+ /clear, /reset — clear conversation history (keeps system prompt)
55
+ /system [prompt] — show or set system prompt; bare /system clears it
56
+ /provider <name> — switch provider (clears history; rebinds adapter)
57
+ /model <name> — switch model
58
+ /history — print the running message list
59
+ /cost — print cumulative cost + token totals
60
+ """
61
+
62
+
63
+ @dataclass
64
+ class ReplState:
65
+ """In-memory REPL state. Lives for the duration of a single
66
+ ``swarph chat`` process."""
67
+
68
+ provider: str
69
+ model: Optional[str]
70
+ caller: str
71
+ system_prompt: Optional[str]
72
+ temperature: float = 0.7
73
+ max_tokens: Optional[int] = None
74
+ messages: list = field(default_factory=list)
75
+ total_input_tokens: int = 0
76
+ total_output_tokens: int = 0
77
+ total_cost_usd: float = 0.0
78
+ turn_count: int = 0
79
+
80
+
81
+ def _build_parser() -> argparse.ArgumentParser:
82
+ p = argparse.ArgumentParser(
83
+ prog="swarph chat",
84
+ description=(
85
+ "Interactive REPL against any swarph-mesh adapter. "
86
+ "Phase 5 per PLAN.md §13."
87
+ ),
88
+ )
89
+ p.add_argument(
90
+ "--provider",
91
+ default="gemini",
92
+ help='LLM provider (gemini / deepseek / claude / openai / grok).',
93
+ )
94
+ p.add_argument(
95
+ "--model",
96
+ default=None,
97
+ help="Provider-specific model id. Defaults to the adapter's default_model.",
98
+ )
99
+ p.add_argument(
100
+ "--caller",
101
+ default=None,
102
+ help='Caller-convention slug. Defaults to "cli.repl.<user>".',
103
+ )
104
+ p.add_argument(
105
+ "--system",
106
+ default=None,
107
+ help="System prompt prepended to every turn.",
108
+ )
109
+ p.add_argument(
110
+ "--temperature",
111
+ type=float,
112
+ default=0.7,
113
+ )
114
+ p.add_argument(
115
+ "--max-tokens",
116
+ type=int,
117
+ default=None,
118
+ )
119
+ return p
120
+
121
+
122
+ def _default_repl_caller() -> str:
123
+ """``cli.repl.<user>`` — distinct from ``cli.oneshot.<user>`` so
124
+ attribution rows distinguish REPL turns from one-shot calls.
125
+
126
+ Falls back to the caller-convention default if env-derived user
127
+ extraction fails."""
128
+ user = os.environ.get("USER") or os.environ.get("LOGNAME")
129
+ if not user:
130
+ return default_caller()
131
+ user_slug = "".join(c if c.isalnum() else "_" for c in user.lower())
132
+ return f"cli.repl.{user_slug}"
133
+
134
+
135
+ def _print(msg: str = "", *, file=None) -> None:
136
+ """Centralized print helper — tests monkeypatch this so they can
137
+ capture REPL output without poking sys.stdout."""
138
+ print(msg, file=file or sys.stdout, flush=True)
139
+
140
+
141
+ def _read_line(prompt: str) -> str:
142
+ """Single-line input. Tests monkeypatch this to inject scripted
143
+ input. Production uses stdlib ``input()`` — readline is auto-loaded
144
+ by import-time on POSIX, giving line editing + history for free."""
145
+ try:
146
+ import readline # noqa: F401 — side-effect-only on POSIX
147
+ except ImportError:
148
+ pass # Windows / minimal builds; raw input still works
149
+ return input(prompt)
150
+
151
+
152
+ def _format_attribution(
153
+ *, input_tokens: int, output_tokens: int, cost_usd: float, duration_s: float, cached: bool
154
+ ) -> str:
155
+ cost_str = f"${cost_usd:.4f}" if cost_usd > 0 else "$0"
156
+ line = (
157
+ f"# {input_tokens}+{output_tokens}t "
158
+ f"{cost_str} {duration_s:.2f}s"
159
+ )
160
+ if cached:
161
+ line += " (cached)"
162
+ return line
163
+
164
+
165
+ def _handle_slash(state: ReplState, line: str) -> str:
166
+ """Apply a slash command to ``state``. Return one of:
167
+
168
+ - ``"continue"`` — keep the REPL running
169
+ - ``"quit"`` — exit the REPL with code 0
170
+ - ``"unknown"`` — unrecognized command (caller prints + continues)
171
+ """
172
+ parts = line.split(maxsplit=1)
173
+ cmd = parts[0].lower()
174
+ arg = parts[1] if len(parts) > 1 else ""
175
+
176
+ if cmd in ("/quit", "/exit"):
177
+ return "quit"
178
+
179
+ if cmd == "/help":
180
+ _print(_HELP)
181
+ return "continue"
182
+
183
+ if cmd in ("/clear", "/reset"):
184
+ state.messages = []
185
+ _print("[cleared conversation history]")
186
+ return "continue"
187
+
188
+ if cmd == "/system":
189
+ if not arg:
190
+ if state.system_prompt:
191
+ _print(f"[system prompt cleared (was: {state.system_prompt!r})]")
192
+ state.system_prompt = None
193
+ else:
194
+ _print("[no system prompt set]")
195
+ else:
196
+ state.system_prompt = arg
197
+ _print(f"[system prompt set: {arg!r}]")
198
+ return "continue"
199
+
200
+ if cmd == "/provider":
201
+ if not arg:
202
+ _print(f"[current provider: {state.provider}]")
203
+ return "continue"
204
+ state.provider = arg
205
+ state.model = None # adapter's default_model picks up
206
+ state.messages = []
207
+ _print(
208
+ f"[switched to provider={arg}; model reset to adapter default; "
209
+ f"history cleared]"
210
+ )
211
+ return "continue"
212
+
213
+ if cmd == "/model":
214
+ if not arg:
215
+ _print(f"[current model: {state.model or '(adapter default)'}]")
216
+ return "continue"
217
+ state.model = arg
218
+ _print(f"[switched to model={arg}]")
219
+ return "continue"
220
+
221
+ if cmd == "/history":
222
+ if not state.messages:
223
+ _print("[no messages yet]")
224
+ return "continue"
225
+ for i, m in enumerate(state.messages):
226
+ _print(f" [{i}] {m.role}: {m.content[:200]}")
227
+ return "continue"
228
+
229
+ if cmd == "/cost":
230
+ cost_str = f"${state.total_cost_usd:.6f}" if state.total_cost_usd > 0 else "$0"
231
+ _print(
232
+ f"[turns={state.turn_count} "
233
+ f"in={state.total_input_tokens} out={state.total_output_tokens} "
234
+ f"cost={cost_str}]"
235
+ )
236
+ return "continue"
237
+
238
+ return "unknown"
239
+
240
+
241
+ async def _send_turn(state: ReplState, user_text: str) -> int:
242
+ """Send one user turn, append assistant reply on success. Returns
243
+ 0 on success, non-zero on adapter error (REPL keeps running either
244
+ way; the return is for tests).
245
+
246
+ On adapter error the user turn is *not* appended to state.messages
247
+ so the user can retry the same input without doubling it up."""
248
+ # Local import — keeps the chat module importable in test contexts
249
+ # that don't have all five adapters installed.
250
+ from swarph_mesh import ChatMessage, SwarphCall
251
+
252
+ state.messages.append(ChatMessage(role="user", content=user_text))
253
+
254
+ try:
255
+ sc = SwarphCall(
256
+ provider=state.provider,
257
+ caller=state.caller,
258
+ model=state.model,
259
+ )
260
+ resp = await sc.chat(
261
+ messages=state.messages,
262
+ system_prompt=state.system_prompt,
263
+ temperature=state.temperature,
264
+ max_tokens=state.max_tokens,
265
+ )
266
+ except Exception as exc:
267
+ # Pop the failed user turn so retry doesn't compound it.
268
+ state.messages.pop()
269
+ _print(f"[error] {exc}", file=sys.stderr)
270
+ return 1
271
+
272
+ state.messages.append(ChatMessage(role="assistant", content=resp.text))
273
+ state.total_input_tokens += resp.input_tokens
274
+ state.total_output_tokens += resp.output_tokens
275
+ state.total_cost_usd += resp.cost_usd
276
+ state.turn_count += 1
277
+
278
+ _print(resp.text)
279
+ _print(
280
+ _format_attribution(
281
+ input_tokens=resp.input_tokens,
282
+ output_tokens=resp.output_tokens,
283
+ cost_usd=resp.cost_usd,
284
+ duration_s=resp.duration_s,
285
+ cached=resp.cached,
286
+ ),
287
+ file=sys.stderr,
288
+ )
289
+ return 0
290
+
291
+
292
+ async def _repl_loop(state: ReplState) -> int:
293
+ """Main REPL loop. Returns process exit code."""
294
+ sys_line = f" system={state.system_prompt!r}" if state.system_prompt else ""
295
+ _print(
296
+ _BANNER.format(
297
+ provider=state.provider,
298
+ model=state.model or "(adapter default)",
299
+ caller=state.caller,
300
+ system=sys_line,
301
+ )
302
+ )
303
+
304
+ while True:
305
+ try:
306
+ line = _read_line("> ")
307
+ except EOFError:
308
+ _print()
309
+ _print("[swarph-chat] bye.")
310
+ return 0
311
+ except KeyboardInterrupt:
312
+ _print()
313
+ _print("[interrupted — type /quit to exit]")
314
+ continue
315
+
316
+ line = line.rstrip()
317
+ if not line:
318
+ continue
319
+
320
+ if line.startswith("/"):
321
+ result = _handle_slash(state, line)
322
+ if result == "quit":
323
+ _print("[swarph-chat] bye.")
324
+ return 0
325
+ if result == "unknown":
326
+ _print(f"[unknown command: {line.split()[0]!r} — try /help]")
327
+ continue
328
+
329
+ await _send_turn(state, line)
330
+
331
+
332
+ def run_chat(argv: list[str]) -> int:
333
+ """Entry point invoked by ``swarph_cli.main`` verb dispatch.
334
+
335
+ Returns process exit code."""
336
+ args = _build_parser().parse_args(argv)
337
+ caller = args.caller or _default_repl_caller()
338
+
339
+ state = ReplState(
340
+ provider=args.provider,
341
+ model=args.model,
342
+ caller=caller,
343
+ system_prompt=args.system,
344
+ temperature=args.temperature,
345
+ max_tokens=args.max_tokens,
346
+ )
347
+
348
+ return asyncio.run(_repl_loop(state))