noah-code 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.
@@ -0,0 +1,269 @@
1
+ """Permission-gated workspace tools wrapping ShellTools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ from nooa import Skill, hidden, spec
10
+ from nooa.tools.shell_tools import Match, ShellResult, ShellTools, StreamDone, StreamEvent
11
+
12
+ from noah_code.approvals import ApprovalBroker
13
+ from noah_code.permissions import PermissionCategory, PermissionDecision, PermissionEngine
14
+ from noah_code.snapshots import SnapshotJournal
15
+ from noah_code.workspace import Workspace, WorkspaceError
16
+
17
+
18
+ def _truncate(text: str, limit: int) -> str:
19
+ if len(text) <= limit:
20
+ return text
21
+ head = limit // 2
22
+ tail = limit - head - 40
23
+ return (
24
+ text[:head]
25
+ + f"\n...[{len(text) - head - max(tail, 0)} chars truncated]...\n"
26
+ + text[-max(tail, 0) :]
27
+ )
28
+
29
+
30
+ class WorkspaceTools(Skill):
31
+ """Read, search, edit, and run commands inside the active workspace.
32
+
33
+ All mutating operations go through the permission engine. Prefer
34
+ Match-based ``replace`` over rewriting whole files. Paths are
35
+ canonicalized and must remain inside the workspace unless
36
+ ``external_directory`` is approved.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ workspace: Workspace,
42
+ shell: ShellTools,
43
+ engine: PermissionEngine,
44
+ approvals: ApprovalBroker,
45
+ journal: SnapshotJournal,
46
+ *,
47
+ max_output_chars: int = 80_000,
48
+ default_timeout: float = 60.0,
49
+ ) -> None:
50
+ super().__init__()
51
+ self._workspace = workspace
52
+ self._shell = shell
53
+ self._engine = engine
54
+ self._approvals = approvals
55
+ self._journal = journal
56
+ self._max_output = max_output_chars
57
+ self._default_timeout = default_timeout
58
+ self._on_shell_chunk: Any = None
59
+
60
+ def set_shell_chunk_handler(self, handler: Any) -> None:
61
+ """Optional callback(stream: str, text: str) for UI streaming."""
62
+ self._on_shell_chunk = handler
63
+
64
+ @hidden
65
+ @property
66
+ def raw_shell(self) -> ShellTools:
67
+ """Raw shell - host only; not for the model."""
68
+ return self._shell
69
+
70
+ async def read(
71
+ self,
72
+ path: Annotated[str, spec(description="File path relative to workspace")],
73
+ lines: Annotated[
74
+ tuple[int, int] | None,
75
+ spec(description="Optional (start, end) 1-indexed inclusive range"),
76
+ ] = None,
77
+ ) -> Match:
78
+ """Read a file (or line range) and return a Match anchor for editing."""
79
+ resolved = await self._authorize_path(path, PermissionCategory.READ)
80
+ rel = self._workspace.relpath(resolved)
81
+ result = await self._shell.read(rel, lines=lines)
82
+ if len(result.text) > self._max_output:
83
+ truncated = _truncate(result.text, self._max_output)
84
+ return Match(result.path, result.start, result.end, truncated)
85
+ return result
86
+
87
+ async def search(
88
+ self,
89
+ pattern: Annotated[str, spec(description="Regex or fixed pattern for ripgrep")],
90
+ path: Annotated[str, spec(description="Subdirectory or file to search")] = ".",
91
+ ) -> ShellResult:
92
+ """Search the workspace with ripgrep; results may include Match anchors."""
93
+ resolved = await self._authorize_path(path, PermissionCategory.READ)
94
+ import shlex
95
+
96
+ target = self._workspace.relpath(resolved) or "."
97
+ cmd = " ".join(
98
+ shlex.quote(a) for a in ["rg", "-n", "--no-heading", "-S", "--", pattern, target]
99
+ )
100
+ result = await self._shell.run(cmd, timeout=self._default_timeout)
101
+ return self._cap_shell_result(result)
102
+
103
+ async def list_files(
104
+ self,
105
+ pattern: Annotated[str, spec(description="Glob pattern")] = "**/*",
106
+ path: Annotated[str, spec(description="Subdirectory")] = ".",
107
+ ) -> list[str]:
108
+ """List files under path matching a glob (deterministic, no shell)."""
109
+ root = await self._authorize_path(path, PermissionCategory.READ)
110
+ matches = sorted(
111
+ str(p.relative_to(self._workspace.root)) for p in root.glob(pattern) if p.is_file()
112
+ )
113
+ if len(matches) > 2000:
114
+ return matches[:2000] + [f"...[{len(matches) - 2000} more]"]
115
+ return matches
116
+
117
+ async def replace(
118
+ self,
119
+ match: Annotated[Any, spec(description="Match from read()/search() or path string")],
120
+ new_text: Annotated[str, spec(description="Replacement text for Match form")] = "",
121
+ new: Annotated[str | None, spec(description="Path-form replacement")] = None,
122
+ ) -> Any:
123
+ """Edit via Match anchor (preferred) or unique string replacement."""
124
+ if isinstance(match, Match):
125
+ resolved = await self._authorize_path(match.path, PermissionCategory.EDIT)
126
+ mut = self._journal.record_preimage(resolved)
127
+ try:
128
+ result = await self._shell.replace(match, new_text)
129
+ except Exception:
130
+ self._journal.discard_mutation(mut)
131
+ raise
132
+ self._journal.record_postimage(mut, resolved)
133
+ return result
134
+ if isinstance(match, str):
135
+ resolved = await self._authorize_path(match, PermissionCategory.EDIT)
136
+ mut = self._journal.record_preimage(resolved)
137
+ try:
138
+ result = await self._shell.replace(match, new_text, new)
139
+ except Exception:
140
+ self._journal.discard_mutation(mut)
141
+ raise
142
+ self._journal.record_postimage(mut, resolved)
143
+ return result
144
+ raise TypeError("replace expects a Match or path string")
145
+
146
+ async def write_file(
147
+ self,
148
+ path: Annotated[str, spec(description="File path relative to workspace")],
149
+ content: Annotated[str, spec(description="Full file content")],
150
+ ) -> Any:
151
+ """Create or overwrite a file with content."""
152
+ resolved = await self._authorize_path(path, PermissionCategory.EDIT)
153
+ mut = self._journal.record_preimage(resolved)
154
+ try:
155
+ result = await self._shell.write_file(path, content)
156
+ except Exception:
157
+ self._journal.discard_mutation(mut)
158
+ raise
159
+ self._journal.record_postimage(mut, resolved)
160
+ return result
161
+
162
+ async def run(
163
+ self,
164
+ command: Annotated[str, spec(description="Shell command")],
165
+ stdin: Annotated[str | None, spec(description="Optional stdin payload")] = None,
166
+ timeout: Annotated[float | None, spec(description="Timeout seconds")] = None,
167
+ ) -> ShellResult:
168
+ """Run a command in the workspace shell session."""
169
+ decision = self._shell_decision(command)
170
+ await self._approvals.require(decision)
171
+ if not self._engine.is_readonly_command(command):
172
+ self._journal.mark_shell_bypass()
173
+ if self._on_shell_chunk is not None:
174
+ self._on_shell_chunk("status", f"$ {command}\n")
175
+ result = await self._shell.run(
176
+ command,
177
+ stdin=stdin,
178
+ timeout=timeout or self._default_timeout,
179
+ )
180
+ if self._on_shell_chunk is not None:
181
+ if result.stdout:
182
+ self._on_shell_chunk("stdout", result.stdout)
183
+ if result.stderr:
184
+ self._on_shell_chunk("stderr", result.stderr)
185
+ self._on_shell_chunk("status", f"[exit {result.returncode}]\n")
186
+ return self._cap_shell_result(result)
187
+
188
+ async def run_stream(
189
+ self,
190
+ command: Annotated[str, spec(description="Shell command")],
191
+ timeout: Annotated[float | None, spec(description="Timeout seconds")] = None,
192
+ ) -> AsyncIterator[StreamEvent | StreamDone]:
193
+ """Stream command output; same permission rules as run()."""
194
+ decision = self._shell_decision(command)
195
+ await self._approvals.require(decision)
196
+ if not self._engine.is_readonly_command(command):
197
+ self._journal.mark_shell_bypass()
198
+ if self._on_shell_chunk is not None:
199
+ self._on_shell_chunk("status", f"$ {command}\n")
200
+ async for event in self._shell.run_stream(
201
+ command, timeout=timeout or self._default_timeout
202
+ ):
203
+ if self._on_shell_chunk is not None and hasattr(event, "kind"):
204
+ self._on_shell_chunk(getattr(event, "kind", "stdout"), getattr(event, "text", ""))
205
+ yield event
206
+
207
+ def _shell_decision(self, command: str) -> PermissionDecision:
208
+ decision = self._engine.decide(PermissionCategory.BASH, command)
209
+ if decision.denied or not self._engine.is_uncertain_shell(command):
210
+ return decision
211
+ # Shell syntax is too ambiguous to auto-approve safely. Interactive
212
+ # sessions may still approve the exact command once.
213
+ action = "deny" if self._engine.auto_approve else "ask"
214
+ reason = (
215
+ "compound/uncertain shell commands cannot be auto-approved"
216
+ if action == "deny"
217
+ else "compound/uncertain shell command requires approval"
218
+ )
219
+ return PermissionDecision(
220
+ category=PermissionCategory.BASH,
221
+ target=command,
222
+ action=action,
223
+ matching_rule=decision.matching_rule,
224
+ reason=reason,
225
+ remember_pattern=decision.remember_pattern,
226
+ )
227
+
228
+ @hidden
229
+ async def run_trusted_readonly(self, command: str) -> ShellResult:
230
+ """Run a host-constructed, strictly read-only command without a model approval."""
231
+ if not self._engine.is_readonly_command(command):
232
+ raise PermissionError(f"trusted command is not read-only: {command}")
233
+ result = await self._shell.run(command, timeout=self._default_timeout)
234
+ return self._cap_shell_result(result)
235
+
236
+ def _cap_shell_result(self, result: ShellResult) -> ShellResult:
237
+ stdout = _truncate(result.stdout, self._max_output)
238
+ stderr = _truncate(result.stderr, self._max_output)
239
+ if stdout is result.stdout and stderr is result.stderr:
240
+ return result
241
+ return ShellResult(
242
+ stdout=stdout,
243
+ stderr=stderr,
244
+ returncode=result.returncode,
245
+ matches=result.matches,
246
+ )
247
+
248
+ async def _authorize_path(self, path: str, category: str) -> Path:
249
+ try:
250
+ resolved = self._workspace.resolve(path)
251
+ rel = str(resolved.relative_to(self._workspace.root))
252
+ decision = self._engine.decide(category, rel)
253
+ except WorkspaceError as exc:
254
+ abs_path = Path(path).expanduser()
255
+ if not abs_path.is_absolute():
256
+ abs_path = (self._workspace.root / path).resolve()
257
+ else:
258
+ abs_path = abs_path.resolve()
259
+ decision = self._engine.decide(PermissionCategory.EXTERNAL_DIRECTORY, str(abs_path))
260
+ await self._approvals.require(decision)
261
+ raise WorkspaceError(
262
+ f"path outside workspace requires dedicated external handling: {path}"
263
+ ) from exc
264
+ await self._approvals.require(decision)
265
+ return resolved
266
+
267
+ @hidden
268
+ async def close(self) -> None:
269
+ await self._shell.close()
@@ -0,0 +1,6 @@
1
+ """UI package for Noah Code hosts."""
2
+
3
+ from noah_code.ui.console import ConsoleUI
4
+ from noah_code.ui.protocol import HostUI
5
+
6
+ __all__ = ["ConsoleUI", "HostUI"]
@@ -0,0 +1,88 @@
1
+ """Line-oriented console renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import TextIO
8
+
9
+ from rich.console import Console
10
+ from rich.markdown import Markdown
11
+ from rich.panel import Panel
12
+
13
+ from noah_code.approvals import ApprovalChoice, ApprovalRequest
14
+ from noah_code.events import HostEvent, HostEventKind
15
+
16
+
17
+ class ConsoleUI:
18
+ """Simple line-oriented console client for the host."""
19
+
20
+ def __init__(self, *, markdown: bool = True, file: TextIO | None = None) -> None:
21
+ self.console = Console(file=file or sys.stdout)
22
+ self.markdown = markdown
23
+ self._status_line = ""
24
+ self._busy = False
25
+
26
+ def set_status(self, text: str) -> None:
27
+ self._status_line = text
28
+
29
+ def set_busy(self, busy: bool) -> None:
30
+ self._busy = busy
31
+
32
+ def render(self, event: HostEvent) -> None:
33
+ if event.kind == HostEventKind.MESSAGE:
34
+ if self.markdown:
35
+ self.console.print(Markdown(event.text))
36
+ else:
37
+ self.console.print(event.text)
38
+ elif event.kind == HostEventKind.REASONING:
39
+ self.console.print(f"[dim]thinking:[/dim] {event.text}")
40
+ elif event.kind == HostEventKind.TOOL_START:
41
+ self.console.print(f"[cyan]→[/cyan] {event.text}")
42
+ elif event.kind == HostEventKind.TOOL_FINISH:
43
+ self.console.print(f"[green]✓[/green] {event.text}")
44
+ elif event.kind == HostEventKind.SHELL_CHUNK:
45
+ stream = event.meta.get("stream", "stdout")
46
+ style = "red" if stream == "stderr" else "white"
47
+ self.console.print(event.text.rstrip("\n"), style=style, highlight=False)
48
+ elif event.kind == HostEventKind.ERROR:
49
+ self.console.print(f"[bold red]error:[/bold red] {event.text}")
50
+ elif event.kind == HostEventKind.SUMMARY:
51
+ self.console.print(Panel(event.text, title="summary", border_style="blue"))
52
+ elif event.kind == HostEventKind.STATUS:
53
+ self.console.print(f"[dim]{event.text}[/dim]")
54
+ elif event.kind == HostEventKind.STOP:
55
+ self.console.print(f"[bold]stop:[/bold] {event.text}")
56
+ else:
57
+ self.console.print(event.text)
58
+
59
+ async def ask_approval(self, request: ApprovalRequest) -> ApprovalChoice:
60
+ d = request.decision
61
+ self.console.print(
62
+ Panel(
63
+ f"[yellow]{d.category}[/yellow] {d.target}\n{d.reason}\n"
64
+ f"remember pattern: {d.remember_pattern}",
65
+ title=f"Approval {request.id[:8]}",
66
+ border_style="yellow",
67
+ )
68
+ )
69
+ self.console.print("[1] once [2] session [3] reject")
70
+ while True:
71
+ try:
72
+ choice = await asyncio.to_thread(input, "approve> ")
73
+ choice = choice.strip().lower()
74
+ except EOFError:
75
+ return ApprovalChoice.REJECT
76
+ if choice in {"1", "once", "o", "y", "yes"}:
77
+ return ApprovalChoice.ONCE
78
+ if choice in {"2", "session", "s"}:
79
+ return ApprovalChoice.SESSION
80
+ if choice in {"3", "reject", "r", "n", "no"}:
81
+ return ApprovalChoice.REJECT
82
+ self.console.print("Enter 1/2/3")
83
+
84
+ async def prompt(self, status: str) -> str | None:
85
+ try:
86
+ return await asyncio.to_thread(input, f"{status}> ")
87
+ except EOFError:
88
+ return None
@@ -0,0 +1,33 @@
1
+ """Host UI protocol - console and Textual implement this."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from noah_code.approvals import ApprovalChoice, ApprovalRequest
8
+ from noah_code.events import HostEvent
9
+
10
+
11
+ @runtime_checkable
12
+ class HostUI(Protocol):
13
+ """Thin UI client of AgentHost. No agent/permission logic here."""
14
+
15
+ def render(self, event: HostEvent) -> None:
16
+ """Display a host event to the user."""
17
+ ...
18
+
19
+ async def ask_approval(self, request: ApprovalRequest) -> ApprovalChoice:
20
+ """Prompt for an allow-once / session / reject decision."""
21
+ ...
22
+
23
+ async def prompt(self, status: str) -> str | None:
24
+ """Read the next user line. Return None on EOF/quit."""
25
+ ...
26
+
27
+ def set_status(self, text: str) -> None:
28
+ """Update status chrome (optional for console)."""
29
+ ...
30
+
31
+ def set_busy(self, busy: bool) -> None:
32
+ """Indicate whether a turn is in progress."""
33
+ ...
@@ -0,0 +1,9 @@
1
+ /* Optional reference stylesheet - App embeds CSS to avoid token clashes.
2
+ Do not use Textual reserved names ($panel, $primary, …) here. */
3
+
4
+ $nc-bg: #14161a;
5
+ $nc-surface: #1c1f26;
6
+ $nc-border: #2a303a;
7
+ $nc-fg: #e6e8eb;
8
+ $nc-muted: #8b939e;
9
+ $nc-accent: #5b9fd4;