crow-cli 0.1.32__tar.gz → 0.1.34__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 (71) hide show
  1. {crow_cli-0.1.32 → crow_cli-0.1.34}/PKG-INFO +7 -5
  2. {crow_cli-0.1.32 → crow_cli-0.1.34}/README.md +3 -2
  3. {crow_cli-0.1.32 → crow_cli-0.1.34}/crow-cli.spec +1 -0
  4. {crow_cli-0.1.32 → crow_cli-0.1.34}/pyproject.toml +7 -3
  5. crow_cli-0.1.34/run_tests.sh +4 -0
  6. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/compact.py +7 -6
  7. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/configure.py +114 -14
  8. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/default/defaults.py +21 -6
  9. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/main.py +51 -8
  10. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/memory.py +25 -17
  11. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/model_routing.py +15 -13
  12. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/react.py +149 -105
  13. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/session.py +3 -3
  14. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/tools.py +0 -503
  15. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/init_cmd.py +6 -6
  16. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/main.py +27 -8
  17. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/conftest.py +4 -3
  18. crow_cli-0.1.34/tests/e2e/test_react_live.py +79 -0
  19. crow_cli-0.1.34/tests/integration/test_acp_http.py +75 -0
  20. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/integration/test_auth_validation.py +1 -1
  21. crow_cli-0.1.34/tests/integration/test_react_loop_cancel_integrity.py +320 -0
  22. crow_cli-0.1.34/tests/integration/test_react_loop_tool_round.py +297 -0
  23. crow_cli-0.1.34/tests/integration/test_reasoning_effort_react_loop.py +97 -0
  24. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_compact.py +37 -1
  25. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_configure.py +17 -0
  26. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_model_routing.py +23 -31
  27. crow_cli-0.1.34/tests/unit/test_react.py +432 -0
  28. crow_cli-0.1.34/tests/unit/test_reasoning_effort.py +277 -0
  29. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_session.py +4 -3
  30. {crow_cli-0.1.32 → crow_cli-0.1.34}/uv.lock +103 -5
  31. crow_cli-0.1.32/run_tests.sh +0 -6
  32. crow_cli-0.1.32/src/crow_cli/agent/db.py +0 -393
  33. crow_cli-0.1.32/test_send_request_error_handling.py +0 -195
  34. crow_cli-0.1.32/tests/unit/test_db.py +0 -100
  35. {crow_cli-0.1.32 → crow_cli-0.1.34}/.gitignore +0 -0
  36. {crow_cli-0.1.32 → crow_cli-0.1.34}/.python-version +0 -0
  37. {crow_cli-0.1.32 → crow_cli-0.1.34}/agent.json +0 -0
  38. {crow_cli-0.1.32 → crow_cli-0.1.34}/examples/mc_escher_loop.py +0 -0
  39. {crow_cli-0.1.32 → crow_cli-0.1.34}/examples/quick_test.py +0 -0
  40. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/__init__.py +0 -0
  41. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/__init__.py +0 -0
  42. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/context.py +0 -0
  43. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/default/__init__.py +0 -0
  44. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/default/searxng_settings.py +0 -0
  45. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/hooks.py +0 -0
  46. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/llm.py +0 -0
  47. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/logger.py +0 -0
  48. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/mcp_client.py +0 -0
  49. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/prompt.py +0 -0
  50. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/slash.py +0 -0
  51. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent_runner.py +0 -0
  52. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/__init__.py +0 -0
  53. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/install.py +0 -0
  54. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/client/__init__.py +0 -0
  55. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/client/main.py +0 -0
  56. {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/client/terminal.py +0 -0
  57. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/__init__.py +0 -0
  58. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/analyze_payload_cache_invalidation.py +0 -0
  59. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/analyze_payload_deep.py +0 -0
  60. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/e2e/test_session_update_transmission.py +0 -0
  61. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/integration/test_agent_init.py +0 -0
  62. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_client_terminal.py +0 -0
  63. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_compact_last_messages_list_content.json +0 -0
  64. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_compact_last_messages_list_content.py +0 -0
  65. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_json_repair.py +0 -0
  66. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_model_override.py +0 -0
  67. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_request_logging.py +0 -0
  68. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_stream_processing.py +0 -0
  69. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_system_prompt_skills.py +0 -0
  70. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_usage_update.py +0 -0
  71. {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_uv_project_enforcement.py +0 -0
@@ -1,23 +1,24 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: crow-cli
3
- Version: 0.1.32
3
+ Version: 0.1.34
4
4
  Summary: Add your description here
5
5
  Project-URL: Homepage, https://github.com/crow-cli/crow-cli
6
6
  Project-URL: Repository, https://github.com/crow-cli/crow-cli
7
7
  Project-URL: Issues, https://github.com/crow-cli/crow-cli/issues
8
8
  Project-URL: Documentation, https://github.com/crow-cli/crow-cli#readme
9
9
  Requires-Python: >=3.14
10
- Requires-Dist: agent-client-protocol<0.13.0,>=0.12.0
10
+ Requires-Dist: agent-client-protocol[http]<0.13.0,>=0.12.0
11
11
  Requires-Dist: coolname>=2.2.0
12
+ Requires-Dist: crow-memory==0.1.34
12
13
  Requires-Dist: directory-tree>=1.0.0
13
14
  Requires-Dist: docker>=7.2.0
14
15
  Requires-Dist: fastmcp>=2.14.5
15
16
  Requires-Dist: httpx>=0.28.1
17
+ Requires-Dist: hypercorn>=0.17
16
18
  Requires-Dist: jinja2>=3.1.6
17
19
  Requires-Dist: openai>=2.21.0
18
20
  Requires-Dist: pyyaml>=6.0.3
19
21
  Requires-Dist: rich>=14.3.3
20
- Requires-Dist: sqlalchemy>=2.0.51
21
22
  Requires-Dist: typer>=0.24.1
22
23
  Description-Content-Type: text/markdown
23
24
 
@@ -171,13 +172,13 @@ cd crow-cli
171
172
  uv sync --project crow-cli
172
173
  ```
173
174
 
174
- Run the unit tests — fast and hermetic, no services required (tests that touch sessions use an in-memory fake of the memory service):
175
+ Run the unit tests — fast and hermetic, no services required (unit tests that touch sessions use an in-memory fake of the persistence client; the integration tier uses a real throwaway sqlite db):
175
176
 
176
177
  ```bash
177
178
  uv run --project crow-cli pytest crow-cli/tests/unit
178
179
  ```
179
180
 
180
- The persistence layer itself is tested in `crow-cli/tests/unit/test_db.py`. Integration and end-to-end tiers are opt-in:
181
+ The persistence layer itself lives in the `crow-memory` package and is tested in `crow-memory/tests/test_store.py`. Integration and end-to-end tiers are opt-in:
181
182
 
182
183
  ```bash
183
184
  uv run --project crow-cli pytest crow-cli/tests --run-integration # spawn the agent
@@ -189,6 +190,7 @@ uv run --project crow-cli pytest crow-cli/tests --run-e2e # live LLM c
189
190
  ```
190
191
  crow-cli/ the agent — ACP server, ReAct loop, CLI
191
192
  crow-mcp/ built-in MCP tool server
193
+ crow-memory/ shared SQL persistence (sqlite default, postgres-ready)
192
194
  ```
193
195
 
194
196
  ## License
@@ -148,13 +148,13 @@ cd crow-cli
148
148
  uv sync --project crow-cli
149
149
  ```
150
150
 
151
- Run the unit tests — fast and hermetic, no services required (tests that touch sessions use an in-memory fake of the memory service):
151
+ Run the unit tests — fast and hermetic, no services required (unit tests that touch sessions use an in-memory fake of the persistence client; the integration tier uses a real throwaway sqlite db):
152
152
 
153
153
  ```bash
154
154
  uv run --project crow-cli pytest crow-cli/tests/unit
155
155
  ```
156
156
 
157
- The persistence layer itself is tested in `crow-cli/tests/unit/test_db.py`. Integration and end-to-end tiers are opt-in:
157
+ The persistence layer itself lives in the `crow-memory` package and is tested in `crow-memory/tests/test_store.py`. Integration and end-to-end tiers are opt-in:
158
158
 
159
159
  ```bash
160
160
  uv run --project crow-cli pytest crow-cli/tests --run-integration # spawn the agent
@@ -166,6 +166,7 @@ uv run --project crow-cli pytest crow-cli/tests --run-e2e # live LLM c
166
166
  ```
167
167
  crow-cli/ the agent — ACP server, ReAct loop, CLI
168
168
  crow-mcp/ built-in MCP tool server
169
+ crow-memory/ shared SQL persistence (sqlite default, postgres-ready)
169
170
  ```
170
171
 
171
172
  ## License
@@ -22,6 +22,7 @@ a = Analysis(
22
22
  ),
23
23
  hiddenimports=[
24
24
  'crow_cli',
25
+ 'crow_memory',
25
26
  'crow_cli.agent',
26
27
  'crow_cli.agent.main',
27
28
  'crow_cli.agent.mcp_client',
@@ -1,11 +1,13 @@
1
1
  [project]
2
2
  name = "crow-cli"
3
- version = "0.1.32"
3
+ version = "0.1.34"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  dependencies = [
8
- "agent-client-protocol>=0.12.0,<0.13.0",
8
+ "agent-client-protocol[http]>=0.12.0,<0.13.0",
9
+ "crow-memory==0.1.34",
10
+ "hypercorn>=0.17",
9
11
  "coolname>=2.2.0",
10
12
  "directory-tree>=1.0.0",
11
13
  "docker>=7.2.0",
@@ -15,10 +17,12 @@ dependencies = [
15
17
  "openai>=2.21.0",
16
18
  "pyyaml>=6.0.3",
17
19
  "rich>=14.3.3",
18
- "sqlalchemy>=2.0.51",
19
20
  "typer>=0.24.1",
20
21
  ]
21
22
 
23
+ [tool.uv.sources]
24
+ crow-memory = { path = "../crow-memory", editable = true }
25
+
22
26
 
23
27
  [project.urls]
24
28
  Homepage = "https://github.com/crow-cli/crow-cli"
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Run the crow-cli test suite (unit by default; pass extra pytest args, e.g.
3
+ # ./run_tests.sh tests/integration --run-integration).
4
+ cd "$(dirname "$0")" && exec uv --project . run pytest "$@"
@@ -17,7 +17,7 @@ from logging import Logger
17
17
 
18
18
  from openai import AsyncOpenAI
19
19
 
20
- from crow_cli.agent.configure import Config
20
+ from crow_cli.agent.configure import Config, sampling_params_for
21
21
  from crow_cli.agent.session import (
22
22
  AgentSession,
23
23
  make_agent_session,
@@ -123,16 +123,17 @@ async def compact(
123
123
  # 3. Append compaction prompt
124
124
  messages.append({"role": "user", "content": COMPACTION_PROMPT})
125
125
 
126
- # 4. Send to LLM
127
- request_params = dict(session.request_params)
128
- request_params["max_tokens"] = MAX_OUTPUT_TOKENS
129
-
126
+ # 4. Send to LLM — same per-model sampling rule as the react loop: the
127
+ # model's reasoning_effort XOR temperature. Never the provider default
128
+ # (temp 1.0 makes the model ramble instead of compress) and never the
129
+ # session's request_params temperature.
130
130
  response = await llm.chat.completions.create(
131
131
  model=session.model_identifier,
132
132
  messages=messages,
133
133
  tools=session.tools if session.tools else None,
134
134
  tool_choice="none",
135
- **request_params,
135
+ max_tokens=MAX_OUTPUT_TOKENS,
136
+ **sampling_params_for(config, session.model_identifier),
136
137
  )
137
138
  summary = response.choices[0].message.content
138
139
  usage = {
@@ -10,10 +10,12 @@ import os
10
10
  import re
11
11
  from dataclasses import dataclass, field
12
12
  from pathlib import Path
13
- from typing import Any
13
+ from typing import Any, Literal
14
14
 
15
15
  import yaml
16
+ from crow_memory import normalize_db_uri
16
17
  from dotenv import load_dotenv
18
+ from pydantic import BaseModel, ValidationError
17
19
 
18
20
  from crow_cli.agent.default import (
19
21
  COMPOSE_YAML,
@@ -88,6 +90,69 @@ def resolve_env_vars(value: Any, missing: set[str] | None = None) -> Any:
88
90
  return value
89
91
 
90
92
 
93
+ # OpenAI reasoning models (gpt-5, o3, ...) accept `reasoning_effort` instead
94
+ # of `temperature`. These are the enumerable values from the OpenAI API
95
+ # reference (developers.openai.com): none, minimal, low, medium, high, xhigh,
96
+ # max. We validate config.yaml against this set so a typo fails fast at load
97
+ # time instead of as an opaque provider 400 mid-turn.
98
+ ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
99
+ REASONING_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
100
+
101
+ # Per-model modality LIST — what the model can take as input. Default
102
+ # ["text", "image"] = assume vision-capable until proven otherwise; a
103
+ # text-only model gets ["text"]. audio/video are filled in by hand (or by a
104
+ # probe) for models that natively take them (qwen3.x-max takes video).
105
+ Modality = Literal["text", "image", "audio", "video"]
106
+ MODALITY_VALUES = ("text", "image", "audio", "video")
107
+
108
+
109
+ class ReasoningEffortModel(BaseModel):
110
+ """Validate a reasoning_effort value against OpenAI's enumerable set."""
111
+
112
+ reasoning_effort: ReasoningEffort
113
+
114
+
115
+ def parse_reasoning_effort(raw: Any) -> str:
116
+ """Validate and normalize a config.yaml reasoning_effort value.
117
+
118
+ Raises ValueError naming the valid set when the value is not enumerable.
119
+ """
120
+ value = str(raw).strip().lower()
121
+ try:
122
+ return ReasoningEffortModel(reasoning_effort=value).reasoning_effort
123
+ except ValidationError:
124
+ raise ValueError(
125
+ f"reasoning_effort must be one of {', '.join(REASONING_EFFORT_VALUES)}, "
126
+ f"got {raw!r}"
127
+ ) from None
128
+
129
+
130
+ def build_sampling_params(
131
+ reasoning_effort: str | None,
132
+ temperature: float,
133
+ ) -> dict[str, Any]:
134
+ """The one sampling rule for every LLM call (react loop AND compaction):
135
+ reasoning_effort when set — temperature omitted, reasoning models reject
136
+ it — else temperature. Never both, never neither (no provider defaults)."""
137
+ return (
138
+ {"reasoning_effort": reasoning_effort}
139
+ if reasoning_effort
140
+ else {"temperature": temperature}
141
+ )
142
+
143
+
144
+ def sampling_params_for(config: "Config", model_id: str) -> dict[str, Any]:
145
+ """Per-model sampling rule for every LLM call (react loop AND compaction):
146
+ the configured model's reasoning_effort XOR temperature. Models not in
147
+ config get the defaults (temperature 0.6, no effort)."""
148
+ model = next(
149
+ (m for m in config.llm.models.values() if m.model_id == model_id), None
150
+ )
151
+ if model is None:
152
+ return {"temperature": 0.6}
153
+ return build_sampling_params(model.reasoning_effort, model.temperature)
154
+
155
+
91
156
  @dataclass
92
157
  class LLMProvider:
93
158
  name: str
@@ -100,9 +165,14 @@ class LLModel:
100
165
  name: str
101
166
  provider_name: str
102
167
  model_id: str
103
- # None = unknown → assume capable of everything (permissive default).
104
- # A set restricts: e.g. set() is text-only, {"vision"} handles images.
105
- capabilities: set[str] | None = None
168
+ # Per-model sampling, XOR: reasoning_effort wins and temperature is
169
+ # omitted entirely (reasoning models reject it); else temperature.
170
+ temperature: float = 0.6
171
+ reasoning_effort: ReasoningEffort | None = None
172
+ # Input modalities. Assume vision-capable until proven otherwise;
173
+ # ["text"] models get image/audio/video blocks stripped / routed around
174
+ # (see model_routing).
175
+ modality: list[Modality] = field(default_factory=lambda: ["text", "image"])
106
176
  # Ordered fallback chain (model NAMES from this config) used when this
107
177
  # model cannot handle the modalities present in the conversation.
108
178
  fallbacks: list[str] = field(default_factory=list)
@@ -118,13 +188,12 @@ class LLMConfig:
118
188
  class Config:
119
189
  config_dir: Path
120
190
  llm: LLMConfig = field(default_factory=LLMConfig)
121
- memory_path: str = ""
191
+ db_uri: str = ""
122
192
  skills_dir: str = str(SKILLS_DIR)
123
193
  mcp_servers: dict[str, Any] = field(default_factory=dict)
124
194
  max_retries_per_step: int = 3
125
195
  MAX_COMPACT_TOKENS: int = 190000
126
196
  MAX_TOKENS: int = 38192
127
- TEMPERATURE: float = 0.6
128
197
  chunk_log: bool = False # Write every raw chunk to JSONL for debugging
129
198
  system_prompt: str = SYSTEM_PROMPT
130
199
  system_prompt_path: Path | None = None
@@ -164,12 +233,21 @@ class Config:
164
233
  _logger.info("No config.yaml found, returning bare Config")
165
234
  return cls(
166
235
  config_dir=target_dir,
167
- memory_path=str(DEFAULT_CONFIG_DIR / "crow.db"),
236
+ db_uri=f"sqlite:///{DEFAULT_CONFIG_DIR / 'crow.db'}",
168
237
  )
169
238
 
170
239
  with open(config_file) as f:
171
240
  raw = yaml.safe_load(f) or {}
172
241
 
242
+ # Sampling is per-model now (see LLModel) — a leftover global would be
243
+ # silently ignored, so reject it with a migration hint instead.
244
+ for stale in ("TEMPERATURE", "reasoning_effort"):
245
+ if stale in raw:
246
+ raise ValueError(
247
+ f"config.yaml: global {stale!r} was removed; set it per "
248
+ f"model under models: (temperature / reasoning_effort)."
249
+ )
250
+
173
251
  _logger.info("RAW config.yaml mcpServers: %s", raw.get("mcpServers", {}))
174
252
  missing_vars: set[str] = set()
175
253
  parsed = resolve_env_vars(raw, missing_vars)
@@ -191,25 +269,47 @@ class Config:
191
269
 
192
270
  # Parse models
193
271
  for name, data in parsed.get("models", {}).items():
194
- raw_caps = data.get("capabilities")
195
- capabilities = set(raw_caps) if raw_caps is not None else None
272
+ raw_effort = data.get("reasoning_effort")
273
+ raw_modality = data.get("modality", ["text", "image"])
274
+ if isinstance(raw_modality, str):
275
+ raw_modality = [raw_modality]
276
+ if not isinstance(raw_modality, list):
277
+ raise ValueError(
278
+ f"model {name!r}: modality must be a list, got {raw_modality!r}"
279
+ )
280
+ modality = [str(m).strip().lower() for m in raw_modality]
281
+ bad = [m for m in modality if m not in MODALITY_VALUES]
282
+ if bad:
283
+ raise ValueError(
284
+ f"model {name!r}: modality entries must be one of "
285
+ f"{', '.join(MODALITY_VALUES)}, got {bad}"
286
+ )
196
287
  llm.models[name] = LLModel(
197
288
  name=name,
198
289
  provider_name=data.get("provider", ""),
199
290
  model_id=data.get("model", ""),
200
- capabilities=capabilities,
291
+ temperature=float(data.get("temperature", 0.6)),
292
+ reasoning_effort=(
293
+ parse_reasoning_effort(raw_effort)
294
+ if raw_effort is not None
295
+ else None
296
+ ),
297
+ modality=modality,
201
298
  fallbacks=list(data.get("fallbacks") or []),
202
299
  )
203
300
 
204
- # Parse overrides
205
- memory_path = os.path.expanduser(parsed.get("memory_path") or "~/.agents/crow/crow.db")
301
+ # Parse overrides. db_uri is the canonical key; legacy memory_path
302
+ # (a plain path) still works and becomes a sqlite URI. Default is
303
+ # config_dir-relative so custom config dirs stay self-contained.
304
+ db_uri = normalize_db_uri(
305
+ parsed.get("db_uri") or parsed.get("memory_path") or str(target_dir / "crow.db")
306
+ )
206
307
  skills_dir = os.path.expanduser(parsed.get("skills_dir") or str(SKILLS_DIR))
207
308
  overrides = {}
208
309
  for key, typ in (
209
310
  ("max_retries_per_step", int),
210
311
  ("MAX_COMPACT_TOKENS", int),
211
312
  ("MAX_TOKENS", int),
212
- ("TEMPERATURE", float),
213
313
  ):
214
314
  if key in parsed:
215
315
  overrides[key] = typ(parsed[key])
@@ -225,7 +325,7 @@ class Config:
225
325
  return cls(
226
326
  config_dir=target_dir,
227
327
  llm=llm,
228
- memory_path=memory_path,
328
+ db_uri=db_uri,
229
329
  skills_dir=skills_dir,
230
330
  mcp_servers=mcp_servers,
231
331
  system_prompt_path=system_prompt_path,
@@ -209,18 +209,20 @@ volumes:
209
209
  driver: local
210
210
  """
211
211
 
212
- CONFIG_YAML = """# config.yaml for the spec at
213
- # ../src/crow_acp/agent/config.py
212
+ CONFIG_YAML = """# config.yaml — crow-cli configuration
213
+ # (loaded by crow_cli/agent/configure.py)
214
214
 
215
215
  # DEFAULT mcpServers
216
216
  mcpServers:
217
- crow-mcp-dev:
217
+ crow-mcp:
218
218
  transport: http
219
219
  url: http://127.0.0.1:2770/mcp
220
220
 
221
- # Memory is an in-process sqlite database (schema v3). Images are stored as
222
- # files next to it (images/) and hydrated to base64 only when sent to the LLM.
223
- memory_path: ~/.agents/crow/crow.db
221
+ # Memory is a SQL database reached via a SQLAlchemy db_uri — sqlite by
222
+ # default, any SQLAlchemy URI (e.g. postgresql://) works. Owned by the
223
+ # crow-memory package. Images are stored as files next to it (images/) and
224
+ # hydrated to base64 only when sent to the LLM.
225
+ db_uri: sqlite:///~/.agents/crow/crow.db
224
226
 
225
227
  # Where agent skills live (one directory per skill, each with a SKILL.md).
226
228
  # Scanned at session creation and injected into the system prompt.
@@ -243,6 +245,19 @@ system_prompt_path: ~/.agents/crow/prompts/system_prompt.jinja2
243
245
  # placeholder-model:
244
246
  # provider: placeholder-provider
245
247
  # model: gpt-3.5-turbo
248
+ # temperature: 0.6
249
+ # # reasoning_effort: medium
250
+ # modality: [text, image]
251
+
252
+ # Per-model sampling (all optional):
253
+ # temperature — sampling temperature, default 0.6
254
+ # reasoning_effort — for reasoning models (gpt-5, o3, ...). When set, crow
255
+ # sends it and OMITS temperature (reasoning models reject
256
+ # temperature). One of: none | minimal | low | medium |
257
+ # high | xhigh | max
258
+ # modality — list of input modalities (text | image | audio | video).
259
+ # Default [text, image] = assume vision-capable until
260
+ # proven otherwise; [text] = strip/route images around.
246
261
 
247
262
  # Compaction parameters
248
263
  MAX_COMPACT_TOKENS: 180000
@@ -183,7 +183,7 @@ class AcpAgent(Agent):
183
183
  raise ValueError(
184
184
  f"model {model!r} not found in config.yaml models: {valid}"
185
185
  )
186
- self._memory_path = self._config.memory_path
186
+ self._memory_db_uri = self._config.db_uri
187
187
  self._exit_stack = AsyncExitStack()
188
188
  self._agent_id: str | None = None
189
189
  self._session_id: str | None = None # stripped version for ACP upstream
@@ -438,14 +438,14 @@ class AcpAgent(Agent):
438
438
 
439
439
  try:
440
440
  # Find the highest-indexed agent for this session
441
- max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_path)
441
+ max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_db_uri)
442
442
  agent_id = f"{session_id}-{max_idx}"
443
443
  self._logger.info(
444
444
  "LOAD_SESSION: Step 1: Loading agent %s from DB", agent_id
445
445
  )
446
446
  session = await AgentSession.load(
447
447
  agent_id,
448
- memory_path=self._memory_path,
448
+ memory_path=self._memory_db_uri,
449
449
  )
450
450
  self._logger.info("LOAD_SESSION: Step 1 complete: Agent loaded from DB")
451
451
 
@@ -553,7 +553,7 @@ class AcpAgent(Agent):
553
553
  if self._session_id == session_id and self._agent_id:
554
554
  agent_id = self._agent_id
555
555
  else:
556
- max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_path)
556
+ max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_db_uri)
557
557
  agent_id = f"{session_id}-{max_idx}"
558
558
 
559
559
  # Initialize if not set
@@ -605,7 +605,7 @@ class AcpAgent(Agent):
605
605
  if self._session_id == session_id and self._agent_id:
606
606
  agent_id = self._agent_id
607
607
  else:
608
- max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_path)
608
+ max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_db_uri)
609
609
  agent_id = f"{session_id}-{max_idx}"
610
610
 
611
611
  async def _execute_turn() -> PromptResponse:
@@ -839,21 +839,64 @@ class AcpAgent(Agent):
839
839
  return ListSessionsResponse(sessions=sessions, next_cursor=None)
840
840
 
841
841
 
842
+ async def serve_http(
843
+ config: Config, model: str | None, host: str, port: int
844
+ ) -> None:
845
+ """Serve the agent over Streamable HTTP + WebSocket (experimental, same
846
+ JSON-RPC lifecycle as stdio). One AcpAgent instance per connection; all
847
+ instances share the sqlite session store, so any client can address the
848
+ same sessions."""
849
+ import hypercorn.asyncio
850
+ from acp.http.asgi import create_asgi_app
851
+ from hypercorn.config import Config as HypercornConfig
852
+
853
+ app = create_asgi_app(lambda conn: AcpAgent(config=config, model=model))
854
+ hcfg = HypercornConfig()
855
+ hcfg.bind = [f"{host}:{port}"]
856
+ # Streamable HTTP requires HTTP/2; hypercorn negotiates h2c/h2.
857
+ hcfg.alpn_protocols = ["h2", "http/1.1"]
858
+ await hypercorn.asyncio.serve(app, hcfg)
859
+
860
+
842
861
  async def agent_run(
843
862
  config_dir: Path | None = None,
844
863
  config: Config | None = None,
845
864
  debug: bool = False,
846
865
  model: str | None = None,
866
+ http: bool = False,
867
+ host: str = "127.0.0.1",
868
+ port: int = 2769,
847
869
  ) -> None:
848
870
  if config is None:
849
871
  config = Config.load(config_dir=config_dir)
850
872
  if debug:
851
873
  config.chunk_log = True
852
- await run_agent(AcpAgent(config=config, model=model))
874
+ if http:
875
+ await serve_http(config, model, host, port)
876
+ else:
877
+ await run_agent(AcpAgent(config=config, model=model))
853
878
 
854
879
 
855
- def main(config_dir: Path | None = None, config: Config | None = None, debug: bool = False, model: str | None = None):
856
- asyncio.run(agent_run(config_dir=config_dir, config=config, debug=debug, model=model))
880
+ def main(
881
+ config_dir: Path | None = None,
882
+ config: Config | None = None,
883
+ debug: bool = False,
884
+ model: str | None = None,
885
+ http: bool = False,
886
+ host: str = "127.0.0.1",
887
+ port: int = 2769,
888
+ ):
889
+ asyncio.run(
890
+ agent_run(
891
+ config_dir=config_dir,
892
+ config=config,
893
+ debug=debug,
894
+ model=model,
895
+ http=http,
896
+ host=host,
897
+ port=port,
898
+ )
899
+ )
857
900
 
858
901
 
859
902
  if __name__ == "__main__":
@@ -1,9 +1,11 @@
1
- """Memory layer for crow-cli — in-process SQLite, images on disk.
1
+ """Memory layer for crow-cli — SQL via crow-memory, images on disk.
2
2
 
3
- Back to the pre-service design (schema v3 in db.py) with one wrinkle: image
4
- blobs never enter the database. Writes extract inline base64 blocks to
5
- ``config_dir/images/<sha256hex><ext>`` and store ``image_ref`` blocks; loads
6
- with ``hydrate=True`` swap refs back to base64 data URLs for the LLM.
3
+ The store contract lives in the ``crow-memory`` package (schema v4): this
4
+ class only resolves the db_uri from config and wraps the sync helpers in the
5
+ async shape session.py / main.py expect. One wrinkle: image blobs never
6
+ enter the database. Writes extract inline base64 blocks to
7
+ ``<db parent>/images/<sha256hex><ext>`` and store ``image_ref`` blocks;
8
+ loads with ``hydrate=True`` swap refs back to base64 data URLs for the LLM.
7
9
 
8
10
  Search is SQLite FTS5 + bm25 (keyword). No embeddings, no HTTP service.
9
11
 
@@ -13,11 +15,10 @@ sync db helpers.
13
15
  """
14
16
 
15
17
  import logging
16
- import os
17
18
  from dataclasses import dataclass, field
18
19
  from pathlib import Path
19
20
 
20
- from crow_cli.agent import db
21
+ import crow_memory as db
21
22
  from crow_cli.agent.configure import Config
22
23
 
23
24
  log = logging.getLogger(__name__)
@@ -125,19 +126,26 @@ def _agent_record(a: db.Agent) -> AgentRecord:
125
126
 
126
127
 
127
128
  class MemoryClient:
128
- """SQLite-backed memory. `path` is ignored (kept positionally for compat)."""
129
+ """crow-memory-backed store. `path` overrides the configured db_uri."""
129
130
 
130
131
  def __init__(self, path: str | None = None, config_dir: Path | None = None, **_kwargs):
131
132
  cfg = Config.load(config_dir)
132
- db_path = Path(os.path.expanduser(path or str(cfg.config_dir / "crow.db")))
133
- if db_path.is_dir():
134
- raise MemoryServiceError(
135
- 500,
136
- f"{db_path} is a directory (leftover lance dataset from the old "
137
- "crow-memory service) — remove it so sqlite can own this path",
138
- )
139
- self.db_uri = f"sqlite:///{db_path}"
140
- self.images_dir = db_path.parent / "images"
133
+ # DEFAULT_MEMORY_PATH is the legacy positional sentinel — it means
134
+ # "whatever the config says", not a literal override.
135
+ override = None if path in (None, DEFAULT_MEMORY_PATH) else path
136
+ self.db_uri = db.normalize_db_uri(override or cfg.db_uri)
137
+ if self.db_uri.startswith("sqlite:///"):
138
+ db_path = Path(self.db_uri.removeprefix("sqlite:///"))
139
+ if db_path.is_dir():
140
+ raise MemoryServiceError(
141
+ 500,
142
+ f"{db_path} is a directory (leftover lance dataset from the old "
143
+ "crow-memory service) — remove it so sqlite can own this path",
144
+ )
145
+ self.images_dir = db_path.parent / "images"
146
+ else:
147
+ # Non-file backends (e.g. postgres): images stay beside the config.
148
+ self.images_dir = cfg.config_dir / "images"
141
149
  db.create_database(self.db_uri)
142
150
  self._engine = db.get_engine(self.db_uri)
143
151
 
@@ -3,14 +3,17 @@
3
3
  Conversations carry modalities (images, audio) that not every model accepts.
4
4
  Per request:
5
5
 
6
- 1. Requested model's capabilities unknown (unset in config) or covering the
7
- present modalities → use it unchanged.
6
+ 1. Requested model's modality list covers the present modalities (the
7
+ default ["text", "image"] assumes vision-capable until proven otherwise)
8
+ or no images are present → use it unchanged.
8
9
  2. Otherwise walk the model's `fallbacks` chain and take the first model
9
- capable of every present modality. The LLM client is bound to one
10
- provider, so only same-provider fallbacks are considered.
10
+ whose list covers them. The LLM client is bound to one provider, so only
11
+ same-provider fallbacks are considered.
11
12
  3. No capable model anywhere → keep the requested model and report which
12
13
  modalities to strip; send_request replaces those blocks with placeholders
13
14
  (auto-strip on downgrade) instead of hard-failing.
15
+
16
+ Only image blocks are routed for now; audio/video are not mapped.
14
17
  """
15
18
 
16
19
  from __future__ import annotations
@@ -22,17 +25,15 @@ from crow_cli.agent.configure import Config
22
25
 
23
26
  logger = logging.getLogger(__name__)
24
27
 
25
- # OpenAI chat-completions content block type -> modality
28
+ # OpenAI chat-completions content block type -> modality (image only for now;
29
+ # audio/video blocks are not routed yet)
26
30
  BLOCK_MODALITIES: dict[str, str] = {
27
- "image_url": "vision",
28
- "image": "vision",
29
- "input_audio": "audio",
30
- "audio": "audio",
31
+ "image_url": "image",
32
+ "image": "image",
31
33
  }
32
34
 
33
35
  PLACEHOLDERS: dict[str, str] = {
34
- "vision": "[image omitted: model has no vision capability]",
35
- "audio": "[audio omitted: model has no audio capability]",
36
+ "image": "[image omitted: model has no image capability]",
36
37
  }
37
38
 
38
39
 
@@ -58,8 +59,9 @@ def _model_by_id(config: Config, model_id: str):
58
59
 
59
60
 
60
61
  def _capable(model, modalities: set[str]) -> bool:
61
- # Unknown capabilities (None) = permissive: assume the model handles it.
62
- return model.capabilities is None or modalities <= model.capabilities
62
+ # Default ["text", "image"] = permissive: assume vision-capable until
63
+ # proven otherwise; ["text"] models fail the subset check for images.
64
+ return modalities <= set(model.modality)
63
65
 
64
66
 
65
67
  def route_model(