delimit-cli 4.0.5 → 4.1.0
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.
- package/README.md +2 -2
- package/bin/delimit-cli.js +33 -0
- package/bin/delimit-os.sh +105 -0
- package/gateway/ai/agent_dispatch.py +2 -34
- package/gateway/ai/github_scanner.py +1 -1
- package/gateway/ai/ledger_manager.py +3 -13
- package/gateway/ai/loop_engine.py +372 -175
- package/gateway/ai/notify.py +2 -1662
- package/gateway/ai/reddit_scanner.py +0 -34
- package/gateway/ai/server.py +4 -3
- package/gateway/ai/tui.py +377 -0
- package/lib/delimit-template.js +0 -5
- package/package.json +2 -2
- package/scripts/security-check.sh +0 -12
|
@@ -1,211 +1,408 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- enforce max-iteration, max-error, and max-cost safeguards
|
|
1
|
+
"""Autonomous build loop engine — governed, throttled, cross-model.
|
|
2
|
+
|
|
3
|
+
Provides the core loop primitives that any AI model can use via MCP:
|
|
4
|
+
- next_task: get the next prioritized item with safeguard checks
|
|
5
|
+
- task_complete: record completion, check if loop should continue
|
|
6
|
+
- loop_status: current session metrics
|
|
7
|
+
- loop_config: configure safeguards
|
|
8
|
+
|
|
9
|
+
Session state persisted at ~/.delimit/loop/sessions/<session_id>.json
|
|
11
10
|
"""
|
|
12
11
|
|
|
13
12
|
import json
|
|
14
|
-
import logging
|
|
15
|
-
from datetime import datetime, timezone
|
|
16
13
|
import os
|
|
17
14
|
import time
|
|
18
15
|
import uuid
|
|
19
16
|
from pathlib import Path
|
|
20
17
|
from typing import Any, Dict, List, Optional
|
|
21
18
|
|
|
22
|
-
|
|
19
|
+
LOOP_DIR = Path.home() / ".delimit" / "loop"
|
|
20
|
+
SESSIONS_DIR = LOOP_DIR / "sessions"
|
|
21
|
+
|
|
22
|
+
# Actions the AI model must never auto-execute without human approval
|
|
23
|
+
DEFAULT_REQUIRE_APPROVAL = ["deploy", "social_post", "outreach", "publish"]
|
|
24
|
+
|
|
25
|
+
VALID_STATUSES = {"running", "paused", "stopped", "circuit_broken"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ensure_dir():
|
|
29
|
+
"""Create the loop sessions directory if it doesn't exist."""
|
|
30
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
31
|
|
|
24
|
-
# ── Configuration ────────────────────────────────────────────────────
|
|
25
|
-
ROOT_LEDGER_PATH = Path("/root/.delimit")
|
|
26
|
-
BUILD_SAFE_TYPES = ["feat", "fix", "task"]
|
|
27
|
-
MAX_ITERATIONS_DEFAULT = 10
|
|
28
|
-
MAX_COST_DEFAULT = 2.0
|
|
29
|
-
MAX_ERRORS_DEFAULT = 2
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
def _session_path(session_id: str) -> Path:
|
|
34
|
+
"""Return the filesystem path for a session state file."""
|
|
35
|
+
return SESSIONS_DIR / f"{session_id}.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_session(session_id: str) -> Optional[Dict[str, Any]]:
|
|
39
|
+
"""Load session state from disk. Returns None if not found."""
|
|
40
|
+
path = _session_path(session_id)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(path.read_text())
|
|
45
|
+
except (json.JSONDecodeError, OSError):
|
|
46
|
+
return None
|
|
33
47
|
|
|
34
|
-
def _ensure_session_dir():
|
|
35
|
-
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
48
|
|
|
37
49
|
def _save_session(session: Dict[str, Any]):
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
"""Persist session state to disk."""
|
|
51
|
+
_ensure_dir()
|
|
52
|
+
path = _session_path(session["session_id"])
|
|
40
53
|
path.write_text(json.dumps(session, indent=2))
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
|
|
56
|
+
def _create_session(session_id: str = "") -> Dict[str, Any]:
|
|
57
|
+
"""Create a new loop session with default safeguards."""
|
|
58
|
+
if not session_id:
|
|
59
|
+
session_id = str(uuid.uuid4())[:12]
|
|
44
60
|
session = {
|
|
45
61
|
"session_id": session_id,
|
|
46
|
-
"
|
|
47
|
-
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
62
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
48
63
|
"iterations": 0,
|
|
49
|
-
"max_iterations":
|
|
64
|
+
"max_iterations": 50,
|
|
50
65
|
"cost_incurred": 0.0,
|
|
51
|
-
"cost_cap":
|
|
66
|
+
"cost_cap": 5.0,
|
|
52
67
|
"errors": 0,
|
|
53
|
-
"error_threshold":
|
|
68
|
+
"error_threshold": 3,
|
|
54
69
|
"tasks_completed": [],
|
|
55
|
-
"
|
|
70
|
+
"auto_consensus": False,
|
|
71
|
+
"require_approval_for": list(DEFAULT_REQUIRE_APPROVAL),
|
|
72
|
+
"status": "running",
|
|
56
73
|
}
|
|
57
74
|
_save_session(session)
|
|
58
75
|
return session
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
|
|
78
|
+
def _get_or_create_session(session_id: str = "") -> Dict[str, Any]:
|
|
79
|
+
"""Load an existing session or create a new one."""
|
|
80
|
+
if session_id:
|
|
81
|
+
existing = _load_session(session_id)
|
|
82
|
+
if existing:
|
|
83
|
+
return existing
|
|
84
|
+
return _create_session(session_id)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_safeguards(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
88
|
+
"""Check all safeguards. Returns a STOP action if any are tripped, else None."""
|
|
89
|
+
if session.get("status") == "paused":
|
|
90
|
+
return {
|
|
91
|
+
"action": "STOP",
|
|
92
|
+
"reason": "Loop is paused. Call loop_config to resume.",
|
|
93
|
+
"safeguard": "paused",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if session.get("status") == "stopped":
|
|
97
|
+
return {
|
|
98
|
+
"action": "STOP",
|
|
99
|
+
"reason": "Loop has been stopped.",
|
|
100
|
+
"safeguard": "stopped",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if session.get("status") == "circuit_broken":
|
|
104
|
+
return {
|
|
105
|
+
"action": "STOP",
|
|
106
|
+
"reason": f"Circuit breaker tripped after {session['errors']} errors.",
|
|
107
|
+
"safeguard": "circuit_breaker",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if session["iterations"] >= session["max_iterations"]:
|
|
111
|
+
return {
|
|
112
|
+
"action": "STOP",
|
|
113
|
+
"reason": f"Reached max iterations ({session['max_iterations']}).",
|
|
114
|
+
"safeguard": "max_iterations",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if session["cost_incurred"] >= session["cost_cap"]:
|
|
118
|
+
return {
|
|
119
|
+
"action": "STOP",
|
|
120
|
+
"reason": f"Cost cap reached (${session['cost_incurred']:.2f} >= ${session['cost_cap']:.2f}).",
|
|
121
|
+
"safeguard": "cost_cap",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if session["errors"] >= session["error_threshold"]:
|
|
125
|
+
session["status"] = "circuit_broken"
|
|
126
|
+
_save_session(session)
|
|
127
|
+
return {
|
|
128
|
+
"action": "STOP",
|
|
129
|
+
"reason": f"Circuit breaker: {session['errors']} errors hit threshold ({session['error_threshold']}).",
|
|
130
|
+
"safeguard": "circuit_breaker",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_open_items(venture: str = "", project_path: str = ".") -> List[Dict[str, Any]]:
|
|
137
|
+
"""Query the ledger for open items, sorted by priority."""
|
|
93
138
|
from ai.ledger_manager import list_items
|
|
94
|
-
|
|
95
|
-
# Authoritative root ledger check
|
|
96
|
-
result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
|
|
139
|
+
result = list_items(status="open", project_path=project_path)
|
|
97
140
|
items = []
|
|
98
141
|
for ledger_items in result.get("items", {}).values():
|
|
99
142
|
items.extend(ledger_items)
|
|
100
|
-
|
|
101
|
-
#
|
|
102
|
-
|
|
143
|
+
|
|
144
|
+
# Sort by priority
|
|
145
|
+
priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
146
|
+
items.sort(key=lambda x: priority_order.get(x.get("priority", "P2"), 9))
|
|
147
|
+
return items
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _filter_actionable(items: List[Dict[str, Any]], max_risk: str = "") -> List[Dict[str, Any]]:
|
|
151
|
+
"""Filter out owner-only items and apply risk filtering.
|
|
152
|
+
|
|
153
|
+
Owner-only items are those with source='owner' or tags containing 'owner-action'.
|
|
154
|
+
"""
|
|
155
|
+
filtered = []
|
|
103
156
|
for item in items:
|
|
104
|
-
|
|
105
|
-
continue
|
|
106
|
-
# Skip items that explicitly require owner action or are not for AI
|
|
157
|
+
# Skip owner-only items
|
|
107
158
|
tags = item.get("tags", [])
|
|
108
|
-
if "owner-action" in tags or "
|
|
159
|
+
if "owner-action" in tags or "owner-only" in tags:
|
|
109
160
|
continue
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def
|
|
124
|
-
"""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if session["status"] != "running":
|
|
135
|
-
return {"status": "stopped", "reason": f"Session status is {session['status']}"}
|
|
136
|
-
|
|
137
|
-
if session["iterations"] >= session["max_iterations"]:
|
|
138
|
-
session["status"] = "finished"
|
|
139
|
-
_save_session(session)
|
|
140
|
-
return {"status": "finished", "reason": "Max iterations reached"}
|
|
141
|
-
|
|
142
|
-
if session["cost_incurred"] >= session["cost_cap"]:
|
|
143
|
-
session["status"] = "stopped"
|
|
144
|
-
_save_session(session)
|
|
145
|
-
return {"status": "stopped", "reason": "Cost cap reached"}
|
|
146
|
-
|
|
147
|
-
# 2. Select Task
|
|
148
|
-
task = get_next_build_task(session)
|
|
149
|
-
if not task:
|
|
150
|
-
return {"status": "idle", "reason": "No build-safe items in ledger"}
|
|
151
|
-
|
|
152
|
-
# 3. Resolve Context
|
|
153
|
-
v_name = task.get("venture", "root")
|
|
154
|
-
ctx = resolve_venture_context(v_name)
|
|
155
|
-
|
|
156
|
-
# 4. Dispatch through Swarm (Control Plane)
|
|
157
|
-
logger.info(f"Dispatching build task {task['id']} for venture {v_name}")
|
|
158
|
-
|
|
159
|
-
start_time = time.time()
|
|
161
|
+
if item.get("source") == "owner":
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Risk filtering
|
|
165
|
+
if max_risk:
|
|
166
|
+
item_risk = item.get("risk", "")
|
|
167
|
+
if item_risk and _risk_level(item_risk) > _risk_level(max_risk):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
filtered.append(item)
|
|
171
|
+
return filtered
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _resolve_project_path(venture: str) -> str:
|
|
175
|
+
"""Resolve a venture name or path to a project directory path."""
|
|
176
|
+
if not venture:
|
|
177
|
+
return "."
|
|
178
|
+
# Direct path — use as-is
|
|
179
|
+
if venture.startswith("/") or venture.startswith("~"):
|
|
180
|
+
return str(Path(venture).expanduser())
|
|
181
|
+
if venture.startswith(".") or os.sep in venture:
|
|
182
|
+
return str(Path(venture).resolve())
|
|
183
|
+
# Try registered ventures
|
|
160
184
|
try:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
185
|
+
from ai.ledger_manager import list_ventures
|
|
186
|
+
ventures = list_ventures()
|
|
187
|
+
for name, info in ventures.get("ventures", {}).items():
|
|
188
|
+
if name == venture or venture in name:
|
|
189
|
+
return info.get("path", ".")
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
return "."
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _risk_level(risk: str) -> int:
|
|
196
|
+
"""Convert risk string to numeric level for comparison."""
|
|
197
|
+
levels = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
198
|
+
return levels.get(risk.lower(), 2)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def next_task(
|
|
202
|
+
venture: str = "",
|
|
203
|
+
max_risk: str = "",
|
|
204
|
+
session_id: str = "",
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""Get the next task to work on with safeguard checks.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Dict with action: BUILD (with task), CONSENSUS (generate new items), or STOP.
|
|
210
|
+
"""
|
|
211
|
+
session = _get_or_create_session(session_id)
|
|
212
|
+
|
|
213
|
+
# Check safeguards
|
|
214
|
+
stop = _check_safeguards(session)
|
|
215
|
+
if stop:
|
|
216
|
+
stop["session"] = _session_summary(session)
|
|
217
|
+
return stop
|
|
218
|
+
|
|
219
|
+
# Resolve venture path
|
|
220
|
+
project_path = _resolve_project_path(venture)
|
|
221
|
+
|
|
222
|
+
# Get open items
|
|
223
|
+
items = _get_open_items(venture=venture, project_path=project_path)
|
|
224
|
+
actionable = _filter_actionable(items, max_risk=max_risk)
|
|
225
|
+
|
|
226
|
+
if not actionable:
|
|
227
|
+
if session.get("auto_consensus"):
|
|
228
|
+
return {
|
|
229
|
+
"action": "CONSENSUS",
|
|
230
|
+
"message": "No actionable items. Run consensus to generate new work.",
|
|
231
|
+
"session": _session_summary(session),
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
"action": "STOP",
|
|
235
|
+
"reason": "No actionable items in the ledger.",
|
|
236
|
+
"safeguard": "empty_ledger",
|
|
237
|
+
"session": _session_summary(session),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
task = actionable[0]
|
|
241
|
+
|
|
242
|
+
# Check if this task requires approval
|
|
243
|
+
require_approval = session.get("require_approval_for", [])
|
|
244
|
+
task_tags = task.get("tags", [])
|
|
245
|
+
needs_approval = any(tag in require_approval for tag in task_tags)
|
|
246
|
+
task_type = task.get("type", "")
|
|
247
|
+
if task_type in require_approval:
|
|
248
|
+
needs_approval = True
|
|
249
|
+
|
|
250
|
+
result = {
|
|
251
|
+
"action": "BUILD",
|
|
252
|
+
"task": task,
|
|
253
|
+
"remaining_items": len(actionable) - 1,
|
|
254
|
+
"session": _session_summary(session),
|
|
255
|
+
}
|
|
256
|
+
if needs_approval:
|
|
257
|
+
result["approval_required"] = True
|
|
258
|
+
result["approval_reason"] = "Task type or tags match require_approval_for list."
|
|
259
|
+
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def task_complete(
|
|
264
|
+
task_id: str,
|
|
265
|
+
result: str = "",
|
|
266
|
+
cost_incurred: float = 0.0,
|
|
267
|
+
error: str = "",
|
|
268
|
+
session_id: str = "",
|
|
269
|
+
venture: str = "",
|
|
270
|
+
) -> Dict[str, Any]:
|
|
271
|
+
"""Mark current task done and get the next one.
|
|
272
|
+
|
|
273
|
+
Records completion, updates session metrics, returns the next task.
|
|
274
|
+
"""
|
|
275
|
+
session = _get_or_create_session(session_id)
|
|
276
|
+
|
|
277
|
+
# Update metrics
|
|
278
|
+
session["iterations"] += 1
|
|
279
|
+
session["cost_incurred"] += cost_incurred
|
|
280
|
+
|
|
281
|
+
if error:
|
|
205
282
|
session["errors"] += 1
|
|
206
|
-
|
|
207
|
-
|
|
283
|
+
session["tasks_completed"].append({
|
|
284
|
+
"task_id": task_id,
|
|
285
|
+
"status": "error",
|
|
286
|
+
"error": error,
|
|
287
|
+
"completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
288
|
+
"cost": cost_incurred,
|
|
289
|
+
})
|
|
290
|
+
else:
|
|
291
|
+
session["tasks_completed"].append({
|
|
292
|
+
"task_id": task_id,
|
|
293
|
+
"status": "done",
|
|
294
|
+
"result": result[:500] if result else "",
|
|
295
|
+
"completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
296
|
+
"cost": cost_incurred,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
_save_session(session)
|
|
300
|
+
|
|
301
|
+
# Mark ledger item as done (best-effort)
|
|
302
|
+
if not error:
|
|
303
|
+
try:
|
|
304
|
+
from ai.ledger_manager import update_item
|
|
305
|
+
project_path = _resolve_project_path(venture)
|
|
306
|
+
update_item(item_id=task_id, status="done", note=result[:200] if result else "Completed via build loop", project_path=project_path)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass # Never let ledger sync break the loop
|
|
309
|
+
|
|
310
|
+
# Return the next task
|
|
311
|
+
return next_task(venture=venture, session_id=session["session_id"])
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def loop_status(session_id: str = "") -> Dict[str, Any]:
|
|
315
|
+
"""Return current session metrics."""
|
|
316
|
+
if not session_id:
|
|
317
|
+
# Try to find the most recent session
|
|
318
|
+
sessions = _list_sessions()
|
|
319
|
+
if not sessions:
|
|
320
|
+
return {"error": "No active loop sessions found."}
|
|
321
|
+
session_id = sessions[0]["session_id"]
|
|
322
|
+
|
|
323
|
+
session = _load_session(session_id)
|
|
324
|
+
if not session:
|
|
325
|
+
return {"error": f"Session {session_id} not found."}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
"session": _session_summary(session),
|
|
329
|
+
"tasks_completed": session.get("tasks_completed", []),
|
|
330
|
+
"safeguards": {
|
|
331
|
+
"max_iterations": session["max_iterations"],
|
|
332
|
+
"cost_cap": session["cost_cap"],
|
|
333
|
+
"error_threshold": session["error_threshold"],
|
|
334
|
+
"require_approval_for": session.get("require_approval_for", []),
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
208
338
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
339
|
+
def loop_config(
|
|
340
|
+
session_id: str = "",
|
|
341
|
+
max_iterations: int = 0,
|
|
342
|
+
cost_cap: float = 0.0,
|
|
343
|
+
auto_consensus: Optional[bool] = None,
|
|
344
|
+
error_threshold: int = 0,
|
|
345
|
+
status: str = "",
|
|
346
|
+
require_approval_for: Optional[List[str]] = None,
|
|
347
|
+
) -> Dict[str, Any]:
|
|
348
|
+
"""Update session configuration. Only provided values are changed."""
|
|
349
|
+
session = _get_or_create_session(session_id)
|
|
350
|
+
|
|
351
|
+
changes = {}
|
|
352
|
+
if max_iterations > 0:
|
|
353
|
+
session["max_iterations"] = max_iterations
|
|
354
|
+
changes["max_iterations"] = max_iterations
|
|
355
|
+
if cost_cap > 0:
|
|
356
|
+
session["cost_cap"] = cost_cap
|
|
357
|
+
changes["cost_cap"] = cost_cap
|
|
358
|
+
if auto_consensus is not None:
|
|
359
|
+
session["auto_consensus"] = auto_consensus
|
|
360
|
+
changes["auto_consensus"] = auto_consensus
|
|
361
|
+
if error_threshold > 0:
|
|
362
|
+
session["error_threshold"] = error_threshold
|
|
363
|
+
changes["error_threshold"] = error_threshold
|
|
364
|
+
if status and status in VALID_STATUSES:
|
|
365
|
+
session["status"] = status
|
|
366
|
+
changes["status"] = status
|
|
367
|
+
if require_approval_for is not None:
|
|
368
|
+
session["require_approval_for"] = require_approval_for
|
|
369
|
+
changes["require_approval_for"] = require_approval_for
|
|
370
|
+
|
|
371
|
+
_save_session(session)
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
"session_id": session["session_id"],
|
|
375
|
+
"changes": changes,
|
|
376
|
+
"current_config": _session_summary(session),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _session_summary(session: Dict[str, Any]) -> Dict[str, Any]:
|
|
381
|
+
"""Return a concise session summary for inclusion in responses."""
|
|
382
|
+
return {
|
|
383
|
+
"session_id": session["session_id"],
|
|
384
|
+
"status": session["status"],
|
|
385
|
+
"iterations": session["iterations"],
|
|
386
|
+
"max_iterations": session["max_iterations"],
|
|
387
|
+
"cost_incurred": round(session["cost_incurred"], 4),
|
|
388
|
+
"cost_cap": session["cost_cap"],
|
|
389
|
+
"errors": session["errors"],
|
|
390
|
+
"error_threshold": session["error_threshold"],
|
|
391
|
+
"tasks_done": len(session.get("tasks_completed", [])),
|
|
392
|
+
"auto_consensus": session.get("auto_consensus", False),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _list_sessions() -> List[Dict[str, Any]]:
|
|
397
|
+
"""List all sessions, most recent first."""
|
|
398
|
+
if not SESSIONS_DIR.exists():
|
|
399
|
+
return []
|
|
400
|
+
sessions = []
|
|
401
|
+
for f in SESSIONS_DIR.glob("*.json"):
|
|
402
|
+
try:
|
|
403
|
+
s = json.loads(f.read_text())
|
|
404
|
+
sessions.append(s)
|
|
405
|
+
except (json.JSONDecodeError, OSError):
|
|
406
|
+
continue
|
|
407
|
+
sessions.sort(key=lambda x: x.get("started_at", ""), reverse=True)
|
|
408
|
+
return sessions
|