dulus 0.2.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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
checkpoint/store.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Checkpoint store: file-level backup + snapshot persistence.
|
|
2
|
+
|
|
3
|
+
Directory layout:
|
|
4
|
+
~/.dulus/checkpoints/<session_id>/
|
|
5
|
+
snapshots.json # list of Snapshot metadata
|
|
6
|
+
backups/
|
|
7
|
+
<hash>@v<N> # actual file copies
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .types import FileBackup, Snapshot, MAX_SNAPSHOTS
|
|
21
|
+
|
|
22
|
+
# Max file size to back up (1 MB)
|
|
23
|
+
_MAX_FILE_SIZE = 1 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
# Per-file version counters (reset per session)
|
|
26
|
+
_file_versions: dict[str, int] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _checkpoints_root() -> Path:
|
|
30
|
+
return Path.home() / ".dulus" / "checkpoints"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _session_dir(session_id: str) -> Path:
|
|
34
|
+
return _checkpoints_root() / session_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _backups_dir(session_id: str) -> Path:
|
|
38
|
+
d = _session_dir(session_id) / "backups"
|
|
39
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
return d
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _snapshots_file(session_id: str) -> Path:
|
|
44
|
+
d = _session_dir(session_id)
|
|
45
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return d / "snapshots.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _path_hash(file_path: str) -> str:
|
|
50
|
+
"""Deterministic short hash from file path (not content)."""
|
|
51
|
+
return hashlib.sha256(file_path.encode()).hexdigest()[:16]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _next_version(file_path: str) -> int:
|
|
55
|
+
v = _file_versions.get(file_path, 0) + 1
|
|
56
|
+
_file_versions[file_path] = v
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Load / save snapshots JSON ──────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _load_snapshots(session_id: str) -> list[Snapshot]:
|
|
63
|
+
f = _snapshots_file(session_id)
|
|
64
|
+
if not f.exists():
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
68
|
+
return [Snapshot.from_dict(s) for s in data]
|
|
69
|
+
except Exception:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _save_snapshots(session_id: str, snapshots: list[Snapshot]) -> None:
|
|
74
|
+
f = _snapshots_file(session_id)
|
|
75
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
data = [s.to_dict() for s in snapshots]
|
|
77
|
+
f.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def track_file_edit(session_id: str, file_path: str) -> str | None:
|
|
83
|
+
"""Back up a file before it is edited (first-write-wins per snapshot interval).
|
|
84
|
+
|
|
85
|
+
Returns the backup filename, or None if the file doesn't exist yet.
|
|
86
|
+
"""
|
|
87
|
+
p = Path(file_path)
|
|
88
|
+
bdir = _backups_dir(session_id)
|
|
89
|
+
|
|
90
|
+
if not p.exists():
|
|
91
|
+
# File doesn't exist — record that so restore can delete it
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Size guard
|
|
95
|
+
try:
|
|
96
|
+
size = p.stat().st_size
|
|
97
|
+
except OSError:
|
|
98
|
+
return None
|
|
99
|
+
if size > _MAX_FILE_SIZE:
|
|
100
|
+
print(f"[checkpoint] skipping large file ({size} bytes): {file_path}")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Copy file to backups/
|
|
104
|
+
version = _next_version(file_path)
|
|
105
|
+
backup_name = f"{_path_hash(file_path)}@v{version}"
|
|
106
|
+
backup_path = bdir / backup_name
|
|
107
|
+
try:
|
|
108
|
+
shutil.copy2(str(p), str(backup_path))
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f"[checkpoint] backup failed for {file_path}: {e}")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
return backup_name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def make_snapshot(
|
|
117
|
+
session_id: str,
|
|
118
|
+
state: Any,
|
|
119
|
+
config: dict,
|
|
120
|
+
user_prompt: str,
|
|
121
|
+
tracked_edits: dict[str, str | None] | None = None,
|
|
122
|
+
) -> Snapshot | None:
|
|
123
|
+
"""Create a snapshot after a user prompt has been processed.
|
|
124
|
+
|
|
125
|
+
tracked_edits: dict mapping file_path → backup_filename (or None if new file).
|
|
126
|
+
Populated by hooks.py during the turn.
|
|
127
|
+
"""
|
|
128
|
+
snapshots = _load_snapshots(session_id)
|
|
129
|
+
|
|
130
|
+
# Build file_backups: merge previous snapshot's backups with new edits
|
|
131
|
+
prev_backups: dict[str, FileBackup] = {}
|
|
132
|
+
if snapshots:
|
|
133
|
+
prev_backups = dict(snapshots[-1].file_backups)
|
|
134
|
+
|
|
135
|
+
now = datetime.now().isoformat()
|
|
136
|
+
new_backups: dict[str, FileBackup] = {}
|
|
137
|
+
|
|
138
|
+
# Carry forward unchanged files from previous snapshot
|
|
139
|
+
for path, fb in prev_backups.items():
|
|
140
|
+
new_backups[path] = fb
|
|
141
|
+
|
|
142
|
+
# Add/update files that were edited this turn — back up their CURRENT state
|
|
143
|
+
if tracked_edits:
|
|
144
|
+
for path in tracked_edits:
|
|
145
|
+
p = Path(path)
|
|
146
|
+
if p.exists():
|
|
147
|
+
try:
|
|
148
|
+
size = p.stat().st_size
|
|
149
|
+
except OSError:
|
|
150
|
+
continue
|
|
151
|
+
if size > _MAX_FILE_SIZE:
|
|
152
|
+
continue
|
|
153
|
+
version = _next_version(path)
|
|
154
|
+
backup_name = f"{_path_hash(path)}@v{version}"
|
|
155
|
+
bdir = _backups_dir(session_id)
|
|
156
|
+
try:
|
|
157
|
+
shutil.copy2(str(p), str(bdir / backup_name))
|
|
158
|
+
except Exception:
|
|
159
|
+
continue
|
|
160
|
+
new_backups[path] = FileBackup(
|
|
161
|
+
backup_filename=backup_name,
|
|
162
|
+
version=version,
|
|
163
|
+
backup_time=now,
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
# File was deleted during the turn (unlikely but possible)
|
|
167
|
+
new_backups[path] = FileBackup(
|
|
168
|
+
backup_filename=None,
|
|
169
|
+
version=_file_versions.get(path, 0),
|
|
170
|
+
backup_time=now,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
next_id = (snapshots[-1].id + 1) if snapshots else 1
|
|
174
|
+
|
|
175
|
+
snapshot = Snapshot(
|
|
176
|
+
id=next_id,
|
|
177
|
+
session_id=session_id,
|
|
178
|
+
created_at=now,
|
|
179
|
+
turn_count=getattr(state, "turn_count", 0),
|
|
180
|
+
message_index=len(getattr(state, "messages", [])),
|
|
181
|
+
user_prompt_preview=user_prompt[:80] if user_prompt else "",
|
|
182
|
+
token_snapshot={
|
|
183
|
+
"input": getattr(state, "total_input_tokens", 0),
|
|
184
|
+
"output": getattr(state, "total_output_tokens", 0),
|
|
185
|
+
},
|
|
186
|
+
file_backups=new_backups,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
snapshots.append(snapshot)
|
|
190
|
+
|
|
191
|
+
# Sliding window: keep only the last MAX_SNAPSHOTS
|
|
192
|
+
if len(snapshots) > MAX_SNAPSHOTS:
|
|
193
|
+
snapshots = snapshots[-MAX_SNAPSHOTS:]
|
|
194
|
+
|
|
195
|
+
_save_snapshots(session_id, snapshots)
|
|
196
|
+
return snapshot
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def list_snapshots(session_id: str) -> list[dict]:
|
|
200
|
+
"""Return lightweight summaries of all snapshots."""
|
|
201
|
+
snapshots = _load_snapshots(session_id)
|
|
202
|
+
result = []
|
|
203
|
+
for s in snapshots:
|
|
204
|
+
result.append({
|
|
205
|
+
"id": s.id,
|
|
206
|
+
"turn_count": s.turn_count,
|
|
207
|
+
"message_index": s.message_index,
|
|
208
|
+
"created_at": s.created_at,
|
|
209
|
+
"user_prompt_preview": s.user_prompt_preview,
|
|
210
|
+
"file_count": len(s.file_backups),
|
|
211
|
+
})
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_snapshot(session_id: str, snapshot_id: int) -> Snapshot | None:
|
|
216
|
+
snapshots = _load_snapshots(session_id)
|
|
217
|
+
for s in snapshots:
|
|
218
|
+
if s.id == snapshot_id:
|
|
219
|
+
return s
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def rewind_files(session_id: str, snapshot_id: int) -> list[str]:
|
|
224
|
+
"""Restore files to their state at the given snapshot.
|
|
225
|
+
|
|
226
|
+
Returns list of restored/deleted file paths.
|
|
227
|
+
"""
|
|
228
|
+
snapshot = get_snapshot(session_id, snapshot_id)
|
|
229
|
+
if snapshot is None:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
bdir = _backups_dir(session_id)
|
|
233
|
+
restored: list[str] = []
|
|
234
|
+
|
|
235
|
+
for file_path, fb in snapshot.file_backups.items():
|
|
236
|
+
try:
|
|
237
|
+
if fb.backup_filename is None:
|
|
238
|
+
# File didn't exist at snapshot time → delete it
|
|
239
|
+
p = Path(file_path)
|
|
240
|
+
if p.exists():
|
|
241
|
+
p.unlink()
|
|
242
|
+
restored.append(f"deleted: {file_path}")
|
|
243
|
+
else:
|
|
244
|
+
# Restore from backup
|
|
245
|
+
backup_path = bdir / fb.backup_filename
|
|
246
|
+
if backup_path.exists():
|
|
247
|
+
p = Path(file_path)
|
|
248
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
shutil.copy2(str(backup_path), str(p))
|
|
250
|
+
restored.append(f"restored: {file_path}")
|
|
251
|
+
else:
|
|
252
|
+
restored.append(f"backup missing: {file_path}")
|
|
253
|
+
except Exception as e:
|
|
254
|
+
restored.append(f"error restoring {file_path}: {e}")
|
|
255
|
+
|
|
256
|
+
return restored
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def files_changed_since(session_id: str, snapshot_id: int) -> list[str]:
|
|
260
|
+
"""List files that have been changed in snapshots after the given one."""
|
|
261
|
+
snapshots = _load_snapshots(session_id)
|
|
262
|
+
target = None
|
|
263
|
+
for s in snapshots:
|
|
264
|
+
if s.id == snapshot_id:
|
|
265
|
+
target = s
|
|
266
|
+
break
|
|
267
|
+
if target is None:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
changed: set[str] = set()
|
|
271
|
+
for s in snapshots:
|
|
272
|
+
if s.id <= snapshot_id:
|
|
273
|
+
continue
|
|
274
|
+
for path in s.file_backups:
|
|
275
|
+
if path not in target.file_backups or \
|
|
276
|
+
s.file_backups[path].version != target.file_backups[path].version:
|
|
277
|
+
changed.add(path)
|
|
278
|
+
return sorted(changed)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def delete_session_checkpoints(session_id: str) -> bool:
|
|
282
|
+
"""Delete all checkpoints for a session."""
|
|
283
|
+
d = _session_dir(session_id)
|
|
284
|
+
if d.exists():
|
|
285
|
+
shutil.rmtree(str(d), ignore_errors=True)
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cleanup_old_sessions(max_age_days: int = 30) -> int:
|
|
291
|
+
"""Remove checkpoint sessions older than max_age_days. Returns count removed."""
|
|
292
|
+
root = _checkpoints_root()
|
|
293
|
+
if not root.exists():
|
|
294
|
+
return 0
|
|
295
|
+
cutoff = time.time() - (max_age_days * 86400)
|
|
296
|
+
removed = 0
|
|
297
|
+
try:
|
|
298
|
+
for d in root.iterdir():
|
|
299
|
+
if d.is_dir():
|
|
300
|
+
try:
|
|
301
|
+
mtime = d.stat().st_mtime
|
|
302
|
+
if mtime < cutoff:
|
|
303
|
+
shutil.rmtree(str(d), ignore_errors=True)
|
|
304
|
+
removed += 1
|
|
305
|
+
except OSError:
|
|
306
|
+
pass
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
return removed
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def reset_file_versions() -> None:
|
|
313
|
+
"""Reset per-file version counters (for testing)."""
|
|
314
|
+
_file_versions.clear()
|
checkpoint/types.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Checkpoint system types: FileBackup and Snapshot dataclasses."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MAX_SNAPSHOTS = 100
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FileBackup:
|
|
13
|
+
"""A single file's backup reference within a snapshot.
|
|
14
|
+
|
|
15
|
+
backup_filename: hash@vN name in the backups/ dir, or None if the file
|
|
16
|
+
did not exist before (meaning restore = delete).
|
|
17
|
+
version: monotonically increasing per-file version counter.
|
|
18
|
+
backup_time: ISO timestamp of when the backup was created.
|
|
19
|
+
"""
|
|
20
|
+
backup_filename: str | None
|
|
21
|
+
version: int
|
|
22
|
+
backup_time: str
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"backup_filename": self.backup_filename,
|
|
27
|
+
"version": self.version,
|
|
28
|
+
"backup_time": self.backup_time,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict) -> FileBackup:
|
|
33
|
+
return cls(
|
|
34
|
+
backup_filename=data.get("backup_filename"),
|
|
35
|
+
version=data.get("version", 0),
|
|
36
|
+
backup_time=data.get("backup_time", ""),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Snapshot:
|
|
42
|
+
"""A checkpoint snapshot — metadata about conversation + file state."""
|
|
43
|
+
id: int
|
|
44
|
+
session_id: str
|
|
45
|
+
created_at: str
|
|
46
|
+
turn_count: int
|
|
47
|
+
message_index: int # len(state.messages) at snapshot time
|
|
48
|
+
user_prompt_preview: str # first 80 chars of the triggering prompt
|
|
49
|
+
token_snapshot: dict[str, int] # {"input": N, "output": N}
|
|
50
|
+
file_backups: dict[str, FileBackup] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"id": self.id,
|
|
55
|
+
"session_id": self.session_id,
|
|
56
|
+
"created_at": self.created_at,
|
|
57
|
+
"turn_count": self.turn_count,
|
|
58
|
+
"message_index": self.message_index,
|
|
59
|
+
"user_prompt_preview": self.user_prompt_preview,
|
|
60
|
+
"token_snapshot": self.token_snapshot,
|
|
61
|
+
"file_backups": {
|
|
62
|
+
path: fb.to_dict() for path, fb in self.file_backups.items()
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, data: dict) -> Snapshot:
|
|
68
|
+
backups = {}
|
|
69
|
+
for path, fb_data in data.get("file_backups", {}).items():
|
|
70
|
+
backups[path] = FileBackup.from_dict(fb_data)
|
|
71
|
+
return cls(
|
|
72
|
+
id=data["id"],
|
|
73
|
+
session_id=data.get("session_id", ""),
|
|
74
|
+
created_at=data.get("created_at", ""),
|
|
75
|
+
turn_count=data.get("turn_count", 0),
|
|
76
|
+
message_index=data.get("message_index", 0),
|
|
77
|
+
user_prompt_preview=data.get("user_prompt_preview", ""),
|
|
78
|
+
token_snapshot=data.get("token_snapshot", {}),
|
|
79
|
+
file_backups=backups,
|
|
80
|
+
)
|
claude_code_watcher.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
claude_code_watcher.py
|
|
4
|
+
|
|
5
|
+
Watches a Claude Code session JSONL file and extracts assistant responses
|
|
6
|
+
in real time. Can print to stdout or POST to a Dulus/webhook endpoint.
|
|
7
|
+
|
|
8
|
+
v2: Groups multi-part assistant turns (text + tool_use + text) into one
|
|
9
|
+
complete message before sending. Fixes the bug where text after a
|
|
10
|
+
tool call was sent as a separate/missing message.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python claude_code_watcher.py
|
|
14
|
+
python claude_code_watcher.py --session <path_to.jsonl>
|
|
15
|
+
python claude_code_watcher.py --post http://localhost:5000/claude_code_response
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import os
|
|
22
|
+
import argparse
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SESSION_DIR = Path.home() / ".claude" / "projects" / "C--Users-Admin-Desktop-DULUSV2"
|
|
27
|
+
|
|
28
|
+
# How long to wait (seconds) with no new assistant entries before flushing
|
|
29
|
+
# the accumulated turn as complete.
|
|
30
|
+
FLUSH_TIMEOUT = 2.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_latest_session() -> Path | None:
|
|
34
|
+
"""Find the most recently modified JSONL session file."""
|
|
35
|
+
files = list(SESSION_DIR.glob("*.jsonl"))
|
|
36
|
+
if not files:
|
|
37
|
+
return None
|
|
38
|
+
return max(files, key=lambda f: f.stat().st_mtime)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_text_blocks(entry: dict) -> list[str]:
|
|
42
|
+
"""Return all text strings from an assistant entry's content blocks."""
|
|
43
|
+
msg = entry.get("message", {})
|
|
44
|
+
if msg.get("role") != "assistant":
|
|
45
|
+
return []
|
|
46
|
+
content = msg.get("content", "")
|
|
47
|
+
if isinstance(content, str):
|
|
48
|
+
t = content.strip()
|
|
49
|
+
return [t] if t else []
|
|
50
|
+
if isinstance(content, list):
|
|
51
|
+
parts = []
|
|
52
|
+
for block in content:
|
|
53
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
54
|
+
t = block.get("text", "").strip()
|
|
55
|
+
if t:
|
|
56
|
+
parts.append(t)
|
|
57
|
+
return parts
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_tool_use(entry: dict) -> bool:
|
|
62
|
+
"""True if this entry contains a tool_use block (mid-turn, more may follow)."""
|
|
63
|
+
msg = entry.get("message", {})
|
|
64
|
+
if msg.get("role") != "assistant":
|
|
65
|
+
return False
|
|
66
|
+
content = msg.get("content", "")
|
|
67
|
+
if isinstance(content, list):
|
|
68
|
+
return any(b.get("type") == "tool_use" for b in content if isinstance(b, dict))
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_assistant(entry: dict) -> bool:
|
|
73
|
+
return entry.get("message", {}).get("role") == "assistant"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def post_message(text: str, post_url: str):
|
|
77
|
+
try:
|
|
78
|
+
import urllib.request
|
|
79
|
+
payload = json.dumps({
|
|
80
|
+
"role": "assistant",
|
|
81
|
+
"source": "claude-code",
|
|
82
|
+
"text": text,
|
|
83
|
+
}).encode("utf-8")
|
|
84
|
+
req = urllib.request.Request(
|
|
85
|
+
post_url,
|
|
86
|
+
data=payload,
|
|
87
|
+
headers={"Content-Type": "application/json"},
|
|
88
|
+
method="POST",
|
|
89
|
+
)
|
|
90
|
+
urllib.request.urlopen(req, timeout=5)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f"[watcher] POST failed: {e}", flush=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def watch(session_path: Path, post_url: str | None = None, poll_interval: float = 0.5):
|
|
96
|
+
"""Tail the JSONL file and emit complete assistant turns."""
|
|
97
|
+
print(f"[watcher] Watching: {session_path}", flush=True)
|
|
98
|
+
print(f"[watcher] Post URL: {post_url or 'stdout only'}", flush=True)
|
|
99
|
+
print(f"[watcher] Flush timeout: {FLUSH_TIMEOUT}s after last assistant entry", flush=True)
|
|
100
|
+
|
|
101
|
+
seen_uuids: set = set()
|
|
102
|
+
|
|
103
|
+
# Seed existing entries
|
|
104
|
+
try:
|
|
105
|
+
with open(session_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
106
|
+
for line in f:
|
|
107
|
+
line = line.strip()
|
|
108
|
+
if not line:
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
entry = json.loads(line)
|
|
112
|
+
uid = entry.get("uuid") or entry.get("id")
|
|
113
|
+
if uid:
|
|
114
|
+
seen_uuids.add(uid)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
print(f"[watcher] Seeded {len(seen_uuids)} existing entries.", flush=True)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
print(f"[watcher] Could not seed: {e}", flush=True)
|
|
120
|
+
|
|
121
|
+
# Accumulator for the current in-progress assistant turn
|
|
122
|
+
pending_texts: list[str] = []
|
|
123
|
+
pending_has_tool: bool = False
|
|
124
|
+
last_assistant_time: float = 0.0
|
|
125
|
+
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
with open(session_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
129
|
+
for line in f:
|
|
130
|
+
line = line.strip()
|
|
131
|
+
if not line:
|
|
132
|
+
continue
|
|
133
|
+
try:
|
|
134
|
+
entry = json.loads(line)
|
|
135
|
+
uid = entry.get("uuid") or entry.get("id")
|
|
136
|
+
if uid in seen_uuids:
|
|
137
|
+
continue
|
|
138
|
+
seen_uuids.add(uid)
|
|
139
|
+
|
|
140
|
+
if not is_assistant(entry):
|
|
141
|
+
# Non-assistant entry (user / tool_result) — if we have
|
|
142
|
+
# pending text that ended with a tool_use, keep accumulating.
|
|
143
|
+
# We'll flush on timeout or when the next text-only turn arrives.
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
texts = extract_text_blocks(entry)
|
|
147
|
+
tool = has_tool_use(entry)
|
|
148
|
+
|
|
149
|
+
if texts:
|
|
150
|
+
pending_texts.extend(texts)
|
|
151
|
+
last_assistant_time = time.time()
|
|
152
|
+
|
|
153
|
+
if tool:
|
|
154
|
+
pending_has_tool = True
|
|
155
|
+
last_assistant_time = time.time()
|
|
156
|
+
|
|
157
|
+
# If this entry has ONLY tool_use (no text) it means we're
|
|
158
|
+
# mid-turn — keep accumulating.
|
|
159
|
+
# If this entry has text AND no tool_use, it MIGHT be the
|
|
160
|
+
# final piece of the turn. We'll let the timeout decide.
|
|
161
|
+
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print(f"[watcher] Read error: {e}", flush=True)
|
|
167
|
+
|
|
168
|
+
# Flush if we have accumulated text and the turn has been quiet for FLUSH_TIMEOUT
|
|
169
|
+
if pending_texts and last_assistant_time > 0:
|
|
170
|
+
elapsed = time.time() - last_assistant_time
|
|
171
|
+
if elapsed >= FLUSH_TIMEOUT:
|
|
172
|
+
full_text = "\n\n".join(pending_texts)
|
|
173
|
+
print(f"\n[CLAUDE-CODE] {full_text[:300]}{'...' if len(full_text) > 300 else ''}", flush=True)
|
|
174
|
+
|
|
175
|
+
if post_url:
|
|
176
|
+
post_message(full_text, post_url)
|
|
177
|
+
|
|
178
|
+
# Reset accumulator
|
|
179
|
+
pending_texts = []
|
|
180
|
+
pending_has_tool = False
|
|
181
|
+
last_assistant_time = 0.0
|
|
182
|
+
|
|
183
|
+
time.sleep(poll_interval)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main():
|
|
187
|
+
parser = argparse.ArgumentParser(description="Watch Claude Code session for new assistant messages.")
|
|
188
|
+
parser.add_argument("--session", type=str, default=None, help="Path to .jsonl session file")
|
|
189
|
+
parser.add_argument("--post", type=str, default=None, help="POST new messages to this URL")
|
|
190
|
+
parser.add_argument("--interval", type=float, default=0.5, help="Poll interval in seconds (default 0.5)")
|
|
191
|
+
parser.add_argument("--flush-timeout", type=float, default=FLUSH_TIMEOUT,
|
|
192
|
+
help=f"Seconds of silence before flushing turn (default {FLUSH_TIMEOUT})")
|
|
193
|
+
args = parser.parse_args()
|
|
194
|
+
|
|
195
|
+
global FLUSH_TIMEOUT
|
|
196
|
+
FLUSH_TIMEOUT = args.flush_timeout
|
|
197
|
+
|
|
198
|
+
if args.session:
|
|
199
|
+
session_path = Path(args.session)
|
|
200
|
+
else:
|
|
201
|
+
session_path = find_latest_session()
|
|
202
|
+
|
|
203
|
+
if not session_path or not session_path.exists():
|
|
204
|
+
print(f"[watcher] Session file not found. Pass --session <path>")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
watch(session_path, post_url=args.post, poll_interval=args.interval)
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
print("\n[watcher] Stopped.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
main()
|