solohq-cli 0.1.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.
@@ -0,0 +1,10 @@
1
+ # Provide API keys for the providers you want to use (not all required)
2
+ ANTHROPIC_API_KEY=sk-ant-...
3
+ OPENAI_API_KEY=sk-...
4
+ GOOGLE_API_KEY=...
5
+
6
+ # SOLOHQ_MODEL=claude-sonnet-4-6
7
+ # SOLOHQ_EMBEDDING_PROVIDER=openai
8
+ # SOLOHQ_DB_PATH=~/.solohq/memory.db
9
+ # SOLOHQ_USER_ID=default
10
+ # SOLOHQ_DEBUG=false
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.swp
8
+ *.swo
9
+ *~
10
+ .env
11
+ *.db
12
+ .pytest_cache/
13
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 SoloHQ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: solohq-cli
3
+ Version: 0.1.0
4
+ Summary: Interactive CLI agent for SoloHQ Context Memory — terminal chat with persistent context memory
5
+ Project-URL: Repository, https://github.com/whaleventure13/solohq-agent
6
+ Author: SoloHQ
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: agno>=2.0
20
+ Requires-Dist: duckduckgo-search>=7.0
21
+ Requires-Dist: prompt-toolkit>=3.0
22
+ Requires-Dist: python-dotenv>=1.0
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: solohq-agno>=0.1.0
25
+ Requires-Dist: solohq-memory[all]>=0.1.0
26
+ Requires-Dist: typer>=0.15
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # solohq-cli
33
+
34
+ Interactive terminal chat agent with persistent context memory powered by [SoloHQ](https://github.com/whaleventure13/solohq-agent).
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install solohq-cli
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```bash
45
+ # Set your API key
46
+ export ANTHROPIC_API_KEY=sk-ant-...
47
+
48
+ # Start chatting
49
+ solohq chat
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ Create a `.env` file or set environment variables:
55
+
56
+ ```bash
57
+ # LLM provider (pick one)
58
+ ANTHROPIC_API_KEY=sk-ant-...
59
+ OPENAI_API_KEY=sk-...
60
+ GOOGLE_API_KEY=...
61
+
62
+ # Chat model (optional, auto-detected from API key)
63
+ SOLOHQ_MODEL=claude-sonnet-4-6
64
+
65
+ # Embedding provider: "openai" (default) or "google"
66
+ SOLOHQ_EMBEDDING_PROVIDER=openai
67
+
68
+ # Database path (optional)
69
+ SOLOHQ_DB_PATH=~/.solohq/memory.db
70
+ ```
71
+
72
+ ## CLI Options
73
+
74
+ ```bash
75
+ solohq chat --help
76
+
77
+ Options:
78
+ -m, --model TEXT Chat model ID (e.g. claude-sonnet-4-6, gpt-4o)
79
+ --anthropic-api-key TEXT Anthropic API key
80
+ --openai-api-key TEXT OpenAI API key
81
+ --google-api-key TEXT Google API key
82
+ --embedding-provider TEXT Embedding provider: openai or google
83
+ --db-path TEXT SQLite database path
84
+ ```
85
+
86
+ ## Features
87
+
88
+ - Multi-provider support: Anthropic, OpenAI, Google
89
+ - Persistent context memory across conversations
90
+ - Automatic context classification and switching
91
+ - Artifact management with versioning
92
+ - File indexing and search
93
+ - Conversation boundary detection
94
+ - Context relationship graph
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,67 @@
1
+ # solohq-cli
2
+
3
+ Interactive terminal chat agent with persistent context memory powered by [SoloHQ](https://github.com/whaleventure13/solohq-agent).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install solohq-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Set your API key
15
+ export ANTHROPIC_API_KEY=sk-ant-...
16
+
17
+ # Start chatting
18
+ solohq chat
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ Create a `.env` file or set environment variables:
24
+
25
+ ```bash
26
+ # LLM provider (pick one)
27
+ ANTHROPIC_API_KEY=sk-ant-...
28
+ OPENAI_API_KEY=sk-...
29
+ GOOGLE_API_KEY=...
30
+
31
+ # Chat model (optional, auto-detected from API key)
32
+ SOLOHQ_MODEL=claude-sonnet-4-6
33
+
34
+ # Embedding provider: "openai" (default) or "google"
35
+ SOLOHQ_EMBEDDING_PROVIDER=openai
36
+
37
+ # Database path (optional)
38
+ SOLOHQ_DB_PATH=~/.solohq/memory.db
39
+ ```
40
+
41
+ ## CLI Options
42
+
43
+ ```bash
44
+ solohq chat --help
45
+
46
+ Options:
47
+ -m, --model TEXT Chat model ID (e.g. claude-sonnet-4-6, gpt-4o)
48
+ --anthropic-api-key TEXT Anthropic API key
49
+ --openai-api-key TEXT OpenAI API key
50
+ --google-api-key TEXT Google API key
51
+ --embedding-provider TEXT Embedding provider: openai or google
52
+ --db-path TEXT SQLite database path
53
+ ```
54
+
55
+ ## Features
56
+
57
+ - Multi-provider support: Anthropic, OpenAI, Google
58
+ - Persistent context memory across conversations
59
+ - Automatic context classification and switching
60
+ - Artifact management with versioning
61
+ - File indexing and search
62
+ - Conversation boundary detection
63
+ - Context relationship graph
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "solohq-cli"
7
+ version = "0.1.0"
8
+ description = "Interactive CLI agent for SoloHQ Context Memory — terminal chat with persistent context memory"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "SoloHQ"},
13
+ ]
14
+ readme = "README.md"
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "solohq-memory[all]>=0.1.0",
28
+ "solohq-agno>=0.1.0",
29
+ "agno>=2.0",
30
+ "typer>=0.15",
31
+ "python-dotenv>=1.0",
32
+ "rich>=13.0",
33
+ "prompt_toolkit>=3.0",
34
+ "duckduckgo-search>=7.0",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24"]
39
+
40
+ [project.scripts]
41
+ solohq = "solohq_cli.main:app"
42
+
43
+ [project.urls]
44
+ Repository = "https://github.com/whaleventure13/solohq-agent"
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "strict"
File without changes
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from io import StringIO
6
+ from typing import TYPE_CHECKING
7
+
8
+ from prompt_toolkit import Application
9
+ from prompt_toolkit.formatted_text import ANSI, FormattedText, to_formatted_text
10
+ from prompt_toolkit.key_binding import KeyBindings
11
+ from prompt_toolkit.layout.containers import HSplit, Window
12
+ from prompt_toolkit.layout.controls import FormattedTextControl
13
+ from prompt_toolkit.layout.layout import Layout
14
+ from prompt_toolkit.styles import Style
15
+
16
+ from .display import (
17
+ console,
18
+ print_artifact_detail,
19
+ print_context_detail,
20
+ print_episode_detail,
21
+ print_info,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from solohq_agno import AgnoContextMemory
26
+ from solohq_memory import ContextMemoryManager
27
+
28
+ MAX_VISIBLE = 15
29
+
30
+ _STYLE = Style.from_dict({
31
+ "title": "#888888",
32
+ "selected": "bold #ffffff",
33
+ "item": "#aaaaaa",
34
+ "hint": "#666666",
35
+ "sep": "#555555",
36
+ })
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def _clear_up(n: int) -> None:
45
+ """Move cursor up n lines and clear everything below."""
46
+ sys.stdout.write(f"\033[{n}A\033[J")
47
+ sys.stdout.flush()
48
+
49
+
50
+ def _term_height() -> int:
51
+ try:
52
+ return os.get_terminal_size().lines
53
+ except (ValueError, OSError):
54
+ return 24
55
+
56
+
57
+ def _capture_rich(fn, *args, **kwargs) -> str: # noqa: ANN001, ANN003
58
+ """Call a Rich display function and capture its ANSI output."""
59
+ from rich.console import Console as RichConsole
60
+
61
+ buf = StringIO()
62
+ capture = RichConsole(
63
+ file=buf,
64
+ force_terminal=True,
65
+ width=console.width or 80,
66
+ color_system=console.color_system or "256",
67
+ )
68
+ import solohq_cli.display as _display
69
+
70
+ old = _display.console
71
+ _display.console = capture
72
+ try:
73
+ fn(*args, **kwargs)
74
+ finally:
75
+ _display.console = old
76
+ return buf.getvalue()
77
+
78
+
79
+ def _ansi_lines(text: str) -> list[list[tuple[str, str]]]:
80
+ """Parse ANSI text into lines of prompt_toolkit (style, text) fragments."""
81
+ raw = to_formatted_text(ANSI(text))
82
+ merged: list[tuple[str, str]] = []
83
+ for style, ch in raw:
84
+ if merged and merged[-1][0] == style:
85
+ merged[-1] = (style, merged[-1][1] + ch)
86
+ else:
87
+ merged.append((style, ch))
88
+ lines: list[list[tuple[str, str]]] = [[]]
89
+ for style, chunk in merged:
90
+ parts = chunk.split("\n")
91
+ for i, part in enumerate(parts):
92
+ if i > 0:
93
+ lines.append([])
94
+ if part:
95
+ lines[-1].append((style, part))
96
+ if lines and not lines[-1]:
97
+ lines.pop()
98
+ return lines
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Full-screen scrollable detail viewer
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ async def _show_detail(fn, *args, **kwargs) -> None: # noqa: ANN001, ANN003
107
+ """Show Rich output in a full-screen scrollable view."""
108
+ captured = _capture_rich(fn, *args, **kwargs)
109
+ lines = _ansi_lines(captured)
110
+ total = len(lines)
111
+ if total == 0:
112
+ return
113
+
114
+ view_h = [_term_height() - 2]
115
+ scroll = [0]
116
+
117
+ def _max_scroll() -> int:
118
+ return max(0, total - view_h[0])
119
+
120
+ def get_fragments() -> FormattedText:
121
+ result: list[tuple[str, str]] = []
122
+ vis = lines[scroll[0] : scroll[0] + view_h[0]]
123
+ for line_frags in vis:
124
+ result.extend(line_frags)
125
+ result.append(("", "\n"))
126
+ ms = _max_scroll()
127
+ if ms > 0:
128
+ lo = scroll[0] + 1
129
+ hi = min(scroll[0] + view_h[0], total)
130
+ result.append(("class:hint", f" [{lo}-{hi}/{total}] \u2191\u2193 scroll Esc back"))
131
+ else:
132
+ result.append(("class:hint", " Esc to go back"))
133
+ return FormattedText(result)
134
+
135
+ control = FormattedTextControl(get_fragments, focusable=True)
136
+ window = Window(content=control, always_hide_cursor=True)
137
+
138
+ kb = KeyBindings()
139
+
140
+ @kb.add("up")
141
+ @kb.add("k")
142
+ def _up(event) -> None: # noqa: ANN001
143
+ if scroll[0] > 0:
144
+ scroll[0] -= 1
145
+
146
+ @kb.add("down")
147
+ @kb.add("j")
148
+ def _down(event) -> None: # noqa: ANN001
149
+ if scroll[0] < _max_scroll():
150
+ scroll[0] += 1
151
+
152
+ @kb.add("pageup")
153
+ def _pgup(event) -> None: # noqa: ANN001
154
+ scroll[0] = max(0, scroll[0] - view_h[0])
155
+
156
+ @kb.add("pagedown")
157
+ @kb.add(" ")
158
+ def _pgdn(event) -> None: # noqa: ANN001
159
+ scroll[0] = min(_max_scroll(), scroll[0] + view_h[0])
160
+
161
+ @kb.add("escape")
162
+ def _dismiss(event) -> None: # noqa: ANN001
163
+ event.app.exit()
164
+
165
+ app: Application[None] = Application(
166
+ layout=Layout(HSplit([window])),
167
+ key_bindings=kb,
168
+ style=_STYLE,
169
+ full_screen=True,
170
+ )
171
+ await app.run_async()
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Inline picker — renders in-place, erases on exit
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ async def pick_item(items: list[tuple[str, str]], title: str = "") -> str | None:
180
+ """Inline vertical picker (\u2191\u2193). Self-cleaning via save/restore."""
181
+ if not items:
182
+ return None
183
+
184
+ selected = [0]
185
+ offset = [0]
186
+
187
+ def get_fragments() -> FormattedText:
188
+ frags: list[tuple[str, str]] = []
189
+ if title:
190
+ frags.append(("class:title", f" {title}\n"))
191
+ vis_count = min(len(items), MAX_VISIBLE)
192
+ for i in range(offset[0], offset[0] + vis_count):
193
+ if i >= len(items):
194
+ break
195
+ _id, label = items[i]
196
+ if i == selected[0]:
197
+ frags.append(("class:selected", f" > {label}\n"))
198
+ else:
199
+ frags.append(("class:item", f" {label}\n"))
200
+ frags.append(("class:hint", " \u2191\u2193 navigate Enter select Esc back"))
201
+ return FormattedText(frags)
202
+
203
+ kb = KeyBindings()
204
+
205
+ @kb.add("up")
206
+ @kb.add("k")
207
+ def _up(event) -> None: # noqa: ANN001
208
+ if selected[0] > 0:
209
+ selected[0] -= 1
210
+ if selected[0] < offset[0]:
211
+ offset[0] = selected[0]
212
+
213
+ @kb.add("down")
214
+ @kb.add("j")
215
+ def _down(event) -> None: # noqa: ANN001
216
+ if selected[0] < len(items) - 1:
217
+ selected[0] += 1
218
+ if selected[0] >= offset[0] + MAX_VISIBLE:
219
+ offset[0] = selected[0] - MAX_VISIBLE + 1
220
+
221
+ @kb.add("enter")
222
+ def _select(event) -> None: # noqa: ANN001
223
+ event.app.exit(result=items[selected[0]][0])
224
+
225
+ @kb.add("escape")
226
+ def _cancel(event) -> None: # noqa: ANN001
227
+ event.app.exit(result=None)
228
+
229
+ control = FormattedTextControl(get_fragments, focusable=True)
230
+ window = Window(content=control, always_hide_cursor=True)
231
+
232
+ app: Application[str | None] = Application(
233
+ layout=Layout(HSplit([window])),
234
+ key_bindings=kb,
235
+ style=_STYLE,
236
+ full_screen=False,
237
+ )
238
+
239
+ result = await app.run_async()
240
+
241
+ # Erase picker: title + visible items + hint line.
242
+ height = (1 if title else 0) + min(len(items), MAX_VISIBLE) + 1
243
+ _clear_up(height)
244
+
245
+ return result
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Browse functions
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ async def browse_contexts(
254
+ memory: ContextMemoryManager, user_id: str, plugin: AgnoContextMemory
255
+ ) -> None:
256
+ """Interactive context browser."""
257
+ contexts = await memory._storage.list_contexts(user_id)
258
+ if not contexts:
259
+ print_info("No contexts found.")
260
+ return
261
+
262
+ items = [
263
+ (ctx.id, f"{ctx.title} {ctx.updated_at:%m-%d %H:%M}")
264
+ for ctx in contexts
265
+ ]
266
+ while True:
267
+ chosen = await pick_item(items, title="Contexts")
268
+ if not chosen:
269
+ return
270
+ ctx = await memory._storage.get_context(chosen)
271
+ if not ctx:
272
+ continue
273
+ loaded = await memory.load_context(chosen)
274
+ await _ctx_menu(ctx, loaded, memory, plugin)
275
+
276
+
277
+ async def browse_current(
278
+ ctx, loaded, memory: ContextMemoryManager, plugin: AgnoContextMemory # noqa: ANN001
279
+ ) -> None:
280
+ """Entry point for /current."""
281
+ await _ctx_menu(ctx, loaded, memory, plugin)
282
+
283
+
284
+ async def _ctx_menu(
285
+ ctx, loaded, memory: ContextMemoryManager, plugin: AgnoContextMemory # noqa: ANN001
286
+ ) -> None:
287
+ """Context sub-menu — inline picker with drill-down options."""
288
+ ep_count = len(loaded.episodes)
289
+ art_count = len(loaded.artifacts)
290
+ options: list[tuple[str, str]] = [
291
+ ("detail", "View details"),
292
+ ("episodes", f"{ep_count} episodes"),
293
+ ("artifacts", f"{art_count} artifacts"),
294
+ ]
295
+ while True:
296
+ choice = await pick_item(options, title=ctx.title)
297
+ match choice:
298
+ case "detail":
299
+ await _show_detail(print_context_detail, ctx, loaded)
300
+ case "episodes":
301
+ await _episodes_view(loaded.episodes, memory)
302
+ case "artifacts":
303
+ await _artifacts_view(loaded.artifacts, memory)
304
+ case _:
305
+ return
306
+
307
+
308
+ async def browse_artifacts(memory: ContextMemoryManager, ctx_id: str) -> None:
309
+ """Interactive artifact browser for /artifacts."""
310
+ artifacts = await memory._storage.list_artifacts_for_context(ctx_id)
311
+ if not artifacts:
312
+ print_info("No artifacts in current context.")
313
+ return
314
+ await _artifacts_view(list(artifacts), memory)
315
+
316
+
317
+ async def _artifacts_view(artifacts: list, memory: ContextMemoryManager) -> None:
318
+ """Artifact list -> scrollable detail."""
319
+ if not artifacts:
320
+ print_info("No artifacts.")
321
+ return
322
+ items = [
323
+ (art.id, f"{art.title} ({art.type}, v{art.current_version})")
324
+ for art in artifacts
325
+ ]
326
+ while True:
327
+ chosen = await pick_item(items, title="Artifacts")
328
+ if not chosen:
329
+ return
330
+ art = await memory._storage.get_artifact(chosen)
331
+ if art:
332
+ await _show_detail(print_artifact_detail, art)
333
+
334
+
335
+ async def browse_episodes(memory: ContextMemoryManager, ctx_id: str) -> None:
336
+ """Interactive episode browser for /episodes."""
337
+ episodes = await memory._storage.list_episodes(ctx_id)
338
+ if not episodes:
339
+ print_info("No episodes in current context.")
340
+ return
341
+ await _episodes_view(list(episodes), memory)
342
+
343
+
344
+ async def _episodes_view(episodes: list, memory: ContextMemoryManager) -> None:
345
+ """Episode list -> scrollable detail."""
346
+ if not episodes:
347
+ print_info("No episodes.")
348
+ return
349
+ items = [
350
+ (ep.id, f"{ep.summary[:60]} {ep.time_start:%m-%d %H:%M}")
351
+ for ep in episodes
352
+ ]
353
+ while True:
354
+ chosen = await pick_item(items, title="Episodes")
355
+ if not chosen:
356
+ return
357
+ ep = await memory._storage.get_episode(chosen)
358
+ if ep:
359
+ messages = await memory._storage.list_messages(ep.id)
360
+ await _show_detail(print_episode_detail, ep, messages)