attache 0.0.1__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.
- attache/__init__.py +3 -0
- attache/__main__.py +3 -0
- attache/agent.py +85 -0
- attache/app.py +82 -0
- attache/backends/__init__.py +40 -0
- attache/backends/anthropic.py +62 -0
- attache/backends/claude_cli.py +160 -0
- attache/backends/openai.py +101 -0
- attache/bundled/attache.toml +56 -0
- attache/bundled/memory/ATTACHE.md +11 -0
- attache/bundled/memory/USER.md +18 -0
- attache/bundled/skills/artifact-kit/SKILL.md +118 -0
- attache/bundled/skills/artifact-kit/templates/board.html +315 -0
- attache/bundled/skills/artifact-kit/templates/home.html +371 -0
- attache/bundled/skills/artifact-kit/tokens.css +176 -0
- attache/bundled/skills/reminders/SKILL.md +82 -0
- attache/bundled/skills/skill-writer/SKILL.md +91 -0
- attache/bundled/skills/todos/SKILL.md +91 -0
- attache/channels.py +71 -0
- attache/cli.py +126 -0
- attache/config.py +160 -0
- attache/router.py +248 -0
- attache/scheduler.py +88 -0
- attache/skills.py +138 -0
- attache/static/bridge.js +73 -0
- attache/static/fonts/OFL-Inter.txt +92 -0
- attache/static/fonts/OFL-JetBrainsMono.txt +93 -0
- attache/static/fonts/OFL-SpaceGrotesk.txt +93 -0
- attache/static/fonts/inter-latin-400-normal.woff2 +0 -0
- attache/static/fonts/inter-latin-500-normal.woff2 +0 -0
- attache/static/fonts/inter-latin-600-normal.woff2 +0 -0
- attache/static/fonts/inter-latin-700-normal.woff2 +0 -0
- attache/static/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- attache/static/fonts/jetbrains-mono-latin-600-normal.woff2 +0 -0
- attache/static/fonts/space-grotesk-latin-wght-normal.woff2 +0 -0
- attache/static/fonts.css +33 -0
- attache/static/oah-tokens.css +45 -0
- attache/static/ui.html +363 -0
- attache/store.py +234 -0
- attache/tools.py +258 -0
- attache/types.py +133 -0
- attache/web.py +314 -0
- attache-0.0.1.dist-info/METADATA +109 -0
- attache-0.0.1.dist-info/RECORD +47 -0
- attache-0.0.1.dist-info/WHEEL +4 -0
- attache-0.0.1.dist-info/entry_points.txt +2 -0
- attache-0.0.1.dist-info/licenses/LICENSE +21 -0
attache/__init__.py
ADDED
attache/__main__.py
ADDED
attache/agent.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The shared tool loop for API backends.
|
|
2
|
+
|
|
3
|
+
An Adapter speaks one wire protocol and keeps its own native message list (`state`). The loop
|
|
4
|
+
asks it for one model step at a time, runs any tool calls, and feeds the results back.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from attache.types import Endpoint, Reply, Tool, ToolCall, Turn
|
|
14
|
+
|
|
15
|
+
MAX_STEPS = 20
|
|
16
|
+
MAX_TOOL_OUTPUT = 20_000 # characters; longer results are cut with a note saying so
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Step:
|
|
21
|
+
text: str
|
|
22
|
+
calls: list[ToolCall] = field(default_factory=list)
|
|
23
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Adapter(Protocol):
|
|
27
|
+
def start(self, turn: Turn) -> Any:
|
|
28
|
+
"""Build the native message list from turn.history and turn.text."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
async def step(self, state: Any, turn: Turn) -> Step:
|
|
32
|
+
"""One model call with turn.system and turn.tools. Appends the model's message to state."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def add_results(self, state: Any, results: list[tuple[ToolCall, str]]) -> None:
|
|
36
|
+
"""Append tool results in the protocol's native form."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def api_key(endpoint: Endpoint) -> str | None:
|
|
41
|
+
"""The value of $key_env, or None when no key_env is set; RuntimeError when it is empty."""
|
|
42
|
+
env = endpoint.key_env
|
|
43
|
+
if env and not os.environ.get(env):
|
|
44
|
+
raise RuntimeError(f"endpoint '{endpoint.name}' needs the {env} environment variable")
|
|
45
|
+
return os.environ[env] if env else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tool_spec(tool: Tool, key: str = "parameters") -> dict[str, Any]:
|
|
49
|
+
"""{"name", "description", <key>: schema}; Anthropic and MCP call the schema key differently."""
|
|
50
|
+
return {"name": tool.name, "description": tool.description, key: tool.schema}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def run_tool(call: ToolCall, turn: Turn) -> str:
|
|
54
|
+
"""Run one call against turn.tools. Unknown tools and exceptions become "error: ..." text,
|
|
55
|
+
never exceptions, so the model can recover. Output is capped at MAX_TOOL_OUTPUT."""
|
|
56
|
+
if "__error__" in call.args: # set by adapters when the model sent malformed arguments
|
|
57
|
+
return f"error: {call.args['__error__']}"
|
|
58
|
+
tool = next((t for t in turn.tools if t.name == call.name), None)
|
|
59
|
+
if tool is None:
|
|
60
|
+
return f"error: unknown tool {call.name!r}"
|
|
61
|
+
try:
|
|
62
|
+
out = await tool.fn(call.args, turn.ctx)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return f"error: {type(e).__name__}: {e}"
|
|
65
|
+
if len(out) > MAX_TOOL_OUTPUT:
|
|
66
|
+
out = f"{out[:MAX_TOOL_OUTPUT]}\n[cut: showing {MAX_TOOL_OUTPUT} of {len(out)} characters]"
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def tool_loop(adapter: Adapter, turn: Turn, max_steps: int = MAX_STEPS) -> Reply:
|
|
71
|
+
"""Step until the model stops calling tools or max_steps is hit (then say so in the reply).
|
|
72
|
+
Usage from every step is summed into Reply.usage."""
|
|
73
|
+
state = adapter.start(turn)
|
|
74
|
+
usage: dict[str, Any] = {}
|
|
75
|
+
text = ""
|
|
76
|
+
for _ in range(max_steps):
|
|
77
|
+
step = await adapter.step(state, turn)
|
|
78
|
+
for key, value in step.usage.items():
|
|
79
|
+
usage[key] = usage.get(key, 0) + (value or 0)
|
|
80
|
+
text = step.text or text
|
|
81
|
+
if not step.calls:
|
|
82
|
+
return Reply(step.text, usage=usage)
|
|
83
|
+
results = [(call, await run_tool(call, turn)) for call in step.calls]
|
|
84
|
+
adapter.add_results(state, results)
|
|
85
|
+
return Reply(f"{text}\n\n(I stopped after {max_steps} steps.)".lstrip(), usage=usage)
|
attache/app.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Wire the services together and run them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from socket import AF_INET, AF_INET6, create_server, socket
|
|
11
|
+
|
|
12
|
+
from attache import channels, config, router, scheduler, store, web
|
|
13
|
+
from attache.types import Turn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Services:
|
|
18
|
+
config: config.Config
|
|
19
|
+
store: store.Store
|
|
20
|
+
scheduler: scheduler.Scheduler
|
|
21
|
+
channels: channels.ChannelManager
|
|
22
|
+
gateway: dict[str, Turn] # MCP token -> the turn it serves (the claude-cli backend's tools)
|
|
23
|
+
web_url: str # e.g. http://127.0.0.1:8765
|
|
24
|
+
ui_token: str = field(default_factory=lambda: secrets.token_urlsafe(24)) # new every run
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def home(self) -> Path:
|
|
28
|
+
return self.config.home
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def listen(cfg: config.Config) -> socket:
|
|
32
|
+
"""Bind [web] host:port. Phase 1 serves this machine only: another host is a SystemExit.
|
|
33
|
+
A port in use raises OSError."""
|
|
34
|
+
if cfg.host not in ("127.0.0.1", "localhost", "::1"):
|
|
35
|
+
raise SystemExit(f"[web] host = {cfg.host!r}: use 127.0.0.1, localhost or ::1 for now")
|
|
36
|
+
return create_server((cfg.host, cfg.port), family=AF_INET6 if cfg.host == "::1" else AF_INET)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build(home: Path) -> Services:
|
|
40
|
+
"""Load config and open the store. No network, no tasks started."""
|
|
41
|
+
cfg = config.load(home)
|
|
42
|
+
db = store.Store(home / "attache.db")
|
|
43
|
+
url = f"http://{'[::1]' if cfg.host == '::1' else cfg.host}:{cfg.port}"
|
|
44
|
+
return Services(cfg, db, scheduler.Scheduler(db), channels.ChannelManager(), {}, url)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def run(home: Path, *, terminal: bool) -> None:
|
|
48
|
+
"""Start the web server, scheduler and channels (plus the terminal chat when asked).
|
|
49
|
+
|
|
50
|
+
Returns when any task ends: the terminal chat's, at EOF or /quit, is the normal one; the
|
|
51
|
+
others only stop by failing. Ctrl+C also returns. A port in use or a host that is not this
|
|
52
|
+
machine is a SystemExit saying so; so is the web server stopping. On the way out every
|
|
53
|
+
task is cancelled and awaited (any that failed is logged), the channels are stopped, and
|
|
54
|
+
only then is the store closed. fire is set before the scheduler starts: no wake() needed."""
|
|
55
|
+
cfg = config.load(home)
|
|
56
|
+
try:
|
|
57
|
+
sock = listen(cfg)
|
|
58
|
+
except OSError:
|
|
59
|
+
raise SystemExit(f"Port {cfg.port} is in use. Is `attache run` already running?") from None
|
|
60
|
+
services = build(home)
|
|
61
|
+
rt, page = router.Router(services), web.WebChannel() # rt sets itself as scheduler.fire
|
|
62
|
+
url = f"{services.web_url}/#t={services.ui_token}"
|
|
63
|
+
print(f"Open {url}", flush=True)
|
|
64
|
+
services.channels.add(page)
|
|
65
|
+
if terminal:
|
|
66
|
+
services.channels.add(channels.TerminalChannel(cfg.owner.name, url))
|
|
67
|
+
server = asyncio.create_task(web.serve(web.create_app(services, rt, page), sock))
|
|
68
|
+
tasks = [server, asyncio.create_task(services.scheduler.run())]
|
|
69
|
+
tasks += await services.channels.start(rt.handle)
|
|
70
|
+
try:
|
|
71
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
72
|
+
if server in done:
|
|
73
|
+
raise SystemExit("The web server stopped.")
|
|
74
|
+
finally:
|
|
75
|
+
everything = [*tasks, *rt.background.values()] # background: scheduled turns running
|
|
76
|
+
for task in everything:
|
|
77
|
+
task.cancel()
|
|
78
|
+
for result in await asyncio.gather(*everything, return_exceptions=True):
|
|
79
|
+
if isinstance(result, Exception):
|
|
80
|
+
logging.getLogger(__name__).error("a task failed", exc_info=result)
|
|
81
|
+
await services.channels.stop()
|
|
82
|
+
services.store.close()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Pick a backend for an endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from attache.agent import Adapter, tool_loop
|
|
8
|
+
from attache.backends.anthropic import AnthropicAdapter
|
|
9
|
+
from attache.backends.claude_cli import ClaudeCLI
|
|
10
|
+
from attache.backends.openai import ChatAdapter, ResponsesAdapter
|
|
11
|
+
from attache.types import Backend, Endpoint, Reply, Turn
|
|
12
|
+
|
|
13
|
+
ADAPTERS = {
|
|
14
|
+
"anthropic": AnthropicAdapter,
|
|
15
|
+
"openai-chat": ChatAdapter,
|
|
16
|
+
"openai-responses": ResponsesAdapter,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class APIBackend:
|
|
22
|
+
adapter: Adapter
|
|
23
|
+
|
|
24
|
+
async def run(self, turn: Turn) -> Reply:
|
|
25
|
+
return await tool_loop(self.adapter, turn)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make(endpoint: Endpoint) -> Backend:
|
|
29
|
+
"""anthropic | openai-chat | openai-responses -> tool_loop over that adapter;
|
|
30
|
+
claude-cli -> ClaudeCLI. Unknown api names raise ValueError listing the valid ones.
|
|
31
|
+
SDK clients are created lazily so a missing API key fails at the first turn, with a
|
|
32
|
+
message naming the endpoint and its key_env."""
|
|
33
|
+
if endpoint.api == "claude-cli":
|
|
34
|
+
return ClaudeCLI(endpoint)
|
|
35
|
+
if endpoint.api not in ADAPTERS:
|
|
36
|
+
valid = ", ".join([*ADAPTERS, "claude-cli"])
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"endpoint '{endpoint.name}' has unknown api '{endpoint.api}'; use {valid}"
|
|
39
|
+
)
|
|
40
|
+
return APIBackend(ADAPTERS[endpoint.api](endpoint))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter, for the Claude API and any Anthropic-compatible base_url."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from attache.agent import Step, api_key, tool_spec
|
|
8
|
+
from attache.types import Endpoint, ToolCall, Turn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnthropicAdapter:
|
|
12
|
+
def __init__(self, endpoint: Endpoint, client: Any = None) -> None:
|
|
13
|
+
"""client: an AsyncAnthropic-like object; tests pass a fake. None builds one lazily from
|
|
14
|
+
endpoint.base_url and the env var named by endpoint.key_env."""
|
|
15
|
+
self.endpoint = endpoint
|
|
16
|
+
self.client = client
|
|
17
|
+
|
|
18
|
+
def _client(self) -> Any:
|
|
19
|
+
if self.client is None:
|
|
20
|
+
key = api_key(self.endpoint)
|
|
21
|
+
import anthropic
|
|
22
|
+
|
|
23
|
+
self.client = anthropic.AsyncAnthropic(api_key=key, base_url=self.endpoint.base_url)
|
|
24
|
+
return self.client
|
|
25
|
+
|
|
26
|
+
def start(self, turn: Turn) -> list[dict[str, Any]]:
|
|
27
|
+
# The API wants strict user/assistant alternation that opens with a user message.
|
|
28
|
+
msgs: list[dict[str, Any]] = []
|
|
29
|
+
for m in [*turn.history, {"role": "user", "content": turn.text}]:
|
|
30
|
+
if msgs and msgs[-1]["role"] == m["role"]:
|
|
31
|
+
msgs[-1]["content"] += "\n\n" + m["content"]
|
|
32
|
+
elif msgs or m["role"] == "user":
|
|
33
|
+
msgs.append({"role": m["role"], "content": m["content"]})
|
|
34
|
+
return msgs
|
|
35
|
+
|
|
36
|
+
async def step(self, state: list[dict[str, Any]], turn: Turn) -> Step:
|
|
37
|
+
tools = [tool_spec(t, key="input_schema") for t in turn.tools]
|
|
38
|
+
resp = await self._client().messages.create(
|
|
39
|
+
model=self.endpoint.model,
|
|
40
|
+
max_tokens=self.endpoint.options.get("max_tokens", 4096),
|
|
41
|
+
system=turn.system,
|
|
42
|
+
messages=state,
|
|
43
|
+
**({"tools": tools} if tools else {}),
|
|
44
|
+
)
|
|
45
|
+
# Send the blocks back as they came, so thinking blocks keep their signatures.
|
|
46
|
+
blocks = [b.model_dump(exclude_none=True) for b in resp.content]
|
|
47
|
+
state.append({"role": "assistant", "content": blocks})
|
|
48
|
+
return Step(
|
|
49
|
+
text="".join(b.text for b in resp.content if b.type == "text"),
|
|
50
|
+
calls=[ToolCall(b.id, b.name, b.input) for b in resp.content if b.type == "tool_use"],
|
|
51
|
+
usage={
|
|
52
|
+
"input_tokens": resp.usage.input_tokens,
|
|
53
|
+
"output_tokens": resp.usage.output_tokens,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def add_results(self, state: list[dict[str, Any]], results: list[tuple[ToolCall, str]]) -> None:
|
|
58
|
+
blocks = [
|
|
59
|
+
{"type": "tool_result", "tool_use_id": call.id, "content": output}
|
|
60
|
+
for call, output in results
|
|
61
|
+
]
|
|
62
|
+
state.append({"role": "user", "content": blocks})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Claude Code as a backend: `claude -p` runs its own tool loop and reaches our tools over MCP.
|
|
2
|
+
|
|
3
|
+
Only the owner's turns may use it, because a Claude subscription login is for your own use
|
|
4
|
+
(Anthropic's terms). The message goes on stdin, which avoids Windows command-line length
|
|
5
|
+
limits. claude runs in a private directory next to <home> ("<home>-claude"), with <home> added
|
|
6
|
+
by --add-dir, so a CLAUDE.md or .claude/settings.json (hooks) planted in <home> is never loaded
|
|
7
|
+
as project config; Claude Code looks for those in its working directory and the ones above it.
|
|
8
|
+
That directory stays the same across turns so every session is in one Claude Code project and
|
|
9
|
+
--resume finds it. The user's own ~/.claude config still applies; that is the owner's choice.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import AsyncIterator, Iterable
|
|
22
|
+
from dataclasses import replace
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from attache.types import Endpoint, Reply, Turn
|
|
27
|
+
|
|
28
|
+
OWNER_BUILTINS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "WebSearch"]
|
|
29
|
+
# Our tools that Claude Code's own Read/Write/Edit/Bash already cover, so they are not served.
|
|
30
|
+
DUPLICATE_TOOLS = {"read_file", "write_file", "edit_file", "run"}
|
|
31
|
+
# Errors a fresh session fixes: an unknown --resume id, or a conversation past the context limit.
|
|
32
|
+
RESET_ON = ("no conversation found", "prompt is too long", "context window", "context length")
|
|
33
|
+
SCRATCH = "Your working directory is a scratch folder. Every relative path below is relative to"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StaleSessionError(RuntimeError):
|
|
37
|
+
"""The resumed session can't go on, and no tool ran, so a fresh session can safely retry."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _spawn(argv: list[str], text: str, cwd: Path, env: dict[str, str]) -> AsyncIterator[str]:
|
|
41
|
+
pipe = asyncio.subprocess.PIPE
|
|
42
|
+
proc = await asyncio.create_subprocess_exec(
|
|
43
|
+
*argv, cwd=cwd, env=env, stdin=pipe, stdout=pipe, stderr=pipe, limit=2**24
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
stderr = asyncio.ensure_future(proc.stderr.read()) # drain so a full pipe can't block it
|
|
47
|
+
with contextlib.suppress(ConnectionError): # it exited early; stderr says why
|
|
48
|
+
proc.stdin.write(text.encode("utf-8"))
|
|
49
|
+
await proc.stdin.drain()
|
|
50
|
+
proc.stdin.close()
|
|
51
|
+
got_result = False
|
|
52
|
+
async for raw in proc.stdout:
|
|
53
|
+
line = raw.decode("utf-8", "replace")
|
|
54
|
+
got_result = got_result or '"type":"result"' in line.replace(" ", "")
|
|
55
|
+
yield line
|
|
56
|
+
code = await proc.wait()
|
|
57
|
+
if code and not got_result:
|
|
58
|
+
tail = (await stderr).decode("utf-8", "replace").strip()[-2000:]
|
|
59
|
+
raise RuntimeError(f"claude exited with code {code}: {tail}")
|
|
60
|
+
finally:
|
|
61
|
+
if proc.returncode is None:
|
|
62
|
+
proc.kill()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _env() -> dict[str, str]:
|
|
66
|
+
"""os.environ, with our local MCP traffic kept off proxies and room for 10-minute tools."""
|
|
67
|
+
skip = os.environ.get("NO_PROXY") or os.environ.get("no_proxy")
|
|
68
|
+
no_proxy = ",".join(filter(None, [skip, "127.0.0.1,localhost,::1"]))
|
|
69
|
+
env = {**os.environ, "NO_PROXY": no_proxy, "no_proxy": no_proxy, "MCP_TOOL_TIMEOUT": "660000"}
|
|
70
|
+
env.pop("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD", None) # keep <home>'s CLAUDE.md out
|
|
71
|
+
return env
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def prompt(turn: Turn) -> str:
|
|
75
|
+
"""turn.text after a fenced transcript of the history this Claude session hasn't seen: all
|
|
76
|
+
of it (the last 30 messages, 20,000 characters) when fresh, else what followed its last reply.
|
|
77
|
+
"""
|
|
78
|
+
history, label = turn.history, "Earlier messages in this chat"
|
|
79
|
+
replies = [i for i, m in enumerate(history) if m["role"] == "assistant"]
|
|
80
|
+
if turn.session.get("session_id") and replies:
|
|
81
|
+
history, label = history[replies[-1] + 1 :], "Messages in this chat since your last reply"
|
|
82
|
+
body = "\n\n".join(f"{m['role']}: {m['content']}" for m in history[-30:])[-20_000:]
|
|
83
|
+
if not body:
|
|
84
|
+
return turn.text
|
|
85
|
+
fence = "```"
|
|
86
|
+
while fence in body: # longer than any run of backticks inside, so the text can't close it
|
|
87
|
+
fence += "`"
|
|
88
|
+
return f"{label}, oldest first:\n{fence}\n{body}\n{fence}\n\nNew message:\n{turn.text}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ClaudeCLI:
|
|
92
|
+
def __init__(self, endpoint: Endpoint, runner: Any = None) -> None:
|
|
93
|
+
"""runner(argv, stdin_text, cwd, env) -> async iterable of stdout lines; tests pass one."""
|
|
94
|
+
self.endpoint, self.runner = endpoint, runner or _spawn
|
|
95
|
+
|
|
96
|
+
def command(self, turn: Turn, mcp_config: Path, system_file: Path) -> list[str]:
|
|
97
|
+
name = self.endpoint.options.get("bin", "claude")
|
|
98
|
+
if not (exe := shutil.which(name)):
|
|
99
|
+
raise RuntimeError(f"endpoint '{self.endpoint.name}': claude binary {name!r} not found")
|
|
100
|
+
argv = [exe, "-p", "--output-format", "stream-json", "--verbose"]
|
|
101
|
+
argv += ["--model", self.endpoint.model, "--append-system-prompt-file", str(system_file)]
|
|
102
|
+
argv += ["--mcp-config", str(mcp_config), "--strict-mcp-config"]
|
|
103
|
+
argv += ["--add-dir", str(turn.ctx.services.home)]
|
|
104
|
+
argv += ["--allowedTools", ",".join([*OWNER_BUILTINS, "mcp__attache"])]
|
|
105
|
+
if session_id := turn.session.get("session_id"):
|
|
106
|
+
argv += ["--resume", session_id]
|
|
107
|
+
return argv
|
|
108
|
+
|
|
109
|
+
async def run(self, turn: Turn) -> Reply:
|
|
110
|
+
if turn.ctx.role != "owner" or turn.ctx.is_group: # groups carry untrusted text
|
|
111
|
+
raise PermissionError("the claude-cli backend only answers the owner's own chats")
|
|
112
|
+
services = turn.ctx.services
|
|
113
|
+
token = secrets.token_urlsafe(24)
|
|
114
|
+
cwd = services.home.parent / f"{services.home.name}-claude" # outside <home>: see above
|
|
115
|
+
cwd.mkdir(exist_ok=True)
|
|
116
|
+
files = Path(tempfile.mkdtemp(prefix="attache-")) # this turn's MCP config and prompt
|
|
117
|
+
try:
|
|
118
|
+
tools = [t for t in turn.tools if t.name not in DUPLICATE_TOOLS]
|
|
119
|
+
services.gateway[token] = replace(turn, tools=tools)
|
|
120
|
+
server = {"type": "http", "url": f"{services.web_url}/mcp/{token}"}
|
|
121
|
+
config = json.dumps({"mcpServers": {"attache": server}})
|
|
122
|
+
(files / "mcp.json").write_text(config, encoding="utf-8")
|
|
123
|
+
system = f"{SCRATCH} {services.home}; use absolute paths under it.\n\n{turn.system}"
|
|
124
|
+
(files / "system.md").write_text(system, encoding="utf-8")
|
|
125
|
+
try:
|
|
126
|
+
return await self._once(turn, files, cwd)
|
|
127
|
+
except StaleSessionError:
|
|
128
|
+
if not turn.session.get("session_id"):
|
|
129
|
+
raise
|
|
130
|
+
return await self._once(replace(turn, session={}), files, cwd) # full transcript
|
|
131
|
+
finally:
|
|
132
|
+
services.gateway.pop(token, None)
|
|
133
|
+
shutil.rmtree(files, ignore_errors=True)
|
|
134
|
+
|
|
135
|
+
async def _once(self, turn: Turn, files: Path, cwd: Path) -> Reply:
|
|
136
|
+
argv = self.command(turn, files / "mcp.json", files / "system.md")
|
|
137
|
+
return parse_events([line async for line in self.runner(argv, prompt(turn), cwd, _env())])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse_events(lines: Iterable[str]) -> Reply:
|
|
141
|
+
"""Reply.text from the final "result" event (fall back to joined assistant text blocks),
|
|
142
|
+
Reply.session = {"session_id": ...}, Reply.usage = {"cost_usd": ..., ...}. A result with
|
|
143
|
+
is_error true raises RuntimeError with its message (StaleSessionError when a fresh session would
|
|
144
|
+
fix it and no tool has run yet, since running tools twice could repeat a send or a write).
|
|
145
|
+
Non-JSON lines are ignored."""
|
|
146
|
+
events = []
|
|
147
|
+
for line in lines:
|
|
148
|
+
with contextlib.suppress(ValueError):
|
|
149
|
+
events.append(json.loads(line))
|
|
150
|
+
result = next((e for e in reversed(events) if e.get("type") == "result"), {})
|
|
151
|
+
blocks = [b for e in events if e.get("type") == "assistant" for b in e["message"]["content"]]
|
|
152
|
+
if result.get("is_error"):
|
|
153
|
+
message = result.get("result") or "; ".join(result.get("errors") or ["claude failed"])
|
|
154
|
+
used_tools = any(b.get("type") == "tool_use" for b in blocks)
|
|
155
|
+
stale = not used_tools and any(s in message.lower() for s in RESET_ON)
|
|
156
|
+
raise (StaleSessionError if stale else RuntimeError)(message)
|
|
157
|
+
usage = {k: (result.get("usage") or {}).get(k) for k in ("input_tokens", "output_tokens")}
|
|
158
|
+
usage["cost_usd"] = result.get("total_cost_usd")
|
|
159
|
+
text = result.get("result") or "\n\n".join(b["text"] for b in blocks if b.get("type") == "text")
|
|
160
|
+
return Reply(text, {"session_id": result.get("session_id")}, usage)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""OpenAI adapters, both on openai.AsyncOpenAI with endpoint.base_url.
|
|
2
|
+
|
|
3
|
+
ChatAdapter speaks Chat Completions, the format most custom endpoints share (Ollama, vLLM,
|
|
4
|
+
LM Studio, llama.cpp, OpenRouter, Groq, Azure v1). ResponsesAdapter speaks the Responses API,
|
|
5
|
+
which OpenAI's reasoning models need for tool calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from attache.agent import Step, api_key, tool_spec
|
|
15
|
+
from attache.types import Endpoint, ToolCall, Turn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client(adapter: ChatAdapter | ResponsesAdapter) -> Any:
|
|
19
|
+
if adapter.client is None:
|
|
20
|
+
key = api_key(adapter.endpoint) or "not-needed" # local servers take any key
|
|
21
|
+
from openai import AsyncOpenAI
|
|
22
|
+
|
|
23
|
+
adapter.client = AsyncOpenAI(api_key=key, base_url=adapter.endpoint.base_url)
|
|
24
|
+
return adapter.client
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _args(raw: Any) -> dict[str, Any]:
|
|
28
|
+
"""Parsed tool-call arguments; malformed ones become {"__error__": ...} for run_tool."""
|
|
29
|
+
if isinstance(raw, dict): # some local servers send an object, not a JSON string
|
|
30
|
+
return raw
|
|
31
|
+
with contextlib.suppress(ValueError):
|
|
32
|
+
if isinstance(args := json.loads(raw or "{}"), dict):
|
|
33
|
+
return args
|
|
34
|
+
return {"__error__": f"arguments were not valid JSON: {(raw or '')[:200]}"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChatAdapter:
|
|
38
|
+
def __init__(self, endpoint: Endpoint, client: Any = None) -> None:
|
|
39
|
+
self.endpoint = endpoint
|
|
40
|
+
self.client = client
|
|
41
|
+
|
|
42
|
+
def start(self, turn: Turn) -> list[dict[str, Any]]:
|
|
43
|
+
return [
|
|
44
|
+
{"role": "system", "content": turn.system},
|
|
45
|
+
*map(dict, turn.history),
|
|
46
|
+
{"role": "user", "content": turn.text},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
async def step(self, state: list[dict[str, Any]], turn: Turn) -> Step:
|
|
50
|
+
kwargs: dict[str, Any] = {"model": self.endpoint.model, "messages": state}
|
|
51
|
+
if turn.tools:
|
|
52
|
+
kwargs["tools"] = [{"type": "function", "function": tool_spec(t)} for t in turn.tools]
|
|
53
|
+
resp = await _client(self).chat.completions.create(**kwargs)
|
|
54
|
+
msg = resp.choices[0].message
|
|
55
|
+
tool_calls = msg.tool_calls or []
|
|
56
|
+
message: dict[str, Any] = {"role": "assistant", "content": msg.content}
|
|
57
|
+
if tool_calls:
|
|
58
|
+
message["tool_calls"] = [tc.model_dump(exclude_none=True) for tc in tool_calls]
|
|
59
|
+
state.append(message)
|
|
60
|
+
calls = [
|
|
61
|
+
ToolCall(tc.id, tc.function.name, _args(tc.function.arguments)) for tc in tool_calls
|
|
62
|
+
]
|
|
63
|
+
u = resp.usage # some local servers omit it
|
|
64
|
+
usage = {"input_tokens": u.prompt_tokens, "output_tokens": u.completion_tokens} if u else {}
|
|
65
|
+
return Step(msg.content or "", calls, usage)
|
|
66
|
+
|
|
67
|
+
def add_results(self, state: list[dict[str, Any]], results: list[tuple[ToolCall, str]]) -> None:
|
|
68
|
+
for call, output in results:
|
|
69
|
+
state.append({"role": "tool", "tool_call_id": call.id, "content": output})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ResponsesAdapter:
|
|
73
|
+
def __init__(self, endpoint: Endpoint, client: Any = None) -> None:
|
|
74
|
+
self.endpoint = endpoint
|
|
75
|
+
self.client = client
|
|
76
|
+
|
|
77
|
+
def start(self, turn: Turn) -> list[dict[str, Any]]:
|
|
78
|
+
return [*map(dict, turn.history), {"role": "user", "content": turn.text}]
|
|
79
|
+
|
|
80
|
+
async def step(self, state: list[dict[str, Any]], turn: Turn) -> Step:
|
|
81
|
+
resp = await _client(self).responses.create(
|
|
82
|
+
model=self.endpoint.model,
|
|
83
|
+
instructions=turn.system,
|
|
84
|
+
input=state,
|
|
85
|
+
# strict mode would reject schemas with optional fields, so turn it off
|
|
86
|
+
tools=[{"type": "function", **tool_spec(t), "strict": False} for t in turn.tools],
|
|
87
|
+
store=False, # stateless: reasoning comes back encrypted and is replayed from state
|
|
88
|
+
include=["reasoning.encrypted_content"],
|
|
89
|
+
)
|
|
90
|
+
calls = []
|
|
91
|
+
for item in resp.output:
|
|
92
|
+
state.append(item.model_dump(exclude_none=True))
|
|
93
|
+
if item.type == "function_call":
|
|
94
|
+
calls.append(ToolCall(item.call_id, item.name, _args(item.arguments)))
|
|
95
|
+
u = resp.usage
|
|
96
|
+
usage = {"input_tokens": u.input_tokens, "output_tokens": u.output_tokens} if u else {}
|
|
97
|
+
return Step(resp.output_text or "", calls, usage)
|
|
98
|
+
|
|
99
|
+
def add_results(self, state: list[dict[str, Any]], results: list[tuple[ToolCall, str]]) -> None:
|
|
100
|
+
for call, output in results:
|
|
101
|
+
state.append({"type": "function_call_output", "call_id": call.id, "output": output})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Attaché settings. Restart `attache run` after editing.
|
|
2
|
+
# People and chats are keyed by platform ids, never by display names.
|
|
3
|
+
|
|
4
|
+
[assistant]
|
|
5
|
+
name = "Attaché" # how the assistant names itself; also its trigger word in group chats
|
|
6
|
+
|
|
7
|
+
[owner]
|
|
8
|
+
name = "{owner}"
|
|
9
|
+
|
|
10
|
+
# Your own sender id on each chat app, so your direct messages there reach you as the owner.
|
|
11
|
+
# Ids can also go straight in [owner] (or [people.<id>]), like slack = "U0123".
|
|
12
|
+
[owner.ids]
|
|
13
|
+
slack = ""
|
|
14
|
+
|
|
15
|
+
[routing]
|
|
16
|
+
own_chats = "{endpoint}" # your own chat (terminal and web) and your direct messages
|
|
17
|
+
group_chats = "claude-api" # group chats, and anyone but you
|
|
18
|
+
|
|
19
|
+
[web]
|
|
20
|
+
host = "127.0.0.1" # only this machine can reach the web page
|
|
21
|
+
port = 8765
|
|
22
|
+
|
|
23
|
+
# Models. api: anthropic | openai-chat | openai-responses | claude-cli. Other keys, such as
|
|
24
|
+
# max_tokens, are passed to the backend as options.
|
|
25
|
+
[endpoints.claude]
|
|
26
|
+
api = "claude-cli" # your Claude Code login
|
|
27
|
+
model = "claude-sonnet-5-5"
|
|
28
|
+
|
|
29
|
+
[endpoints.claude-api]
|
|
30
|
+
api = "anthropic"
|
|
31
|
+
model = "claude-sonnet-5-5"
|
|
32
|
+
key_env = "ANTHROPIC_API_KEY"
|
|
33
|
+
|
|
34
|
+
# [endpoints.openai]
|
|
35
|
+
# api = "openai-responses"
|
|
36
|
+
# model = "gpt-5.4"
|
|
37
|
+
# key_env = "OPENAI_API_KEY"
|
|
38
|
+
|
|
39
|
+
# [endpoints.local]
|
|
40
|
+
# api = "openai-chat"
|
|
41
|
+
# base_url = "http://localhost:11434/v1"
|
|
42
|
+
# model = "qwen3:32b"
|
|
43
|
+
|
|
44
|
+
# People you share chats with. Keys other than name, role and ids are their ids per chat app.
|
|
45
|
+
# [people.sam]
|
|
46
|
+
# name = "Sam"
|
|
47
|
+
# role = "member" # member | guest
|
|
48
|
+
# slack = "U0123"
|
|
49
|
+
|
|
50
|
+
# Group chats Attaché answers in, keyed "<channel>:<chat id>". Unlisted groups are ignored.
|
|
51
|
+
# [chats."slack:C0123"]
|
|
52
|
+
# name = "Family"
|
|
53
|
+
# engage = "mention" # mention | always | a regular expression matched against the text
|
|
54
|
+
# members = ["sam"] # person ids from [people]
|
|
55
|
+
# guests = "ignore" # ignore | public
|
|
56
|
+
# endpoint = "claude-api"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Attaché
|
|
2
|
+
|
|
3
|
+
You are Attaché, {owner}'s personal assistant. This file is your persona; {owner} can edit it.
|
|
4
|
+
|
|
5
|
+
- Be concise and warm. Answer first, then offer more if it would help.
|
|
6
|
+
- Ask before you send email, delete anything, or message any chat other than this one.
|
|
7
|
+
- Read a skill's SKILL.md before you use the skill.
|
|
8
|
+
- Use an artifact for lists, boards and anything interactive.
|
|
9
|
+
- Use `schedule` for reminders and anything that should happen later.
|
|
10
|
+
- When {owner} shares a lasting fact or preference, keep the notes in `memory/` up to date:
|
|
11
|
+
USER.md for {owner}, `memory/chats/` for a chat.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# {owner}
|
|
2
|
+
|
|
3
|
+
What Attaché knows about you. Edit freely; Attaché adds to it when you share lasting facts.
|
|
4
|
+
|
|
5
|
+
- Name: {owner}
|
|
6
|
+
- Time zone:
|
|
7
|
+
- Languages:
|
|
8
|
+
|
|
9
|
+
## Preferences
|
|
10
|
+
|
|
11
|
+
- How you like replies (short, detailed, bullet points):
|
|
12
|
+
- Working hours:
|
|
13
|
+
|
|
14
|
+
## VIPs
|
|
15
|
+
|
|
16
|
+
People whose messages matter most, and how you know them.
|
|
17
|
+
|
|
18
|
+
-
|