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/sessions.py ADDED
@@ -0,0 +1,157 @@
1
+ """Session store: one SQLite DB per session via SQLiteStorageManager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ import uuid
9
+ from dataclasses import asdict, dataclass, field
10
+ from pathlib import Path
11
+
12
+ from nooa.storage import SQLiteStorageManager
13
+
14
+ from noah_code.workspace import Workspace
15
+
16
+
17
+ class SessionError(RuntimeError):
18
+ """Session load/create failure."""
19
+
20
+
21
+ @dataclass
22
+ class SessionMeta:
23
+ session_id: str
24
+ workspace_path: str
25
+ workspace_identity: str
26
+ title: str = "untitled"
27
+ mode: str = "build"
28
+ model: str = "gpt-4o-mini"
29
+ created_at: float = field(default_factory=time.time)
30
+ updated_at: float = field(default_factory=time.time)
31
+ permission_rules: list[dict] = field(default_factory=list)
32
+ journal: dict = field(default_factory=dict)
33
+ todos: dict = field(default_factory=dict)
34
+
35
+ def to_json(self) -> str:
36
+ return json.dumps(asdict(self), indent=2)
37
+
38
+ @classmethod
39
+ def from_json(cls, raw: str) -> SessionMeta:
40
+ return cls(**json.loads(raw))
41
+
42
+
43
+ class SessionStore:
44
+ """Manage session directories and SQLite storage managers."""
45
+
46
+ def __init__(self, session_dir: Path) -> None:
47
+ self.session_dir = session_dir.expanduser().resolve()
48
+ self.session_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
49
+ self.session_dir.chmod(0o700)
50
+
51
+ def _session_path(self, session_id: str) -> Path:
52
+ if re.fullmatch(r"[0-9a-f]{12}", session_id) is None:
53
+ raise SessionError(f"invalid session id: {session_id}")
54
+ path = (self.session_dir / session_id).resolve()
55
+ if path.parent != self.session_dir:
56
+ raise SessionError(f"session path escapes session directory: {session_id}")
57
+ return path
58
+
59
+ def _meta_path(self, session_id: str) -> Path:
60
+ return self._session_path(session_id) / "meta.json"
61
+
62
+ def _db_path(self, session_id: str) -> Path:
63
+ return self._session_path(session_id) / "session.db"
64
+
65
+ def create(self, workspace: Workspace, *, model: str, mode: str = "build") -> SessionMeta:
66
+ session_id = uuid.uuid4().hex[:12]
67
+ path = self._session_path(session_id)
68
+ path.mkdir(parents=True, exist_ok=False, mode=0o700)
69
+ meta = SessionMeta(
70
+ session_id=session_id,
71
+ workspace_path=str(workspace.root),
72
+ workspace_identity=workspace.identity,
73
+ model=model,
74
+ mode=mode,
75
+ )
76
+ self.save_meta(meta)
77
+ return meta
78
+
79
+ def save_meta(self, meta: SessionMeta) -> None:
80
+ meta.updated_at = time.time()
81
+ path = self._meta_path(meta.session_id)
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ path.write_text(meta.to_json())
84
+ path.chmod(0o600)
85
+
86
+ def load_meta(self, session_id: str) -> SessionMeta:
87
+ path = self._meta_path(session_id)
88
+ if not path.is_file():
89
+ raise SessionError(f"session not found: {session_id}")
90
+ try:
91
+ meta = SessionMeta.from_json(path.read_text())
92
+ except (json.JSONDecodeError, TypeError, KeyError) as exc:
93
+ raise SessionError(
94
+ f"damaged session metadata for {session_id}: {exc}. "
95
+ f"Delete with: noah-code sessions delete {session_id}"
96
+ ) from exc
97
+ if meta.session_id != session_id:
98
+ raise SessionError(
99
+ f"damaged session metadata for {session_id}: embedded id is {meta.session_id!r}"
100
+ )
101
+ return meta
102
+
103
+ def open_storage(self, session_id: str) -> SQLiteStorageManager:
104
+ db = self._db_path(session_id)
105
+ db.parent.mkdir(parents=True, exist_ok=True)
106
+ try:
107
+ storage = SQLiteStorageManager(db)
108
+ if db.exists():
109
+ db.chmod(0o600)
110
+ return storage
111
+ except Exception as exc: # noqa: BLE001 - surface recovery path
112
+ raise SessionError(
113
+ f"cannot open session database {db}: {exc}. "
114
+ f"If damaged, delete with: noah-code sessions delete {session_id}"
115
+ ) from exc
116
+
117
+ def list_sessions(self, workspace: Workspace | None = None) -> list[SessionMeta]:
118
+ items: list[SessionMeta] = []
119
+ for child in sorted(
120
+ self.session_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True
121
+ ):
122
+ if not child.is_dir():
123
+ continue
124
+ meta_path = child / "meta.json"
125
+ if not meta_path.is_file():
126
+ continue
127
+ try:
128
+ meta = SessionMeta.from_json(meta_path.read_text())
129
+ except (json.JSONDecodeError, TypeError, KeyError):
130
+ continue
131
+ if (
132
+ meta.session_id != child.name
133
+ or re.fullmatch(r"[0-9a-f]{12}", meta.session_id) is None
134
+ ):
135
+ continue
136
+ if workspace and meta.workspace_identity != workspace.identity:
137
+ continue
138
+ items.append(meta)
139
+ return items
140
+
141
+ def latest_for_workspace(self, workspace: Workspace) -> SessionMeta | None:
142
+ sessions = self.list_sessions(workspace)
143
+ return sessions[0] if sessions else None
144
+
145
+ def delete(self, session_id: str) -> None:
146
+ import shutil
147
+
148
+ path = self._session_path(session_id)
149
+ if not path.exists():
150
+ raise SessionError(f"session not found: {session_id}")
151
+ shutil.rmtree(path)
152
+
153
+ def verify_workspace(self, meta: SessionMeta, workspace: Workspace) -> None:
154
+ if meta.workspace_identity != workspace.identity:
155
+ raise SessionError(
156
+ f"session {meta.session_id} belongs to {meta.workspace_path}, not {workspace.root}"
157
+ )
@@ -0,0 +1,51 @@
1
+ """Skill discovery helpers for Noah Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from noah_code.config import NoahCodeConfig
9
+
10
+
11
+ def skill_dirs(workspace: Path) -> list[Path]:
12
+ return [
13
+ Path.home() / ".config" / "noah-code" / "skills",
14
+ workspace / ".noah-code" / "skills",
15
+ workspace / "skills",
16
+ ]
17
+
18
+
19
+ def install_skills(agent: Any, workspace: Path, config: NoahCodeConfig) -> str:
20
+ """Attach SkillRegistry, discover dirs, activate configured patterns.
21
+
22
+ Returns a short status string for diagnostics.
23
+ """
24
+ try:
25
+ from nooa.skill_registry import SkillRegistry
26
+ except ImportError:
27
+ return "SkillRegistry unavailable"
28
+
29
+ registry = SkillRegistry(agent)
30
+ agent.skills = registry
31
+ found: list[str] = []
32
+ existing = [d for d in skill_dirs(workspace) if d.is_dir()]
33
+ if existing:
34
+ registry.discover_skills_dirs(existing)
35
+ found = list(registry.discovered())
36
+
37
+ # Activation is opt-in from trusted user configuration. Repository config is
38
+ # not allowed to set enabled_skills (see config._USER_ONLY_CONFIG_KEYS).
39
+ patterns = list(config.enabled_skills)
40
+ try:
41
+ if patterns:
42
+ registry.activate(patterns)
43
+ approved = getattr(agent, "_sandbox_approved_roots", None)
44
+ if isinstance(approved, set):
45
+ for name in registry.activated():
46
+ attr = registry._attr_map.get(name) # noqa: SLF001 - registry has no public map
47
+ if attr:
48
+ approved.add(attr)
49
+ except Exception as exc: # noqa: BLE001
50
+ return f"skills discovered={len(found)} activate_error={exc}"
51
+ return f"skills discovered={len(found)} activated={len(registry.activated())}"
noah_code/snapshots.py ADDED
@@ -0,0 +1,313 @@
1
+ """File mutation journal for safe undo/redo of WorkspaceTools edits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+
14
+ def _sha256(data: bytes) -> str:
15
+ return hashlib.sha256(data).hexdigest()
16
+
17
+
18
+ @dataclass
19
+ class FileMutation:
20
+ id: str
21
+ path: str
22
+ existed_before: bool
23
+ pre_hash: str | None
24
+ post_hash: str | None
25
+ pre_bytes: bytes | None
26
+ mode: int | None
27
+ turn_id: str
28
+ timestamp: float
29
+ post_bytes: bytes | None = None
30
+ post_mode: int | None = None
31
+
32
+
33
+ @dataclass
34
+ class TurnJournal:
35
+ turn_id: str
36
+ mutations: list[FileMutation] = field(default_factory=list)
37
+ shell_may_bypass: bool = False
38
+
39
+
40
+ class SnapshotJournal:
41
+ """Journal WorkspaceTools mutations; refuse concurrent overwrites."""
42
+
43
+ def __init__(self, *, blob_limit: int = 2_000_000) -> None:
44
+ self.blob_limit = blob_limit
45
+ self._turns: list[TurnJournal] = []
46
+ self._redo: list[TurnJournal] = []
47
+ self._current: TurnJournal | None = None
48
+
49
+ def begin_turn(self) -> str:
50
+ turn_id = str(uuid.uuid4())
51
+ self._current = TurnJournal(turn_id=turn_id)
52
+ return turn_id
53
+
54
+ def end_turn(self) -> None:
55
+ if self._current and (self._current.mutations or self._current.shell_may_bypass):
56
+ self._turns.append(self._current)
57
+ self._redo.clear()
58
+ self._current = None
59
+
60
+ def mark_shell_bypass(self) -> None:
61
+ if self._current is not None:
62
+ self._current.shell_may_bypass = True
63
+
64
+ def record_preimage(self, path: Path) -> FileMutation:
65
+ existed = path.exists()
66
+ pre_bytes: bytes | None = None
67
+ pre_hash: str | None = None
68
+ mode: int | None = None
69
+ if existed:
70
+ data = path.read_bytes()
71
+ if len(data) <= self.blob_limit:
72
+ pre_bytes = data
73
+ pre_hash = _sha256(data)
74
+ mode = path.stat().st_mode
75
+ mut = FileMutation(
76
+ id=str(uuid.uuid4()),
77
+ path=str(path),
78
+ existed_before=existed,
79
+ pre_hash=pre_hash,
80
+ post_hash=None,
81
+ pre_bytes=pre_bytes,
82
+ mode=mode,
83
+ turn_id=self._current.turn_id if self._current else "none",
84
+ timestamp=time.time(),
85
+ )
86
+ if self._current is not None:
87
+ self._current.mutations.append(mut)
88
+ return mut
89
+
90
+ def record_postimage(self, mut: FileMutation, path: Path) -> None:
91
+ if path.exists():
92
+ data = path.read_bytes()
93
+ mut.post_hash = _sha256(data)
94
+ mut.post_bytes = data if len(data) <= self.blob_limit else None
95
+ mut.post_mode = path.stat().st_mode
96
+ else:
97
+ mut.post_hash = None
98
+ mut.post_bytes = None
99
+ mut.post_mode = None
100
+
101
+ def discard_mutation(self, mut: FileMutation) -> None:
102
+ """Forget a preimage when the corresponding edit failed."""
103
+ if self._current is not None:
104
+ self._current.mutations = [item for item in self._current.mutations if item is not mut]
105
+
106
+ def can_undo(self) -> bool:
107
+ return bool(self._turns)
108
+
109
+ def can_redo(self) -> bool:
110
+ return bool(self._redo)
111
+
112
+ def last_turn_reversible(self) -> bool:
113
+ if not self._turns:
114
+ return False
115
+ return not self._turns[-1].shell_may_bypass
116
+
117
+ def undo(self) -> TurnJournal:
118
+ if not self._turns:
119
+ raise RuntimeError("nothing to undo")
120
+ turn = self._turns[-1]
121
+ if turn.shell_may_bypass:
122
+ raise RuntimeError(
123
+ "this turn may include shell mutations outside the file journal; "
124
+ "full undo is not available"
125
+ )
126
+
127
+ # Preflight every mutation before touching the filesystem. For repeated
128
+ # edits to one path, simulate the intermediate hashes in reverse order.
129
+ simulated: dict[str, str | None] = {}
130
+ for mut in reversed(turn.mutations):
131
+ path = Path(mut.path)
132
+ actual = simulated.get(mut.path, self._path_hash(path))
133
+ if actual != mut.post_hash:
134
+ raise RuntimeError(
135
+ f"refuse undo: {mut.path} changed since the edit (concurrent modification)"
136
+ )
137
+ if mut.existed_before:
138
+ if mut.pre_bytes is None:
139
+ raise RuntimeError(f"cannot undo {mut.path}: preimage not stored (too large)")
140
+ if mut.pre_hash and _sha256(mut.pre_bytes) != mut.pre_hash:
141
+ raise RuntimeError(f"corrupt preimage for {mut.path}")
142
+ if mut.post_hash is not None:
143
+ if mut.post_bytes is None:
144
+ raise RuntimeError(f"cannot undo {mut.path}: postimage not stored (too large)")
145
+ if _sha256(mut.post_bytes) != mut.post_hash:
146
+ raise RuntimeError(f"corrupt postimage for {mut.path}")
147
+ simulated[mut.path] = mut.pre_hash if mut.existed_before else None
148
+
149
+ applied: list[FileMutation] = []
150
+ try:
151
+ for mut in reversed(turn.mutations):
152
+ self._write_state(
153
+ Path(mut.path),
154
+ mut.pre_bytes if mut.existed_before else None,
155
+ mut.mode,
156
+ )
157
+ applied.append(mut)
158
+ except Exception as exc:
159
+ for mut in reversed(applied):
160
+ self._write_state(Path(mut.path), mut.post_bytes, mut.post_mode)
161
+ raise RuntimeError(f"undo failed and was rolled back: {exc}") from exc
162
+
163
+ self._turns.pop()
164
+ self._redo.append(turn)
165
+ return turn
166
+
167
+ def redo(self) -> TurnJournal:
168
+ if not self._redo:
169
+ raise RuntimeError("nothing to redo")
170
+ turn = self._redo[-1]
171
+ simulated: dict[str, str | None] = {}
172
+ for mut in turn.mutations:
173
+ path = Path(mut.path)
174
+ expected = mut.pre_hash if mut.existed_before else None
175
+ actual = simulated.get(mut.path, self._path_hash(path))
176
+ if actual != expected:
177
+ raise RuntimeError(f"refuse redo: {mut.path} does not match undo state")
178
+ if mut.post_hash is not None:
179
+ if mut.post_bytes is None:
180
+ raise RuntimeError(f"cannot redo {mut.path}: postimage not stored (too large)")
181
+ if _sha256(mut.post_bytes) != mut.post_hash:
182
+ raise RuntimeError(f"corrupt postimage for {mut.path}")
183
+ simulated[mut.path] = mut.post_hash
184
+
185
+ applied: list[FileMutation] = []
186
+ try:
187
+ for mut in turn.mutations:
188
+ self._write_state(Path(mut.path), mut.post_bytes, mut.post_mode)
189
+ applied.append(mut)
190
+ except Exception as exc:
191
+ for mut in reversed(applied):
192
+ self._write_state(
193
+ Path(mut.path),
194
+ mut.pre_bytes if mut.existed_before else None,
195
+ mut.mode,
196
+ )
197
+ raise RuntimeError(f"redo failed and was rolled back: {exc}") from exc
198
+
199
+ self._redo.pop()
200
+ self._turns.append(turn)
201
+ return turn
202
+
203
+ def capture_post_bytes_before_undo(self, turn: TurnJournal) -> None:
204
+ # Backward compatibility for journals written before postimages were
205
+ # persisted. Only the latest mutation for each path can be reconstructed
206
+ # from the current filesystem state.
207
+ seen: set[str] = set()
208
+ for mut in reversed(turn.mutations):
209
+ if mut.path in seen:
210
+ continue
211
+ seen.add(mut.path)
212
+ path = Path(mut.path)
213
+ if mut.post_bytes is None and path.exists() and path.stat().st_size <= self.blob_limit:
214
+ mut.post_bytes = path.read_bytes()
215
+ mut.post_mode = path.stat().st_mode
216
+
217
+ @staticmethod
218
+ def _path_hash(path: Path) -> str | None:
219
+ return _sha256(path.read_bytes()) if path.exists() else None
220
+
221
+ @staticmethod
222
+ def _write_state(path: Path, data: bytes | None, mode: int | None) -> None:
223
+ if data is None:
224
+ if path.exists():
225
+ path.unlink()
226
+ return
227
+ path.parent.mkdir(parents=True, exist_ok=True)
228
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
229
+ try:
230
+ with os.fdopen(fd, "wb") as fh:
231
+ fh.write(data)
232
+ fh.flush()
233
+ os.fsync(fh.fileno())
234
+ if mode is not None:
235
+ Path(temp_name).chmod(mode)
236
+ os.replace(temp_name, path)
237
+ finally:
238
+ with __import__("contextlib").suppress(FileNotFoundError):
239
+ Path(temp_name).unlink()
240
+
241
+ def to_dict(self) -> dict:
242
+ return {
243
+ "turns": [self._turn_to_dict(t) for t in self._turns],
244
+ "redo": [self._turn_to_dict(t) for t in self._redo],
245
+ }
246
+
247
+ def load_dict(self, data: dict | None) -> None:
248
+ if not data:
249
+ self._turns = []
250
+ self._redo = []
251
+ return
252
+ self._turns = [self._turn_from_dict(t) for t in data.get("turns", [])]
253
+ self._redo = [self._turn_from_dict(t) for t in data.get("redo", [])]
254
+
255
+ @staticmethod
256
+ def _turn_to_dict(turn: TurnJournal) -> dict:
257
+ return {
258
+ "turn_id": turn.turn_id,
259
+ "shell_may_bypass": turn.shell_may_bypass,
260
+ "mutations": [
261
+ {
262
+ "id": m.id,
263
+ "path": m.path,
264
+ "existed_before": m.existed_before,
265
+ "pre_hash": m.pre_hash,
266
+ "post_hash": m.post_hash,
267
+ "pre_bytes_b64": (
268
+ __import__("base64").b64encode(m.pre_bytes).decode()
269
+ if m.pre_bytes is not None
270
+ else None
271
+ ),
272
+ "post_bytes_b64": (
273
+ __import__("base64").b64encode(m.post_bytes).decode()
274
+ if m.post_bytes is not None
275
+ else None
276
+ ),
277
+ "mode": m.mode,
278
+ "post_mode": m.post_mode,
279
+ "turn_id": m.turn_id,
280
+ "timestamp": m.timestamp,
281
+ }
282
+ for m in turn.mutations
283
+ ],
284
+ }
285
+
286
+ @staticmethod
287
+ def _turn_from_dict(data: dict) -> TurnJournal:
288
+ import base64
289
+
290
+ muts = []
291
+ for m in data.get("mutations", []):
292
+ pre = m.get("pre_bytes_b64")
293
+ post = m.get("post_bytes_b64")
294
+ muts.append(
295
+ FileMutation(
296
+ id=m["id"],
297
+ path=m["path"],
298
+ existed_before=m["existed_before"],
299
+ pre_hash=m.get("pre_hash"),
300
+ post_hash=m.get("post_hash"),
301
+ pre_bytes=base64.b64decode(pre) if pre else None,
302
+ mode=m.get("mode"),
303
+ turn_id=m["turn_id"],
304
+ timestamp=m.get("timestamp", 0.0),
305
+ post_bytes=base64.b64decode(post) if post else None,
306
+ post_mode=m.get("post_mode"),
307
+ )
308
+ )
309
+ return TurnJournal(
310
+ turn_id=data["turn_id"],
311
+ mutations=muts,
312
+ shell_may_bypass=bool(data.get("shell_may_bypass")),
313
+ )
@@ -0,0 +1,6 @@
1
+ """Re-export workspace and git tools."""
2
+
3
+ from noah_code.tools.git_tools import GitTools
4
+ from noah_code.tools.workspace_tools import WorkspaceTools
5
+
6
+ __all__ = ["GitTools", "WorkspaceTools"]
@@ -0,0 +1,44 @@
1
+ """Narrow git helpers - status/diff/log only by default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from nooa import Skill, spec
8
+
9
+ from noah_code.tools.workspace_tools import WorkspaceTools
10
+
11
+
12
+ class GitTools(Skill):
13
+ """Read-oriented git helpers. Mutating git still goes through workspace.run with policy."""
14
+
15
+ def __init__(self, workspace_tools: WorkspaceTools) -> None:
16
+ super().__init__()
17
+ self._ws = workspace_tools
18
+
19
+ async def status(self) -> str:
20
+ """Return ``git status --short --branch`` output."""
21
+ result = await self._ws.run_trusted_readonly("git status --short --branch")
22
+ return result.stdout or result.stderr
23
+
24
+ async def diff(
25
+ self,
26
+ path: Annotated[str | None, spec(description="Optional path limit")] = None,
27
+ ) -> str:
28
+ """Return ``git diff`` (unstaged + staged summary via --stat if no path)."""
29
+ if path:
30
+ import shlex
31
+
32
+ cmd = f"git diff -- {shlex.quote(path)}"
33
+ else:
34
+ cmd = "git diff"
35
+ result = await self._ws.run_trusted_readonly(cmd)
36
+ return result.stdout or "(no diff)"
37
+
38
+ async def log(
39
+ self,
40
+ n: Annotated[int, spec(description="Number of commits")] = 5,
41
+ ) -> str:
42
+ """Return recent commit subjects."""
43
+ result = await self._ws.run_trusted_readonly(f"git log -n {int(n)} --oneline")
44
+ return result.stdout or "(no commits)"