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/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
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,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)"
|