cade-cli 0.8.0__tar.gz → 0.9.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 (78) hide show
  1. {cade_cli-0.8.0 → cade_cli-0.9.0}/PKG-INFO +2 -1
  2. {cade_cli-0.8.0 → cade_cli-0.9.0}/pyproject.toml +6 -1
  3. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/__init__.py +1 -1
  4. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/server.py +4 -0
  5. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/__init__.py +3 -0
  6. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/filesystem.py +8 -0
  7. cade_cli-0.9.0/src/cade_mcp_local/tools/snippets.py +109 -0
  8. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_results.py +13 -0
  9. cade_cli-0.9.0/src/cadecoder/__init__.py +1 -0
  10. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ai/prompts.py +40 -24
  11. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/app.py +26 -4
  12. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/constants.py +2 -2
  13. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/orchestrator.py +19 -6
  14. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_result_store.py +89 -2
  15. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/composite.py +12 -4
  16. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/mcp.py +19 -4
  17. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/display.py +36 -20
  18. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/session.py +91 -0
  19. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/session.py +69 -45
  20. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/tts.py +34 -1
  21. cade_cli-0.8.0/src/cadecoder/__init__.py +0 -1
  22. {cade_cli-0.8.0 → cade_cli-0.9.0}/.gitignore +0 -0
  23. {cade_cli-0.8.0 → cade_cli-0.9.0}/LICENSE +0 -0
  24. {cade_cli-0.8.0 → cade_cli-0.9.0}/README.md +0 -0
  25. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/__main__.py +0 -0
  26. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/config.py +0 -0
  27. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/errors.py +0 -0
  28. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/context.py +0 -0
  29. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/git.py +0 -0
  30. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/search.py +0 -0
  31. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/shell.py +0 -0
  32. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_schemas.py +0 -0
  33. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/utils.py +3 -3
  34. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ai/__init__.py +0 -0
  35. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/__init__.py +0 -0
  36. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/auth.py +0 -0
  37. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/__init__.py +0 -0
  38. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/auth.py +0 -0
  39. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/chat.py +0 -0
  40. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/context.py +0 -0
  41. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/mcp.py +0 -0
  42. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/model.py +0 -0
  43. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/persona.py +0 -0
  44. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/thread.py +0 -0
  45. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/tools.py +0 -0
  46. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/__init__.py +0 -0
  47. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/config.py +0 -0
  48. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/errors.py +0 -0
  49. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/git.py +0 -0
  50. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/logging.py +0 -0
  51. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/names.py +0 -0
  52. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/types.py +0 -0
  53. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/__init__.py +0 -0
  54. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/context_window.py +0 -0
  55. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/parallel.py +0 -0
  56. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_schema_cache.py +0 -0
  57. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/__init__.py +0 -0
  58. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/anthropic.py +0 -0
  59. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/base.py +0 -0
  60. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/ollama.py +0 -0
  61. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/openai.py +0 -0
  62. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/storage/__init__.py +0 -0
  63. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/storage/personas.py +0 -0
  64. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/storage/threads.py +0 -0
  65. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_failed.html +0 -0
  66. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_success.html +0 -0
  67. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/templates/styles.css +0 -0
  68. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/__init__.py +0 -0
  69. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/__init__.py +0 -0
  70. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/base.py +0 -0
  71. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/config.py +0 -0
  72. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager.py +0 -0
  73. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/__init__.py +0 -0
  74. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/input.py +0 -0
  75. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/__init__.py +0 -0
  76. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/audio.py +0 -0
  77. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/cleanup.py +0 -0
  78. {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/stt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cade-cli
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Cade - The CLI Agent from Arcade.dev
5
5
  Project-URL: Homepage, https://arcade.dev
6
6
  Project-URL: Documentation, https://docs.arcade.dev
@@ -57,6 +57,7 @@ Requires-Dist: pydantic[email]<3.0.0,>=2.0.0
57
57
  Requires-Dist: pyperclip<2.0.0,>=1.8.0
58
58
  Requires-Dist: pyyaml<7.0.0,>=6.0
59
59
  Requires-Dist: rich<14.0.0,>=13.0.0
60
+ Requires-Dist: sounddevice>=0.5.5
60
61
  Requires-Dist: tiktoken<1.0.0,>=0.11.0
61
62
  Requires-Dist: toml<1.0.0,>=0.10.0
62
63
  Requires-Dist: typer>0.10.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cade-cli"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Cade - The CLI Agent from Arcade.dev"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -41,6 +41,7 @@ dependencies = [
41
41
  "httpx>=0.27.0,<1.0.0",
42
42
  "filelock>=3.0.0,<4.0.0",
43
43
  "agent-library>=0.11.0,<1.0.0",
44
+ "sounddevice>=0.5.5",
44
45
  ]
45
46
 
46
47
  [project.urls]
@@ -103,6 +104,10 @@ target-version = "py311"
103
104
  select = ["E", "F", "W", "I", "UP"]
104
105
  ignore = ["E501"]
105
106
 
107
+ [tool.ruff.lint.per-file-ignores]
108
+ # Imports must come after os.environ and warnings setup at module top
109
+ "src/cadecoder/cli/app.py" = ["E402"]
110
+
106
111
  [tool.ruff.format]
107
112
  quote-style = "double"
108
113
 
@@ -69,4 +69,4 @@ __all__ = [
69
69
  "FileOperationError",
70
70
  ]
71
71
 
72
- __version__ = "0.8.0"
72
+ __version__ = "0.9.0"
@@ -18,6 +18,7 @@ from cade_mcp_local.tools import (
18
18
  edit_tool,
19
19
  git_tool,
20
20
  list_files_tool,
21
+ pin_context_tool,
21
22
  read_file_tool,
22
23
  retrieve_tool_result_tool,
23
24
  search_recent_context_tool,
@@ -67,6 +68,9 @@ def create_app() -> MCPApp:
67
68
  # Context tools
68
69
  app.add_tool(search_recent_context_tool)
69
70
 
71
+ # Pinned context
72
+ app.add_tool(pin_context_tool)
73
+
70
74
  # Tool result retrieval (consolidated: full, search, summarize, lines, json, list)
71
75
  app.add_tool(retrieve_tool_result_tool)
72
76
 
@@ -20,6 +20,7 @@ from cade_mcp_local.tools.git import (
20
20
  )
21
21
  from cade_mcp_local.tools.search import search_tool
22
22
  from cade_mcp_local.tools.shell import bash_tool
23
+ from cade_mcp_local.tools.snippets import pin_context_tool
23
24
  from cade_mcp_local.tools.tool_results import retrieve_tool_result_tool
24
25
  from cade_mcp_local.tools.tool_schemas import tool_schema_tool
25
26
 
@@ -40,6 +41,8 @@ __all__ = [
40
41
  "bash_tool",
41
42
  # Context
42
43
  "search_recent_context_tool",
44
+ # Pinned context
45
+ "pin_context_tool",
43
46
  # Tool Results
44
47
  "retrieve_tool_result_tool",
45
48
  # Tool Schemas
@@ -173,6 +173,8 @@ def read_file_tool(
173
173
  ] = 0,
174
174
  ) -> Annotated[dict[str, Any], "File content, line numbers, and message."]:
175
175
  """Read content of a specified file, optionally a specific line range."""
176
+ if not file_path_arg:
177
+ raise InvalidInputError("file_path_arg", "file path is required")
176
178
  file_path = resolve_path(file_path_arg, confine_to_root=True)
177
179
 
178
180
  if not file_path.is_file():
@@ -257,6 +259,8 @@ def write_file_tool(
257
259
  ] = "overwrite",
258
260
  ) -> Annotated[dict[str, Any], "Result of the write operation."]:
259
261
  """Write content to a file."""
262
+ if not file_path_arg:
263
+ raise InvalidInputError("file_path_arg", "file path is required")
260
264
  resolved = resolve_path(file_path_arg)
261
265
 
262
266
  try:
@@ -301,6 +305,8 @@ def edit_tool(
301
305
  ],
302
306
  ) -> Annotated[dict[str, Any], "Edit result with diff."]:
303
307
  """Find old_string in the file and replace it with new_string."""
308
+ if not file_path_arg:
309
+ raise InvalidInputError("file_path_arg", "file path is required")
304
310
  resolved = resolve_path(file_path_arg)
305
311
 
306
312
  if not resolved.is_file():
@@ -375,6 +381,8 @@ def insert_text_tool(
375
381
  ] = "before",
376
382
  ) -> Annotated[dict[str, Any], "Insert result with diff."]:
377
383
  """Insert text at a specific line in a file."""
384
+ if not file_path_arg:
385
+ raise InvalidInputError("file_path_arg", "file path is required")
378
386
  resolved = resolve_path(file_path_arg)
379
387
 
380
388
  if not resolved.is_file():
@@ -0,0 +1,109 @@
1
+ """MCP tool for pinning named context to a session."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import Annotated, Any
6
+
7
+ from arcade_tdk import ToolContext, tool
8
+
9
+ from cade_mcp_local.errors import InvalidInputError, PathNotFoundError
10
+ from cade_mcp_local.utils import resolve_path
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ MAX_PIN_BYTES = 100_000 # 100KB cap
15
+ PREVIEW_CHARS = 2000
16
+
17
+
18
+ @tool(
19
+ name="PinContext",
20
+ desc=(
21
+ "Pin named reference material to the current session. The user can later "
22
+ "ask about it and you can retrieve the full content via RetrieveToolResult. "
23
+ "Use when the user provides specs, docs, notes, or other material they want "
24
+ "you to remember. Provide either content (inline text) or file_path (read from file)."
25
+ ),
26
+ )
27
+ def pin_context_tool(
28
+ context: ToolContext,
29
+ name: Annotated[
30
+ str,
31
+ "Short identifier for this context (e.g., 'api-spec', 'design-notes'). "
32
+ "Letters, numbers, hyphens, underscores only.",
33
+ ],
34
+ thread_id: Annotated[str, "The current thread/session ID."],
35
+ content: Annotated[
36
+ str,
37
+ "The text content to pin. Provide this OR file_path, not both.",
38
+ ] = "",
39
+ file_path: Annotated[
40
+ str,
41
+ "Path to a file to read and pin. Relative to project root.",
42
+ ] = "",
43
+ ) -> Annotated[dict[str, Any], "Pin result with preview."]:
44
+ """Pin named reference material to the session."""
45
+ if not name or not re.match(r"^[a-zA-Z0-9_-]+$", name):
46
+ raise InvalidInputError("name", "must be alphanumeric with hyphens/underscores only")
47
+
48
+ if not thread_id:
49
+ raise InvalidInputError("thread_id", "thread ID is required")
50
+
51
+ source = "inline"
52
+ source_path = ""
53
+
54
+ if file_path and content:
55
+ raise InvalidInputError("content/file_path", "provide one or the other, not both")
56
+
57
+ if file_path:
58
+ resolved = resolve_path(file_path, confine_to_root=True)
59
+ if not resolved.is_file():
60
+ raise PathNotFoundError(file_path)
61
+ source = "file"
62
+ source_path = file_path
63
+ content = resolved.read_text(encoding="utf-8", errors="replace")
64
+
65
+ if not content or not content.strip():
66
+ raise InvalidInputError("content", "cannot be empty")
67
+
68
+ if len(content) > MAX_PIN_BYTES:
69
+ raise InvalidInputError("content", f"exceeds {MAX_PIN_BYTES // 1024}KB limit")
70
+
71
+ from cadecoder.execution.tool_result_store import ToolResultStore
72
+
73
+ store = ToolResultStore()
74
+
75
+ # Check for duplicate name
76
+ existing = store.get_user_context_by_name(thread_id, name)
77
+ if existing:
78
+ raise InvalidInputError(
79
+ "name",
80
+ f"'{name}' already pinned (id={existing.record_id}). Use /unpin to remove first.",
81
+ )
82
+
83
+ record = store.store_user_context(
84
+ thread_id=thread_id,
85
+ name=name,
86
+ content=content,
87
+ source=source,
88
+ source_path=source_path,
89
+ )
90
+
91
+ # Build preview
92
+ preview = content[:PREVIEW_CHARS]
93
+ if len(content) > PREVIEW_CHARS:
94
+ preview += f"\n... ({len(content) - PREVIEW_CHARS} more chars)"
95
+
96
+ return {
97
+ "status": "pinned",
98
+ "name": name,
99
+ "record_id": record.record_id,
100
+ "chars": len(content),
101
+ "source": source,
102
+ "source_path": source_path or None,
103
+ "preview": preview,
104
+ "message": (
105
+ f"Pinned as '{name}' ({len(content):,} chars). "
106
+ f'Use Local_RetrieveToolResult(record_id="{record.record_id}") '
107
+ f"for full content."
108
+ ),
109
+ }
@@ -41,6 +41,10 @@ def retrieve_tool_result_tool(
41
41
  "The record ID from the [EXTERNALIZED_RESULT] reference. "
42
42
  "Required for all modes except 'list'.",
43
43
  ] = "",
44
+ name: Annotated[
45
+ str,
46
+ "Name of a pinned context snippet (shorthand for looking up UserContext by name).",
47
+ ] = "",
44
48
  mode: Annotated[
45
49
  RetrieveMode,
46
50
  "Retrieval strategy: 'full' (raw, paginated), 'search' (regex/substring match), "
@@ -111,6 +115,7 @@ def retrieve_tool_result_tool(
111
115
  {
112
116
  "record_id": r.record_id,
113
117
  "tool_name": r.tool_name,
118
+ "name": r.name,
114
119
  "summary": r.summary,
115
120
  "content_chars": r.content_chars,
116
121
  "content_token_estimate": r.content_token_estimate,
@@ -131,6 +136,14 @@ def retrieve_tool_result_tool(
131
136
  ),
132
137
  }
133
138
 
139
+ # -- name-based lookup for pinned context --
140
+ if name and not record_id:
141
+ named_record = store.get_user_context_by_name(thread_id, name)
142
+ if named_record:
143
+ record_id = named_record.record_id
144
+ else:
145
+ return {"error": f"No pinned context named '{name}' found."}
146
+
134
147
  # -- all other modes require record_id --
135
148
  if not record_id:
136
149
  return {
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -199,6 +199,7 @@ def build_system_prompt(
199
199
  persona_name: str | None = None,
200
200
  tools_list: str = "(Tools available - use tools to see list)",
201
201
  include_discovery: bool = False,
202
+ thread_id: str | None = None,
202
203
  ) -> str:
203
204
  """Build the final system prompt, optionally with a persona.
204
205
 
@@ -206,6 +207,7 @@ def build_system_prompt(
206
207
  persona_name: Name of persona to use (None = default Cade behavior)
207
208
  tools_list: Formatted list of available tools
208
209
  include_discovery: Whether to include the Arcade discovery section
210
+ thread_id: Current thread ID for loading pinned context index
209
211
 
210
212
  Returns:
211
213
  Complete system prompt string
@@ -226,40 +228,54 @@ def build_system_prompt(
226
228
  .replace("{_TOOL_DISCOVERY}", discovery_section)
227
229
  )
228
230
 
229
- # If no persona, return the base prompt
230
- if not persona_name:
231
- return base_prompt
231
+ # Apply persona if specified
232
+ if persona_name:
233
+ try:
234
+ from cadecoder.storage.personas import get_persona_store
232
235
 
233
- # Load persona from storage
234
- try:
235
- from cadecoder.storage.personas import get_persona_store
236
-
237
- store = get_persona_store()
238
- persona = store.find_persona(persona_name)
236
+ persona_store = get_persona_store()
237
+ persona = persona_store.find_persona(persona_name)
239
238
 
240
- if not persona:
241
- log.warning(f"Persona '{persona_name}' not found, using default prompt")
242
- return base_prompt
239
+ if not persona:
240
+ log.warning(f"Persona '{persona_name}' not found, using default prompt")
241
+ else:
242
+ log.info(f"Using persona '{persona.name}' (replace_base={persona.replace_base})")
243
243
 
244
- log.info(f"Using persona '{persona.name}' (replace_base={persona.replace_base})")
245
-
246
- if persona.replace_base:
247
- # Full replacement mode - persona prompt replaces the base entirely
248
- # But we still provide environment context and tools for functionality
249
- return f"""{persona.system_prompt}
244
+ if persona.replace_base:
245
+ base_prompt = f"""{persona.system_prompt}
250
246
 
251
247
  {env_context}
252
248
  {discovery_section}
253
249
  Tools:
254
250
  {tools_list}"""
255
- else:
256
- # Prepend mode - persona instructions come first, then base prompt
257
- return f"""{persona.system_prompt}
251
+ else:
252
+ base_prompt = f"""{persona.system_prompt}
258
253
 
259
254
  ---
260
255
 
261
256
  {base_prompt}"""
262
257
 
263
- except Exception as e:
264
- log.error(f"Error loading persona '{persona_name}': {e}")
265
- return base_prompt
258
+ except Exception as e:
259
+ log.error(f"Error loading persona '{persona_name}': {e}")
260
+
261
+ # Append pinned context index if any exists for this thread
262
+ if thread_id:
263
+ try:
264
+ from cadecoder.execution.tool_result_store import ToolResultStore
265
+
266
+ store = ToolResultStore()
267
+ snippets = store.list_user_context(thread_id)
268
+ if snippets:
269
+ lines = [
270
+ "\n=== PINNED CONTEXT ===",
271
+ "The user has pinned reference material for this session.",
272
+ 'Use Local_RetrieveToolResult(record_id="<id>") to read full content.',
273
+ "Use Local_PinContext to save new material when the user asks.\n",
274
+ ]
275
+ for s in snippets:
276
+ lines.append(f"- {s.name} [id={s.record_id}] ({s.content_chars:,} chars)")
277
+ base_prompt += "\n".join(lines)
278
+ except Exception as e:
279
+ log.warning(f"Failed to load pinned context index: {e}")
280
+
281
+ return base_prompt
@@ -1,11 +1,26 @@
1
1
  """Main CLI application setup and entry point for CadeCoder."""
2
2
 
3
3
  import os
4
+ import warnings
4
5
 
5
6
  # Set environment variable to disable tokenizers parallelism warning
6
7
  # This must be set before any imports that use tokenizers
7
8
  os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
8
9
 
10
+ # Suppress pkg_resources deprecation warning from webrtcvad and other packages
11
+ # that haven't migrated to importlib.resources yet.
12
+ # This MUST be set before any imports that might trigger the warning.
13
+ warnings.filterwarnings(
14
+ "ignore",
15
+ message=".*pkg_resources is deprecated.*",
16
+ category=DeprecationWarning,
17
+ )
18
+ warnings.filterwarnings(
19
+ "ignore",
20
+ message=".*Deprecated call to.*",
21
+ category=DeprecationWarning,
22
+ )
23
+
9
24
  from typing import Annotated
10
25
 
11
26
  import typer
@@ -85,6 +100,14 @@ def main_callback(
85
100
  is_eager=True,
86
101
  ),
87
102
  ] = False,
103
+ voice: Annotated[
104
+ bool,
105
+ typer.Option(
106
+ "--voice",
107
+ "-V",
108
+ help="Enable voice mode (speak to chat, hear responses).",
109
+ ),
110
+ ] = False,
88
111
  persona: Annotated[
89
112
  str | None,
90
113
  typer.Option(
@@ -120,6 +143,7 @@ def main_callback(
120
143
  ctx: Typer context object
121
144
  verbose: Enable verbose logging if True
122
145
  resume_flag: Resume most recent thread if True
146
+ voice: Enable voice mode for the session
123
147
  persona: Persona name to use for the session
124
148
  message: Single message mode - process one message and exit
125
149
  version: Show version and exit if True
@@ -160,14 +184,12 @@ def main_callback(
160
184
 
161
185
  # Handle eager resume flag before default chat
162
186
  if resume_flag and ctx.invoked_subcommand is None:
163
- # Invoke resume command directly with persona
164
- chat.resume(persona=persona)
187
+ chat.resume(persona=persona, voice=voice)
165
188
  raise typer.Exit()
166
189
 
167
190
  # If no command was invoked (just "cade"), launch chat
168
191
  if ctx.invoked_subcommand is None:
169
- # Import and run chat command directly with persona
170
- chat.chat(persona=persona)
192
+ chat.chat(persona=persona, voice=voice)
171
193
 
172
194
 
173
195
  if __name__ == "__main__":
@@ -80,8 +80,8 @@ UI_STYLE: Final[str] = os.environ.get("CADE_UI_STYLE", "minimal") # Options: "m
80
80
 
81
81
  DEFAULT_STT_MODEL: Final[str] = "mlx-community/whisper-small-mlx"
82
82
  DEFAULT_TTS_MODEL: Final[str] = "mlx-community/Kokoro-82M-bf16"
83
- DEFAULT_TTS_VOICE: Final[str] = "af_heart"
84
- DEFAULT_TTS_SPEED: Final[float] = 1.0
83
+ DEFAULT_TTS_VOICE: Final[str] = "af_bella"
84
+ DEFAULT_TTS_SPEED: Final[float] = 1.3
85
85
  DEFAULT_VAD_AGGRESSIVENESS: Final[int] = 1 # 0-3, lower = more sensitive to speech
86
86
  DEFAULT_SILENCE_FRAMES: Final[int] = 50 # 50 * 30ms = 1.5s of non-speech before stop
87
87
 
@@ -62,6 +62,18 @@ def create_orchestrator(
62
62
  Returns:
63
63
  Configured Orchestrator instance
64
64
  """
65
+ # Compute threads_dir and publish it as an env var BEFORE creating the
66
+ # composite tool manager or orchestrator — the MCP subprocess config
67
+ # captures env vars at construction time.
68
+ import os
69
+
70
+ persona_name = persona or "default"
71
+ persona_dir = Path(get_config().app_dir) / "memory" / persona_name
72
+ persona_dir.mkdir(parents=True, exist_ok=True)
73
+ threads_dir = persona_dir / "threads"
74
+ threads_dir.mkdir(exist_ok=True)
75
+ os.environ["CADE_TOOL_RESULT_DIR"] = str(threads_dir)
76
+
65
77
  if tool_manager is None:
66
78
  tool_manager = CompositeToolManager(local_only=local_only, persona=persona)
67
79
 
@@ -164,11 +176,10 @@ class Orchestrator:
164
176
  self._tools_cache: list[dict[str, Any]] | None = None
165
177
  self._tools_description_cache: str | None = None
166
178
 
167
- # All per-thread storage lives under ~/.cadecoder/memory/{persona}/threads/
179
+ # Per-persona storage paths (tool results use CADE_TOOL_RESULT_DIR env var
180
+ # set by create_orchestrator; threads_dir still needed for ToolSchemaCache)
168
181
  persona_dir = Path(get_config().app_dir) / "memory" / self._persona
169
- persona_dir.mkdir(parents=True, exist_ok=True)
170
182
  threads_dir = persona_dir / "threads"
171
- threads_dir.mkdir(exist_ok=True)
172
183
 
173
184
  # Context window management — backups scoped to persona
174
185
  backup_dir = persona_dir / "context_backups"
@@ -176,8 +187,8 @@ class Orchestrator:
176
187
  model=self.default_model, backup_dir=backup_dir
177
188
  )
178
189
 
179
- # Tool result store for externalizing large results
180
- self.tool_result_store = ToolResultStore(storage_dir=threads_dir)
190
+ # Tool result store uses CADE_TOOL_RESULT_DIR env var (set by create_orchestrator)
191
+ self.tool_result_store = ToolResultStore()
181
192
  self.context_manager.tool_result_store = self.tool_result_store
182
193
 
183
194
  # Per-thread cache for Arcade tool schemas
@@ -717,14 +728,16 @@ class Orchestrator:
717
728
  and self.tool_manager.has_external_tools()
718
729
  )
719
730
 
720
- # Get persona from context metadata
731
+ # Get persona and thread_id from context metadata
721
732
  persona_name = context.metadata.get("persona") if context.metadata else None
733
+ thread_id = context.metadata.get("thread_id") if context.metadata else None
722
734
 
723
735
  # Build system prompt using the persona-aware function
724
736
  system_content = build_system_prompt(
725
737
  persona_name=persona_name,
726
738
  tools_list=tools_list,
727
739
  include_discovery=has_external,
740
+ thread_id=thread_id,
728
741
  )
729
742
 
730
743
  messages.append(
@@ -41,6 +41,7 @@ NEVER_EXTERNALIZE_TOOLS: frozenset[str] = frozenset(
41
41
  "Local_RetrieveToolResult",
42
42
  "Local_SearchRecentContext",
43
43
  "Local_ToolSchema",
44
+ "Local_PinContext",
44
45
  }
45
46
  )
46
47
 
@@ -73,6 +74,7 @@ class ToolResultRecord:
73
74
  summary: str
74
75
  status: str # "success" | "error"
75
76
  turn_sequence: int # index within the turn
77
+ name: str = "" # human-readable label for UserContext records
76
78
 
77
79
  def to_json_line(self) -> str:
78
80
  """Serialize to a single JSON line (no embedded newlines)."""
@@ -82,6 +84,7 @@ class ToolResultRecord:
82
84
  def from_json_line(cls, line: str) -> ToolResultRecord:
83
85
  """Deserialize from a JSON line."""
84
86
  data = json.loads(line)
87
+ data.setdefault("name", "") # backward compat
85
88
  return cls(**data)
86
89
 
87
90
 
@@ -136,6 +139,8 @@ class RetrieveMode(str, Enum):
136
139
 
137
140
  def _generate_summary(content: str, tool_name: str, status: str, max_len: int = 120) -> str:
138
141
  """Generate a brief plain-text summary for the LLM."""
142
+ if not content:
143
+ return f"(empty result from {tool_name})"
139
144
  if status == "error":
140
145
  # Extract HTTP status if present
141
146
  for code in ("404", "401", "403", "500", "502", "503"):
@@ -854,7 +859,13 @@ class ToolResultStore:
854
859
 
855
860
  def __init__(self, storage_dir: Path | None = None) -> None:
856
861
  if storage_dir is None:
857
- storage_dir = Path.home() / ".cadecoder" / "tool_results"
862
+ import os
863
+
864
+ env_dir = os.environ.get("CADE_TOOL_RESULT_DIR")
865
+ if env_dir:
866
+ storage_dir = Path(env_dir)
867
+ else:
868
+ storage_dir = Path.home() / ".cadecoder" / "tool_results"
858
869
  self.storage_dir = storage_dir
859
870
 
860
871
  # In-memory session index: record_id -> ToolResultRecord
@@ -976,7 +987,7 @@ class ToolResultStore:
976
987
 
977
988
  result = tool_results[i]
978
989
  tc = tool_calls[i] if i < len(tool_calls) else {}
979
- content = result.get("content", "")
990
+ content = result.get("content") or ""
980
991
  tool_name = tool_names[i]
981
992
  status = result.get("status", "success")
982
993
  tool_call_id = tc.get("id", "")
@@ -1073,6 +1084,82 @@ class ToolResultStore:
1073
1084
  results.sort(key=lambda r: r.record_id, reverse=True)
1074
1085
  return results[:limit]
1075
1086
 
1087
+ # ------------------------------------------------------------------
1088
+ # User context (pinned snippets)
1089
+ # ------------------------------------------------------------------
1090
+
1091
+ def store_user_context(
1092
+ self,
1093
+ thread_id: str,
1094
+ name: str,
1095
+ content: str,
1096
+ source: str = "inline",
1097
+ source_path: str = "",
1098
+ ) -> ToolResultRecord:
1099
+ """Store user-pinned context as a record with tool_name='UserContext'."""
1100
+ source_info = f": {source_path}" if source_path else ""
1101
+ record = ToolResultRecord(
1102
+ record_id=str(ulid()).lower(),
1103
+ tool_call_id="",
1104
+ tool_name="UserContext",
1105
+ thread_id=thread_id,
1106
+ timestamp=datetime.now(UTC).isoformat(),
1107
+ content=content,
1108
+ content_chars=len(content),
1109
+ content_token_estimate=int(len(content) / 4),
1110
+ summary=f"{name} ({source}{source_info}, {len(content)} chars)",
1111
+ status="success",
1112
+ turn_sequence=0,
1113
+ name=name,
1114
+ )
1115
+ self.store_result(record)
1116
+ return record
1117
+
1118
+ def list_user_context(self, thread_id: str) -> list[ToolResultRecord]:
1119
+ """List pinned context for a thread (records where tool_name='UserContext')."""
1120
+ all_records = self.list_by_thread(thread_id, limit=100)
1121
+ return [r for r in all_records if r.tool_name == "UserContext"]
1122
+
1123
+ def get_user_context_by_name(self, thread_id: str, name: str) -> ToolResultRecord | None:
1124
+ """Find a pinned context record by name."""
1125
+ for r in self.list_user_context(thread_id):
1126
+ if r.name == name:
1127
+ return r
1128
+ return None
1129
+
1130
+ def remove_user_context(self, thread_id: str, name: str) -> bool:
1131
+ """Remove a pinned context record by name."""
1132
+ record = self.get_user_context_by_name(thread_id, name)
1133
+ if not record:
1134
+ return False
1135
+ self._index.pop(record.record_id, None)
1136
+ if record.tool_call_id:
1137
+ self._call_id_index.pop(record.tool_call_id, None)
1138
+ self._rewrite_jsonl_without(thread_id, record.record_id)
1139
+ return True
1140
+
1141
+ def _rewrite_jsonl_without(self, thread_id: str, exclude_record_id: str) -> None:
1142
+ """Rewrite JSONL file excluding a specific record. Protected by filelock."""
1143
+ if not self._disk_ok:
1144
+ return
1145
+ jsonl_path = self._thread_jsonl(thread_id)
1146
+ if not jsonl_path.exists():
1147
+ return
1148
+ try:
1149
+ from filelock import FileLock
1150
+
1151
+ lock = FileLock(str(self._thread_lock(thread_id)), timeout=10)
1152
+ with lock:
1153
+ kept = []
1154
+ for record in self._iter_disk_records(thread_id):
1155
+ if record.record_id != exclude_record_id:
1156
+ kept.append(record.to_json_line())
1157
+ with open(jsonl_path, "w", encoding="utf-8") as f:
1158
+ for line in kept:
1159
+ f.write(line + "\n")
1160
+ except Exception as e:
1161
+ log.warning(f"JSONL rewrite failed for thread {thread_id}: {e}")
1162
+
1076
1163
  # ------------------------------------------------------------------
1077
1164
  # Cleanup
1078
1165
  # ------------------------------------------------------------------
@@ -59,6 +59,7 @@ class CompositeToolManager(ToolManager):
59
59
  def _create_local_tools_config(self) -> MCPServerConfig:
60
60
  """Create MCP config for built-in local tools via stdio."""
61
61
  import importlib.util
62
+ import os
62
63
 
63
64
  # Get the path without importing the module (which would trigger tool registration)
64
65
  spec = importlib.util.find_spec("cade_mcp_local.tools")
@@ -66,6 +67,16 @@ class CompositeToolManager(ToolManager):
66
67
  raise ImportError("Could not find cade_mcp_local.tools module")
67
68
  local_tools_dir = Path(spec.origin).parent
68
69
 
70
+ env: dict[str, str] = {
71
+ "LOGURU_LEVEL": "WARNING",
72
+ "CADE_PROJECT_ROOT": str(Path.cwd().resolve()),
73
+ }
74
+ # Forward tool result storage dir so MCP tools use the same
75
+ # persona-scoped location as the orchestrator.
76
+ tool_result_dir = os.environ.get("CADE_TOOL_RESULT_DIR")
77
+ if tool_result_dir:
78
+ env["CADE_TOOL_RESULT_DIR"] = tool_result_dir
79
+
69
80
  return MCPServerConfig(
70
81
  name="__builtin_local__",
71
82
  url="uv",
@@ -86,10 +97,7 @@ class CompositeToolManager(ToolManager):
86
97
  ],
87
98
  enabled=True,
88
99
  auth_type=MCPAuthType.NONE,
89
- env={
90
- "LOGURU_LEVEL": "WARNING",
91
- "CADE_PROJECT_ROOT": str(Path.cwd().resolve()),
92
- },
100
+ env=env,
93
101
  suppress_stderr=True, # Avoid DEBUG "Added tool" spam in terminal
94
102
  )
95
103