From 6255b698353b8d6b409cf7b298aa68e68c796ce3 Mon Sep 17 00:00:00 2001 From: Allegro Date: Tue, 31 Mar 2026 21:09:41 +0000 Subject: [PATCH] feat: Initial Claw Agent core architecture --- README.md | 36 +++++ src/__init__.py | 22 +++ .../execution_registry.cpython-312.pyc | Bin 0 -> 7474 bytes src/__pycache__/permissions.cpython-312.pyc | Bin 0 -> 3639 bytes src/__pycache__/session_store.cpython-312.pyc | Bin 0 -> 7051 bytes src/execution_registry.py | 151 ++++++++++++++++++ src/permissions.py | 87 ++++++++++ src/session_store.py | 128 +++++++++++++++ 8 files changed, 424 insertions(+) create mode 100644 README.md create mode 100644 src/__init__.py create mode 100644 src/__pycache__/execution_registry.cpython-312.pyc create mode 100644 src/__pycache__/permissions.cpython-312.pyc create mode 100644 src/__pycache__/session_store.cpython-312.pyc create mode 100644 src/execution_registry.py create mode 100644 src/permissions.py create mode 100644 src/session_store.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fee275 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Claw Agent + +Agent harness built using architectural patterns from [Claw Code](http://143.198.27.163:3000/Timmy/claw-code). + +## Components + +- **permissions.py** - Fine-grained tool access control +- **execution_registry.py** - Command/tool routing registry +- **session_store.py** - JSON-based session persistence + +## Usage + +```python +from claw_agent import ToolPermissionContext, ExecutionRegistry, SessionStore + +# Create permission context +ctx = ToolPermissionContext( + deny_tools={"bash"}, + deny_prefixes={"dangerous_"} +) + +# Build registry +registry = build_default_registry() + +# Create session +store = SessionStore() +session = RuntimeSession.create(prompt="Hello") +session.history.add("user", "Hello") +store.save(session) +``` + +## Architecture + +This agent replaces idle Allegro-Primus with real work capabilities. + +See EPIC-202: http://143.198.27.163:3000/Timmy_Foundation/timmy-home/issues/191 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..a8955e0 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,22 @@ +"""Claw Agent - Agent harness inspired by Claw Code architecture.""" + +from .permissions import ToolPermissionContext, READONLY_CONTEXT, SAFE_CONTEXT, UNRESTRICTED_CONTEXT +from .execution_registry import ExecutionRegistry, ExecutionResult, CommandHandler, ToolHandler, build_default_registry +from .session_store import SessionStore, RuntimeSession, HistoryLog, HistoryEntry + +__version__ = "0.1.0" +__all__ = [ + "ToolPermissionContext", + "READONLY_CONTEXT", + "SAFE_CONTEXT", + "UNRESTRICTED_CONTEXT", + "ExecutionRegistry", + "ExecutionResult", + "CommandHandler", + "ToolHandler", + "build_default_registry", + "SessionStore", + "RuntimeSession", + "HistoryLog", + "HistoryEntry", +] diff --git a/src/__pycache__/execution_registry.cpython-312.pyc b/src/__pycache__/execution_registry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ea622539b3778d1b901b7b76f2e247e166c38d7 GIT binary patch literal 7474 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 P8w`M(gblDQlr#SXF*JFu literal 0 HcmV?d00001 diff --git a/src/__pycache__/permissions.cpython-312.pyc b/src/__pycache__/permissions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef292236da2d51378b5d18a84138a297c2418ae5 GIT binary patch literal 3639 zcmaJ^-ES1v6~A|OcD*}mdtKYa)HY#opk@+SM{Ovr114Zhm3}zbY@~D}cRI|y>m8Wg zaqgY9y&F(0m8yx92l~+3NR=Q;q$Yw&JVxqYP_++s7YQ^%>O-Y|yxBEXQ=WS6o!wd6 zY#G7XbMLvIzwd1GLlKCV?3Zg2B-%H%Z;W`TX9wHq#5J}!u!k)X~56Y)tY^sPz z>Qf9-`xM_xH^<~PVI!SIOu9&SQQ2Wh3rn;{XEkH=)G9g8bM*zvN)G3aSJruz`?RDx zWxn7rO7zQBebTj-^hu9U-C}mp@u}@s7|m+hMdmF!gmS&$l$X?+ z8LVCKnC^>B=rXk{KJ1az{wXeE;2>?4%bstEPHrecidep7yB6m`yx>rmKws<<^@Dh> zvf$Dj%t#cNSEFUSfrR{tg$=x20A&?Xl*fR*@-`rzj|o(1{GQT8HJ>0c+CkJ)FqXvg zNs@q8hir9`!#hdN09_7z^C7g5Cb-krMm^Z=p7Ae!0ijG~Bn1dIraszm9 zShVBRvQ+{=$7PS`wf$xNwBGhcN25VvAfe@|Dcs3Vzdv%>;>D3;`bfcXsky{}86($^ zJv)4X(Sox~#SCJV=PC0l+!XUdu9+j(qd7*T!HKe$UC+MCkB0jgy8e=$V-?zVNNZt2 zT?z+z{&0C>MlBMKyI{FITo)*oA+%ssT;D8Mw(qfOEeRBSm-}ok&|2630@X5-mBMmN z&t|i8L6Xqb57=HnPSgWW;KJm=rA2oq}$KvFC7If*A(1Uxc=x3=lSCg>Y?z>sdC>ThtIH z;c&BTmFT!W4N~_Cx?iN>(!QrlPDj&stu;#_SCscGC%h;Q7p=jIUd4r6Lz}0J7R%X< zd&!Fn1imFud;^qU;U6Fj@qP`>?L+7~{z$p9bF7?z&^6pd=GS-?WAyQWimK>$u_bKa zY4EnjeT(^g$?=OuEKstq28qytK`Od67v9kIm8Y+aG0*eImYkZ!2p@w~wRGH?r)7VP zGkdHR?xo*5O5+JO*jVo@nH~O{R4fqg8tk)bN|B4 z?>_7s-RK+rJn^t^Y@>JPzg(d4v!NHU5o4X81~IE#W!<2Cp#&*G z)W3iA^ltG`X|N$M?Sdr*9TJurD68=7UqgOG+-vxXfNjr5@H}ch@J%}V zjdDX!yzLz2**W)O%~b$uwf473NP#|WrUD(_){9E3s^`* zyQIxKpsens47#%Or2(t>BAP^Nw0}XG;|TtZ0;EuCN5Hop^CMD-{QfK@*g@C>AXHrk z-anCkB)||#Uk9CwO&dW3X|xI3!S=941I~-7M?tJ0NHaqR*ZkWb-1?wCIQIC!p*x2k zj=Z)p@>>1CiLKozO&h9Abl^^(1qmi!oIt${Zx&b%!*YQtXv|&)m5K7*P(od#S))5h zn(}Qk%|J8Fl1C~~&6q)_X}(vnT=D8btHU&jXPYJyQO9&4WX{c)=8hT`>>~e$>Er-t zL0XoxCF&PF!j1@=xF72hLSzP+Xyl=!NJAID0OIFpOYMp$w}#R1q5FLghEA+}|7NnT zo_KU*bmd1k&(zh?ZM8c-i?{W7{N>GVl=#}el+L)e9RqDsXitXY@sLGw;R!(>00kU^_z53o;@r$T>Duye4cU?sl_Vl##@-cn*% z{dW@Km;R29{skS_N+2bD^Y8;a@E_$ghA;#Hcq;ohMRV(%g8GdhGZFND6T-A#Sw}rv m8tOUnsONFues~JuZRFvRyx73|9E{MyMg+pm;VPQu%P<@ literal 0 HcmV?d00001 diff --git a/src/__pycache__/session_store.cpython-312.pyc b/src/__pycache__/session_store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d3a5f51f5be09ac16a7db2a267a10314ba70b4a GIT binary patch literal 7051 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 literal 0 HcmV?d00001 diff --git a/src/execution_registry.py b/src/execution_registry.py new file mode 100644 index 0000000..2fde2d6 --- /dev/null +++ b/src/execution_registry.py @@ -0,0 +1,151 @@ +"""Execution registry for command and tool routing. + +Inspired by Claw Code's execution patterns. +Provides clean separation between routing and execution. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional +from pathlib import Path +import json + +from permissions import ToolPermissionContext + + +@dataclass +class ExecutionResult: + """Result of a command or tool execution.""" + success: bool + output: str + error: Optional[str] = None + metadata: Dict[str, Any] = None + + def to_json(self) -> str: + return json.dumps({ + "success": self.success, + "output": self.output, + "error": self.error, + "metadata": self.metadata or {} + }) + + @classmethod + def from_json(cls, data: str) -> ExecutionResult: + d = json.loads(data) + return cls(**d) + + +class CommandHandler: + """Handler for a specific command.""" + + def __init__(self, name: str, fn: Callable[..., ExecutionResult], description: str = ""): + self.name = name + self.fn = fn + self.description = description + + def execute(self, prompt: str, context: ToolPermissionContext = None) -> ExecutionResult: + """Execute the command with given prompt.""" + try: + return self.fn(prompt, context) + except Exception as e: + return ExecutionResult( + success=False, + output="", + error=str(e) + ) + + +class ToolHandler: + """Handler for a specific tool.""" + + def __init__(self, name: str, fn: Callable[..., ExecutionResult], description: str = ""): + self.name = name + self.fn = fn + self.description = description + + def execute(self, payload: str, context: ToolPermissionContext = None) -> ExecutionResult: + """Execute the tool with given payload.""" + # Check permission + if context and context.blocks(self.name): + return ExecutionResult( + success=False, + output="", + error=f"Tool '{self.name}' is blocked by permission context" + ) + + try: + return self.fn(payload, context) + except Exception as e: + return ExecutionResult( + success=False, + output="", + error=str(e) + ) + + +class ExecutionRegistry: + """Registry for commands and tools. + + Routes prompts to appropriate handlers and manages execution. + """ + + def __init__(self): + self._commands: Dict[str, CommandHandler] = {} + self._tools: Dict[str, ToolHandler] = {} + + def register_command(self, name: str, fn: Callable, description: str = "") -> None: + """Register a command handler.""" + self._commands[name] = CommandHandler(name, fn, description) + + def register_tool(self, name: str, fn: Callable, description: str = "") -> None: + """Register a tool handler.""" + self._tools[name] = ToolHandler(name, fn, description) + + def command(self, name: str) -> Optional[CommandHandler]: + """Get a command handler by name.""" + return self._commands.get(name) + + def tool(self, name: str) -> Optional[ToolHandler]: + """Get a tool handler by name.""" + return self._tools.get(name) + + def list_commands(self) -> list[str]: + """List all registered command names.""" + return list(self._commands.keys()) + + def list_tools(self) -> list[str]: + """List all registered tool names.""" + return list(self._tools.keys()) + + def execute_command(self, name: str, prompt: str, context: ToolPermissionContext = None) -> ExecutionResult: + """Execute a command by name.""" + handler = self.command(name) + if not handler: + return ExecutionResult( + success=False, + output="", + error=f"Unknown command: {name}" + ) + return handler.execute(prompt, context) + + def execute_tool(self, name: str, payload: str, context: ToolPermissionContext = None) -> ExecutionResult: + """Execute a tool by name.""" + handler = self.tool(name) + if not handler: + return ExecutionResult( + success=False, + output="", + error=f"Unknown tool: {name}" + ) + return handler.execute(payload, context) + + +def build_default_registry() -> ExecutionRegistry: + """Build a registry with default handlers.""" + registry = ExecutionRegistry() + + # Register basic commands + registry.register_command("help", lambda p, c: ExecutionResult(True, "Available commands: help, status")) + registry.register_command("status", lambda p, c: ExecutionResult(True, "Claw Agent running")) + + return registry diff --git a/src/permissions.py b/src/permissions.py new file mode 100644 index 0000000..32cf6f8 --- /dev/null +++ b/src/permissions.py @@ -0,0 +1,87 @@ +"""Tool permission system inspired by Claw Code architecture. + +Provides fine-grained access control for tool execution. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Set, Tuple + + +@dataclass(frozen=True) +class ToolPermissionContext: + """Context for tool permission checking. + + Inspired by Claw Code's permission system. + Allows fine-grained control over which tools can execute. + + Example: + ctx = ToolPermissionContext( + deny_tools={"bash", "file_write"}, + deny_prefixes={"dangerous_", "system_"} + ) + ctx.blocks("bash") # True + ctx.blocks("dangerous_delete") # True + ctx.blocks("file_read") # False + """ + deny_tools: Set[str] = field(default_factory=set) + deny_prefixes: Tuple[str, ...] = () + + def blocks(self, tool_name: str) -> bool: + """Check if a tool is blocked by this context. + + Args: + tool_name: Name of the tool to check + + Returns: + True if the tool should be blocked + """ + # Exact match check + if tool_name in self.deny_tools: + return True + + # Prefix check + return any(tool_name.startswith(prefix) for prefix in self.deny_prefixes) + + def allows(self, tool_name: str) -> bool: + """Check if a tool is explicitly allowed. + + This is the inverse of blocks() for convenience. + """ + return not self.blocks(tool_name) + + @classmethod + def from_config(cls, config: dict) -> ToolPermissionContext: + """Create context from configuration dict. + + Expected config format: + { + "deny_tools": ["bash", "file_write"], + "deny_prefixes": ["dangerous_", "system_"] + } + """ + return cls( + deny_tools=set(config.get("deny_tools", [])), + deny_prefixes=tuple(config.get("deny_prefixes", [])) + ) + + def to_config(self) -> dict: + """Export context to configuration dict.""" + return { + "deny_tools": list(self.deny_tools), + "deny_prefixes": list(self.deny_prefixes) + } + + +# Predefined permission contexts +READONLY_CONTEXT = ToolPermissionContext( + deny_tools={"bash", "file_write", "file_edit", "terminal"}, + deny_prefixes={"write_", "delete_", "modify_"} +) + +SAFE_CONTEXT = ToolPermissionContext( + deny_tools={"bash"}, + deny_prefixes={"system_", "dangerous_"} +) + +UNRESTRICTED_CONTEXT = ToolPermissionContext() diff --git a/src/session_store.py b/src/session_store.py new file mode 100644 index 0000000..9bfaeec --- /dev/null +++ b/src/session_store.py @@ -0,0 +1,128 @@ +"""Session persistence layer. + +JSON-based session storage inspired by Claw Code. +More portable and inspectable than SQLite. +""" +from __future__ import annotations + +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional +import json +import uuid + + +@dataclass +class HistoryEntry: + """Single entry in session history.""" + timestamp: str + role: str # 'user', 'assistant', 'system', 'tool' + content: str + metadata: Dict = field(default_factory=dict) + + +@dataclass +class HistoryLog: + """Log of all interactions in a session.""" + entries: List[HistoryEntry] = field(default_factory=list) + + def add(self, role: str, content: str, metadata: Dict = None): + """Add an entry to the log.""" + self.entries.append(HistoryEntry( + timestamp=datetime.now().isoformat(), + role=role, + content=content, + metadata=metadata or {} + )) + + def as_markdown(self) -> str: + """Export as markdown.""" + lines = ["## Session History", ""] + for entry in self.entries: + lines.append(f"**{entry.role}** ({entry.timestamp}):") + lines.append(entry.content) + lines.append("") + return "\n".join(lines) + + +@dataclass +class RuntimeSession: + """Complete runtime session state. + + Inspired by Claw Code's session structure. + Persisted as JSON for portability. + """ + session_id: str + created_at: str + prompt: str + context: Dict + history: HistoryLog + persisted_path: Optional[Path] = None + + def __post_init__(self): + if isinstance(self.history, list): + # Convert from dict if loaded from JSON + self.history = HistoryLog(entries=[HistoryEntry(**e) for e in self.history]) + + def save(self) -> Path: + """Save session to disk.""" + if not self.persisted_path: + # Generate path based on session_id + base = Path.home() / ".claw-agent" / "sessions" + base.mkdir(parents=True, exist_ok=True) + self.persisted_path = base / f"{self.session_id}.json" + + data = { + "session_id": self.session_id, + "created_at": self.created_at, + "prompt": self.prompt, + "context": self.context, + "history": [asdict(e) for e in self.history.entries], + } + + self.persisted_path.write_text(json.dumps(data, indent=2)) + return self.persisted_path + + @classmethod + def load(cls, path: Path) -> RuntimeSession: + """Load session from disk.""" + data = json.loads(path.read_text()) + data["persisted_path"] = path + return cls(**data) + + @classmethod + def create(cls, prompt: str = "", context: Dict = None) -> RuntimeSession: + """Create a new session.""" + return cls( + session_id=str(uuid.uuid4())[:8], + created_at=datetime.now().isoformat(), + prompt=prompt, + context=context or {}, + history=HistoryLog() + ) + + +class SessionStore: + """Persistent store for sessions.""" + + def __init__(self, base_path: Path = None): + self.base_path = base_path or (Path.home() / ".claw-agent" / "sessions") + self.base_path.mkdir(parents=True, exist_ok=True) + + def list_sessions(self) -> List[Path]: + """List all session files.""" + return sorted(self.base_path.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + + def load_latest(self) -> Optional[RuntimeSession]: + """Load the most recent session.""" + sessions = self.list_sessions() + if sessions: + return RuntimeSession.load(sessions[0]) + return None + + def save(self, session: RuntimeSession) -> Path: + """Save a session.""" + if not session.persisted_path: + session.persisted_path = self.base_path / f"{session.session_id}.json" + return session.save()