feat: orchestrator Agent mode UI + claude_allow_dir tool + fix DDG search

- Add Agent mode toggle to web UI input row — routes through POST /orchestrate
  instead of /chat; polls for result with live tool-call count in thinking bubble
- Add cortex/tools/system.py with claude_allow_dir tool; registers in tool registry
- Fix web search: duckduckgo_search renamed to ddgs, update import + requirements.txt
- Allow WebSearch and WebFetch in ~/.claude/settings.json for Claude CLI fallback
- Add claude-allow-dir script docs and security note to CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-18 22:42:44 -04:00
parent 97438f1a0f
commit 9b818aa5c7
7 changed files with 234 additions and 10 deletions

View File

@@ -6,7 +6,7 @@ python-dotenv>=1.0.0
# Orchestrator — Gemini API (native tool calling) + web search
google-genai>=1.0.0
duckduckgo-search>=6.3.0
ddgs>=0.1.0
# anthropic SDK not needed — using claude CLI subprocess for auth
# anthropic>=0.40.0

View File

@@ -9,9 +9,10 @@
const heightRow = document.getElementById('height-row');
const heightSel = document.getElementById('height-sel');
const enterToggle = document.getElementById('enter-toggle');
const noteTypeBtnEl = document.getElementById('note-type-btn');
const noteBtnEl = document.getElementById('note-btn');
const stopBtn = document.getElementById('stop');
const noteTypeBtnEl = document.getElementById('note-type-btn');
const noteBtnEl = document.getElementById('note-btn');
const agentModeBtnEl = document.getElementById('agent-mode-btn');
const stopBtn = document.getElementById('stop');
let sessionId = null;
let primaryBackend = 'claude';
@@ -58,6 +59,23 @@
syncHeight();
});
// ── Agent mode ───────────────────────────────────────────────
let agentMode = localStorage.getItem('agentMode') === 'true';
function updateAgentModeUI() {
agentModeBtnEl.classList.toggle('active', agentMode);
updateInputPlaceholder();
if (agentMode) sendBtn.textContent = 'Run';
else if (!noteMode) sendBtn.textContent = 'Send';
}
agentModeBtnEl.addEventListener('click', () => {
agentMode = !agentMode;
localStorage.setItem('agentMode', agentMode);
updateAgentModeUI();
inputEl.focus();
});
// ── Note mode ────────────────────────────────────────────────
let noteMode = false;
let notePublic = false;
@@ -82,7 +100,7 @@
} else {
noteBtnEl.classList.remove('active', 'public');
noteTypeBtnEl.style.display = 'none';
sendBtn.textContent = 'Send';
sendBtn.textContent = agentMode ? 'Run' : 'Send';
inputEl.classList.remove('note-mode', 'public');
}
updateInputPlaceholder();
@@ -93,6 +111,10 @@
inputEl.placeholder = notePublic
? 'Public note — LLM sees this next turn…'
: 'Private note — only you see this…';
} else if (agentMode) {
inputEl.placeholder = ctrlEnterMode
? 'Task for Inara… (Gemini tool loop — Ctrl+Enter to run)'
: 'Task for Inara… (Gemini tool loop)';
} else {
inputEl.placeholder = ctrlEnterMode
? 'Message Inara… (Ctrl+Enter to send)'
@@ -617,16 +639,108 @@
inputEl.focus();
}
sendBtn.addEventListener('click', () => {
if (noteMode) addNote(); else sendMessage();
});
async function sendOrchestrate() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'block';
headerEmoji.classList.add('processing');
activeController = new AbortController();
addMessage('user', text);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
try {
const res = await fetch('/orchestrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task: text,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
include_mid: memMid,
include_short: memShort,
}),
signal: activeController.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { job_id } = await res.json();
// Poll until complete or stopped
let job;
while (true) {
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
await new Promise(r => setTimeout(r, 2000));
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
const pollRes = await fetch(`/orchestrate/${job_id}`, {
signal: activeController.signal,
});
if (!pollRes.ok) throw new Error(`Poll failed: HTTP ${pollRes.status}`);
job = await pollRes.json();
const n = job.tool_calls?.length || 0;
if (job.status === 'queued' || job.status === 'running') {
thinkingDiv.textContent = n
? `⚡ working… (${n} tool${n !== 1 ? 's' : ''} used)`
: '⚡ working…';
continue;
}
break;
}
if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed');
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', job.response || '(no response)');
const n = job.tool_calls?.length || 0;
if (n) {
const names = job.tool_calls.map(t => t.name).join(', ');
addMessage('system', `${n} tool call${n !== 1 ? 's' : ''}: ${names}`);
}
} catch (err) {
if (err.name === 'AbortError') {
thinkingDiv.className = 'message system';
thinkingDiv.textContent = 'Stopped.';
} else {
thinkingDiv.className = 'message error';
thinkingDiv.textContent = `Error: ${err.message}`;
}
}
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
}
function dispatchSend() {
if (noteMode) addNote();
else if (agentMode) sendOrchestrate();
else sendMessage();
}
sendBtn.addEventListener('click', dispatchSend);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
if (shouldSend) {
e.preventDefault();
if (noteMode) addNote(); else sendMessage();
dispatchSend();
}
}
});
@@ -923,6 +1037,7 @@
updateTierUI();
updateMemUI();
updateAgentModeUI();
// ── Init ─────────────────────────────────────────────────────
updateEnterToggleUI();

View File

@@ -137,6 +137,7 @@
<!-- Note mode controls -->
<button id="note-type-btn">private</button>
<button id="note-btn">Note</button>
<button id="agent-mode-btn" title="Agent mode — Gemini tool loop + Claude response">Agent</button>
<button id="send">Send</button>
<button id="stop">Stop</button>
</div>

View File

@@ -20,6 +20,7 @@ from tools.ae_knowledge import journal_search as _ae_journal_search
from tools.ae_knowledge import journal_entry_create as _ae_journal_entry_create
from tools.ae_tasks import task_list as _ae_task_list
from tools.files import file_read as _file_read
from tools.system import claude_allow_dir as _claude_allow_dir
# ---------------------------------------------------------------------------
@@ -171,8 +172,36 @@ _CALLABLES: dict[str, callable] = {
"ae_journal_entry_create": _ae_journal_entry_create,
"ae_task_list": _ae_task_list,
"file_read": _file_read,
"claude_allow_dir": _claude_allow_dir,
}
_claude_allow_dir_declaration = types.FunctionDeclaration(
name="claude_allow_dir",
description=(
"Add a directory to Claude Code's auto-allow list so Claude can read or write "
"files there without prompting. Edits ~/.claude/settings.json on the local machine. "
"Use this when Claude is silently hanging or being blocked from accessing a directory. "
"Changes take effect in the next Claude Code session."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"path": types.Schema(
type=types.Type.STRING,
description=(
"Absolute or home-relative path to the directory "
"(e.g. ~/OSIT_dev/aether_api_fastapi or /home/scott/agents_sync)"
),
),
"mode": types.Schema(
type=types.Type.STRING,
description="Permission mode: 'r' (read-only), 'w' (write-only), or 'rw' (both). Default: rw",
),
},
required=["path"],
),
)
# Gemini Tool object — pass this to GenerateContentConfig
TOOL_DECLARATIONS = [
types.Tool(function_declarations=[
@@ -181,6 +210,7 @@ TOOL_DECLARATIONS = [
_ae_journal_entry_create_declaration,
_ae_task_list_declaration,
_file_read_declaration,
_claude_allow_dir_declaration,
])
]

44
cortex/tools/system.py Normal file
View File

@@ -0,0 +1,44 @@
"""
System tools — local machine operations.
These tools affect the host system directly. Use with care.
"""
import asyncio
import logging
logger = logging.getLogger(__name__)
ALLOW_SCRIPT = "/home/scott/.local/bin/claude-allow-dir"
async def claude_allow_dir(path: str, mode: str = "rw") -> str:
"""Add Read/Edit allow rules to ~/.claude/settings.json for a directory.
Calls the claude-allow-dir script, which edits settings.json directly.
Changes take effect in the next Claude Code session (or after /hooks reload).
"""
if mode not in ("r", "w", "rw"):
return f"Error: mode must be r, w, or rw (got '{mode}')"
try:
proc = await asyncio.create_subprocess_exec(
"python3", ALLOW_SCRIPT, path, mode,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = stdout.decode().strip()
err = stderr.decode().strip()
if proc.returncode != 0:
logger.warning("claude-allow-dir failed (rc=%d): %s", proc.returncode, err)
return f"Failed (exit {proc.returncode}): {err or output}"
return output or "Done."
except asyncio.TimeoutError:
return "Error: script timed out"
except Exception as e:
logger.error("claude_allow_dir error: %s", e)
return f"Error: {e}"

View File

@@ -35,7 +35,7 @@ async def search(query: str, max_results: int | None = None) -> str:
def _sync_search(query: str, max_results: int) -> list[dict]:
"""Synchronous DuckDuckGo search — run via asyncio.to_thread."""
from duckduckgo_search import DDGS
from ddgs import DDGS
kwargs = {}
if settings.ddg_api_key: