dulus 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
dulus_mcp/tools.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Register MCP tools into the central tool_registry.
|
|
2
|
+
|
|
3
|
+
Importing this module:
|
|
4
|
+
1. Loads .mcp.json config files
|
|
5
|
+
2. Connects to each configured MCP server
|
|
6
|
+
3. Discovers tools from each server
|
|
7
|
+
4. Registers each tool into tool_registry so Claude can use them
|
|
8
|
+
|
|
9
|
+
MCP tool qualified names follow the pattern:
|
|
10
|
+
mcp__<server_name>__<tool_name>
|
|
11
|
+
|
|
12
|
+
This matches the Claude Code convention (mcp__serverName__toolName).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from tool_registry import ToolDef, register_tool
|
|
20
|
+
from .client import MCPClient, MCPManager, get_mcp_manager
|
|
21
|
+
from .config import load_mcp_configs
|
|
22
|
+
from .types import MCPServerConfig, MCPTool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Global state ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
_initialized = False
|
|
28
|
+
_init_lock = threading.Lock()
|
|
29
|
+
_connect_errors: Dict[str, Optional[str]] = {} # server → error or None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Tool wrapper ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def _make_mcp_func(qualified_name: str):
|
|
35
|
+
"""Return a tool func that calls the MCP server for a given qualified name."""
|
|
36
|
+
def _mcp_tool(params: dict, config: dict) -> str:
|
|
37
|
+
mgr = get_mcp_manager()
|
|
38
|
+
try:
|
|
39
|
+
return mgr.call_tool(qualified_name, params)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return f"Error calling MCP tool '{qualified_name}': {e}"
|
|
42
|
+
return _mcp_tool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _register_tool(tool: MCPTool) -> None:
|
|
46
|
+
td = ToolDef(
|
|
47
|
+
name=tool.qualified_name,
|
|
48
|
+
schema=tool.to_tool_schema(),
|
|
49
|
+
func=_make_mcp_func(tool.qualified_name),
|
|
50
|
+
read_only=tool.read_only,
|
|
51
|
+
concurrent_safe=False,
|
|
52
|
+
)
|
|
53
|
+
register_tool(td)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Initialization ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def initialize_mcp(verbose: bool = False) -> Dict[str, Optional[str]]:
|
|
59
|
+
"""Load configs, connect servers, register tools. Idempotent.
|
|
60
|
+
|
|
61
|
+
Returns a dict of {server_name: error_message_or_None}.
|
|
62
|
+
"""
|
|
63
|
+
global _initialized, _connect_errors
|
|
64
|
+
|
|
65
|
+
with _init_lock:
|
|
66
|
+
if _initialized:
|
|
67
|
+
return _connect_errors
|
|
68
|
+
|
|
69
|
+
configs = load_mcp_configs()
|
|
70
|
+
if not configs:
|
|
71
|
+
_initialized = True
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
mgr = get_mcp_manager()
|
|
75
|
+
for cfg in configs.values():
|
|
76
|
+
mgr.add_server(cfg)
|
|
77
|
+
|
|
78
|
+
errors = mgr.connect_all()
|
|
79
|
+
_connect_errors = errors
|
|
80
|
+
|
|
81
|
+
# Register tools from all successfully connected servers
|
|
82
|
+
for client in mgr.list_servers():
|
|
83
|
+
if client.state.value == "connected":
|
|
84
|
+
for tool in client._tools:
|
|
85
|
+
_register_tool(tool)
|
|
86
|
+
if verbose:
|
|
87
|
+
print(f"[MCP] {client.config.name}: {len(client._tools)} tool(s) registered")
|
|
88
|
+
|
|
89
|
+
_initialized = True
|
|
90
|
+
return errors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reload_mcp() -> Dict[str, Optional[str]]:
|
|
94
|
+
"""Force a full reload: re-read configs, reconnect, re-register all tools."""
|
|
95
|
+
global _initialized
|
|
96
|
+
with _init_lock:
|
|
97
|
+
_initialized = False
|
|
98
|
+
return initialize_mcp()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def refresh_server(server_name: str) -> Optional[str]:
|
|
102
|
+
"""Reconnect a single server and re-register its tools. Returns error or None."""
|
|
103
|
+
mgr = get_mcp_manager()
|
|
104
|
+
client = next((c for c in mgr.list_servers() if c.config.name == server_name), None)
|
|
105
|
+
if client is None:
|
|
106
|
+
return f"Server '{server_name}' not configured"
|
|
107
|
+
try:
|
|
108
|
+
mgr.reload_server(server_name)
|
|
109
|
+
for tool in client._tools:
|
|
110
|
+
_register_tool(tool)
|
|
111
|
+
return None
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return str(e)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_connect_errors() -> Dict[str, Optional[str]]:
|
|
117
|
+
return dict(_connect_errors)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Auto-initialize on import ─────────────────────────────────────────────────
|
|
121
|
+
# Connect in a background thread so startup is not blocked.
|
|
122
|
+
|
|
123
|
+
def _background_init():
|
|
124
|
+
try:
|
|
125
|
+
initialize_mcp()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_bg_thread = threading.Thread(target=_background_init, daemon=True)
|
|
131
|
+
_bg_thread.start()
|
dulus_mcp/types.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""MCP type definitions: server configs, tool descriptors, connection state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ── Server config ─────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
class MCPTransport(str, Enum):
|
|
12
|
+
STDIO = "stdio"
|
|
13
|
+
SSE = "sse"
|
|
14
|
+
HTTP = "http"
|
|
15
|
+
WS = "ws"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MCPServerConfig:
|
|
20
|
+
"""Configuration for a single MCP server.
|
|
21
|
+
|
|
22
|
+
Mirrors the Claude Code schema (types.ts) for the two most useful transports.
|
|
23
|
+
|
|
24
|
+
Stdio example:
|
|
25
|
+
{"type": "stdio", "command": "uvx", "args": ["mcp-server-git"]}
|
|
26
|
+
|
|
27
|
+
SSE/HTTP example:
|
|
28
|
+
{"type": "sse", "url": "http://localhost:8080/sse",
|
|
29
|
+
"headers": {"Authorization": "Bearer token"}}
|
|
30
|
+
"""
|
|
31
|
+
name: str # logical name in mcpServers dict
|
|
32
|
+
transport: MCPTransport = MCPTransport.STDIO
|
|
33
|
+
# stdio fields
|
|
34
|
+
command: str = ""
|
|
35
|
+
args: List[str] = field(default_factory=list)
|
|
36
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
37
|
+
# sse / http / ws fields
|
|
38
|
+
url: str = ""
|
|
39
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
40
|
+
# optional
|
|
41
|
+
timeout: int = 30 # seconds per request
|
|
42
|
+
disabled: bool = False
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, name: str, d: dict) -> "MCPServerConfig":
|
|
46
|
+
transport_str = d.get("type", "stdio").lower()
|
|
47
|
+
try:
|
|
48
|
+
transport = MCPTransport(transport_str)
|
|
49
|
+
except ValueError:
|
|
50
|
+
transport = MCPTransport.STDIO
|
|
51
|
+
return cls(
|
|
52
|
+
name=name,
|
|
53
|
+
transport=transport,
|
|
54
|
+
command=d.get("command", ""),
|
|
55
|
+
args=d.get("args", []),
|
|
56
|
+
env=d.get("env", {}),
|
|
57
|
+
url=d.get("url", ""),
|
|
58
|
+
headers=d.get("headers", {}),
|
|
59
|
+
timeout=int(d.get("timeout", 30)),
|
|
60
|
+
disabled=bool(d.get("disabled", False)),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Connection state ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
class MCPServerState(str, Enum):
|
|
67
|
+
DISCONNECTED = "disconnected"
|
|
68
|
+
CONNECTING = "connecting"
|
|
69
|
+
CONNECTED = "connected"
|
|
70
|
+
ERROR = "error"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Tool descriptor ───────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MCPTool:
|
|
77
|
+
"""A tool provided by an MCP server, ready to register in tool_registry."""
|
|
78
|
+
server_name: str
|
|
79
|
+
tool_name: str # original name from server
|
|
80
|
+
qualified_name: str # mcp__<server>__<tool>
|
|
81
|
+
description: str
|
|
82
|
+
input_schema: Dict[str, Any] # JSON Schema object
|
|
83
|
+
read_only: bool = False # from annotations.readOnlyHint
|
|
84
|
+
|
|
85
|
+
def to_tool_schema(self) -> dict:
|
|
86
|
+
"""Convert to the schema format expected by the Claude API."""
|
|
87
|
+
return {
|
|
88
|
+
"name": self.qualified_name,
|
|
89
|
+
"description": f"[MCP:{self.server_name}] {self.description}",
|
|
90
|
+
"input_schema": self.input_schema or {"type": "object", "properties": {}},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── JSON-RPC helpers ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def make_request(method: str, params: Optional[dict], req_id: int) -> dict:
|
|
97
|
+
msg: dict = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
98
|
+
if params is not None:
|
|
99
|
+
msg["params"] = params
|
|
100
|
+
return msg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def make_notification(method: str, params: Optional[dict] = None) -> dict:
|
|
104
|
+
msg: dict = {"jsonrpc": "2.0", "method": method}
|
|
105
|
+
if params is not None:
|
|
106
|
+
msg["params"] = params
|
|
107
|
+
return msg
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
111
|
+
|
|
112
|
+
CLIENT_INFO = {
|
|
113
|
+
"name": "dulus",
|
|
114
|
+
"version": "1.0.0",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
INIT_PARAMS = {
|
|
118
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
119
|
+
"capabilities": {
|
|
120
|
+
"tools": {},
|
|
121
|
+
"roots": {"listChanged": False},
|
|
122
|
+
},
|
|
123
|
+
"clientInfo": CLIENT_INFO,
|
|
124
|
+
}
|
gui/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Dulus GUI package — professional desktop interface."""
|
|
2
|
+
from gui.main_window import DulusMainWindow
|
|
3
|
+
from gui.chat_widget import ChatWidget
|
|
4
|
+
from gui.agent_bridge import DulusBridge
|
|
5
|
+
from gui.sidebar import DulusSidebar
|
|
6
|
+
from gui.settings_dialog import SettingsDialog
|
|
7
|
+
from gui.tool_panel import ToolPanel
|
|
8
|
+
from gui.tasks_view import TasksView
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DulusMainWindow",
|
|
12
|
+
"ChatWidget",
|
|
13
|
+
"DulusBridge",
|
|
14
|
+
"DulusSidebar",
|
|
15
|
+
"SettingsDialog",
|
|
16
|
+
"ToolPanel",
|
|
17
|
+
"TasksView",
|
|
18
|
+
]
|
gui/agent_bridge.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Bridge between the GUI and Dulus's core agent engine.
|
|
2
|
+
|
|
3
|
+
Handles AgentState, config, threaded execution, MemPalace injection,
|
|
4
|
+
skill injection, and permission requests. Based on Nayeli's design.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import queue
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from agent import (
|
|
13
|
+
AgentState,
|
|
14
|
+
run,
|
|
15
|
+
TextChunk,
|
|
16
|
+
ThinkingChunk,
|
|
17
|
+
ToolStart,
|
|
18
|
+
ToolEnd,
|
|
19
|
+
TurnDone,
|
|
20
|
+
PermissionRequest,
|
|
21
|
+
)
|
|
22
|
+
from config import load_config
|
|
23
|
+
from context import build_system_prompt
|
|
24
|
+
from common import sanitize_text
|
|
25
|
+
from gui.session_utils import save_session
|
|
26
|
+
|
|
27
|
+
# Ensure all tool modules are loaded so registration side-effects run
|
|
28
|
+
import tools as _tools_init
|
|
29
|
+
import memory.tools as _mem_tools_init
|
|
30
|
+
import multi_agent.tools as _ma_tools_init
|
|
31
|
+
import skill.tools as _sk_tools_init
|
|
32
|
+
import dulus_mcp.tools as _mcp_tools_init
|
|
33
|
+
import task.tools as _task_tools_init
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import tmux_tools as _tmux_tools_init
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from plugin.loader import register_plugin_tools
|
|
42
|
+
register_plugin_tools()
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DulusBridge:
|
|
48
|
+
"""Thread-safe bridge between GUI and Dulus core.
|
|
49
|
+
|
|
50
|
+
Runs the agent loop in a background thread and streams events
|
|
51
|
+
back to the UI via an internal event queue (poll from GUI thread).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: dict | None = None):
|
|
55
|
+
self.config = config or load_config()
|
|
56
|
+
self.state = AgentState()
|
|
57
|
+
self._cancelled = threading.Event()
|
|
58
|
+
self._running = True
|
|
59
|
+
self._worker_thread: threading.Thread | None = None
|
|
60
|
+
self._input_queue: queue.Queue[str | None] = queue.Queue()
|
|
61
|
+
self.event_queue: queue.Queue[dict] = queue.Queue()
|
|
62
|
+
|
|
63
|
+
# Permission handling
|
|
64
|
+
self._permission_queue: queue.Queue[bool] = queue.Queue()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Session ID tracking
|
|
68
|
+
self.session_id: str | None = None
|
|
69
|
+
self.pending_history: list[dict] = []
|
|
70
|
+
|
|
71
|
+
# Skill injection buffer (one-shot, consumed on next message)
|
|
72
|
+
self._skill_inject: str = ""
|
|
73
|
+
|
|
74
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def start(self) -> None:
|
|
77
|
+
"""Start the background worker thread."""
|
|
78
|
+
if self._worker_thread is None or not self._worker_thread.is_alive():
|
|
79
|
+
self._running = True
|
|
80
|
+
self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
|
|
81
|
+
self._worker_thread.start()
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
"""Clean shutdown of the bridge worker thread."""
|
|
85
|
+
self._running = False
|
|
86
|
+
self._cancelled.set()
|
|
87
|
+
self._input_queue.put(None)
|
|
88
|
+
if self._worker_thread:
|
|
89
|
+
self._worker_thread.join(timeout=3.0)
|
|
90
|
+
|
|
91
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def send_message(self, text: str) -> None:
|
|
94
|
+
"""Enqueue a user message. Pre-loads pending history if needed."""
|
|
95
|
+
if self.pending_history:
|
|
96
|
+
self.load_session(self.pending_history, self.session_id)
|
|
97
|
+
self.pending_history = []
|
|
98
|
+
self._input_queue.put(text)
|
|
99
|
+
|
|
100
|
+
def stop_generation(self) -> None:
|
|
101
|
+
"""Signal the current generation to stop as soon as possible."""
|
|
102
|
+
self._cancelled.set()
|
|
103
|
+
|
|
104
|
+
def grant_permission(self, granted: bool) -> None:
|
|
105
|
+
"""Respond to a pending permission request."""
|
|
106
|
+
self._permission_queue.put(granted)
|
|
107
|
+
|
|
108
|
+
def get_context_usage(self) -> tuple[int, int]:
|
|
109
|
+
"""Return (tokens_used, token_limit)."""
|
|
110
|
+
used = self.state.total_input_tokens + self.state.total_output_tokens
|
|
111
|
+
limit = self.config.get("max_tokens", 250000)
|
|
112
|
+
return used, limit
|
|
113
|
+
|
|
114
|
+
def save_current_session(self) -> str | None:
|
|
115
|
+
"""Manually save the current active state to disk. Returns session_id."""
|
|
116
|
+
if self.state and self.state.messages:
|
|
117
|
+
self.session_id = save_session(self.state, self.config, self.session_id)
|
|
118
|
+
return self.session_id
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def clear_session(self) -> None:
|
|
122
|
+
"""Reset the agent state (new conversation)."""
|
|
123
|
+
self.state = AgentState()
|
|
124
|
+
self.session_id = None
|
|
125
|
+
self.pending_history = []
|
|
126
|
+
|
|
127
|
+
def load_session(self, messages: list[dict], session_id: str | None = None) -> None:
|
|
128
|
+
"""Load a previous session's messages into the current state."""
|
|
129
|
+
self.state = AgentState()
|
|
130
|
+
self.session_id = session_id
|
|
131
|
+
self.pending_history = []
|
|
132
|
+
for m in messages:
|
|
133
|
+
# Preserve all fields (role, content, tool_calls, tool_call_id, etc.)
|
|
134
|
+
self.state.messages.append(m.copy())
|
|
135
|
+
|
|
136
|
+
def inject_skill(self, skill_body: str) -> None:
|
|
137
|
+
"""Inject skill context into the next user message (one-shot)."""
|
|
138
|
+
self._skill_inject = skill_body
|
|
139
|
+
|
|
140
|
+
def set_model(self, model: str) -> None:
|
|
141
|
+
"""Change the active model."""
|
|
142
|
+
self.config["model"] = model
|
|
143
|
+
|
|
144
|
+
# ── Worker loop ───────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def _worker_loop(self) -> None:
|
|
147
|
+
while self._running:
|
|
148
|
+
try:
|
|
149
|
+
user_message = self._input_queue.get(timeout=0.5)
|
|
150
|
+
if user_message is None:
|
|
151
|
+
continue
|
|
152
|
+
if not isinstance(user_message, str):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
self._cancelled.clear()
|
|
156
|
+
self._process_turn(user_message)
|
|
157
|
+
|
|
158
|
+
except queue.Empty:
|
|
159
|
+
continue
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
self._emit("error", message=str(exc))
|
|
162
|
+
|
|
163
|
+
def _process_turn(self, user_message: str) -> None:
|
|
164
|
+
# ── Skill inject (one-shot) ────────────────────────────────────────
|
|
165
|
+
skill_body = self._skill_inject
|
|
166
|
+
self._skill_inject = ""
|
|
167
|
+
if skill_body:
|
|
168
|
+
user_message = (
|
|
169
|
+
"[SKILL CONTEXT — follow these instructions for this turn]\n\n"
|
|
170
|
+
+ skill_body
|
|
171
|
+
+ "\n\n---\n\n[USER MESSAGE]\n"
|
|
172
|
+
+ user_message
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# ── MemPalace: per-turn memory injection ───────────────────────────
|
|
176
|
+
user_message = self._apply_mempalace(user_message)
|
|
177
|
+
|
|
178
|
+
# Sanitize input
|
|
179
|
+
user_message = sanitize_text(user_message)
|
|
180
|
+
|
|
181
|
+
# Rebuild system prompt each turn (picks up cwd changes, etc.)
|
|
182
|
+
system_prompt = build_system_prompt(self.config)
|
|
183
|
+
|
|
184
|
+
for event in run(
|
|
185
|
+
user_message=user_message,
|
|
186
|
+
state=self.state,
|
|
187
|
+
config=self.config,
|
|
188
|
+
system_prompt=system_prompt,
|
|
189
|
+
cancel_check=lambda: self._cancelled.is_set(),
|
|
190
|
+
):
|
|
191
|
+
if isinstance(event, TextChunk):
|
|
192
|
+
self._emit("text", text=event.text)
|
|
193
|
+
|
|
194
|
+
elif isinstance(event, ThinkingChunk):
|
|
195
|
+
self._emit("thinking", text=event.text)
|
|
196
|
+
|
|
197
|
+
elif isinstance(event, ToolStart):
|
|
198
|
+
self._emit("tool_start", name=event.name, inputs=event.inputs)
|
|
199
|
+
|
|
200
|
+
elif isinstance(event, ToolEnd):
|
|
201
|
+
self._emit(
|
|
202
|
+
"tool_end",
|
|
203
|
+
name=event.name,
|
|
204
|
+
result=event.result,
|
|
205
|
+
permitted=event.permitted,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
elif isinstance(event, TurnDone):
|
|
209
|
+
# Auto-save session to disk
|
|
210
|
+
self.session_id = save_session(self.state, self.config, self.session_id)
|
|
211
|
+
self._emit(
|
|
212
|
+
"turn_done",
|
|
213
|
+
input_tokens=event.input_tokens,
|
|
214
|
+
output_tokens=event.output_tokens,
|
|
215
|
+
session_id=self.session_id
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
elif isinstance(event, PermissionRequest):
|
|
219
|
+
self._emit("permission", description=event.description)
|
|
220
|
+
try:
|
|
221
|
+
granted = self._permission_queue.get(timeout=300.0)
|
|
222
|
+
event.granted = bool(granted)
|
|
223
|
+
except queue.Empty:
|
|
224
|
+
event.granted = False
|
|
225
|
+
|
|
226
|
+
def _apply_mempalace(self, user_input: str) -> str:
|
|
227
|
+
"""Copy of dulus.py MemPalace injection logic."""
|
|
228
|
+
if not self.config.get("mem_palace", True):
|
|
229
|
+
return user_input
|
|
230
|
+
|
|
231
|
+
# Skip trivial messages so we don't burn tokens on "klk"
|
|
232
|
+
if not user_input or len(user_input.strip()) < 12:
|
|
233
|
+
return user_input
|
|
234
|
+
|
|
235
|
+
_trivial = {
|
|
236
|
+
"hola", "klk", "gracias", "ok", "si", "no", "dale",
|
|
237
|
+
"exit", "quit", "help", "thanks", "bien",
|
|
238
|
+
}
|
|
239
|
+
_first = user_input.strip().lower().split()[0]
|
|
240
|
+
if _first in _trivial:
|
|
241
|
+
return user_input
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
from memory import find_relevant_memories
|
|
245
|
+
|
|
246
|
+
_q = user_input.strip()[:200]
|
|
247
|
+
_raw_hits = find_relevant_memories(_q, max_results=3)
|
|
248
|
+
if not _raw_hits:
|
|
249
|
+
return user_input
|
|
250
|
+
|
|
251
|
+
_parts = []
|
|
252
|
+
for _i, _h in enumerate(_raw_hits, 1):
|
|
253
|
+
_name = _h.get("name", f"hit_{_i}")
|
|
254
|
+
_desc = _h.get("description", "")
|
|
255
|
+
_body = _h.get("content", "").strip()
|
|
256
|
+
_snip = _body[:300] + ("..." if len(_body) > 300 else "")
|
|
257
|
+
if _desc:
|
|
258
|
+
_parts.append(f"### {_name}\n_{_desc}_\n{_snip}")
|
|
259
|
+
else:
|
|
260
|
+
_parts.append(f"### {_name}\n{_snip}")
|
|
261
|
+
|
|
262
|
+
_hits_str = "\n\n".join(_parts)
|
|
263
|
+
if len(_hits_str) > 2000:
|
|
264
|
+
_hits_str = _hits_str[:2000] + "\n[...truncated]"
|
|
265
|
+
|
|
266
|
+
_inject = (
|
|
267
|
+
"[MemPalace — relevant memories pre-loaded for this turn. "
|
|
268
|
+
"Do NOT re-query unless the user explicitly asks for more. "
|
|
269
|
+
"The answer to the user's question is very likely already "
|
|
270
|
+
"below — read it BEFORE reaching for any tool.]\n\n"
|
|
271
|
+
+ _hits_str
|
|
272
|
+
)
|
|
273
|
+
return (
|
|
274
|
+
_inject
|
|
275
|
+
+ "\n\n---\n\n[USER MESSAGE]\n"
|
|
276
|
+
+ user_input
|
|
277
|
+
)
|
|
278
|
+
except Exception:
|
|
279
|
+
return user_input
|
|
280
|
+
|
|
281
|
+
def _emit(self, event_type: str, **kwargs) -> None:
|
|
282
|
+
"""Put an event into the public event queue."""
|
|
283
|
+
self.event_queue.put({"type": event_type, **kwargs})
|