webbee 0.2.2__tar.gz → 0.3.0__tar.gz
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.
- {webbee-0.2.2 → webbee-0.3.0}/CHANGELOG.md +21 -0
- {webbee-0.2.2 → webbee-0.3.0}/PKG-INFO +1 -1
- {webbee-0.2.2 → webbee-0.3.0}/pyproject.toml +1 -1
- webbee-0.3.0/src/webbee/__init__.py +1 -0
- webbee-0.3.0/src/webbee/checkpoints.py +213 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/commands.py +6 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/frames.py +9 -1
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/render.py +14 -2
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/repl.py +36 -3
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/session.py +5 -2
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/tools.py +110 -4
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/tui.py +20 -11
- webbee-0.3.0/tests/test_checkpoints.py +251 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_commands.py +25 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_marathon.py +17 -2
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_repl.py +7 -6
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_session.py +1 -1
- webbee-0.3.0/tests/test_tools.py +285 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_tui.py +44 -14
- webbee-0.2.2/src/webbee/__init__.py +0 -1
- webbee-0.2.2/tests/test_tools.py +0 -110
- {webbee-0.2.2 → webbee-0.3.0}/.github/workflows/publish.yml +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/.gitignore +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/LICENSE +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/README.md +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/install.sh +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/account.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/banner_art.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/cli.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/clipboard.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/config.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/details.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/events.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/embed.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/graph.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/models.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/query.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/service.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/store.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/watch.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/output_pane.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/repo.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/sessions.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/stream.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/src/webbee/update.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/__init__.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_account.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_cli.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_clipboard.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_config.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_details.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_events.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_freeze_fix.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_chunker.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_embed.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_graph.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_indexer.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_query.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_service.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_store.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_vectors.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_watch.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_packaging.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_render.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_repo.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_sessions.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_stream.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_tui_hardening.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_update.py +0 -0
- {webbee-0.2.2 → webbee-0.3.0}/tests/test_version.py +0 -0
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- **The agent plans in the open.** A live todo checklist (📋) rendered from the
|
|
6
|
+
kernel's todo facts — you see what Webbee intends and how far it's got.
|
|
7
|
+
- **Precise editing.** `edit_file` requires a unique match (or an explicit
|
|
8
|
+
`replace_all`), and a new atomic `multi_edit` applies coordinated changes
|
|
9
|
+
across files all-or-nothing.
|
|
10
|
+
- **The time machine.** Every change auto-checkpoints into a shadow git — your
|
|
11
|
+
own `.git` is never touched; `/checkpoints` lists them and `/rollback`
|
|
12
|
+
restores, and the agent can checkpoint/diff/rollback too. A wrong step is
|
|
13
|
+
now undoable.
|
|
14
|
+
|
|
15
|
+
## 0.2.3
|
|
16
|
+
|
|
17
|
+
- **Readable big numbers.** Token and credit counts in the toolbar now scale
|
|
18
|
+
(`1.5M tok`, `2M credits`) instead of an awkward `1500.0k`.
|
|
19
|
+
- **Esc really stops a turn now.** Previously only Ctrl-C worked; Esc now cancels
|
|
20
|
+
the running turn too, matching the "Esc/Ctrl-C to stop" hint.
|
|
21
|
+
- **The `❯` prompt takes your mode's colour** — cyan (default), purple (plan),
|
|
22
|
+
yellow (autopilot) — so the current mode is obvious from the input line.
|
|
23
|
+
|
|
3
24
|
## 0.2.2
|
|
4
25
|
|
|
5
26
|
- **See Webbee's reasoning as a distinct 💭 block.** Before each step, Webbee now
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""The session's TIME MACHINE (whole-mind Phase 4, I-WEBBEE-CODE-REVERSIBLE).
|
|
2
|
+
|
|
3
|
+
A shadow git repository whose GIT_DIR lives in the webbee cache and whose
|
|
4
|
+
worktree is the user's workspace (the classic detached-git-dir pattern).
|
|
5
|
+
The user's own VCS -- .git, index, refs, hooks -- is NEVER touched:
|
|
6
|
+
reversibility is ours, not theirs. Every write tool auto-checkpoints before
|
|
7
|
+
it runs (tools.py); `rollback` snapshots the pre-rollback state first, so a
|
|
8
|
+
rollback is itself undoable. Fail-soft everywhere: no git binary / init
|
|
9
|
+
failure => available=False and every operation degrades to an honest
|
|
10
|
+
message -- the time machine must never block the work."""
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
# Identity + safety for shadow commits only (never the user's config/hooks).
|
|
15
|
+
_GIT_CFG = ["-c", "user.name=webbee", "-c", "user.email=webbee@imperal.io",
|
|
16
|
+
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def shadow_key(workspace_root: str) -> str:
|
|
20
|
+
"""Shadow identity is the WORKTREE, never the remote: clones and linked
|
|
21
|
+
git-worktrees of one origin must each get their OWN time machine
|
|
22
|
+
(final-review F3 -- a shared shadow rolls checkout B onto checkout A's
|
|
23
|
+
snapshot). sha256 of the realpath, 12 hex chars (intel keeps its own
|
|
24
|
+
content-addressed repo_key; this one is deliberately different)."""
|
|
25
|
+
import hashlib
|
|
26
|
+
real = os.path.realpath(workspace_root)
|
|
27
|
+
return hashlib.sha256(real.encode("utf-8")).hexdigest()[:12]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _scrubbed_env() -> dict:
|
|
31
|
+
"""A GIT_*-free copy of the environment (final-review F2): git exports
|
|
32
|
+
GIT_INDEX_FILE / GIT_DIR / GIT_OBJECT_DIRECTORY / GIT_COMMON_DIR into hook
|
|
33
|
+
and rebase contexts -- inherited, they silently redirect SHADOW operations
|
|
34
|
+
into the USER's repository. Explicit --git-dir/--work-tree flags override
|
|
35
|
+
only two of them; scrub them all."""
|
|
36
|
+
return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ShadowGit:
|
|
40
|
+
_DIFF_CAP = 20000
|
|
41
|
+
|
|
42
|
+
def __init__(self, workspace_root: str, repo_key: str, cache_dir: str = "") -> None:
|
|
43
|
+
self.root = os.path.realpath(workspace_root)
|
|
44
|
+
base = cache_dir or os.path.expanduser("~/.cache/webbee")
|
|
45
|
+
self.git_dir = os.path.join(base, "shadow", repo_key)
|
|
46
|
+
self.available = False
|
|
47
|
+
self.auto_ok = True # latched False after a failed AUTO snapshot (F8)
|
|
48
|
+
|
|
49
|
+
def _git(self, *args: str) -> "subprocess.CompletedProcess":
|
|
50
|
+
cmd = (["git", "--git-dir", self.git_dir, "--work-tree", self.root]
|
|
51
|
+
+ _GIT_CFG + list(args))
|
|
52
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=120,
|
|
53
|
+
env=_scrubbed_env())
|
|
54
|
+
|
|
55
|
+
def ensure(self) -> bool:
|
|
56
|
+
"""Create/open the shadow. Returns availability; never raises.
|
|
57
|
+
Refuses a workspace that IS a bare git repository (final-review F9:
|
|
58
|
+
tracking its refs/objects and resetting them would rewind the USER's
|
|
59
|
+
VCS -- the one thing the shadow must never do)."""
|
|
60
|
+
try:
|
|
61
|
+
if (os.path.exists(os.path.join(self.root, "HEAD"))
|
|
62
|
+
and os.path.isdir(os.path.join(self.root, "objects"))
|
|
63
|
+
and os.path.isdir(os.path.join(self.root, "refs"))):
|
|
64
|
+
self.available = False
|
|
65
|
+
return False
|
|
66
|
+
os.makedirs(self.git_dir, mode=0o700, exist_ok=True)
|
|
67
|
+
os.chmod(self.git_dir, 0o700)
|
|
68
|
+
if not os.path.exists(os.path.join(self.git_dir, "HEAD")):
|
|
69
|
+
r = subprocess.run(["git"] + _GIT_CFG + ["init", "--bare", self.git_dir],
|
|
70
|
+
capture_output=True, text=True, timeout=60,
|
|
71
|
+
env=_scrubbed_env())
|
|
72
|
+
if r.returncode != 0:
|
|
73
|
+
self.available = False
|
|
74
|
+
return False
|
|
75
|
+
# Detached-git-dir mode: bare layout + explicit --work-tree.
|
|
76
|
+
self._git("config", "core.bare", "false")
|
|
77
|
+
excl = os.path.join(self.git_dir, "info", "exclude")
|
|
78
|
+
os.makedirs(os.path.dirname(excl), exist_ok=True)
|
|
79
|
+
with open(excl, "a", encoding="utf-8") as f:
|
|
80
|
+
f.write("\n.git/\n")
|
|
81
|
+
self.available = self._git("rev-parse", "--git-dir").returncode == 0
|
|
82
|
+
except Exception:
|
|
83
|
+
self.available = False
|
|
84
|
+
return self.available
|
|
85
|
+
|
|
86
|
+
# -- internals ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _count(self) -> int:
|
|
89
|
+
r = self._git("for-each-ref", "--format=%(refname)", "refs/webbee/")
|
|
90
|
+
return len([ln for ln in r.stdout.splitlines() if ln.strip()])
|
|
91
|
+
|
|
92
|
+
def _resolve(self, ref: str) -> "str | None":
|
|
93
|
+
ref = (ref or "").strip()
|
|
94
|
+
if not ref:
|
|
95
|
+
return None
|
|
96
|
+
if ref.isdigit():
|
|
97
|
+
cand = f"refs/webbee/cp-{ref}"
|
|
98
|
+
elif ref.startswith("cp-"):
|
|
99
|
+
cand = f"refs/webbee/{ref}"
|
|
100
|
+
else:
|
|
101
|
+
cand = ref
|
|
102
|
+
r = self._git("rev-parse", "--verify", "--quiet", cand + "^{commit}")
|
|
103
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
104
|
+
|
|
105
|
+
# -- operations ---------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def checkpoint(self, label: str = "", force: bool = False) -> "dict | None":
|
|
108
|
+
"""Commit the workspace state to the shadow history.
|
|
109
|
+
{"id","n","label","changed"} or None when unavailable/failed.
|
|
110
|
+
`force=True` always records a new checkpoint even with nothing dirty
|
|
111
|
+
-- used by rollback() for its mandatory pre-rollback safety snapshot,
|
|
112
|
+
which must exist even when the workspace already matches HEAD."""
|
|
113
|
+
if not self.available:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
if self._git("add", "-A").returncode != 0:
|
|
117
|
+
return None # a partial add must never masquerade as a snapshot
|
|
118
|
+
dirty = self._git("status", "--porcelain").stdout.strip()
|
|
119
|
+
head = self._git("rev-parse", "--short", "HEAD")
|
|
120
|
+
if not dirty and head.returncode == 0 and not force:
|
|
121
|
+
return {"id": head.stdout.strip(), "n": self._count(),
|
|
122
|
+
"label": "(no changes)", "changed": False}
|
|
123
|
+
msg = (label or "checkpoint").strip()[:200] or "checkpoint"
|
|
124
|
+
args = ["commit", "-m", msg, "--no-verify"]
|
|
125
|
+
if head.returncode != 0 or not dirty:
|
|
126
|
+
args.append("--allow-empty") # first-ever baseline / forced snapshot
|
|
127
|
+
c = self._git(*args)
|
|
128
|
+
if c.returncode != 0:
|
|
129
|
+
return None
|
|
130
|
+
sha = self._git("rev-parse", "--short", "HEAD").stdout.strip()
|
|
131
|
+
n = self._count() + 1
|
|
132
|
+
self._git("update-ref", f"refs/webbee/cp-{n}", "HEAD")
|
|
133
|
+
return {"id": sha, "n": n, "label": msg, "changed": True}
|
|
134
|
+
except Exception:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def list_checkpoints(self, limit: int = 10) -> list:
|
|
138
|
+
"""Newest-first [{'n','id','label','when'}] from the refs/webbee ledger."""
|
|
139
|
+
if not self.available:
|
|
140
|
+
return []
|
|
141
|
+
try:
|
|
142
|
+
r = self._git(
|
|
143
|
+
"for-each-ref",
|
|
144
|
+
"--format=%(refname:short)|%(objectname:short)|%(contents:subject)|%(creatordate:relative)",
|
|
145
|
+
"refs/webbee/")
|
|
146
|
+
rows = []
|
|
147
|
+
for line in r.stdout.splitlines():
|
|
148
|
+
parts = line.split("|", 3)
|
|
149
|
+
if len(parts) == 4 and parts[0].startswith("webbee/cp-"):
|
|
150
|
+
rows.append({"n": int(parts[0].rsplit("-", 1)[1]),
|
|
151
|
+
"id": parts[1], "label": parts[2], "when": parts[3]})
|
|
152
|
+
rows.sort(key=lambda x: x["n"], reverse=True)
|
|
153
|
+
return rows[:max(1, int(limit))]
|
|
154
|
+
except Exception:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
def diff(self, since: str = "") -> str:
|
|
158
|
+
"""Workspace changes since a checkpoint (default: the last one).
|
|
159
|
+
Bounded with an HONEST truncation note -- never a silent cut."""
|
|
160
|
+
if not self.available:
|
|
161
|
+
return "reversibility is off (git unavailable)"
|
|
162
|
+
try:
|
|
163
|
+
self._git("add", "-A") # stage into the SHADOW index so new files show
|
|
164
|
+
target = self._resolve(since) or "HEAD"
|
|
165
|
+
stat = self._git("diff", "--cached", "--stat", target).stdout
|
|
166
|
+
body = self._git("diff", "--cached", target).stdout
|
|
167
|
+
out = (stat + "\n" + body).strip() or "(no changes)"
|
|
168
|
+
if len(out) > self._DIFF_CAP:
|
|
169
|
+
out = (out[:self._DIFF_CAP]
|
|
170
|
+
+ f"\n… (diff truncated at {self._DIFF_CAP} chars -- pass a "
|
|
171
|
+
f"narrower `since` or inspect files directly)")
|
|
172
|
+
return out
|
|
173
|
+
except Exception as e:
|
|
174
|
+
return f"diff failed: {type(e).__name__}: {e}"
|
|
175
|
+
|
|
176
|
+
def rollback(self, to: str) -> dict:
|
|
177
|
+
"""Restore every checkpointed file to checkpoint `to`. The current
|
|
178
|
+
state is checkpointed FIRST ("pre-rollback"), so a rollback is itself
|
|
179
|
+
undoable. Files never checkpointed are not removed."""
|
|
180
|
+
if not self.available:
|
|
181
|
+
return {"ok": False, "content": "reversibility is off (git unavailable)"}
|
|
182
|
+
target = self._resolve(to)
|
|
183
|
+
if not target:
|
|
184
|
+
return {"ok": False, "content":
|
|
185
|
+
f"unknown checkpoint '{to}' -- use an id, cp-N or N "
|
|
186
|
+
f"(see the checkpoint list)"}
|
|
187
|
+
undo = self.checkpoint("pre-rollback", force=True)
|
|
188
|
+
if not undo:
|
|
189
|
+
# The safety snapshot is MANDATORY: without it a reset would
|
|
190
|
+
# destroy the current state unrecoverably (final-review F1).
|
|
191
|
+
return {"ok": False, "content":
|
|
192
|
+
"rollback refused: the pre-rollback safety snapshot could not "
|
|
193
|
+
"be created (shadow git error), so rolling back would destroy "
|
|
194
|
+
"the current state unrecoverably. Fix the shadow (disk space / "
|
|
195
|
+
"permissions) or copy your changes out first."}
|
|
196
|
+
r = self._git("reset", "--hard", target)
|
|
197
|
+
if r.returncode != 0:
|
|
198
|
+
return {"ok": False, "content": f"rollback failed: {r.stderr.strip()[:500]}"}
|
|
199
|
+
return {"ok": True, "content":
|
|
200
|
+
(f"restored the workspace to checkpoint {to} ({target[:9]}). "
|
|
201
|
+
f"The previous state -- including any files this rollback removed -- "
|
|
202
|
+
f"was saved first as checkpoint cp-{(undo or {}).get('n', '?')} "
|
|
203
|
+
f"({(undo or {}).get('id', '?')}), so this rollback is itself undoable.")}
|
|
204
|
+
|
|
205
|
+
def describe(self) -> str:
|
|
206
|
+
"""One printable block for the /checkpoints command."""
|
|
207
|
+
if not self.available:
|
|
208
|
+
return "Reversibility is off (git unavailable)."
|
|
209
|
+
rows = self.list_checkpoints(limit=10)
|
|
210
|
+
if not rows:
|
|
211
|
+
return "No checkpoints yet."
|
|
212
|
+
lines = [f"cp-{r['n']} {r['id']} {r['when']:>16} {r['label']}" for r in rows]
|
|
213
|
+
return "Checkpoints (newest first) — /rollback <id|cp-N|N>:\n" + "\n".join(lines)
|
|
@@ -11,6 +11,8 @@ _HELP = """Commands:
|
|
|
11
11
|
/cost (=/usage) tokens + credits this session
|
|
12
12
|
/status cwd · git · surface · tokens · version
|
|
13
13
|
/steps [N] last turn's steps; N expands one (also: Up/Down + Enter)
|
|
14
|
+
/checkpoints the reversibility time machine — list workspace checkpoints
|
|
15
|
+
/rollback <ref> restore the workspace to a checkpoint (id, cp-N or N)
|
|
14
16
|
/sessions list active sessions (this + other devices)
|
|
15
17
|
/sessions revoke <#> revoke a session by its number
|
|
16
18
|
/logout-others sign out every session except this one
|
|
@@ -79,6 +81,10 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
79
81
|
if args:
|
|
80
82
|
return SlashResult(handled=True, action="step_detail", arg=args[0])
|
|
81
83
|
return SlashResult(handled=True, action="steps")
|
|
84
|
+
if cmd == "/checkpoints":
|
|
85
|
+
return SlashResult(handled=True, action="checkpoints")
|
|
86
|
+
if cmd == "/rollback":
|
|
87
|
+
return SlashResult(handled=True, action="rollback", arg=" ".join(args))
|
|
82
88
|
if cmd == "/mode":
|
|
83
89
|
if not args:
|
|
84
90
|
return SlashResult(handled=True, action="mode", new_mode=None,
|
|
@@ -129,7 +129,7 @@ def handle_action_frame(frame: dict, sink, started: set, finished: set, steps: l
|
|
|
129
129
|
# composes ONE human-readable line per fact (I-FRAMES-FACTS-ONLY). Defensive:
|
|
130
130
|
# unknown / missing fields degrade to a bare label, never crash.
|
|
131
131
|
|
|
132
|
-
_MARATHON_FACT_TYPES = ("marathon_plan", "milestone", "marathon_paused")
|
|
132
|
+
_MARATHON_FACT_TYPES = ("marathon_plan", "milestone", "marathon_paused", "todo")
|
|
133
133
|
|
|
134
134
|
|
|
135
135
|
def marathon_note(frame: dict) -> str:
|
|
@@ -154,4 +154,12 @@ def marathon_note(frame: dict) -> str:
|
|
|
154
154
|
if ftype == "marathon_paused":
|
|
155
155
|
reason = str(frame.get("reason") or frame.get("summary") or "").strip()
|
|
156
156
|
return f"⏸ Marathon paused: {reason}" if reason else "⏸ Marathon paused"
|
|
157
|
+
if ftype == "todo":
|
|
158
|
+
todos = frame.get("todos") if isinstance(frame.get("todos"), list) else []
|
|
159
|
+
total = frame.get("total", len(todos))
|
|
160
|
+
done = frame.get("completed", 0)
|
|
161
|
+
current = next((str(t.get("content", "")) for t in todos
|
|
162
|
+
if isinstance(t, dict) and t.get("status") == "in_progress"), "")
|
|
163
|
+
head = f"📋 Todos {done}/{total}"
|
|
164
|
+
return f"{head} — now: {current}" if current else head
|
|
157
165
|
return str(frame.get("type", ""))
|
|
@@ -27,8 +27,20 @@ _ACCENT = "cyan" # interactive chrome ONLY — live caret / mode / panel ur
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _fmt_tokens(n: int) -> str:
|
|
30
|
-
"""Compact
|
|
31
|
-
|
|
30
|
+
"""Compact count for the live toolbar: 900 -> '900', 2_100 -> '2.1k',
|
|
31
|
+
1_500_000 -> '1.5M', 2_000_000 -> '2M', 3_200_000_000 -> '3.2B'. Used for
|
|
32
|
+
both token counts and (integer) credits so big numbers stay readable."""
|
|
33
|
+
try:
|
|
34
|
+
n = int(n or 0)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return "0"
|
|
37
|
+
a = abs(n)
|
|
38
|
+
if a < 1000:
|
|
39
|
+
return str(n)
|
|
40
|
+
for div, suf in ((1_000_000_000, "B"), (1_000_000, "M"), (1000, "k")):
|
|
41
|
+
if a >= div:
|
|
42
|
+
return f"{n / div:.1f}".rstrip("0").rstrip(".") + suf
|
|
43
|
+
return str(n)
|
|
32
44
|
|
|
33
45
|
|
|
34
46
|
def _salient_arg(args: dict) -> str:
|
|
@@ -80,6 +80,17 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
|
|
|
80
80
|
return text
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _default_shadow_factory(cfg, workspace: str):
|
|
84
|
+
"""The reversibility shadow git. Guarded like intel: any failure (no git
|
|
85
|
+
binary, cache not writable) degrades to None -- coding still works, just
|
|
86
|
+
without the time machine."""
|
|
87
|
+
from webbee.checkpoints import ShadowGit, shadow_key
|
|
88
|
+
from webbee.repo import find_repo_root
|
|
89
|
+
root = find_repo_root(workspace)
|
|
90
|
+
sg = ShadowGit(root, shadow_key(root), cache_dir=cfg.cache_dir)
|
|
91
|
+
return sg if sg.ensure() else None
|
|
92
|
+
|
|
93
|
+
|
|
83
94
|
def _default_intel_factory(cfg, workspace: str):
|
|
84
95
|
"""Lazy/guarded -- a base install (no tree-sitter/watchfiles extra) must
|
|
85
96
|
never fail to import here; `_boot` wraps the whole intel boot in
|
|
@@ -93,7 +104,7 @@ def _default_intel_factory(cfg, workspace: str):
|
|
|
93
104
|
|
|
94
105
|
async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
|
|
95
106
|
agent_factory=None, auth=None, account_fetcher=None,
|
|
96
|
-
sessions_client=None, intel_factory=None) -> None:
|
|
107
|
+
sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
|
|
97
108
|
"""Interactive coding REPL. Production (a real tty, no injected sink) runs
|
|
98
109
|
the persistent prompt_toolkit dock (`tui.run_session`): the bordered input
|
|
99
110
|
box is pinned at the bottom, turn output scrolls above it (patch_stdout →
|
|
@@ -103,7 +114,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
103
114
|
from imperal_mcp import auth as _auth
|
|
104
115
|
auth = _auth
|
|
105
116
|
if agent_factory is None:
|
|
106
|
-
agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=intel) # noqa: E731
|
|
117
|
+
agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=intel, shadow=shadow) # noqa: E731
|
|
107
118
|
if account_fetcher is None:
|
|
108
119
|
from webbee.account import fetch_account as account_fetcher
|
|
109
120
|
if sessions_client is None:
|
|
@@ -121,6 +132,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
121
132
|
_sink = None # assigned by _boot
|
|
122
133
|
agent = None # assigned by _boot
|
|
123
134
|
intel = None # assigned by _boot -- IntelService, or None (off/base-install/boot failure)
|
|
135
|
+
shadow = None # assigned by _boot -- ShadowGit, or None (git unavailable / boot failure)
|
|
124
136
|
watcher_task = None # assigned by _boot -- background watchfiles task, cancelled on exit
|
|
125
137
|
|
|
126
138
|
def _cycle() -> None:
|
|
@@ -211,6 +223,21 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
211
223
|
else:
|
|
212
224
|
_sink.note("Detail unavailable (expired or not recorded).")
|
|
213
225
|
return "continue"
|
|
226
|
+
if res.action == "checkpoints":
|
|
227
|
+
if shadow is None:
|
|
228
|
+
_sink.note("Reversibility is off (git unavailable).")
|
|
229
|
+
else:
|
|
230
|
+
_sink.note(await asyncio.to_thread(shadow.describe))
|
|
231
|
+
return "continue"
|
|
232
|
+
if res.action == "rollback":
|
|
233
|
+
if shadow is None:
|
|
234
|
+
_sink.note("Reversibility is off (git unavailable).")
|
|
235
|
+
elif not res.arg:
|
|
236
|
+
_sink.note("Usage: /rollback <id|cp-N|N> (see /checkpoints)")
|
|
237
|
+
else:
|
|
238
|
+
_r = await asyncio.to_thread(shadow.rollback, res.arg)
|
|
239
|
+
_sink.note(str(_r.get("content", "")))
|
|
240
|
+
return "continue"
|
|
214
241
|
if res.action == "clear":
|
|
215
242
|
_sink.clear()
|
|
216
243
|
_sink.note(res.message)
|
|
@@ -238,7 +265,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
238
265
|
return "continue"
|
|
239
266
|
|
|
240
267
|
async def _boot(s) -> None:
|
|
241
|
-
nonlocal _sink, agent, intel, watcher_task
|
|
268
|
+
nonlocal _sink, agent, intel, watcher_task, shadow
|
|
242
269
|
_sink = s
|
|
243
270
|
# Cache git branch OFF the event loop (subprocess.run blocks it). Only
|
|
244
271
|
# /status reads it; recomputing it per input line froze the dock.
|
|
@@ -260,6 +287,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
260
287
|
except Exception:
|
|
261
288
|
intel = None
|
|
262
289
|
watcher_task = None
|
|
290
|
+
# Whole-mind P4: the reversibility shadow (never the user's VCS);
|
|
291
|
+
# guarded -- boot must not fail over the time machine.
|
|
292
|
+
try:
|
|
293
|
+
shadow = await asyncio.to_thread(shadow_factory or _default_shadow_factory, cfg, workspace)
|
|
294
|
+
except Exception:
|
|
295
|
+
shadow = None
|
|
263
296
|
agent = agent_factory(cfg, token_provider, workspace, state["mode"])
|
|
264
297
|
|
|
265
298
|
if use_dock:
|
|
@@ -142,7 +142,8 @@ class AgentSession:
|
|
|
142
142
|
P1: one POST per turn (server reloads the shared webbee-terminal thread,
|
|
143
143
|
so context carries across turns). Persistent signal-based sessions are P3."""
|
|
144
144
|
|
|
145
|
-
def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None
|
|
145
|
+
def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None,
|
|
146
|
+
shadow=None) -> None:
|
|
146
147
|
self.cfg = cfg
|
|
147
148
|
self.token_provider = token_provider
|
|
148
149
|
self.workspace_root = workspace_root
|
|
@@ -151,6 +152,7 @@ class AgentSession:
|
|
|
151
152
|
self.steps: list = []
|
|
152
153
|
self._task_id: str = ""
|
|
153
154
|
self._intel = intel # IntelService, or None (base install / boot failure)
|
|
155
|
+
self._shadow = shadow # ShadowGit, or None (git unavailable / boot failure)
|
|
154
156
|
|
|
155
157
|
async def _headers(self) -> dict:
|
|
156
158
|
token = await self.token_provider()
|
|
@@ -173,7 +175,8 @@ class AgentSession:
|
|
|
173
175
|
verify_cmd = await asyncio.to_thread(detect_verify_cmd, root)
|
|
174
176
|
coding_context = {**coding_context, "verify_cmd": verify_cmd}
|
|
175
177
|
imperal_id = await ImperalClient(self.cfg, self.token_provider).whoami()
|
|
176
|
-
executor = LocalToolExecutor(self.workspace_root, indexer=self._intel
|
|
178
|
+
executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
|
|
179
|
+
shadow=self._shadow)
|
|
177
180
|
|
|
178
181
|
body = {"user_id": imperal_id, "task": task, "coding_context": coding_context}
|
|
179
182
|
if marathon:
|
|
@@ -3,14 +3,20 @@ import re
|
|
|
3
3
|
import subprocess
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
# Tools that MUTATE the workspace -> auto-checkpoint before each run
|
|
7
|
+
# (mirrors the kernel write tier; rollback snapshots itself, so not listed).
|
|
8
|
+
_WRITE_TIER = {"write_file", "edit_file", "multi_edit", "bash"}
|
|
9
|
+
|
|
10
|
+
|
|
6
11
|
class OutsideWorkspaceError(Exception):
|
|
7
12
|
pass
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
class LocalToolExecutor:
|
|
11
|
-
def __init__(self, workspace_root: str, indexer=None) -> None:
|
|
16
|
+
def __init__(self, workspace_root: str, indexer=None, shadow=None) -> None:
|
|
12
17
|
self.root = os.path.realpath(workspace_root)
|
|
13
18
|
self.indexer = indexer # IntelService (or None on a base install) -- Task 5's _t_<verb> shims read this
|
|
19
|
+
self.shadow = shadow # ShadowGit (or None) -- the reversibility time machine
|
|
14
20
|
|
|
15
21
|
def resolve_in_workspace(self, path: str) -> str:
|
|
16
22
|
full = os.path.realpath(os.path.join(self.root, path))
|
|
@@ -28,6 +34,18 @@ class LocalToolExecutor:
|
|
|
28
34
|
args = {}
|
|
29
35
|
if not isinstance(args, dict):
|
|
30
36
|
args = {}
|
|
37
|
+
if (tool in _WRITE_TIER and self.shadow is not None
|
|
38
|
+
and getattr(self.shadow, "auto_ok", True)):
|
|
39
|
+
# The time machine snapshots BEFORE every mutation -- and must
|
|
40
|
+
# never block or fail the actual work. One failed AUTO snapshot
|
|
41
|
+
# latches auto-checkpointing OFF for this session (final-review
|
|
42
|
+
# F8: a huge un-ignored tree would otherwise re-stall every write
|
|
43
|
+
# tool); manual checkpoint/rollback still try.
|
|
44
|
+
try:
|
|
45
|
+
if self.shadow.checkpoint(f"pre:{tool}") is None:
|
|
46
|
+
self.shadow.auto_ok = False
|
|
47
|
+
except Exception:
|
|
48
|
+
self.shadow.auto_ok = False
|
|
31
49
|
try:
|
|
32
50
|
fn = getattr(self, f"_t_{tool}", None)
|
|
33
51
|
if fn is None:
|
|
@@ -82,11 +100,99 @@ class LocalToolExecutor:
|
|
|
82
100
|
return {"ok": False, "content": "edit_file requires 'old' (the text to replace)"}
|
|
83
101
|
with open(p, "r", encoding="utf-8") as f:
|
|
84
102
|
text = f.read()
|
|
85
|
-
|
|
103
|
+
n = text.count(old)
|
|
104
|
+
if n == 0:
|
|
86
105
|
return {"ok": False, "content": "old string not found"}
|
|
106
|
+
_ra = a.get("replace_all")
|
|
107
|
+
replace_all = (_ra.strip().lower() in ("true", "1", "yes")
|
|
108
|
+
if isinstance(_ra, str) else bool(_ra))
|
|
109
|
+
if n > 1 and not replace_all:
|
|
110
|
+
return {"ok": False, "content":
|
|
111
|
+
f"old string occurs {n} times; add surrounding context to make it "
|
|
112
|
+
f"unique, or pass replace_all=true to replace every occurrence"}
|
|
87
113
|
with open(p, "w", encoding="utf-8") as f:
|
|
88
|
-
f.write(text.replace(old, new, 1))
|
|
89
|
-
|
|
114
|
+
f.write(text.replace(old, new) if replace_all else text.replace(old, new, 1))
|
|
115
|
+
note = f" ({n} occurrences)" if replace_all and n > 1 else ""
|
|
116
|
+
return {"ok": True, "content": f"edited {rel}{note}"}
|
|
117
|
+
|
|
118
|
+
def _t_multi_edit(self, a: dict) -> dict:
|
|
119
|
+
edits = a.get("edits")
|
|
120
|
+
if not isinstance(edits, list) or not edits:
|
|
121
|
+
return {"ok": False, "content": "multi_edit requires a non-empty 'edits' list"}
|
|
122
|
+
# Validate EVERYTHING first -- all-or-nothing (a half-applied batch is
|
|
123
|
+
# worse than a failed one).
|
|
124
|
+
staged = []
|
|
125
|
+
problems = []
|
|
126
|
+
for i, e in enumerate(edits):
|
|
127
|
+
if not isinstance(e, dict):
|
|
128
|
+
problems.append(f"edit {i}: not an object")
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
rel = self._rel(e)
|
|
132
|
+
p = self.resolve_in_workspace(rel)
|
|
133
|
+
except (ValueError, OutsideWorkspaceError) as err:
|
|
134
|
+
problems.append(f"edit {i}: {err}")
|
|
135
|
+
continue
|
|
136
|
+
old = e.get("old", e.get("old_string", ""))
|
|
137
|
+
new = e.get("new", e.get("new_string", ""))
|
|
138
|
+
if not old:
|
|
139
|
+
problems.append(f"edit {i} ({rel}): 'old' is required")
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
143
|
+
text = f.read()
|
|
144
|
+
except OSError as err:
|
|
145
|
+
problems.append(f"edit {i} ({rel}): {type(err).__name__}: {err}")
|
|
146
|
+
continue
|
|
147
|
+
n = text.count(old)
|
|
148
|
+
if n != 1:
|
|
149
|
+
problems.append(f"edit {i} ({rel}): 'old' occurs {n} times (must be exactly 1)")
|
|
150
|
+
continue
|
|
151
|
+
if not os.access(p, os.W_OK):
|
|
152
|
+
problems.append(f"edit {i} ({rel}): file is not writable")
|
|
153
|
+
continue
|
|
154
|
+
staged.append((p, rel, old, new))
|
|
155
|
+
if problems:
|
|
156
|
+
return {"ok": False, "content":
|
|
157
|
+
"multi_edit applied NOTHING -- fix these and retry:\n" + "\n".join(problems)}
|
|
158
|
+
# Apply sequentially, re-reading so multiple edits to the SAME file
|
|
159
|
+
# compose; if an earlier edit invalidated a later one, stop honestly.
|
|
160
|
+
applied = []
|
|
161
|
+
for p, rel, old, new in staged:
|
|
162
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
163
|
+
text = f.read()
|
|
164
|
+
if text.count(old) != 1:
|
|
165
|
+
return {"ok": False, "content":
|
|
166
|
+
f"multi_edit stopped at {rel}: an earlier edit in this batch changed "
|
|
167
|
+
f"the text around 'old' (applied so far: {', '.join(applied) or 'none'}); "
|
|
168
|
+
f"re-read the file and retry the remaining edits"}
|
|
169
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
170
|
+
f.write(text.replace(old, new, 1))
|
|
171
|
+
applied.append(rel)
|
|
172
|
+
return {"ok": True, "content": f"applied {len(applied)} edits: " + ", ".join(applied)}
|
|
173
|
+
|
|
174
|
+
def _t_checkpoint(self, a: dict) -> dict:
|
|
175
|
+
if self.shadow is None:
|
|
176
|
+
return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
|
|
177
|
+
cp = self.shadow.checkpoint(str(a.get("label", "") or "manual"))
|
|
178
|
+
if cp is None:
|
|
179
|
+
return {"ok": False, "content": "checkpoint failed (shadow git error)"}
|
|
180
|
+
note = "created" if cp.get("changed") else "no changes since the last checkpoint"
|
|
181
|
+
return {"ok": True, "content": f"checkpoint cp-{cp.get('n')} ({cp.get('id')}): {note}"}
|
|
182
|
+
|
|
183
|
+
def _t_diff(self, a: dict) -> dict:
|
|
184
|
+
if self.shadow is None:
|
|
185
|
+
return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
|
|
186
|
+
return {"ok": True, "content": self.shadow.diff(str(a.get("since", "") or ""))}
|
|
187
|
+
|
|
188
|
+
def _t_rollback(self, a: dict) -> dict:
|
|
189
|
+
if self.shadow is None:
|
|
190
|
+
return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
|
|
191
|
+
to = str(a.get("checkpoint", "") or a.get("to", "") or "")
|
|
192
|
+
if not to:
|
|
193
|
+
return {"ok": False, "content":
|
|
194
|
+
"rollback requires 'checkpoint' (an id, cp-N or N -- see diff/checkpoint output)"}
|
|
195
|
+
return self.shadow.rollback(to)
|
|
90
196
|
|
|
91
197
|
def _t_bash(self, a: dict) -> dict:
|
|
92
198
|
timeout = min(int(a.get("timeout", 120) or 120), 3600)
|
|
@@ -44,15 +44,19 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
|
|
|
44
44
|
return [("class:tb.dim", " mode: "),
|
|
45
45
|
(f"class:tb.mode.{mode}", mode),
|
|
46
46
|
("class:tb.dim",
|
|
47
|
-
f" · {_fmt_tokens(tokens)} tok · {credits} credits · Shift + TAB: switch mode")]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _escape_action(sel: dict, is_busy, stop_turn, event) -> None:
|
|
51
|
-
"""Esc key binding (P5g). While a turn is running,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if is_busy()
|
|
55
|
-
|
|
47
|
+
f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits · Shift + TAB: switch mode")]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event) -> None:
|
|
51
|
+
"""Esc key binding (P5g). While a turn is running, STOP it — cancel the LOCAL
|
|
52
|
+
turn task (what actually tears the turn down, same as Ctrl-C) AND ask the
|
|
53
|
+
server to stop (so it stops spending). While idle, clear the step-selection."""
|
|
54
|
+
if is_busy():
|
|
55
|
+
t = turn.get("task")
|
|
56
|
+
if t is not None and not t.done():
|
|
57
|
+
if stop_turn is not None:
|
|
58
|
+
event.app.create_background_task(stop_turn())
|
|
59
|
+
t.cancel() # cancel the running turn; dock survives
|
|
56
60
|
return
|
|
57
61
|
sel["i"] = None
|
|
58
62
|
event.app.invalidate()
|
|
@@ -157,7 +161,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
157
161
|
|
|
158
162
|
@kb.add("escape")
|
|
159
163
|
def _step_clear(event):
|
|
160
|
-
_escape_action(sel, is_busy, stop_turn, event)
|
|
164
|
+
_escape_action(sel, turn, is_busy, stop_turn, event)
|
|
161
165
|
|
|
162
166
|
@kb.add("pageup")
|
|
163
167
|
def _pgup(event):
|
|
@@ -190,8 +194,13 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
190
194
|
rows = sum(max(1, -(-len(ln) // cols)) for ln in text.split("\n"))
|
|
191
195
|
return min(10, max(1, rows))
|
|
192
196
|
|
|
197
|
+
def _prompt_fragments():
|
|
198
|
+
# The ❯ takes the CURRENT mode's colour (same classes the toolbar uses)
|
|
199
|
+
# so the mode is obvious from the input line itself, not just the toolbar.
|
|
200
|
+
return [(f"class:tb.mode.{mode_getter()}", "❯ ")]
|
|
201
|
+
|
|
193
202
|
input_win = Window(
|
|
194
|
-
BufferControl(buffer=buf, input_processors=[BeforeInput(
|
|
203
|
+
BufferControl(buffer=buf, input_processors=[BeforeInput(_prompt_fragments)]),
|
|
195
204
|
height=_input_height, wrap_lines=True)
|
|
196
205
|
toolbar = Window(FormattedTextControl(_toolbar), height=1, always_hide_cursor=True)
|
|
197
206
|
root = HSplit([pane.window, Frame(input_win), toolbar])
|