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.
noah_code/config.py ADDED
@@ -0,0 +1,279 @@
1
+ """Layered configuration for Noah Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
+ try:
12
+ import tomllib
13
+ except ModuleNotFoundError: # pragma: no cover
14
+ import tomli as tomllib # type: ignore[no-redef]
15
+
16
+
17
+ PermissionAction = Literal["allow", "ask", "deny"]
18
+
19
+
20
+ class PermissionRule(BaseModel):
21
+ """Ordered permission rule; last match wins."""
22
+
23
+ category: str = "*"
24
+ pattern: str = "*"
25
+ action: PermissionAction = "ask"
26
+ reason: str = ""
27
+
28
+
29
+ class TracingConfig(BaseModel):
30
+ enabled: bool = True
31
+ jsonl_dir: str | None = None
32
+ viewer: bool = True
33
+
34
+
35
+ class SummarizationPolicy(BaseModel):
36
+ policy: Literal["token_budget", "none"] = "token_budget"
37
+ max_tokens: int | None = None
38
+ preserve_recent: int = 10
39
+ target_chars: int = 4000
40
+
41
+
42
+ class UIConfig(BaseModel):
43
+ show_reasoning: bool = False
44
+ markdown: bool = True
45
+ stream_shell: bool = True
46
+ frontend: Literal["tui", "console"] = "tui"
47
+
48
+
49
+ class UpdateConfig(BaseModel):
50
+ auto_install: bool = True
51
+ interval_hours: int = Field(default=24, ge=1, le=24 * 30)
52
+ check_timeout_seconds: float = Field(default=3.0, gt=0, le=30)
53
+
54
+
55
+ class NoahCodeConfig(BaseModel):
56
+ """Resolved configuration for a noah-code run."""
57
+
58
+ model: str = "gpt-4o-mini"
59
+ lightweight_model: str | None = None
60
+ max_iterations: int = 40
61
+ cell_timeout: float = 120.0
62
+ command_timeout: float = 60.0
63
+ summarization: SummarizationPolicy = Field(default_factory=SummarizationPolicy)
64
+ tracing: TracingConfig = Field(default_factory=TracingConfig)
65
+ session_dir: Path = Field(
66
+ default_factory=lambda: Path.home() / ".local" / "share" / "noah-code" / "sessions"
67
+ )
68
+ permission_rules: list[PermissionRule] = Field(default_factory=list)
69
+ auto_approve: bool = False
70
+ enabled_skills: list[str] = Field(default_factory=list)
71
+ mcp: dict[str, Any] = Field(default_factory=dict)
72
+ ui: UIConfig = Field(default_factory=UIConfig)
73
+ updates: UpdateConfig = Field(default_factory=UpdateConfig)
74
+ mode: Literal["build", "plan"] = "build"
75
+ max_file_bytes: int = 512_000
76
+ max_output_chars: int = 80_000
77
+ undo_blob_limit: int = 2_000_000
78
+ unsafe_inprocess_code_execution: bool = False
79
+
80
+ @field_validator("session_dir", mode="before")
81
+ @classmethod
82
+ def _coerce_path(cls, value: Any) -> Path:
83
+ return (
84
+ Path(value).expanduser()
85
+ if value is not None
86
+ else Path.home() / ".local/share/noah-code/sessions"
87
+ )
88
+
89
+
90
+ DEFAULT_PERMISSION_RULES: list[PermissionRule] = [
91
+ PermissionRule(category="read", pattern="*", action="allow", reason="reads allowed"),
92
+ PermissionRule(
93
+ category="read",
94
+ pattern="**/.env",
95
+ action="deny",
96
+ reason="secret env files denied",
97
+ ),
98
+ PermissionRule(
99
+ category="read",
100
+ pattern="**/.env.*",
101
+ action="deny",
102
+ reason="secret env files denied",
103
+ ),
104
+ PermissionRule(
105
+ category="read",
106
+ pattern="**/.env.example",
107
+ action="allow",
108
+ reason="example env files are safe",
109
+ ),
110
+ PermissionRule(
111
+ category="read",
112
+ pattern="**/*.pem",
113
+ action="deny",
114
+ reason="private keys denied",
115
+ ),
116
+ PermissionRule(
117
+ category="read",
118
+ pattern="**/*id_rsa*",
119
+ action="deny",
120
+ reason="private keys denied",
121
+ ),
122
+ PermissionRule(
123
+ category="read",
124
+ pattern="**/.git/**",
125
+ action="deny",
126
+ reason=".git internals denied",
127
+ ),
128
+ PermissionRule(
129
+ category="read",
130
+ pattern="**/noah-code/**/*.db",
131
+ action="deny",
132
+ reason="session databases denied",
133
+ ),
134
+ PermissionRule(category="edit", pattern="*", action="ask", reason="edits require approval"),
135
+ PermissionRule(category="bash", pattern="*", action="ask", reason="shell requires approval"),
136
+ PermissionRule(
137
+ category="bash",
138
+ pattern="git push*",
139
+ action="deny",
140
+ reason="push denied by default",
141
+ ),
142
+ PermissionRule(
143
+ category="bash",
144
+ pattern="git clean*",
145
+ action="deny",
146
+ reason="destructive git clean denied",
147
+ ),
148
+ PermissionRule(
149
+ category="bash",
150
+ pattern="git reset --hard*",
151
+ action="deny",
152
+ reason="destructive git reset denied",
153
+ ),
154
+ PermissionRule(
155
+ category="external_directory",
156
+ pattern="*",
157
+ action="ask",
158
+ reason="external paths require approval",
159
+ ),
160
+ PermissionRule(category="task", pattern="*", action="ask", reason="subagents require approval"),
161
+ PermissionRule(category="skill", pattern="*", action="ask", reason="skills require approval"),
162
+ PermissionRule(category="mcp", pattern="*", action="ask", reason="MCP requires approval"),
163
+ PermissionRule(category="lsp", pattern="*", action="allow", reason="LSP read-only by default"),
164
+ ]
165
+
166
+
167
+ def _user_config_path() -> Path:
168
+ return Path.home() / ".config" / "noah-code" / "config.toml"
169
+
170
+
171
+ def _project_config_path(workspace: Path) -> Path:
172
+ return workspace / ".noah-code" / "config.toml"
173
+
174
+
175
+ # Repository-controlled configuration must not be able to weaken the host's
176
+ # trust boundary. These settings are accepted only from user config, the
177
+ # environment, or explicit CLI overrides.
178
+ _USER_ONLY_CONFIG_KEYS = frozenset(
179
+ {
180
+ "auto_approve",
181
+ "enabled_skills",
182
+ "mcp",
183
+ "permission_rules",
184
+ "session_dir",
185
+ "tracing",
186
+ "unsafe_inprocess_code_execution",
187
+ "updates",
188
+ }
189
+ )
190
+
191
+
192
+ def _load_toml(path: Path) -> dict[str, Any]:
193
+ if not path.is_file():
194
+ return {}
195
+ with path.open("rb") as fh:
196
+ data = tomllib.load(fh)
197
+ return data if isinstance(data, dict) else {}
198
+
199
+
200
+ def _load_project_toml(path: Path) -> dict[str, Any]:
201
+ data = _load_toml(path)
202
+ return {key: value for key, value in data.items() if key not in _USER_ONLY_CONFIG_KEYS}
203
+
204
+
205
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
206
+ out = dict(base)
207
+ for key, value in override.items():
208
+ if key in out and isinstance(out[key], dict) and isinstance(value, dict):
209
+ out[key] = _deep_merge(out[key], value)
210
+ else:
211
+ out[key] = value
212
+ return out
213
+
214
+
215
+ def _env_overrides() -> dict[str, Any]:
216
+ out: dict[str, Any] = {}
217
+ if model := os.environ.get("NOAH_CODE_MODEL"):
218
+ out["model"] = model
219
+ if light := os.environ.get("NOAH_CODE_LIGHTWEIGHT_MODEL"):
220
+ out["lightweight_model"] = light
221
+ if auto := os.environ.get("NOAH_CODE_AUTO"):
222
+ out["auto_approve"] = auto.lower() in {"1", "true", "yes", "on"}
223
+ if session_dir := os.environ.get("NOAH_CODE_SESSION_DIR"):
224
+ out["session_dir"] = session_dir
225
+ if mode := os.environ.get("NOAH_CODE_MODE"):
226
+ out["mode"] = mode
227
+ if unsafe := os.environ.get("NOAH_CODE_UNSAFE_INPROCESS"):
228
+ out["unsafe_inprocess_code_execution"] = unsafe.lower() in {"1", "true", "yes", "on"}
229
+ if auto_update := os.environ.get("NOAH_CODE_AUTO_UPDATE"):
230
+ out["updates"] = {
231
+ "auto_install": auto_update.lower() in {"1", "true", "yes", "on"}
232
+ }
233
+ return out
234
+
235
+
236
+ def _normalize_raw(raw: dict[str, Any]) -> dict[str, Any]:
237
+ data = dict(raw)
238
+ if "permission_rules" in data and isinstance(data["permission_rules"], list):
239
+ data["permission_rules"] = [
240
+ rule if isinstance(rule, PermissionRule) else PermissionRule.model_validate(rule)
241
+ for rule in data["permission_rules"]
242
+ ]
243
+ if "summarization" in data and isinstance(data["summarization"], dict):
244
+ data["summarization"] = SummarizationPolicy.model_validate(data["summarization"])
245
+ if "tracing" in data and isinstance(data["tracing"], dict):
246
+ data["tracing"] = TracingConfig.model_validate(data["tracing"])
247
+ if "ui" in data and isinstance(data["ui"], dict):
248
+ data["ui"] = UIConfig.model_validate(data["ui"])
249
+ if "updates" in data and isinstance(data["updates"], dict):
250
+ data["updates"] = UpdateConfig.model_validate(data["updates"])
251
+ return data
252
+
253
+
254
+ def load_config(
255
+ workspace: Path,
256
+ *,
257
+ cli_overrides: dict[str, Any] | None = None,
258
+ ) -> NoahCodeConfig:
259
+ """Load config with precedence: defaults < user < project < env < CLI."""
260
+ merged: dict[str, Any] = {
261
+ "permission_rules": [r.model_dump() for r in DEFAULT_PERMISSION_RULES],
262
+ }
263
+ merged = _deep_merge(merged, _load_toml(_user_config_path()))
264
+ merged = _deep_merge(merged, _load_project_toml(_project_config_path(workspace)))
265
+ merged = _deep_merge(merged, _env_overrides())
266
+ if cli_overrides:
267
+ cleaned = {k: v for k, v in cli_overrides.items() if v is not None}
268
+ merged = _deep_merge(merged, cleaned)
269
+ return NoahCodeConfig.model_validate(_normalize_raw(merged))
270
+
271
+
272
+ def config_sources(workspace: Path) -> dict[str, Path | None]:
273
+ """Return config file locations for diagnostics."""
274
+ user = _user_config_path()
275
+ project = _project_config_path(workspace)
276
+ return {
277
+ "user": user if user.is_file() else None,
278
+ "project": project if project.is_file() else None,
279
+ }
@@ -0,0 +1,103 @@
1
+ """User and project markdown slash commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ try:
11
+ import tomllib
12
+ except ModuleNotFoundError: # pragma: no cover
13
+ import tomli as tomllib # type: ignore[no-redef]
14
+
15
+
16
+ _FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.DOTALL)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CustomCommand:
21
+ name: str
22
+ description: str
23
+ body: str
24
+ mode: str | None = None
25
+ model: str | None = None
26
+ source: str = ""
27
+
28
+ def render(self, arguments: str) -> str:
29
+ """Expand $ARGUMENTS and $1..$9 positional placeholders."""
30
+ parts = _split_args(arguments)
31
+ text = self.body
32
+ text = text.replace("$ARGUMENTS", arguments.strip())
33
+ for i, part in enumerate(parts, start=1):
34
+ text = text.replace(f"${i}", part)
35
+ # Clear unused numbered placeholders.
36
+ text = re.sub(r"\$[1-9]\b", "", text)
37
+ return text.strip()
38
+
39
+
40
+ def _split_args(arguments: str) -> list[str]:
41
+ import shlex
42
+
43
+ try:
44
+ return shlex.split(arguments)
45
+ except ValueError:
46
+ return arguments.split()
47
+
48
+
49
+ def _parse_frontmatter(raw: str) -> tuple[dict[str, Any], str]:
50
+ match = _FRONTMATTER.match(raw)
51
+ if not match:
52
+ return {}, raw
53
+ meta_raw, body = match.group(1), match.group(2)
54
+ meta: dict[str, Any] = {}
55
+ # Prefer YAML-like simple key: value lines; fall back to TOML table.
56
+ for line in meta_raw.splitlines():
57
+ line = line.strip()
58
+ if not line or line.startswith("#"):
59
+ continue
60
+ if ":" in line:
61
+ key, _, val = line.partition(":")
62
+ meta[key.strip()] = val.strip().strip("\"'")
63
+ if not meta:
64
+ try:
65
+ parsed = tomllib.loads(meta_raw)
66
+ if isinstance(parsed, dict):
67
+ meta = parsed
68
+ except Exception: # noqa: BLE001
69
+ meta = {}
70
+ return meta, body
71
+
72
+
73
+ def load_commands_from_dir(directory: Path, *, source: str) -> dict[str, CustomCommand]:
74
+ out: dict[str, CustomCommand] = {}
75
+ if not directory.is_dir():
76
+ return out
77
+ for path in sorted(directory.glob("*.md")):
78
+ name = path.stem.strip().lower().lstrip("/")
79
+ if not name or name.startswith("."):
80
+ continue
81
+ try:
82
+ raw = path.read_text(encoding="utf-8")
83
+ except OSError:
84
+ continue
85
+ meta, body = _parse_frontmatter(raw)
86
+ out[name] = CustomCommand(
87
+ name=name,
88
+ description=str(meta.get("description") or name),
89
+ body=body,
90
+ mode=(str(meta["mode"]).lower() if meta.get("mode") else None),
91
+ model=str(meta["model"]) if meta.get("model") else None,
92
+ source=f"{source}:{path.name}",
93
+ )
94
+ return out
95
+
96
+
97
+ def discover_custom_commands(workspace: Path) -> dict[str, CustomCommand]:
98
+ """Project commands override user commands with the same name."""
99
+ user_dir = Path.home() / ".config" / "noah-code" / "commands"
100
+ project_dir = workspace / ".noah-code" / "commands"
101
+ commands = load_commands_from_dir(user_dir, source="user")
102
+ commands.update(load_commands_from_dir(project_dir, source="project"))
103
+ return commands
@@ -0,0 +1,132 @@
1
+ """Bridge NOOA EventManager events into HostEvent for UI streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from noah_code.events import HostEvent, HostEventKind
9
+
10
+ Unsubscribe = Callable[[], None]
11
+ EmitFn = Callable[[HostEvent], None]
12
+
13
+
14
+ def install_event_bridge(agent: Any, emit: EmitFn) -> list[Unsubscribe]:
15
+ """Subscribe to agent.event_manager and forward useful events to the UI.
16
+
17
+ Returns unsubscribe callables (call all on host close / session switch).
18
+ """
19
+ em = agent.event_manager
20
+ unsubs: list[Unsubscribe] = []
21
+
22
+ def on_tool_call(event: Any) -> None:
23
+ # Fresh ToolCallEvent has result=None; updates do not re-emit.
24
+ if getattr(event, "result", None) is not None:
25
+ return
26
+ name = getattr(event, "name", "tool")
27
+ args = getattr(event, "arguments", {}) or {}
28
+ preview = ""
29
+ if name == "execute_python":
30
+ code = str(args.get("code", ""))
31
+ preview = code.strip().splitlines()[0][:80] if code.strip() else ""
32
+ text = f"execute_python{(': ' + preview) if preview else ''}"
33
+ else:
34
+ text = f"{name}({_brief_args(args)})"
35
+ emit(HostEvent(HostEventKind.TOOL_START, text, meta={"tool": name}))
36
+
37
+ def on_python_output(event: Any) -> None:
38
+ status = str(getattr(event, "execution_status", "") or "")
39
+ err = (getattr(event, "error", "") or "").strip()
40
+ stdout = (getattr(event, "stdout", "") or "").strip()
41
+ stderr = (getattr(event, "stderr", "") or "").strip()
42
+ parts = [f"code cell {status}".strip()]
43
+ if err:
44
+ parts.append(err[:200])
45
+ elif stderr:
46
+ parts.append(stderr[:200])
47
+ elif stdout:
48
+ line = stdout.splitlines()[0][:80]
49
+ parts.append(line)
50
+ emit(
51
+ HostEvent(
52
+ HostEventKind.TOOL_FINISH,
53
+ " · ".join(p for p in parts if p),
54
+ meta={"kind": "python_output"},
55
+ )
56
+ )
57
+ # Stream truncated stdout/stderr as shell-like chunks when present.
58
+ if stdout and len(stdout) > 0:
59
+ emit(
60
+ HostEvent(
61
+ HostEventKind.SHELL_CHUNK,
62
+ _truncate(stdout, 4000),
63
+ meta={"stream": "stdout", "source": "codeact"},
64
+ )
65
+ )
66
+ if stderr:
67
+ emit(
68
+ HostEvent(
69
+ HostEventKind.SHELL_CHUNK,
70
+ _truncate(stderr, 2000),
71
+ meta={"stream": "stderr", "source": "codeact"},
72
+ )
73
+ )
74
+
75
+ def on_error(event: Any) -> None:
76
+ content = str(getattr(event, "content", event) or "")
77
+ if content:
78
+ emit(HostEvent(HostEventKind.ERROR, content))
79
+
80
+ def on_llm_start(event: Any) -> None:
81
+ method = getattr(event, "method_name", "")
82
+ turn = getattr(event, "turn_number", "")
83
+ emit(
84
+ HostEvent(
85
+ HostEventKind.STATUS,
86
+ f"llm · {method} turn {turn}",
87
+ meta={"kind": "llm_start"},
88
+ )
89
+ )
90
+
91
+ def on_llm_end(event: Any) -> None:
92
+ ok = getattr(event, "success", True)
93
+ emit(
94
+ HostEvent(
95
+ HostEventKind.STATUS,
96
+ f"llm · {'ok' if ok else 'failed'}",
97
+ meta={"kind": "llm_end"},
98
+ )
99
+ )
100
+
101
+ def on_summary(event: Any) -> None:
102
+ text = str(getattr(event, "content", "") or getattr(event, "summary", "") or "")
103
+ if text:
104
+ emit(HostEvent(HostEventKind.SUMMARY, _truncate(text, 2000)))
105
+
106
+ for etype, handler in (
107
+ ("ToolCallEvent", on_tool_call),
108
+ ("PythonOutput", on_python_output),
109
+ ("Error", on_error),
110
+ ("LLMCallStart", on_llm_start),
111
+ ("LLMCallEnd", on_llm_end),
112
+ ("Summary", on_summary),
113
+ ):
114
+ try:
115
+ unsubs.append(em.on(etype, handler))
116
+ except Exception: # noqa: BLE001 - event type may be unregistered
117
+ continue
118
+
119
+ return unsubs
120
+
121
+
122
+ def _brief_args(args: dict[str, Any]) -> str:
123
+ if not args:
124
+ return ""
125
+ keys = list(args.keys())[:3]
126
+ return ", ".join(f"{k}=…" for k in keys)
127
+
128
+
129
+ def _truncate(text: str, limit: int) -> str:
130
+ if len(text) <= limit:
131
+ return text
132
+ return text[: limit // 2] + "\n…\n" + text[-(limit // 2) :]
noah_code/events.py ADDED
@@ -0,0 +1,27 @@
1
+ """Host-facing event types for UI rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+
10
+ class HostEventKind(StrEnum):
11
+ MESSAGE = "message"
12
+ REASONING = "reasoning"
13
+ TOOL_START = "tool_start"
14
+ TOOL_FINISH = "tool_finish"
15
+ SHELL_CHUNK = "shell_chunk"
16
+ ERROR = "error"
17
+ SUMMARY = "summary"
18
+ APPROVAL = "approval"
19
+ STATUS = "status"
20
+ STOP = "stop"
21
+
22
+
23
+ @dataclass
24
+ class HostEvent:
25
+ kind: HostEventKind
26
+ text: str = ""
27
+ meta: dict[str, Any] = field(default_factory=dict)