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.
Files changed (47) hide show
  1. attache/__init__.py +3 -0
  2. attache/__main__.py +3 -0
  3. attache/agent.py +85 -0
  4. attache/app.py +82 -0
  5. attache/backends/__init__.py +40 -0
  6. attache/backends/anthropic.py +62 -0
  7. attache/backends/claude_cli.py +160 -0
  8. attache/backends/openai.py +101 -0
  9. attache/bundled/attache.toml +56 -0
  10. attache/bundled/memory/ATTACHE.md +11 -0
  11. attache/bundled/memory/USER.md +18 -0
  12. attache/bundled/skills/artifact-kit/SKILL.md +118 -0
  13. attache/bundled/skills/artifact-kit/templates/board.html +315 -0
  14. attache/bundled/skills/artifact-kit/templates/home.html +371 -0
  15. attache/bundled/skills/artifact-kit/tokens.css +176 -0
  16. attache/bundled/skills/reminders/SKILL.md +82 -0
  17. attache/bundled/skills/skill-writer/SKILL.md +91 -0
  18. attache/bundled/skills/todos/SKILL.md +91 -0
  19. attache/channels.py +71 -0
  20. attache/cli.py +126 -0
  21. attache/config.py +160 -0
  22. attache/router.py +248 -0
  23. attache/scheduler.py +88 -0
  24. attache/skills.py +138 -0
  25. attache/static/bridge.js +73 -0
  26. attache/static/fonts/OFL-Inter.txt +92 -0
  27. attache/static/fonts/OFL-JetBrainsMono.txt +93 -0
  28. attache/static/fonts/OFL-SpaceGrotesk.txt +93 -0
  29. attache/static/fonts/inter-latin-400-normal.woff2 +0 -0
  30. attache/static/fonts/inter-latin-500-normal.woff2 +0 -0
  31. attache/static/fonts/inter-latin-600-normal.woff2 +0 -0
  32. attache/static/fonts/inter-latin-700-normal.woff2 +0 -0
  33. attache/static/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  34. attache/static/fonts/jetbrains-mono-latin-600-normal.woff2 +0 -0
  35. attache/static/fonts/space-grotesk-latin-wght-normal.woff2 +0 -0
  36. attache/static/fonts.css +33 -0
  37. attache/static/oah-tokens.css +45 -0
  38. attache/static/ui.html +363 -0
  39. attache/store.py +234 -0
  40. attache/tools.py +258 -0
  41. attache/types.py +133 -0
  42. attache/web.py +314 -0
  43. attache-0.0.1.dist-info/METADATA +109 -0
  44. attache-0.0.1.dist-info/RECORD +47 -0
  45. attache-0.0.1.dist-info/WHEEL +4 -0
  46. attache-0.0.1.dist-info/entry_points.txt +2 -0
  47. attache-0.0.1.dist-info/licenses/LICENSE +21 -0
attache/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Attaché: a personal assistant you install with one command."""
2
+
3
+ __version__ = "0.0.1" # the single source; pyproject.toml reads it, release.yml checks tags
attache/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from attache.cli import main
2
+
3
+ raise SystemExit(main())
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
+ -