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
batch_api.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dulus Batch API — provider-agnostic OpenAI-compatible batch processing.
|
|
3
|
+
|
|
4
|
+
Works with any provider that supports the OpenAI Batch API format:
|
|
5
|
+
- OpenAI (api.openai.com)
|
|
6
|
+
- Kimi/Moonshot (api.moonshot.ai)
|
|
7
|
+
- Any OpenAI-compatible endpoint
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
mgr = BatchManager(api_key="sk-...", base_url="https://api.openai.com")
|
|
11
|
+
jsonl = mgr.prepare_jsonl(["prompt1", "prompt2"], model="gpt-4o-mini")
|
|
12
|
+
file_id = mgr.upload_file(jsonl)
|
|
13
|
+
batch_id = mgr.create_batch(file_id)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import urllib.request
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from typing import Optional, List, Dict, Any
|
|
21
|
+
|
|
22
|
+
# ── Defaults ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
OPENAI_BASE_URL = "https://api.openai.com"
|
|
25
|
+
KIMI_BASE_URL = "https://api.moonshot.ai"
|
|
26
|
+
|
|
27
|
+
BATCH_SYSTEM_PROMPT = (
|
|
28
|
+
"You are Dulus, an AI assistant. You are processing a batch request — "
|
|
29
|
+
"respond directly to each task. Be concise, precise, and complete. "
|
|
30
|
+
"Output in the same language the user writes in. "
|
|
31
|
+
"No tool calls available — just answer with text."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── BatchManager ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class BatchManager:
|
|
38
|
+
"""Provider-agnostic manager for the OpenAI-compatible Batch API."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: str, base_url: str = OPENAI_BASE_URL):
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.base_url = base_url.rstrip("/")
|
|
43
|
+
|
|
44
|
+
def _headers(self, content_type: str = "application/json") -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"Content-Type": content_type,
|
|
47
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ── JSONL preparation ────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def prepare_jsonl(
|
|
53
|
+
self,
|
|
54
|
+
prompts: List[str],
|
|
55
|
+
model: str = "gpt-4o-mini",
|
|
56
|
+
system_prompt: str = None,
|
|
57
|
+
endpoint: str = "/v1/chat/completions",
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Convert a list of prompts into JSONL content for the Batch API.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
prompts: List of user prompts.
|
|
63
|
+
model: Model name (provider-specific).
|
|
64
|
+
system_prompt: Defaults to BATCH_SYSTEM_PROMPT. Pass "" to omit.
|
|
65
|
+
endpoint: API endpoint for each request.
|
|
66
|
+
"""
|
|
67
|
+
if system_prompt is None:
|
|
68
|
+
system_prompt = BATCH_SYSTEM_PROMPT
|
|
69
|
+
|
|
70
|
+
lines = []
|
|
71
|
+
ts = int(time.time())
|
|
72
|
+
for i, prompt in enumerate(prompts):
|
|
73
|
+
messages = []
|
|
74
|
+
if system_prompt:
|
|
75
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
76
|
+
messages.append({"role": "user", "content": prompt})
|
|
77
|
+
|
|
78
|
+
request = {
|
|
79
|
+
"custom_id": f"req_{ts}_{i}",
|
|
80
|
+
"method": "POST",
|
|
81
|
+
"url": endpoint,
|
|
82
|
+
"body": {
|
|
83
|
+
"model": model,
|
|
84
|
+
"messages": messages,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
lines.append(json.dumps(request, ensure_ascii=False))
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
# ── File upload (multipart/form-data) ────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def upload_file(self, jsonl_content: str, filename: str = "batch_input.jsonl") -> str:
|
|
93
|
+
"""Upload JSONL content and return the file_id."""
|
|
94
|
+
url = f"{self.base_url}/v1/files"
|
|
95
|
+
boundary = f"----DulusBatch{int(time.time())}"
|
|
96
|
+
|
|
97
|
+
parts = []
|
|
98
|
+
# purpose field
|
|
99
|
+
parts.append(f"--{boundary}\r\n"
|
|
100
|
+
f'Content-Disposition: form-data; name="purpose"\r\n\r\n'
|
|
101
|
+
f"batch")
|
|
102
|
+
# file field
|
|
103
|
+
parts.append(f"--{boundary}\r\n"
|
|
104
|
+
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
105
|
+
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
106
|
+
f"{jsonl_content}")
|
|
107
|
+
parts.append(f"--{boundary}--\r\n")
|
|
108
|
+
|
|
109
|
+
full_body = "\r\n".join(parts).encode("utf-8")
|
|
110
|
+
|
|
111
|
+
req = urllib.request.Request(
|
|
112
|
+
url,
|
|
113
|
+
data=full_body,
|
|
114
|
+
headers={
|
|
115
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
116
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
117
|
+
},
|
|
118
|
+
method="POST",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
with urllib.request.urlopen(req) as resp:
|
|
122
|
+
return json.loads(resp.read().decode("utf-8"))["id"]
|
|
123
|
+
|
|
124
|
+
# ── Batch lifecycle ──────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def create_batch(
|
|
127
|
+
self,
|
|
128
|
+
file_id: str,
|
|
129
|
+
endpoint: str = "/v1/chat/completions",
|
|
130
|
+
completion_window: str = "24h",
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Create a batch from an uploaded file. Returns batch_id."""
|
|
133
|
+
url = f"{self.base_url}/v1/batches"
|
|
134
|
+
payload = {
|
|
135
|
+
"input_file_id": file_id,
|
|
136
|
+
"endpoint": endpoint,
|
|
137
|
+
"completion_window": completion_window,
|
|
138
|
+
}
|
|
139
|
+
req = urllib.request.Request(
|
|
140
|
+
url,
|
|
141
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
142
|
+
headers=self._headers(),
|
|
143
|
+
method="POST",
|
|
144
|
+
)
|
|
145
|
+
with urllib.request.urlopen(req) as resp:
|
|
146
|
+
return json.loads(resp.read().decode("utf-8"))["id"]
|
|
147
|
+
|
|
148
|
+
def retrieve_batch(self, batch_id: str) -> Dict[str, Any]:
|
|
149
|
+
"""Get batch status/info."""
|
|
150
|
+
url = f"{self.base_url}/v1/batches/{batch_id}"
|
|
151
|
+
req = urllib.request.Request(url, headers=self._headers(), method="GET")
|
|
152
|
+
with urllib.request.urlopen(req) as resp:
|
|
153
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
154
|
+
|
|
155
|
+
def cancel_batch(self, batch_id: str) -> Dict[str, Any]:
|
|
156
|
+
"""Cancel a running batch."""
|
|
157
|
+
url = f"{self.base_url}/v1/batches/{batch_id}/cancel"
|
|
158
|
+
req = urllib.request.Request(url, headers=self._headers(), method="POST")
|
|
159
|
+
with urllib.request.urlopen(req) as resp:
|
|
160
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
161
|
+
|
|
162
|
+
def get_file_content(self, file_id: str) -> str:
|
|
163
|
+
"""Download file content (e.g. batch results)."""
|
|
164
|
+
url = f"{self.base_url}/v1/files/{file_id}/content"
|
|
165
|
+
req = urllib.request.Request(url, headers=self._headers(), method="GET")
|
|
166
|
+
with urllib.request.urlopen(req) as resp:
|
|
167
|
+
return resp.read().decode("utf-8")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Backward compat alias ────────────────────────────────────────────────────
|
|
171
|
+
KimiBatchManager = BatchManager # old name still works
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Local job persistence ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
_JOBS_DIR = os.path.join(os.path.expanduser("~"), ".dulus", "jobs")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def save_batch_job(batch_id: str, description: str = "", file_id: str = "",
|
|
180
|
+
provider: str = "unknown") -> str:
|
|
181
|
+
"""Save a batch job record locally in ~/.dulus/jobs/."""
|
|
182
|
+
os.makedirs(_JOBS_DIR, exist_ok=True)
|
|
183
|
+
job_file = os.path.join(_JOBS_DIR, f"{batch_id}.json")
|
|
184
|
+
|
|
185
|
+
job_data = {
|
|
186
|
+
"job_id": batch_id,
|
|
187
|
+
"id": batch_id,
|
|
188
|
+
"tool_name": "batch",
|
|
189
|
+
"provider": provider,
|
|
190
|
+
"params": {"description": description, "file_id": file_id},
|
|
191
|
+
"status": "created",
|
|
192
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
193
|
+
"description": description,
|
|
194
|
+
"file_id": file_id,
|
|
195
|
+
"batch_id": batch_id,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
with open(job_file, "w", encoding="utf-8") as f:
|
|
199
|
+
json.dump(job_data, f, indent=2)
|
|
200
|
+
return job_file
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def list_batch_jobs(include_pollers: bool = True, **_kw) -> List[Dict]:
|
|
204
|
+
"""List saved batch jobs from ~/.dulus/jobs/."""
|
|
205
|
+
if not os.path.exists(_JOBS_DIR):
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
batch_map: Dict[str, Dict] = {}
|
|
209
|
+
poller_jobs: List[Dict] = []
|
|
210
|
+
# Accept both old "kimi_batch" and new "batch" tool_name
|
|
211
|
+
_batch_names = {"kimi_batch", "batch"}
|
|
212
|
+
_poller_names = {"kimi_batch_poll", "batch_poll"}
|
|
213
|
+
|
|
214
|
+
for fname in os.listdir(_JOBS_DIR):
|
|
215
|
+
if not fname.endswith(".json"):
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
with open(os.path.join(_JOBS_DIR, fname), "r", encoding="utf-8") as f:
|
|
219
|
+
job = json.load(f)
|
|
220
|
+
|
|
221
|
+
tn = job.get("tool_name", "")
|
|
222
|
+
if tn in _batch_names:
|
|
223
|
+
bid = job.get("batch_id") or job.get("id")
|
|
224
|
+
if bid:
|
|
225
|
+
batch_map[bid] = job
|
|
226
|
+
|
|
227
|
+
elif include_pollers and tn in _poller_names:
|
|
228
|
+
poller_jobs.append(job)
|
|
229
|
+
br = job.get("batch_result", {})
|
|
230
|
+
if br:
|
|
231
|
+
bid = br.get("id")
|
|
232
|
+
if bid and bid in batch_map:
|
|
233
|
+
batch_map[bid]["status"] = br.get("status", "unknown")
|
|
234
|
+
batch_map[bid]["request_counts"] = br.get("request_counts", {})
|
|
235
|
+
batch_map[bid]["output_file_id"] = br.get("output_file_id")
|
|
236
|
+
batch_map[bid]["completed_at"] = br.get("completed_at")
|
|
237
|
+
batch_map[bid]["_poller_job_id"] = job.get("job_id")
|
|
238
|
+
except Exception:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Pollers for batches not yet in map → synthetic entry
|
|
242
|
+
for poller in poller_jobs:
|
|
243
|
+
br = poller.get("batch_result", {})
|
|
244
|
+
bid = br.get("id")
|
|
245
|
+
if bid and bid not in batch_map:
|
|
246
|
+
batch_map[bid] = {
|
|
247
|
+
"job_id": bid, "id": bid,
|
|
248
|
+
"tool_name": "batch",
|
|
249
|
+
"status": br.get("status", "unknown"),
|
|
250
|
+
"created_at": poller.get("created_at"),
|
|
251
|
+
"description": f"(from poller {poller.get('job_id', '?')[:8]}...)",
|
|
252
|
+
"batch_id": bid,
|
|
253
|
+
"request_counts": br.get("request_counts", {}),
|
|
254
|
+
"output_file_id": br.get("output_file_id"),
|
|
255
|
+
"completed_at": br.get("completed_at"),
|
|
256
|
+
"_from_poller": True,
|
|
257
|
+
"_poller_job_id": poller.get("job_id"),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
jobs = list(batch_map.values())
|
|
261
|
+
jobs.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
262
|
+
return jobs
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def update_batch_job_status(batch_id: str, status_info: Dict[str, Any]) -> bool:
|
|
266
|
+
"""Update a batch job's status in its local file."""
|
|
267
|
+
job_file = os.path.join(_JOBS_DIR, f"{batch_id}.json")
|
|
268
|
+
if not os.path.exists(job_file):
|
|
269
|
+
return False
|
|
270
|
+
try:
|
|
271
|
+
with open(job_file, "r", encoding="utf-8") as f:
|
|
272
|
+
job = json.load(f)
|
|
273
|
+
for key in ("status", "request_counts", "output_file_id", "completed_at"):
|
|
274
|
+
if key in status_info:
|
|
275
|
+
job[key] = status_info[key]
|
|
276
|
+
with open(job_file, "w", encoding="utf-8") as f:
|
|
277
|
+
json.dump(job, f, indent=2)
|
|
278
|
+
return True
|
|
279
|
+
except Exception:
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_batch_job_by_id(batch_id: str) -> Optional[Dict]:
|
|
284
|
+
"""Get a batch job by ID (checks both batch and poller files)."""
|
|
285
|
+
# Direct file
|
|
286
|
+
job_file = os.path.join(_JOBS_DIR, f"{batch_id}.json")
|
|
287
|
+
if os.path.exists(job_file):
|
|
288
|
+
try:
|
|
289
|
+
with open(job_file, "r", encoding="utf-8") as f:
|
|
290
|
+
return json.load(f)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
# Scan pollers
|
|
295
|
+
if os.path.exists(_JOBS_DIR):
|
|
296
|
+
for fname in os.listdir(_JOBS_DIR):
|
|
297
|
+
if not fname.endswith(".json"):
|
|
298
|
+
continue
|
|
299
|
+
try:
|
|
300
|
+
with open(os.path.join(_JOBS_DIR, fname), "r", encoding="utf-8") as f:
|
|
301
|
+
job = json.load(f)
|
|
302
|
+
if job.get("tool_name") in ("kimi_batch_poll", "batch_poll"):
|
|
303
|
+
if job.get("params", {}).get("batch_id") == batch_id:
|
|
304
|
+
return job
|
|
305
|
+
except Exception:
|
|
306
|
+
continue
|
|
307
|
+
return None
|
checkpoint/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Checkpoint system: automatic file snapshots with rewind support."""
|
|
2
|
+
from .types import FileBackup, Snapshot, MAX_SNAPSHOTS
|
|
3
|
+
from .store import (
|
|
4
|
+
track_file_edit,
|
|
5
|
+
make_snapshot,
|
|
6
|
+
list_snapshots,
|
|
7
|
+
get_snapshot,
|
|
8
|
+
rewind_files,
|
|
9
|
+
files_changed_since,
|
|
10
|
+
delete_session_checkpoints,
|
|
11
|
+
cleanup_old_sessions,
|
|
12
|
+
reset_file_versions,
|
|
13
|
+
)
|
|
14
|
+
from .hooks import (
|
|
15
|
+
set_session,
|
|
16
|
+
get_tracked_edits,
|
|
17
|
+
reset_tracked,
|
|
18
|
+
install_hooks,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"FileBackup", "Snapshot", "MAX_SNAPSHOTS",
|
|
23
|
+
"track_file_edit", "make_snapshot", "list_snapshots", "get_snapshot",
|
|
24
|
+
"rewind_files", "files_changed_since",
|
|
25
|
+
"delete_session_checkpoints", "cleanup_old_sessions", "reset_file_versions",
|
|
26
|
+
"set_session", "get_tracked_edits", "reset_tracked", "install_hooks",
|
|
27
|
+
]
|
checkpoint/hooks.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Checkpoint hooks: intercept Write/Edit/NotebookEdit to back up files before modification.
|
|
2
|
+
|
|
3
|
+
Import this module after tools are registered to install the hooks.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import store
|
|
10
|
+
|
|
11
|
+
# ── Module state ────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
_current_session_id: str | None = None
|
|
14
|
+
_tracked_edits: dict[str, str | None] = {} # file_path → backup_filename
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_session(session_id: str) -> None:
|
|
18
|
+
global _current_session_id
|
|
19
|
+
_current_session_id = session_id
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_tracked_edits() -> dict[str, str | None]:
|
|
23
|
+
"""Return the current interval's tracked edits (for make_snapshot)."""
|
|
24
|
+
return dict(_tracked_edits)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reset_tracked() -> None:
|
|
28
|
+
"""Clear tracked edits after a snapshot is created."""
|
|
29
|
+
_tracked_edits.clear()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Backup logic ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def _backup_before_write(file_path: str) -> None:
|
|
35
|
+
"""Back up a file before it is modified (first-write-wins per snapshot interval)."""
|
|
36
|
+
if _current_session_id is None:
|
|
37
|
+
return
|
|
38
|
+
if file_path in _tracked_edits:
|
|
39
|
+
return # already backed up this interval
|
|
40
|
+
|
|
41
|
+
backup_name = store.track_file_edit(_current_session_id, file_path)
|
|
42
|
+
_tracked_edits[file_path] = backup_name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Hook installation ───────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
_hooks_installed = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def install_hooks() -> None:
|
|
51
|
+
"""Wrap Write/Edit/NotebookEdit tool functions to call backup before execution."""
|
|
52
|
+
global _hooks_installed
|
|
53
|
+
if _hooks_installed:
|
|
54
|
+
return
|
|
55
|
+
_hooks_installed = True
|
|
56
|
+
|
|
57
|
+
from tool_registry import get_tool
|
|
58
|
+
|
|
59
|
+
# Hook Write
|
|
60
|
+
write_tool = get_tool("Write")
|
|
61
|
+
if write_tool:
|
|
62
|
+
original_write = write_tool.func
|
|
63
|
+
def hooked_write(params, config):
|
|
64
|
+
fp = params.get("file_path", "")
|
|
65
|
+
if fp:
|
|
66
|
+
_backup_before_write(fp)
|
|
67
|
+
return original_write(params, config)
|
|
68
|
+
write_tool.func = hooked_write
|
|
69
|
+
|
|
70
|
+
# Hook Edit
|
|
71
|
+
edit_tool = get_tool("Edit")
|
|
72
|
+
if edit_tool:
|
|
73
|
+
original_edit = edit_tool.func
|
|
74
|
+
def hooked_edit(params, config):
|
|
75
|
+
fp = params.get("file_path", "")
|
|
76
|
+
if fp:
|
|
77
|
+
_backup_before_write(fp)
|
|
78
|
+
return original_edit(params, config)
|
|
79
|
+
edit_tool.func = hooked_edit
|
|
80
|
+
|
|
81
|
+
# Hook NotebookEdit
|
|
82
|
+
nb_tool = get_tool("NotebookEdit")
|
|
83
|
+
if nb_tool:
|
|
84
|
+
original_nb = nb_tool.func
|
|
85
|
+
def hooked_nb(params, config):
|
|
86
|
+
fp = params.get("notebook_path", "")
|
|
87
|
+
if fp:
|
|
88
|
+
_backup_before_write(fp)
|
|
89
|
+
return original_nb(params, config)
|
|
90
|
+
nb_tool.func = hooked_nb
|