nbclaw 0.1.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.
nbclaw/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """nbclaw — No Bullshit Claw.
2
+
3
+ A 24/7 daemon that drives a swival agent from Signal: send it commands, get
4
+ answers, and schedule or cancel recurring tasks.
5
+ """
6
+
7
+ from .config import Config
8
+
9
+ __all__ = ["Config"]
10
+ __version__ = "0.1.0"
nbclaw/__main__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Entry point: ``python -m nbclaw`` / ``nbclaw``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+
9
+ from .config import build_config
10
+ from .daemon import Daemon
11
+
12
+
13
+ def main() -> None:
14
+ logging.basicConfig(
15
+ level=os.environ.get("NBCLAW_LOG", "INFO").upper(),
16
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
17
+ datefmt="%H:%M:%S",
18
+ )
19
+ config = build_config()
20
+ daemon = Daemon(config)
21
+ try:
22
+ asyncio.run(daemon.run())
23
+ except KeyboardInterrupt:
24
+ pass
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
nbclaw/agent_runner.py ADDED
@@ -0,0 +1,83 @@
1
+ """Bridges Signal conversations to the swival agent.
2
+
3
+ ``swival.Session`` is synchronous and CPU/IO heavy, so every call is run in a
4
+ thread pool. Because the target here is a single local model, the daemon funnels
5
+ all agent work through one worker (see daemon.py); this class is therefore not
6
+ trying to be concurrency-safe across many simultaneous runs.
7
+
8
+ Each conversation keeps its own long-lived ``Session`` so chat context carries
9
+ across messages. Cron jobs run as independent one-shots and never touch chat
10
+ context.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from typing import Any
18
+
19
+ from swival import AgentError, Result, Session
20
+
21
+ log = logging.getLogger("nbclaw.agent")
22
+
23
+
24
+ def _safe_close(session: Session) -> None:
25
+ try:
26
+ session.close()
27
+ except Exception as exc: # pragma: no cover - cleanup best effort
28
+ log.debug("session close error: %s", exc)
29
+
30
+
31
+ class AgentRunner:
32
+ def __init__(self, session_kwargs: dict[str, Any]) -> None:
33
+ self._kwargs = session_kwargs
34
+ self._sessions: dict[str, Session] = {}
35
+
36
+ def _session_for(self, key: str) -> Session:
37
+ session = self._sessions.get(key)
38
+ if session is None:
39
+ log.info("creating session for %s", key)
40
+ session = Session(**self._kwargs)
41
+ self._sessions[key] = session
42
+ return session
43
+
44
+ def reset(self, key: str) -> bool:
45
+ """Drop a conversation's context. Returns True if there was one."""
46
+ session = self._sessions.pop(key, None)
47
+ if session is None:
48
+ return False
49
+ _safe_close(session)
50
+ return True
51
+
52
+ # --- blocking primitives (run inside the executor) -----------------
53
+ def _ask_blocking(self, key: str, prompt: str) -> str:
54
+ session = self._session_for(key)
55
+ result = session.ask(prompt)
56
+ return _answer_text(result)
57
+
58
+ def _once_blocking(self, prompt: str) -> str:
59
+ session = Session(**self._kwargs)
60
+ try:
61
+ return _answer_text(session.run(prompt))
62
+ finally:
63
+ _safe_close(session)
64
+
65
+ # --- async wrappers ------------------------------------------------
66
+ async def chat(self, key: str, prompt: str) -> str:
67
+ return await asyncio.to_thread(self._ask_blocking, key, prompt)
68
+
69
+ async def once(self, prompt: str) -> str:
70
+ return await asyncio.to_thread(self._once_blocking, prompt)
71
+
72
+ def close_all(self) -> None:
73
+ for session in self._sessions.values():
74
+ _safe_close(session)
75
+ self._sessions.clear()
76
+
77
+
78
+ def _answer_text(result: Result) -> str:
79
+ if result.answer:
80
+ return result.answer
81
+ if result.exhausted:
82
+ raise AgentError("agent ran out of turns without an answer")
83
+ raise AgentError("agent returned no answer")
nbclaw/commands.py ADDED
@@ -0,0 +1,56 @@
1
+ """Parsing helpers and help text for the slash-command interface.
2
+
3
+ Anything that doesn't start with ``/`` is treated as a prompt for the agent.
4
+ The command dispatch itself lives in :mod:`nbclaw.daemon` because it needs the
5
+ daemon's live state (scheduler, sessions, start time).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ HELP_TEXT = """nbclaw — commands
11
+
12
+ Plain text is sent to the agent. Slash commands:
13
+
14
+ /help show this help
15
+ /status model, uptime, active crons
16
+ /reset forget this conversation's context
17
+
18
+ Scheduling — just say it in plain English after /cron:
19
+ /cron every weekday at 9am summarize my git log in ~/src/app
20
+ /cron remind me to stretch every 2 hours
21
+ /cron tomorrow at 8am say good morning
22
+
23
+ /cron list list scheduled tasks
24
+ /cron del <name> cancel a scheduled task
25
+ /cron run <name> run a scheduled task right now
26
+
27
+ Power-user form (exact cron expression):
28
+ /cron add <name> <schedule> | <prompt>
29
+ schedules: 0 9 * * 1-5 · @every 30m · @hourly @daily @weekly @monthly
30
+ """.strip()
31
+
32
+
33
+ class CronAddError(ValueError):
34
+ pass
35
+
36
+
37
+ def parse_cron_add(args: str) -> tuple[str, str, str]:
38
+ """Parse the body of ``/cron add`` into (name, schedule, prompt).
39
+
40
+ Grammar: ``<name> <schedule...> | <prompt>``
41
+ The ``|`` separates the (space-containing) schedule from the prompt.
42
+ """
43
+ if "|" not in args:
44
+ raise CronAddError("missing '|' separating the schedule from the prompt")
45
+ head, prompt = args.split("|", 1)
46
+ prompt = prompt.strip()
47
+ head_parts = head.split()
48
+ if len(head_parts) < 2:
49
+ raise CronAddError("expected: <name> <schedule> | <prompt>")
50
+ name = head_parts[0]
51
+ schedule = " ".join(head_parts[1:])
52
+ if not prompt:
53
+ raise CronAddError("the prompt is empty")
54
+ if not schedule:
55
+ raise CronAddError("the schedule is empty")
56
+ return name, schedule, prompt
nbclaw/config.py ADDED
@@ -0,0 +1,277 @@
1
+ """Configuration for the nbclaw daemon.
2
+
3
+ Settings come from three layers, lowest precedence first:
4
+
5
+ 1. Built-in defaults (this file).
6
+ 2. A TOML config file (``--config nbclaw.toml``).
7
+ 3. Command-line flags.
8
+
9
+ The TOML file mirrors the dataclass field names. A ``[swival]`` table is
10
+ forwarded verbatim as keyword arguments to ``swival.Session`` so the full
11
+ agent can be configured even for options nbclaw doesn't expose directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import tomllib
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Literal
21
+
22
+ # First line of every AGENTS.md we write. Lets us recognise (and safely
23
+ # overwrite) our own file without clobbering one a user put in their workspace.
24
+ MANAGED_MARKER = "<!-- managed by nbclaw — edits will be overwritten -->"
25
+
26
+ # Injected into the agent (as AGENTS.md in the workspace) so it behaves like a
27
+ # chat assistant, not a coding agent that "keeps going until the task is done".
28
+ # Without this, small local models tool-loop on trivial messages like "Hi".
29
+ DEFAULT_INSTRUCTIONS = """\
30
+ # nbclaw assistant
31
+
32
+ You are a personal assistant reachable over Signal text messages. Replies are
33
+ read on a phone, so keep them short and conversational — no headings or long
34
+ lists unless asked.
35
+
36
+ ## Conversation
37
+
38
+ Every message is part of one ongoing conversation. Read each new message in the
39
+ context of everything said before it, and resolve follow-ups against earlier
40
+ turns instead of asking what they refer to. For example, if you just answered
41
+ "add 2 + 2" with "4" and the next message is "add 10", that means 4 + 10 = 14.
42
+ Only ask for clarification when the conversation genuinely gives you nothing to
43
+ go on.
44
+
45
+ ## Style
46
+
47
+ - For greetings, small talk, and general questions, just answer in a single
48
+ reply. Do NOT read files, run commands, or use any tools for these.
49
+ - Use tools only when the user clearly asks you to do something on this computer
50
+ (e.g. "list my files", "what's in ~/notes.txt", "run the tests"). Do exactly
51
+ that, then report the result briefly.
52
+ - Never go exploring on your own or keep working past what was asked. One
53
+ message in, one helpful reply out.
54
+ """
55
+
56
+
57
+ @dataclass
58
+ class InstructionsWrite:
59
+ """Outcome of :meth:`Config.write_instructions`."""
60
+
61
+ path: Path | None
62
+ action: Literal["written", "skipped", "disabled"]
63
+
64
+
65
+ @dataclass
66
+ class Config:
67
+ # --- Signal / signal-cli ---
68
+ signal_url: str = "http://127.0.0.1:3080"
69
+ # Senders allowed to drive the agent (E.164 numbers and/or UUIDs).
70
+ allow: list[str] = field(default_factory=list)
71
+ allow_all: bool = False
72
+ # Number to notify when the daemon comes online (optional). Note-to-Self works.
73
+ notify: str | None = None
74
+
75
+ # --- Model / provider ---
76
+ provider: str = "lmstudio"
77
+ model: str | None = None
78
+ base_url: str | None = None
79
+ api_key: str | None = None
80
+
81
+ # --- Agent behaviour ---
82
+ # When safe=True the agent is read-only (no shell commands, no edits).
83
+ safe: bool = False
84
+ # Backstop on the agent loop. Kept low: a chat reply rarely needs many turns,
85
+ # and a low cap means even a confused model returns *something* promptly.
86
+ max_turns: int = 20
87
+ # Agent persona/instructions, injected as the workspace AGENTS.md. None uses
88
+ # the built-in chat-assistant framing; set "" to inject nothing.
89
+ instructions: str | None = None
90
+ # MCP servers, in swival's format: {name: {command|url, args?, env?, headers?}}.
91
+ mcp_servers: dict[str, Any] = field(default_factory=dict)
92
+ # Extra kwargs forwarded verbatim to swival.Session (the [swival] TOML table).
93
+ swival: dict[str, Any] = field(default_factory=dict)
94
+
95
+ # --- State / workspace ---
96
+ state_dir: Path = field(default_factory=lambda: Path.home() / ".nbclaw")
97
+ workspace_dir: Path | None = None # defaults to state_dir/workspace
98
+
99
+ # Grace period (seconds) for an in-flight job to finish on shutdown before
100
+ # it's abandoned. TOML-configurable; rarely needs changing.
101
+ shutdown_timeout: float = 20.0
102
+
103
+ def resolved_workspace(self) -> Path:
104
+ return self.workspace_dir or (self.state_dir / "workspace")
105
+
106
+ def crons_path(self) -> Path:
107
+ return self.state_dir / "crons.json"
108
+
109
+ def effective_instructions(self) -> str:
110
+ """The AGENTS.md text to inject, or "" to inject nothing."""
111
+ return DEFAULT_INSTRUCTIONS if self.instructions is None else self.instructions
112
+
113
+ def _owns_agents_md(self, path: Path) -> bool:
114
+ """True if we may overwrite ``path``.
115
+
116
+ The default managed workspace is entirely ours. A custom workspace_dir
117
+ may be a real project, so there we only touch an AGENTS.md that doesn't
118
+ exist yet or that carries our marker (i.e. a previous run wrote it).
119
+ """
120
+ if self.workspace_dir is None:
121
+ return True
122
+ if not path.exists():
123
+ return True
124
+ try:
125
+ first_line = path.read_text().split("\n", 1)[0]
126
+ except OSError:
127
+ return False
128
+ return first_line.strip() == MANAGED_MARKER
129
+
130
+ def write_instructions(self) -> InstructionsWrite:
131
+ """Materialize the agent instructions as the workspace AGENTS.md.
132
+
133
+ swival appends AGENTS.md to its system prompt, so this is how we give the
134
+ agent its chat-assistant persona. To avoid clobbering a user's own
135
+ AGENTS.md when workspace_dir points at a real project, an existing file
136
+ we didn't write is left untouched.
137
+ """
138
+ text = self.effective_instructions().strip()
139
+ if not text:
140
+ return InstructionsWrite(None, "disabled")
141
+ path = self.resolved_workspace() / "AGENTS.md"
142
+ if not self._owns_agents_md(path):
143
+ return InstructionsWrite(path, "skipped")
144
+ path.parent.mkdir(parents=True, exist_ok=True)
145
+ path.write_text(f"{MANAGED_MARKER}\n{text}\n")
146
+ return InstructionsWrite(path, "written")
147
+
148
+ def session_kwargs(self) -> dict[str, Any]:
149
+ """Build the keyword arguments passed to ``swival.Session``."""
150
+ kwargs: dict[str, Any] = {
151
+ "base_dir": str(self.resolved_workspace()),
152
+ "provider": self.provider,
153
+ "max_turns": self.max_turns,
154
+ # We manage multi-turn context ourselves via long-lived Session objects;
155
+ # swival's own on-disk history/continue isn't what we want for a chat bot.
156
+ "history": False,
157
+ "continue_here": False,
158
+ }
159
+ if self.model:
160
+ kwargs["model"] = self.model
161
+ if self.base_url:
162
+ kwargs["base_url"] = self.base_url
163
+ if self.api_key:
164
+ kwargs["api_key"] = self.api_key
165
+ if self.mcp_servers:
166
+ kwargs["mcp_servers"] = self.mcp_servers
167
+ if self.safe:
168
+ kwargs.update(commands="none", files="none", read_guard=True)
169
+ else:
170
+ # Capable assistant: it can act when asked. Access is gated by the
171
+ # sender allowlist. We deliberately avoid yolo — its autonomy push
172
+ # makes small models tool-loop on plain chat; the AGENTS.md persona
173
+ # (see Config.write_instructions) keeps replies tool-free by default.
174
+ kwargs.update(commands="all", files="all")
175
+ # The [swival] table overrides anything above.
176
+ kwargs.update(self.swival)
177
+ return kwargs
178
+
179
+ def parser_session_kwargs(self) -> dict[str, Any]:
180
+ """Minimal, tool-free Session kwargs for a single JSON parse call."""
181
+ kwargs: dict[str, Any] = {
182
+ "provider": self.provider,
183
+ "commands": "none",
184
+ "files": "none",
185
+ "memory": False,
186
+ "history": False,
187
+ "no_skills": True,
188
+ "continue_here": False,
189
+ "max_turns": 2,
190
+ }
191
+ for key in ("model", "base_url", "api_key"):
192
+ value = getattr(self, key)
193
+ if value:
194
+ kwargs[key] = value
195
+ return kwargs
196
+
197
+
198
+ def build_config(argv: list[str] | None = None) -> Config:
199
+ parser = argparse.ArgumentParser(
200
+ prog="nbclaw",
201
+ description="No Bullshit Claw — a 24/7 Signal-driven Swival agent daemon.",
202
+ )
203
+ parser.add_argument("--config", type=Path, help="Path to a TOML config file.")
204
+ parser.add_argument("--signal-url", help="signal-cli HTTP daemon base URL.")
205
+ parser.add_argument(
206
+ "--allow",
207
+ action="append",
208
+ default=None,
209
+ metavar="NUMBER|UUID",
210
+ help="Allow this sender to drive the agent (repeatable).",
211
+ )
212
+ parser.add_argument(
213
+ "--allow-all",
214
+ action="store_true",
215
+ default=None,
216
+ help="Allow ALL senders (dangerous — the agent can run shell commands).",
217
+ )
218
+ parser.add_argument("--notify", help="Number to message when the daemon starts.")
219
+ parser.add_argument("--provider", help="swival provider (lmstudio, generic, ...).")
220
+ parser.add_argument("--model", help="Model id (e.g. ornith-1.0-9b).")
221
+ parser.add_argument("--base-url", help="Override the provider endpoint URL.")
222
+ parser.add_argument("--api-key", help="API key for the provider, if needed.")
223
+ parser.add_argument(
224
+ "--safe",
225
+ action="store_true",
226
+ default=None,
227
+ help="Read-only agent: no shell commands, no file edits.",
228
+ )
229
+ parser.add_argument("--max-turns", type=int, help="Max agent loop iterations.")
230
+ parser.add_argument(
231
+ "--state-dir", type=Path, help="Directory for crons + workspace."
232
+ )
233
+ parser.add_argument("--workspace-dir", type=Path, help="Agent working directory.")
234
+
235
+ args = parser.parse_args(argv)
236
+
237
+ cfg = Config()
238
+ if args.config:
239
+ with args.config.open("rb") as f:
240
+ data = tomllib.load(f)
241
+ swival_table = data.pop("swival", None)
242
+ for key, value in data.items():
243
+ if not hasattr(cfg, key):
244
+ raise SystemExit(f"Unknown config key: {key!r} in {args.config}")
245
+ if key in ("state_dir", "workspace_dir") and value is not None:
246
+ value = Path(value).expanduser()
247
+ setattr(cfg, key, value)
248
+ if isinstance(swival_table, dict):
249
+ cfg.swival = swival_table
250
+
251
+ # CLI overrides (only when explicitly provided).
252
+ if args.signal_url is not None:
253
+ cfg.signal_url = args.signal_url
254
+ if args.allow is not None:
255
+ cfg.allow = args.allow
256
+ if args.allow_all is not None:
257
+ cfg.allow_all = args.allow_all
258
+ if args.notify is not None:
259
+ cfg.notify = args.notify
260
+ if args.provider is not None:
261
+ cfg.provider = args.provider
262
+ if args.model is not None:
263
+ cfg.model = args.model
264
+ if args.base_url is not None:
265
+ cfg.base_url = args.base_url
266
+ if args.api_key is not None:
267
+ cfg.api_key = args.api_key
268
+ if args.safe is not None:
269
+ cfg.safe = args.safe
270
+ if args.max_turns is not None:
271
+ cfg.max_turns = args.max_turns
272
+ if args.state_dir is not None:
273
+ cfg.state_dir = args.state_dir.expanduser()
274
+ if args.workspace_dir is not None:
275
+ cfg.workspace_dir = args.workspace_dir.expanduser()
276
+
277
+ return cfg