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.
- {cade_cli-0.8.0 → cade_cli-0.9.0}/PKG-INFO +2 -1
- {cade_cli-0.8.0 → cade_cli-0.9.0}/pyproject.toml +6 -1
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/__init__.py +1 -1
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/server.py +4 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/__init__.py +3 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/filesystem.py +8 -0
- cade_cli-0.9.0/src/cade_mcp_local/tools/snippets.py +109 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_results.py +13 -0
- cade_cli-0.9.0/src/cadecoder/__init__.py +1 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ai/prompts.py +40 -24
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/app.py +26 -4
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/constants.py +2 -2
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/orchestrator.py +19 -6
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_result_store.py +89 -2
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/composite.py +12 -4
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/mcp.py +19 -4
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/display.py +36 -20
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/session.py +91 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/session.py +69 -45
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/tts.py +34 -1
- cade_cli-0.8.0/src/cadecoder/__init__.py +0 -1
- {cade_cli-0.8.0 → cade_cli-0.9.0}/.gitignore +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/LICENSE +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/README.md +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/__main__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/config.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/errors.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/context.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/git.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/search.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/shell.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/tools/tool_schemas.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cade_mcp_local/utils.py +3 -3
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ai/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/auth.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/auth.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/chat.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/context.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/mcp.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/model.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/persona.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/thread.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/cli/commands/tools.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/config.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/errors.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/git.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/logging.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/names.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/core/types.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/context_window.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/parallel.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/execution/tool_schema_cache.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/anthropic.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/base.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/ollama.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/providers/openai.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/storage/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/storage/personas.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/storage/threads.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_failed.html +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/templates/login_success.html +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/templates/styles.css +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/base.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager/config.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/tools/manager.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/ui/input.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/__init__.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/audio.py +0 -0
- {cade_cli-0.8.0 → cade_cli-0.9.0}/src/cadecoder/voice/cleanup.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
-
#
|
|
230
|
-
if
|
|
231
|
-
|
|
231
|
+
# Apply persona if specified
|
|
232
|
+
if persona_name:
|
|
233
|
+
try:
|
|
234
|
+
from cadecoder.storage.personas import get_persona_store
|
|
232
235
|
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] = "
|
|
84
|
-
DEFAULT_TTS_SPEED: Final[float] = 1.
|
|
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
|
-
#
|
|
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
|
|
180
|
-
self.tool_result_store = ToolResultStore(
|
|
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
|
-
|
|
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
|
|