agent-cli 0.70.5__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 (196) hide show
  1. agent_cli/__init__.py +5 -0
  2. agent_cli/__main__.py +6 -0
  3. agent_cli/_extras.json +14 -0
  4. agent_cli/_requirements/.gitkeep +0 -0
  5. agent_cli/_requirements/audio.txt +79 -0
  6. agent_cli/_requirements/faster-whisper.txt +215 -0
  7. agent_cli/_requirements/kokoro.txt +425 -0
  8. agent_cli/_requirements/llm.txt +183 -0
  9. agent_cli/_requirements/memory.txt +355 -0
  10. agent_cli/_requirements/mlx-whisper.txt +222 -0
  11. agent_cli/_requirements/piper.txt +176 -0
  12. agent_cli/_requirements/rag.txt +402 -0
  13. agent_cli/_requirements/server.txt +154 -0
  14. agent_cli/_requirements/speed.txt +77 -0
  15. agent_cli/_requirements/vad.txt +155 -0
  16. agent_cli/_requirements/wyoming.txt +71 -0
  17. agent_cli/_tools.py +368 -0
  18. agent_cli/agents/__init__.py +23 -0
  19. agent_cli/agents/_voice_agent_common.py +136 -0
  20. agent_cli/agents/assistant.py +383 -0
  21. agent_cli/agents/autocorrect.py +284 -0
  22. agent_cli/agents/chat.py +496 -0
  23. agent_cli/agents/memory/__init__.py +31 -0
  24. agent_cli/agents/memory/add.py +190 -0
  25. agent_cli/agents/memory/proxy.py +160 -0
  26. agent_cli/agents/rag_proxy.py +128 -0
  27. agent_cli/agents/speak.py +209 -0
  28. agent_cli/agents/transcribe.py +671 -0
  29. agent_cli/agents/transcribe_daemon.py +499 -0
  30. agent_cli/agents/voice_edit.py +291 -0
  31. agent_cli/api.py +22 -0
  32. agent_cli/cli.py +106 -0
  33. agent_cli/config.py +503 -0
  34. agent_cli/config_cmd.py +307 -0
  35. agent_cli/constants.py +27 -0
  36. agent_cli/core/__init__.py +1 -0
  37. agent_cli/core/audio.py +461 -0
  38. agent_cli/core/audio_format.py +299 -0
  39. agent_cli/core/chroma.py +88 -0
  40. agent_cli/core/deps.py +191 -0
  41. agent_cli/core/openai_proxy.py +139 -0
  42. agent_cli/core/process.py +195 -0
  43. agent_cli/core/reranker.py +120 -0
  44. agent_cli/core/sse.py +87 -0
  45. agent_cli/core/transcription_logger.py +70 -0
  46. agent_cli/core/utils.py +526 -0
  47. agent_cli/core/vad.py +175 -0
  48. agent_cli/core/watch.py +65 -0
  49. agent_cli/dev/__init__.py +14 -0
  50. agent_cli/dev/cli.py +1588 -0
  51. agent_cli/dev/coding_agents/__init__.py +19 -0
  52. agent_cli/dev/coding_agents/aider.py +24 -0
  53. agent_cli/dev/coding_agents/base.py +167 -0
  54. agent_cli/dev/coding_agents/claude.py +39 -0
  55. agent_cli/dev/coding_agents/codex.py +24 -0
  56. agent_cli/dev/coding_agents/continue_dev.py +15 -0
  57. agent_cli/dev/coding_agents/copilot.py +24 -0
  58. agent_cli/dev/coding_agents/cursor_agent.py +48 -0
  59. agent_cli/dev/coding_agents/gemini.py +28 -0
  60. agent_cli/dev/coding_agents/opencode.py +15 -0
  61. agent_cli/dev/coding_agents/registry.py +49 -0
  62. agent_cli/dev/editors/__init__.py +19 -0
  63. agent_cli/dev/editors/base.py +89 -0
  64. agent_cli/dev/editors/cursor.py +15 -0
  65. agent_cli/dev/editors/emacs.py +46 -0
  66. agent_cli/dev/editors/jetbrains.py +56 -0
  67. agent_cli/dev/editors/nano.py +31 -0
  68. agent_cli/dev/editors/neovim.py +33 -0
  69. agent_cli/dev/editors/registry.py +59 -0
  70. agent_cli/dev/editors/sublime.py +20 -0
  71. agent_cli/dev/editors/vim.py +42 -0
  72. agent_cli/dev/editors/vscode.py +15 -0
  73. agent_cli/dev/editors/zed.py +20 -0
  74. agent_cli/dev/project.py +568 -0
  75. agent_cli/dev/registry.py +52 -0
  76. agent_cli/dev/skill/SKILL.md +141 -0
  77. agent_cli/dev/skill/examples.md +571 -0
  78. agent_cli/dev/terminals/__init__.py +19 -0
  79. agent_cli/dev/terminals/apple_terminal.py +82 -0
  80. agent_cli/dev/terminals/base.py +56 -0
  81. agent_cli/dev/terminals/gnome.py +51 -0
  82. agent_cli/dev/terminals/iterm2.py +84 -0
  83. agent_cli/dev/terminals/kitty.py +77 -0
  84. agent_cli/dev/terminals/registry.py +48 -0
  85. agent_cli/dev/terminals/tmux.py +58 -0
  86. agent_cli/dev/terminals/warp.py +132 -0
  87. agent_cli/dev/terminals/zellij.py +78 -0
  88. agent_cli/dev/worktree.py +856 -0
  89. agent_cli/docs_gen.py +417 -0
  90. agent_cli/example-config.toml +185 -0
  91. agent_cli/install/__init__.py +5 -0
  92. agent_cli/install/common.py +89 -0
  93. agent_cli/install/extras.py +174 -0
  94. agent_cli/install/hotkeys.py +48 -0
  95. agent_cli/install/services.py +87 -0
  96. agent_cli/memory/__init__.py +7 -0
  97. agent_cli/memory/_files.py +250 -0
  98. agent_cli/memory/_filters.py +63 -0
  99. agent_cli/memory/_git.py +157 -0
  100. agent_cli/memory/_indexer.py +142 -0
  101. agent_cli/memory/_ingest.py +408 -0
  102. agent_cli/memory/_persistence.py +182 -0
  103. agent_cli/memory/_prompt.py +91 -0
  104. agent_cli/memory/_retrieval.py +294 -0
  105. agent_cli/memory/_store.py +169 -0
  106. agent_cli/memory/_streaming.py +44 -0
  107. agent_cli/memory/_tasks.py +48 -0
  108. agent_cli/memory/api.py +113 -0
  109. agent_cli/memory/client.py +272 -0
  110. agent_cli/memory/engine.py +361 -0
  111. agent_cli/memory/entities.py +43 -0
  112. agent_cli/memory/models.py +112 -0
  113. agent_cli/opts.py +433 -0
  114. agent_cli/py.typed +0 -0
  115. agent_cli/rag/__init__.py +3 -0
  116. agent_cli/rag/_indexer.py +67 -0
  117. agent_cli/rag/_indexing.py +226 -0
  118. agent_cli/rag/_prompt.py +30 -0
  119. agent_cli/rag/_retriever.py +156 -0
  120. agent_cli/rag/_store.py +48 -0
  121. agent_cli/rag/_utils.py +218 -0
  122. agent_cli/rag/api.py +175 -0
  123. agent_cli/rag/client.py +299 -0
  124. agent_cli/rag/engine.py +302 -0
  125. agent_cli/rag/models.py +55 -0
  126. agent_cli/scripts/.runtime/.gitkeep +0 -0
  127. agent_cli/scripts/__init__.py +1 -0
  128. agent_cli/scripts/check_plugin_skill_sync.py +50 -0
  129. agent_cli/scripts/linux-hotkeys/README.md +63 -0
  130. agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
  131. agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
  132. agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
  133. agent_cli/scripts/macos-hotkeys/README.md +45 -0
  134. agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
  135. agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
  136. agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
  137. agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
  138. agent_cli/scripts/nvidia-asr-server/README.md +99 -0
  139. agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
  140. agent_cli/scripts/nvidia-asr-server/server.py +255 -0
  141. agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
  142. agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
  143. agent_cli/scripts/run-openwakeword.sh +11 -0
  144. agent_cli/scripts/run-piper-windows.ps1 +30 -0
  145. agent_cli/scripts/run-piper.sh +24 -0
  146. agent_cli/scripts/run-whisper-linux.sh +40 -0
  147. agent_cli/scripts/run-whisper-macos.sh +6 -0
  148. agent_cli/scripts/run-whisper-windows.ps1 +51 -0
  149. agent_cli/scripts/run-whisper.sh +9 -0
  150. agent_cli/scripts/run_faster_whisper_server.py +136 -0
  151. agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
  152. agent_cli/scripts/setup-linux.sh +108 -0
  153. agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
  154. agent_cli/scripts/setup-macos.sh +76 -0
  155. agent_cli/scripts/setup-windows.ps1 +63 -0
  156. agent_cli/scripts/start-all-services-windows.ps1 +53 -0
  157. agent_cli/scripts/start-all-services.sh +178 -0
  158. agent_cli/scripts/sync_extras.py +138 -0
  159. agent_cli/server/__init__.py +3 -0
  160. agent_cli/server/cli.py +721 -0
  161. agent_cli/server/common.py +222 -0
  162. agent_cli/server/model_manager.py +288 -0
  163. agent_cli/server/model_registry.py +225 -0
  164. agent_cli/server/proxy/__init__.py +3 -0
  165. agent_cli/server/proxy/api.py +444 -0
  166. agent_cli/server/streaming.py +67 -0
  167. agent_cli/server/tts/__init__.py +3 -0
  168. agent_cli/server/tts/api.py +335 -0
  169. agent_cli/server/tts/backends/__init__.py +82 -0
  170. agent_cli/server/tts/backends/base.py +139 -0
  171. agent_cli/server/tts/backends/kokoro.py +403 -0
  172. agent_cli/server/tts/backends/piper.py +253 -0
  173. agent_cli/server/tts/model_manager.py +201 -0
  174. agent_cli/server/tts/model_registry.py +28 -0
  175. agent_cli/server/tts/wyoming_handler.py +249 -0
  176. agent_cli/server/whisper/__init__.py +3 -0
  177. agent_cli/server/whisper/api.py +413 -0
  178. agent_cli/server/whisper/backends/__init__.py +89 -0
  179. agent_cli/server/whisper/backends/base.py +97 -0
  180. agent_cli/server/whisper/backends/faster_whisper.py +225 -0
  181. agent_cli/server/whisper/backends/mlx.py +270 -0
  182. agent_cli/server/whisper/languages.py +116 -0
  183. agent_cli/server/whisper/model_manager.py +157 -0
  184. agent_cli/server/whisper/model_registry.py +28 -0
  185. agent_cli/server/whisper/wyoming_handler.py +203 -0
  186. agent_cli/services/__init__.py +343 -0
  187. agent_cli/services/_wyoming_utils.py +64 -0
  188. agent_cli/services/asr.py +506 -0
  189. agent_cli/services/llm.py +228 -0
  190. agent_cli/services/tts.py +450 -0
  191. agent_cli/services/wake_word.py +142 -0
  192. agent_cli-0.70.5.dist-info/METADATA +2118 -0
  193. agent_cli-0.70.5.dist-info/RECORD +196 -0
  194. agent_cli-0.70.5.dist-info/WHEEL +4 -0
  195. agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
  196. agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,174 @@
1
+ """Install optional extras at runtime with pinned versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tomllib
9
+ from importlib.metadata import version as get_version
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from agent_cli.cli import app
16
+ from agent_cli.core.deps import EXTRAS as _EXTRAS_META
17
+ from agent_cli.core.utils import console, print_error_message
18
+
19
+ # Extract descriptions from the centralized EXTRAS metadata
20
+ EXTRAS: dict[str, str] = {name: desc for name, (desc, _) in _EXTRAS_META.items()}
21
+
22
+
23
+ def _requirements_dir() -> Path:
24
+ return Path(__file__).parent.parent / "_requirements"
25
+
26
+
27
+ def _available_extras() -> list[str]:
28
+ """List available extras based on requirements files."""
29
+ req_dir = _requirements_dir()
30
+ if not req_dir.exists():
31
+ return []
32
+ return sorted(p.stem for p in req_dir.glob("*.txt"))
33
+
34
+
35
+ def _requirements_path(extra: str) -> Path:
36
+ return _requirements_dir() / f"{extra}.txt"
37
+
38
+
39
+ def _in_virtualenv() -> bool:
40
+ """Check if running inside a virtual environment."""
41
+ return sys.prefix != sys.base_prefix
42
+
43
+
44
+ def _is_uv_tool_install() -> bool:
45
+ """Check if running from a uv tool environment."""
46
+ receipt = Path(sys.prefix) / "uv-receipt.toml"
47
+ return receipt.exists()
48
+
49
+
50
+ def _get_current_uv_tool_extras() -> list[str]:
51
+ """Get extras currently configured in uv-receipt.toml."""
52
+ receipt = Path(sys.prefix) / "uv-receipt.toml"
53
+ if not receipt.exists():
54
+ return []
55
+ data = tomllib.loads(receipt.read_text())
56
+ requirements = data.get("tool", {}).get("requirements", [])
57
+ for req in requirements:
58
+ if req.get("name") == "agent-cli":
59
+ return req.get("extras", [])
60
+ return []
61
+
62
+
63
+ def _install_via_uv_tool(extras: list[str], *, quiet: bool = False) -> bool:
64
+ """Reinstall agent-cli via uv tool with the specified extras."""
65
+ current_version = get_version("agent-cli").split("+")[0] # Strip local version
66
+ extras_str = ",".join(extras)
67
+ package_spec = f"agent-cli[{extras_str}]=={current_version}"
68
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
69
+ cmd = ["uv", "tool", "install", package_spec, "--force", "--python", python_version]
70
+ if quiet:
71
+ cmd.append("-q")
72
+ console.print(f"Running: [cyan]{' '.join(cmd)}[/]")
73
+ result = subprocess.run(cmd, check=False)
74
+ return result.returncode == 0
75
+
76
+
77
+ def _install_cmd() -> list[str]:
78
+ """Build the install command with appropriate flags."""
79
+ in_venv = _in_virtualenv()
80
+ if shutil.which("uv"):
81
+ cmd = ["uv", "pip", "install", "--python", sys.executable]
82
+ if not in_venv:
83
+ # Allow installing to system Python when not in a venv
84
+ cmd.append("--system")
85
+ return cmd
86
+ cmd = [sys.executable, "-m", "pip", "install"]
87
+ if not in_venv:
88
+ # Install to user site-packages when not in a venv
89
+ cmd.append("--user")
90
+ return cmd
91
+
92
+
93
+ def _install_extras_impl(extras: list[str], *, quiet: bool = False) -> bool:
94
+ """Install extras. Returns True on success, False on failure."""
95
+ if _is_uv_tool_install():
96
+ current_extras = _get_current_uv_tool_extras()
97
+ new_extras = sorted(set(current_extras) | set(extras))
98
+ return _install_via_uv_tool(new_extras, quiet=quiet)
99
+
100
+ cmd = _install_cmd()
101
+ for extra in extras:
102
+ req_file = _requirements_path(extra)
103
+ if not quiet:
104
+ console.print(f"Installing [cyan]{extra}[/]...")
105
+ result = subprocess.run(
106
+ [*cmd, "-r", str(req_file)],
107
+ check=False,
108
+ capture_output=quiet,
109
+ )
110
+ if result.returncode != 0:
111
+ return False
112
+ return True
113
+
114
+
115
+ def install_extras_programmatic(extras: list[str], *, quiet: bool = False) -> bool:
116
+ """Install extras programmatically (for auto-install feature)."""
117
+ available = _available_extras()
118
+ valid = [e for e in extras if e in available]
119
+ invalid = [e for e in extras if e not in available]
120
+ if invalid:
121
+ console.print(f"[yellow]Unknown extras (skipped): {', '.join(invalid)}[/]")
122
+ return bool(valid) and _install_extras_impl(valid, quiet=quiet)
123
+
124
+
125
+ @app.command("install-extras", rich_help_panel="Installation", no_args_is_help=True)
126
+ def install_extras(
127
+ extras: Annotated[list[str] | None, typer.Argument(help="Extras to install")] = None,
128
+ list_extras: Annotated[
129
+ bool,
130
+ typer.Option("--list", "-l", help="List available extras"),
131
+ ] = False,
132
+ all_extras: Annotated[
133
+ bool,
134
+ typer.Option("--all", "-a", help="Install all available extras"),
135
+ ] = False,
136
+ ) -> None:
137
+ """Install optional extras (rag, memory, vad, etc.) with pinned versions.
138
+
139
+ Examples:
140
+ - `agent-cli install-extras rag` # Install RAG dependencies
141
+ - `agent-cli install-extras memory vad` # Install multiple extras
142
+ - `agent-cli install-extras --list` # Show available extras
143
+ - `agent-cli install-extras --all` # Install all extras
144
+
145
+ """
146
+ available = _available_extras()
147
+
148
+ if list_extras:
149
+ console.print("[bold]Available extras:[/]")
150
+ for name in available:
151
+ desc = EXTRAS.get(name, "")
152
+ console.print(f" [cyan]{name}[/]: {desc}")
153
+ return
154
+
155
+ if all_extras:
156
+ extras = available
157
+
158
+ if not extras:
159
+ print_error_message("No extras specified. Use --list to see available, or --all.")
160
+ raise typer.Exit(1)
161
+
162
+ invalid = [e for e in extras if e not in available]
163
+ if invalid:
164
+ print_error_message(f"Unknown extras: {invalid}. Use --list to see available.")
165
+ raise typer.Exit(1)
166
+
167
+ if not _install_extras_impl(extras):
168
+ print_error_message("Failed to install extras")
169
+ raise typer.Exit(1)
170
+
171
+ if _is_uv_tool_install():
172
+ console.print("[green]Done! Extras will persist across uv tool upgrade.[/]")
173
+ else:
174
+ console.print("[green]Done![/]")
@@ -0,0 +1,48 @@
1
+ """Hotkey installation commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+
7
+ from agent_cli.cli import app
8
+ from agent_cli.core.utils import print_with_style
9
+ from agent_cli.install.common import execute_installation_script, get_platform_script
10
+
11
+
12
+ @app.command("install-hotkeys", rich_help_panel="Installation")
13
+ def install_hotkeys() -> None:
14
+ """Install system-wide hotkeys for agent-cli commands.
15
+
16
+ Sets up the following hotkeys:
17
+
18
+ macOS:
19
+ - Cmd+Shift+R: Toggle voice transcription
20
+ - Cmd+Shift+A: Autocorrect clipboard text
21
+ - Cmd+Shift+V: Voice edit clipboard text
22
+
23
+ Linux:
24
+ - Super+Shift+R: Toggle voice transcription
25
+ - Super+Shift+A: Autocorrect clipboard text
26
+ - Super+Shift+V: Voice edit clipboard text
27
+
28
+ Note: On macOS, you may need to grant Accessibility permissions to skhd
29
+ in System Settings → Privacy & Security → Accessibility.
30
+ """
31
+ script_name = get_platform_script("setup-macos-hotkeys.sh", "setup-linux-hotkeys.sh")
32
+ system = platform.system().lower()
33
+
34
+ execute_installation_script(
35
+ script_name=script_name,
36
+ operation_name="Set up hotkeys",
37
+ success_message="Hotkeys installed successfully!",
38
+ )
39
+
40
+ # Post-installation steps for macOS
41
+ if system == "darwin":
42
+ print_with_style("\n⚠️ Important:", "yellow")
43
+ print_with_style("If hotkeys don't work, grant Accessibility permissions:", "yellow")
44
+ print_with_style(
45
+ " 1. Open System Settings → Privacy & Security → Accessibility",
46
+ "cyan",
47
+ )
48
+ print_with_style(" 2. Add and enable 'skhd'", "cyan")
@@ -0,0 +1,87 @@
1
+ """Service installation and management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+
8
+ import typer
9
+
10
+ from agent_cli.cli import app
11
+ from agent_cli.core.utils import console, print_error_message, print_with_style
12
+ from agent_cli.install.common import (
13
+ execute_installation_script,
14
+ get_platform_script,
15
+ get_script_path,
16
+ )
17
+
18
+
19
+ @app.command("install-services", rich_help_panel="Installation")
20
+ def install_services() -> None:
21
+ """Install all required services (Ollama, Whisper, Piper, OpenWakeWord).
22
+
23
+ This command installs:
24
+ - Ollama (local LLM server)
25
+ - Wyoming Faster Whisper (speech-to-text)
26
+ - Wyoming Piper (text-to-speech)
27
+ - Wyoming OpenWakeWord (wake word detection)
28
+
29
+ The appropriate installation method is used based on your operating system.
30
+ """
31
+ script_name = get_platform_script("setup-macos.sh", "setup-linux.sh")
32
+
33
+ execute_installation_script(
34
+ script_name=script_name,
35
+ operation_name="Install services",
36
+ success_message="Services installed successfully!",
37
+ next_steps=[
38
+ "Start services: agent-cli start-services",
39
+ "Set up hotkeys: agent-cli install-hotkeys",
40
+ ],
41
+ )
42
+
43
+
44
+ @app.command("start-services", rich_help_panel="Service Management")
45
+ def start_services(
46
+ attach: bool = typer.Option(
47
+ True, # noqa: FBT003
48
+ "--attach/--no-attach",
49
+ help="Attach to Zellij session after starting",
50
+ ),
51
+ ) -> None:
52
+ """Start all agent-cli services in a Zellij session.
53
+
54
+ This starts:
55
+ - Ollama (LLM server)
56
+ - Wyoming Faster Whisper (speech-to-text)
57
+ - Wyoming Piper (text-to-speech)
58
+ - Wyoming OpenWakeWord (wake word detection)
59
+
60
+ Services run in a Zellij terminal multiplexer session named 'agent-cli'.
61
+ Use Ctrl-Q to quit or Ctrl-O d to detach from the session.
62
+ """
63
+ try:
64
+ script_path = get_script_path("start-all-services.sh")
65
+ except FileNotFoundError as e:
66
+ print_error_message("Service scripts not found")
67
+ console.print(str(e))
68
+ raise typer.Exit(1) from None
69
+
70
+ env = os.environ.copy()
71
+ if not attach:
72
+ env["AGENT_CLI_NO_ATTACH"] = "true"
73
+
74
+ try:
75
+ subprocess.run([str(script_path)], check=True, env=env)
76
+ if not attach:
77
+ print_with_style("✅ Services started in background.", "green")
78
+ print_with_style("Run 'zellij attach agent-cli' to view the session.", "yellow")
79
+ else:
80
+ # If we get here with attach=True, user likely detached
81
+ print_with_style("\n👋 Detached from Zellij session.")
82
+ print_with_style(
83
+ "Services are still running. Use 'zellij attach agent-cli' to reattach.",
84
+ )
85
+ except subprocess.CalledProcessError as e:
86
+ print_error_message(f"Failed to start services. Exit code: {e.returncode}")
87
+ raise typer.Exit(e.returncode) from None
@@ -0,0 +1,7 @@
1
+ """Memory module for long-term chat history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_cli.memory.client import MemoryClient
6
+
7
+ __all__ = ["MemoryClient"]
@@ -0,0 +1,250 @@
1
+ """File-backed memory helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+ from uuid import uuid4
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from agent_cli.core.utils import atomic_write_text
15
+ from agent_cli.memory.models import MemoryMetadata
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterable
19
+
20
+ LOGGER = logging.getLogger(__name__)
21
+
22
+ _ENTRIES_DIRNAME = "entries"
23
+ _SNAPSHOT_FILENAME = "memory_index.json"
24
+ _DELETED_DIRNAME = "deleted"
25
+
26
+
27
+ @dataclass
28
+ class MemoryFileRecord:
29
+ """Materialized memory file on disk."""
30
+
31
+ id: str
32
+ path: Path
33
+ metadata: MemoryMetadata
34
+ content: str
35
+
36
+
37
+ def ensure_store_dirs(root: Path) -> tuple[Path, Path]:
38
+ """Ensure base folders exist and return (entries_dir, snapshot_path)."""
39
+ entries_dir = root / _ENTRIES_DIRNAME
40
+ entries_dir.mkdir(parents=True, exist_ok=True)
41
+ snapshot_path = root / _SNAPSHOT_FILENAME
42
+ return entries_dir, snapshot_path
43
+
44
+
45
+ def _safe_timestamp(value: str) -> str:
46
+ """Return a filesystem-safe timestamp-ish token."""
47
+ return "".join(ch if ch.isalnum() or ch in "-._" else "-" for ch in value) or "ts"
48
+
49
+
50
+ def soft_delete_memory_file(
51
+ path: Path,
52
+ entries_dir: Path,
53
+ replaced_by: str | None = None,
54
+ ) -> None:
55
+ """Move a memory file to the deleted directory, updating metadata if replaced."""
56
+ try:
57
+ rel_path = path.relative_to(entries_dir)
58
+ except ValueError:
59
+ # Not in entries_dir? Just unlink.
60
+ path.unlink(missing_ok=True)
61
+ return
62
+
63
+ # Prepare destination
64
+ dest_path = entries_dir / _DELETED_DIRNAME / rel_path
65
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ # If we need to update metadata, read-modify-write
68
+ if replaced_by:
69
+ record = read_memory_file(path)
70
+ if record:
71
+ record.metadata.replaced_by = replaced_by
72
+ front_matter = _render_front_matter(record.id, record.metadata)
73
+ body = front_matter + "\n" + record.content + "\n"
74
+ atomic_write_text(dest_path, body)
75
+ path.unlink(missing_ok=True)
76
+ return
77
+
78
+ # Simple move if no metadata change
79
+ path.rename(dest_path)
80
+
81
+
82
+ def write_memory_file(
83
+ root: Path,
84
+ *,
85
+ conversation_id: str,
86
+ role: str,
87
+ created_at: str,
88
+ content: str,
89
+ summary_kind: str | None = None,
90
+ doc_id: str | None = None,
91
+ source_id: str | None = None,
92
+ ) -> MemoryFileRecord:
93
+ """Render and persist a memory document to disk."""
94
+ entries_dir, _ = ensure_store_dirs(root)
95
+ safe_conversation = _slugify(conversation_id)
96
+ doc_id = doc_id or str(uuid4())
97
+ safe_ts = _safe_timestamp(created_at)
98
+
99
+ # Route by role/category for readability
100
+ if summary_kind:
101
+ subdir = Path("summaries")
102
+ filename = "summary.md"
103
+ elif role == "user":
104
+ subdir = Path("turns") / "user"
105
+ filename = f"{safe_ts}__{doc_id}.md"
106
+ elif role == "assistant":
107
+ subdir = Path("turns") / "assistant"
108
+ filename = f"{safe_ts}__{doc_id}.md"
109
+ elif role == "memory":
110
+ subdir = Path("facts")
111
+ filename = f"{safe_ts}__{doc_id}.md"
112
+ else:
113
+ subdir = Path()
114
+ filename = f"{doc_id}.md"
115
+
116
+ metadata = MemoryMetadata(
117
+ conversation_id=conversation_id,
118
+ role=role,
119
+ created_at=created_at,
120
+ summary_kind=summary_kind,
121
+ source_id=source_id,
122
+ )
123
+
124
+ front_matter = _render_front_matter(doc_id, metadata)
125
+ body = front_matter + "\n" + content.strip() + "\n"
126
+
127
+ file_path = entries_dir / safe_conversation / subdir / filename
128
+ file_path.parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ atomic_write_text(file_path, body)
131
+
132
+ return MemoryFileRecord(id=doc_id, path=file_path, metadata=metadata, content=content)
133
+
134
+
135
+ def load_memory_files(root: Path) -> list[MemoryFileRecord]:
136
+ """Load all memory files from disk."""
137
+ entries_dir, _ = ensure_store_dirs(root)
138
+ records: list[MemoryFileRecord] = []
139
+ for path in entries_dir.rglob("*.md"):
140
+ # Skip anything under a deleted tombstone folder.
141
+ if _DELETED_DIRNAME in path.parts:
142
+ continue
143
+ rec = read_memory_file(path)
144
+ if rec:
145
+ records.append(rec)
146
+ return records
147
+
148
+
149
+ def read_memory_file(path: Path) -> MemoryFileRecord | None:
150
+ """Parse a single memory file; return None if invalid."""
151
+ try:
152
+ text = path.read_text(encoding="utf-8")
153
+ except Exception:
154
+ LOGGER.warning("Failed to read memory file %s", path, exc_info=True)
155
+ return None
156
+
157
+ fm, body = _split_front_matter(text)
158
+ if fm is None:
159
+ LOGGER.warning("Memory file %s missing front matter; skipping", path)
160
+ return None
161
+
162
+ doc_id = fm.pop("id", None)
163
+ if not doc_id:
164
+ LOGGER.warning("Memory file %s missing id; skipping", path)
165
+ return None
166
+
167
+ try:
168
+ metadata = MemoryMetadata(**fm)
169
+ except ValidationError:
170
+ LOGGER.warning("Memory file %s has invalid metadata; skipping", path, exc_info=True)
171
+ return None
172
+
173
+ return MemoryFileRecord(id=str(doc_id), path=path, metadata=metadata, content=body.strip())
174
+
175
+
176
+ def write_snapshot(snapshot_path: Path, records: Iterable[MemoryFileRecord]) -> None:
177
+ """Write a JSON snapshot of current memories for easy inspection."""
178
+ payload = [
179
+ {
180
+ "id": rec.id,
181
+ "path": str(rec.path),
182
+ "metadata": rec.metadata.model_dump(exclude_none=True),
183
+ "content": rec.content,
184
+ }
185
+ for rec in records
186
+ ]
187
+
188
+ atomic_write_text(snapshot_path, json.dumps(payload, ensure_ascii=False, indent=2))
189
+
190
+
191
+ def load_snapshot(snapshot_path: Path) -> dict[str, MemoryFileRecord]:
192
+ """Load snapshot into a mapping from id to record (if the snapshot exists)."""
193
+ if not snapshot_path.exists():
194
+ return {}
195
+ try:
196
+ data = json.loads(snapshot_path.read_text(encoding="utf-8"))
197
+ except Exception:
198
+ LOGGER.warning("Failed to read memory snapshot %s", snapshot_path, exc_info=True)
199
+ return {}
200
+
201
+ records: dict[str, MemoryFileRecord] = {}
202
+ for item in data:
203
+ try:
204
+ metadata = MemoryMetadata(**item["metadata"])
205
+ record = MemoryFileRecord(
206
+ id=str(item["id"]),
207
+ path=Path(item["path"]),
208
+ metadata=metadata,
209
+ content=str(item.get("content") or ""),
210
+ )
211
+ records[record.id] = record
212
+ except Exception:
213
+ LOGGER.warning("Invalid snapshot entry; skipping", exc_info=True)
214
+ continue
215
+ return records
216
+
217
+
218
+ def _render_front_matter(doc_id: str, metadata: MemoryMetadata) -> str:
219
+ """Return YAML front matter string."""
220
+ import yaml # noqa: PLC0415
221
+
222
+ meta_dict = metadata.model_dump(exclude_none=True)
223
+ meta_dict = {"id": doc_id, **meta_dict}
224
+ yaml_block = yaml.safe_dump(meta_dict, sort_keys=False)
225
+ return f"---\n{yaml_block}---"
226
+
227
+
228
+ def _split_front_matter(text: str) -> tuple[dict | None, str]:
229
+ """Split YAML front matter from body."""
230
+ if not text.startswith("---"):
231
+ return None, text
232
+ end = text.find("\n---", 3)
233
+ if end == -1:
234
+ return None, text
235
+ yaml_part = text[3:end]
236
+ try:
237
+ import yaml # noqa: PLC0415
238
+
239
+ meta = yaml.safe_load(yaml_part) or {}
240
+ except Exception:
241
+ return None, text
242
+ body_start = end + len("\n---")
243
+ body = text[body_start:].lstrip("\n")
244
+ return meta, body
245
+
246
+
247
+ def _slugify(value: str) -> str:
248
+ """Filesystem-safe slug for folder names."""
249
+ safe = "".join(ch if ch.isalnum() or ch in "-._" else "_" for ch in value)
250
+ return safe or "default"
@@ -0,0 +1,63 @@
1
+ """Filter conversion utilities for ChromaDB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def _convert_condition(key: str, value: Any) -> dict[str, Any] | None:
9
+ """Convert a single filter condition to ChromaDB format."""
10
+ if isinstance(value, dict):
11
+ # Operator dict: {"gte": 10} → {"$gte": 10}
12
+ for op, val in value.items():
13
+ chroma_op = f"${op}" if not op.startswith("$") else op
14
+ return {key: {chroma_op: val}}
15
+ return None
16
+ # Simple equality
17
+ return {key: {"$eq": value}}
18
+
19
+
20
+ def _process_or(conditions: list[dict[str, Any]]) -> dict[str, Any] | None:
21
+ """Process $or conditions."""
22
+ or_conditions = []
23
+ for cond in conditions:
24
+ for sub_key, sub_val in cond.items():
25
+ converted = _convert_condition(sub_key, sub_val)
26
+ if converted:
27
+ or_conditions.append(converted)
28
+ if len(or_conditions) > 1:
29
+ return {"$or": or_conditions}
30
+ if or_conditions:
31
+ return or_conditions[0]
32
+ return None
33
+
34
+
35
+ def to_chroma_where(filters: dict[str, Any] | None) -> dict[str, Any] | None:
36
+ """Convert universal filter format to ChromaDB WHERE clause.
37
+
38
+ Supports:
39
+ - Simple equality: {"role": "user"} → {"role": {"$eq": "user"}}
40
+ - Operators: {"created_at": {"gte": "2024-01-01"}} → {"created_at": {"$gte": "2024-01-01"}}
41
+ - Logical OR: {"$or": [{"role": "user"}, {"role": "assistant"}]}
42
+
43
+ Operators: eq, ne, gt, gte, lt, lte, in, nin
44
+ """
45
+ if not filters:
46
+ return None
47
+
48
+ processed: list[dict[str, Any]] = []
49
+ for key, value in filters.items():
50
+ if key == "$or":
51
+ or_result = _process_or(value)
52
+ if or_result:
53
+ processed.append(or_result)
54
+ elif not key.startswith("$"):
55
+ converted = _convert_condition(key, value)
56
+ if converted:
57
+ processed.append(converted)
58
+
59
+ if not processed:
60
+ return None
61
+ if len(processed) == 1:
62
+ return processed[0]
63
+ return {"$and": processed}