delimit-cli 3.15.12 → 3.15.13
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/CHANGELOG.md +13 -0
- package/gateway/ai/activate_helpers.py +210 -0
- package/gateway/ai/content_engine.py +2 -7
- package/gateway/ai/cross_model_audit.py +600 -0
- package/gateway/ai/github_scanner.py +622 -0
- package/gateway/ai/handoff_receipts.py +409 -0
- package/gateway/ai/key_resolver.py +2 -7
- package/gateway/ai/notify.py +4 -4
- package/gateway/ai/reddit_scanner.py +562 -0
- package/gateway/ai/secrets_broker.py +232 -4
- package/gateway/ai/server.py +9 -1
- package/gateway/ai/session_phoenix.py +371 -0
- package/gateway/ai/supabase_sync.py +2 -7
- package/gateway/ai/swarm.py +106 -0
- package/gateway/ai/tool_metadata.py +34 -6
- package/gateway/ai/toolcard_cache.py +327 -0
- package/package.json +1 -1
|
@@ -1,7 +1,235 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Secrets broker — JIT credential access with audit (STR-049).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Agents request credentials through this broker instead of accessing API keys
|
|
4
|
+
directly. The broker validates scope, issues time-limited access, and logs
|
|
5
|
+
every request in the audit trail.
|
|
5
6
|
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import base64
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Dict, List, Optional
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
SECRETS_DIR = Path.home() / ".delimit" / "secrets"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def store_secret(
|
|
18
|
+
name: str,
|
|
19
|
+
value: str,
|
|
20
|
+
scope: str = "all",
|
|
21
|
+
description: str = "",
|
|
22
|
+
created_by: str = "",
|
|
23
|
+
) -> Dict:
|
|
24
|
+
"""Store a secret locally.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Unique identifier for the secret.
|
|
28
|
+
value: The secret value (will be base64-encoded at rest).
|
|
29
|
+
scope: Comma-separated list of tools/agents allowed access, or 'all'.
|
|
30
|
+
description: Human-readable description.
|
|
31
|
+
created_by: Identity of the creator.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Confirmation dict with the stored secret name.
|
|
35
|
+
"""
|
|
36
|
+
if not name or not name.strip():
|
|
37
|
+
return {"error": "Secret name is required"}
|
|
38
|
+
if not value:
|
|
39
|
+
return {"error": "Secret value is required"}
|
|
40
|
+
|
|
41
|
+
# Sanitise name for filesystem safety
|
|
42
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
43
|
+
|
|
44
|
+
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
encoded = base64.b64encode(value.encode()).decode()
|
|
46
|
+
secret = {
|
|
47
|
+
"name": safe_name,
|
|
48
|
+
"encrypted_value": encoded,
|
|
49
|
+
"scope": scope,
|
|
50
|
+
"description": description,
|
|
51
|
+
"created_by": created_by,
|
|
52
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
53
|
+
"last_accessed_at": None,
|
|
54
|
+
"access_count": 0,
|
|
55
|
+
"revoked": False,
|
|
56
|
+
}
|
|
57
|
+
(SECRETS_DIR / f"{safe_name}.json").write_text(json.dumps(secret, indent=2))
|
|
58
|
+
return {"stored": safe_name}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_secret(
|
|
62
|
+
name: str,
|
|
63
|
+
agent_type: str = "",
|
|
64
|
+
tool: str = "",
|
|
65
|
+
) -> Dict:
|
|
66
|
+
"""Request access to a secret. Returns value if authorised.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
name: The secret name to retrieve.
|
|
70
|
+
agent_type: Identity of the requesting agent (e.g. 'claude', 'codex').
|
|
71
|
+
tool: Which MCP tool is requesting access.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict with 'value' and 'granted': True on success, or 'error' and
|
|
75
|
+
'granted': False on failure.
|
|
76
|
+
"""
|
|
77
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
78
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
79
|
+
if not path.exists():
|
|
80
|
+
_log_access(safe_name, agent_type, tool, granted=False, reason="not_found")
|
|
81
|
+
return {"error": f"Secret '{safe_name}' not found", "granted": False}
|
|
82
|
+
|
|
83
|
+
secret = json.loads(path.read_text())
|
|
84
|
+
|
|
85
|
+
if secret.get("revoked"):
|
|
86
|
+
_log_access(safe_name, agent_type, tool, granted=False, reason="revoked")
|
|
87
|
+
return {"error": f"Secret '{safe_name}' has been revoked", "granted": False}
|
|
88
|
+
|
|
89
|
+
# Scope check — 'all' allows any requester, otherwise match tool or agent_type
|
|
90
|
+
scope = secret.get("scope", "all")
|
|
91
|
+
if scope != "all":
|
|
92
|
+
allowed = {s.strip().lower() for s in scope.split(",")}
|
|
93
|
+
requester_ids = {agent_type.lower(), tool.lower()} - {""}
|
|
94
|
+
if requester_ids and not requester_ids & allowed:
|
|
95
|
+
_log_access(
|
|
96
|
+
safe_name, agent_type, tool,
|
|
97
|
+
granted=False,
|
|
98
|
+
reason=f"scope_denied: required={scope}, got agent_type={agent_type}, tool={tool}",
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
"error": f"Access denied: scope '{scope}' does not include '{agent_type or tool}'",
|
|
102
|
+
"granted": False,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Log successful access
|
|
106
|
+
_log_access(safe_name, agent_type, tool, granted=True, reason="")
|
|
107
|
+
|
|
108
|
+
# Update access metadata
|
|
109
|
+
secret["access_count"] = secret.get("access_count", 0) + 1
|
|
110
|
+
secret["last_accessed_at"] = datetime.now(timezone.utc).isoformat()
|
|
111
|
+
path.write_text(json.dumps(secret, indent=2))
|
|
112
|
+
|
|
113
|
+
value = base64.b64decode(secret["encrypted_value"]).decode()
|
|
114
|
+
return {"value": value, "granted": True, "name": safe_name}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_secrets() -> List[Dict]:
|
|
118
|
+
"""List all secrets (metadata only, never values).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of dicts with name, scope, description, access_count, etc.
|
|
122
|
+
"""
|
|
123
|
+
if not SECRETS_DIR.exists():
|
|
124
|
+
return []
|
|
125
|
+
secrets = []
|
|
126
|
+
for f in sorted(SECRETS_DIR.glob("*.json")):
|
|
127
|
+
try:
|
|
128
|
+
s = json.loads(f.read_text())
|
|
129
|
+
secrets.append({
|
|
130
|
+
"name": s["name"],
|
|
131
|
+
"scope": s.get("scope", "all"),
|
|
132
|
+
"description": s.get("description", ""),
|
|
133
|
+
"created_by": s.get("created_by", ""),
|
|
134
|
+
"access_count": s.get("access_count", 0),
|
|
135
|
+
"last_accessed_at": s.get("last_accessed_at"),
|
|
136
|
+
"revoked": s.get("revoked", False),
|
|
137
|
+
"created_at": s.get("created_at", ""),
|
|
138
|
+
})
|
|
139
|
+
except (json.JSONDecodeError, KeyError):
|
|
140
|
+
pass
|
|
141
|
+
return secrets
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def revoke_secret(name: str) -> Dict:
|
|
145
|
+
"""Revoke a secret, preventing future access.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
name: The secret name to revoke.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Confirmation dict or error.
|
|
152
|
+
"""
|
|
153
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
154
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return {"error": f"Secret '{safe_name}' not found"}
|
|
157
|
+
secret = json.loads(path.read_text())
|
|
158
|
+
secret["revoked"] = True
|
|
159
|
+
secret["revoked_at"] = datetime.now(timezone.utc).isoformat()
|
|
160
|
+
path.write_text(json.dumps(secret, indent=2))
|
|
161
|
+
_log_access(safe_name, "", "", granted=True, reason="revoked_by_user")
|
|
162
|
+
return {"revoked": safe_name}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_access_log(name: Optional[str] = None) -> List[Dict]:
|
|
166
|
+
"""Return access log entries, optionally filtered by secret name.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
name: If provided, only return entries for this secret.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of access log entries (newest first).
|
|
173
|
+
"""
|
|
174
|
+
log_path = SECRETS_DIR / "access_log" / "log.jsonl"
|
|
175
|
+
if not log_path.exists():
|
|
176
|
+
return []
|
|
177
|
+
entries = []
|
|
178
|
+
for line in log_path.read_text().strip().split("\n"):
|
|
179
|
+
if not line.strip():
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
entry = json.loads(line)
|
|
183
|
+
if name and entry.get("secret_name") != name:
|
|
184
|
+
continue
|
|
185
|
+
entries.append(entry)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
pass
|
|
188
|
+
# Newest first
|
|
189
|
+
entries.reverse()
|
|
190
|
+
return entries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_secret(name: str) -> Dict:
|
|
194
|
+
"""Permanently delete a secret file.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
name: The secret name to delete.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Confirmation dict or error.
|
|
201
|
+
"""
|
|
202
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
203
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
204
|
+
if not path.exists():
|
|
205
|
+
return {"error": f"Secret '{safe_name}' not found"}
|
|
206
|
+
path.unlink()
|
|
207
|
+
_log_access(safe_name, "", "", granted=True, reason="deleted_by_user")
|
|
208
|
+
return {"deleted": safe_name}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Internal helpers
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _log_access(
|
|
217
|
+
secret_name: str,
|
|
218
|
+
agent_type: str,
|
|
219
|
+
tool: str,
|
|
220
|
+
granted: bool,
|
|
221
|
+
reason: str,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Append an entry to the JSONL access log."""
|
|
224
|
+
log_dir = SECRETS_DIR / "access_log"
|
|
225
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
entry = {
|
|
227
|
+
"secret_name": secret_name,
|
|
228
|
+
"agent_type": agent_type,
|
|
229
|
+
"tool": tool,
|
|
230
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
231
|
+
"granted": granted,
|
|
232
|
+
"reason": reason,
|
|
233
|
+
}
|
|
234
|
+
with open(log_dir / "log.jsonl", "a") as f:
|
|
235
|
+
f.write(json.dumps(entry) + "\n")
|
package/gateway/ai/server.py
CHANGED
|
@@ -3697,6 +3697,14 @@ def delimit_swarm(action: str = "status", venture: str = "",
|
|
|
3697
3697
|
return _with_next_steps("swarm", _safe_call(
|
|
3698
3698
|
check_docs_freshness, project_path=repo_path or ".",
|
|
3699
3699
|
))
|
|
3700
|
+
if action == "create_tool":
|
|
3701
|
+
from ai.swarm import create_tool
|
|
3702
|
+
return _with_next_steps("swarm", _safe_call(
|
|
3703
|
+
create_tool, name=target_path, code=repo_path, venture=venture, agent_id=agent_id,
|
|
3704
|
+
))
|
|
3705
|
+
if action == "list_tools":
|
|
3706
|
+
from ai.swarm import list_custom_tools
|
|
3707
|
+
return _with_next_steps("swarm", _safe_call(list_custom_tools, venture=venture))
|
|
3700
3708
|
return _with_next_steps("swarm", _safe_call(get_swarm_status))
|
|
3701
3709
|
|
|
3702
3710
|
|
|
@@ -6513,7 +6521,7 @@ def delimit_notify(channel: str = "webhook", message: str = "",
|
|
|
6513
6521
|
to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
|
|
6514
6522
|
Send to any address — leave empty for default (owner@example.com).
|
|
6515
6523
|
from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
|
|
6516
|
-
(e.g. 'pro@
|
|
6524
|
+
(e.g. 'pro@delimit.ai', 'admin@wire.report'). Email only.
|
|
6517
6525
|
"""
|
|
6518
6526
|
from ai.notify import send_notification
|
|
6519
6527
|
return _with_next_steps("notify", _safe_call(
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Phoenix — Cross-model session resurrection (LED-218).
|
|
3
|
+
|
|
4
|
+
When a session dies from rate limits, context overflow, or model switch,
|
|
5
|
+
the user runs `delimit revive` in any model to restore working state.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
capture_soul() -> ~/.delimit/souls/{project_hash}/{timestamp}.json
|
|
9
|
+
revive() -> structured context blob any AI model can read
|
|
10
|
+
|
|
11
|
+
Complements delimit_session_handoff (ledger state) by saving the
|
|
12
|
+
working context: task, decisions, files, blockers, next steps.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
MAX_SOULS_PER_PROJECT = 10
|
|
27
|
+
SOULS_BASE_DIR = Path.home() / ".delimit" / "souls"
|
|
28
|
+
_capture_counter = 0 # Monotonic counter for sub-second ordering
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SessionSoul:
|
|
33
|
+
"""Compressed session state that survives death."""
|
|
34
|
+
|
|
35
|
+
soul_id: str = ""
|
|
36
|
+
created_at: str = ""
|
|
37
|
+
source_model: str = "unknown"
|
|
38
|
+
project_path: str = ""
|
|
39
|
+
|
|
40
|
+
# What was being worked on
|
|
41
|
+
active_task: str = ""
|
|
42
|
+
task_status: str = "in_progress" # in_progress, blocked, almost_done
|
|
43
|
+
|
|
44
|
+
# Key decisions made this session
|
|
45
|
+
decisions: List[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
# Files touched
|
|
48
|
+
files_modified: List[str] = field(default_factory=list)
|
|
49
|
+
files_created: List[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
# Context that matters
|
|
52
|
+
key_context: List[str] = field(default_factory=list)
|
|
53
|
+
blockers: List[str] = field(default_factory=list)
|
|
54
|
+
next_steps: List[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
# Technical state
|
|
57
|
+
git_branch: str = ""
|
|
58
|
+
git_sha: str = ""
|
|
59
|
+
uncommitted_changes: int = 0
|
|
60
|
+
|
|
61
|
+
# Token stats
|
|
62
|
+
tokens_used: int = 0
|
|
63
|
+
context_fullness: float = 0.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _project_hash(project_path: str) -> str:
|
|
67
|
+
"""Stable hash for a project path, used as directory name."""
|
|
68
|
+
normalized = os.path.realpath(project_path)
|
|
69
|
+
return hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _project_dir(project_path: str) -> Path:
|
|
73
|
+
"""Return the soul storage directory for a project."""
|
|
74
|
+
return SOULS_BASE_DIR / _project_hash(project_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_git(args: List[str], cwd: str = "") -> str:
|
|
78
|
+
"""Run a git command and return stdout, or empty string on failure."""
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["git"] + args,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
timeout=5,
|
|
85
|
+
cwd=cwd or None,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode == 0:
|
|
88
|
+
return result.stdout.strip()
|
|
89
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
90
|
+
pass
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _detect_git_state(project_path: str) -> Dict[str, Any]:
|
|
95
|
+
"""Auto-detect git branch, sha, modified/created files, uncommitted count."""
|
|
96
|
+
cwd = project_path or os.getcwd()
|
|
97
|
+
|
|
98
|
+
branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
99
|
+
sha = _run_git(["rev-parse", "--short", "HEAD"], cwd=cwd)
|
|
100
|
+
|
|
101
|
+
# Uncommitted changes (staged + unstaged + untracked)
|
|
102
|
+
porcelain = _run_git(["status", "--porcelain"], cwd=cwd)
|
|
103
|
+
porcelain_lines = [l for l in porcelain.splitlines() if l.strip()] if porcelain else []
|
|
104
|
+
uncommitted = len(porcelain_lines)
|
|
105
|
+
|
|
106
|
+
# Files modified (tracked, staged or unstaged)
|
|
107
|
+
diff_names = _run_git(["diff", "--name-only", "HEAD"], cwd=cwd)
|
|
108
|
+
files_modified = [l.strip() for l in diff_names.splitlines() if l.strip()] if diff_names else []
|
|
109
|
+
|
|
110
|
+
# New untracked files
|
|
111
|
+
untracked_raw = _run_git(["ls-files", "--others", "--exclude-standard"], cwd=cwd)
|
|
112
|
+
files_created = [l.strip() for l in untracked_raw.splitlines() if l.strip()] if untracked_raw else []
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"git_branch": branch,
|
|
116
|
+
"git_sha": sha,
|
|
117
|
+
"uncommitted_changes": uncommitted,
|
|
118
|
+
"files_modified": files_modified,
|
|
119
|
+
"files_created": files_created,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def capture_soul(
|
|
124
|
+
active_task: str = "",
|
|
125
|
+
decisions: Optional[List[str]] = None,
|
|
126
|
+
key_context: Optional[List[str]] = None,
|
|
127
|
+
blockers: Optional[List[str]] = None,
|
|
128
|
+
next_steps: Optional[List[str]] = None,
|
|
129
|
+
source_model: str = "unknown",
|
|
130
|
+
project_path: str = "",
|
|
131
|
+
task_status: str = "in_progress",
|
|
132
|
+
tokens_used: int = 0,
|
|
133
|
+
context_fullness: float = 0.0,
|
|
134
|
+
) -> SessionSoul:
|
|
135
|
+
"""Capture current session state as a soul and persist it to disk."""
|
|
136
|
+
project_path = project_path or os.getcwd()
|
|
137
|
+
git_state = _detect_git_state(project_path)
|
|
138
|
+
|
|
139
|
+
soul = SessionSoul(
|
|
140
|
+
soul_id=str(uuid.uuid4())[:8],
|
|
141
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
142
|
+
source_model=source_model,
|
|
143
|
+
project_path=project_path,
|
|
144
|
+
active_task=active_task,
|
|
145
|
+
task_status=task_status,
|
|
146
|
+
decisions=decisions or [],
|
|
147
|
+
files_modified=git_state["files_modified"],
|
|
148
|
+
files_created=git_state["files_created"],
|
|
149
|
+
key_context=key_context or [],
|
|
150
|
+
blockers=blockers or [],
|
|
151
|
+
next_steps=next_steps or [],
|
|
152
|
+
git_branch=git_state["git_branch"],
|
|
153
|
+
git_sha=git_state["git_sha"],
|
|
154
|
+
uncommitted_changes=git_state["uncommitted_changes"],
|
|
155
|
+
tokens_used=tokens_used,
|
|
156
|
+
context_fullness=context_fullness,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
_store_soul(soul)
|
|
160
|
+
return soul
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _store_soul(soul: SessionSoul) -> Path:
|
|
164
|
+
"""Persist a soul to disk and maintain the latest pointer."""
|
|
165
|
+
global _capture_counter
|
|
166
|
+
proj_dir = _project_dir(soul.project_path)
|
|
167
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
|
|
169
|
+
# Timestamp + monotonic counter for correct ordering within same second
|
|
170
|
+
_capture_counter += 1
|
|
171
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
172
|
+
filename = f"{ts}_{_capture_counter:06d}_{soul.soul_id}.json"
|
|
173
|
+
filepath = proj_dir / filename
|
|
174
|
+
|
|
175
|
+
data = asdict(soul)
|
|
176
|
+
filepath.write_text(json.dumps(data, indent=2))
|
|
177
|
+
|
|
178
|
+
# Update latest.json as a copy (symlinks can be fragile across systems)
|
|
179
|
+
latest = proj_dir / "latest.json"
|
|
180
|
+
latest.write_text(json.dumps(data, indent=2))
|
|
181
|
+
|
|
182
|
+
# Auto-prune to MAX_SOULS_PER_PROJECT (keep latest N by name sort)
|
|
183
|
+
_prune_souls(proj_dir)
|
|
184
|
+
|
|
185
|
+
return filepath
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _prune_souls(proj_dir: Path) -> None:
|
|
189
|
+
"""Keep only the latest MAX_SOULS_PER_PROJECT souls per project."""
|
|
190
|
+
soul_files = sorted(
|
|
191
|
+
[f for f in proj_dir.iterdir() if f.name != "latest.json" and f.suffix == ".json"],
|
|
192
|
+
key=lambda f: f.name,
|
|
193
|
+
)
|
|
194
|
+
while len(soul_files) > MAX_SOULS_PER_PROJECT:
|
|
195
|
+
oldest = soul_files.pop(0)
|
|
196
|
+
oldest.unlink(missing_ok=True)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _load_soul(path: Path) -> Optional[SessionSoul]:
|
|
200
|
+
"""Load a soul from a JSON file."""
|
|
201
|
+
try:
|
|
202
|
+
data = json.loads(path.read_text())
|
|
203
|
+
return SessionSoul(**{k: v for k, v in data.items() if k in SessionSoul.__dataclass_fields__})
|
|
204
|
+
except (json.JSONDecodeError, TypeError, KeyError, OSError):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def list_souls(project_path: str = "") -> List[SessionSoul]:
|
|
209
|
+
"""List all stored souls for a project, newest first."""
|
|
210
|
+
project_path = project_path or os.getcwd()
|
|
211
|
+
proj_dir = _project_dir(project_path)
|
|
212
|
+
if not proj_dir.exists():
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
soul_files = sorted(
|
|
216
|
+
[f for f in proj_dir.iterdir() if f.name != "latest.json" and f.suffix == ".json"],
|
|
217
|
+
key=lambda f: f.name,
|
|
218
|
+
reverse=True,
|
|
219
|
+
)
|
|
220
|
+
souls = []
|
|
221
|
+
for f in soul_files:
|
|
222
|
+
soul = _load_soul(f)
|
|
223
|
+
if soul:
|
|
224
|
+
souls.append(soul)
|
|
225
|
+
return souls
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_latest_soul(project_path: str = "") -> Optional[SessionSoul]:
|
|
229
|
+
"""Get the most recent soul for a project."""
|
|
230
|
+
project_path = project_path or os.getcwd()
|
|
231
|
+
latest = _project_dir(project_path) / "latest.json"
|
|
232
|
+
if latest.exists():
|
|
233
|
+
return _load_soul(latest)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _format_revival(soul: SessionSoul) -> str:
|
|
238
|
+
"""Format a soul into a readable context string for any AI model."""
|
|
239
|
+
lines = []
|
|
240
|
+
lines.append("=" * 60)
|
|
241
|
+
lines.append("SESSION PHOENIX -- Revived Session Context")
|
|
242
|
+
lines.append("=" * 60)
|
|
243
|
+
lines.append("")
|
|
244
|
+
|
|
245
|
+
lines.append(f"Soul ID: {soul.soul_id}")
|
|
246
|
+
lines.append(f"Captured: {soul.created_at}")
|
|
247
|
+
lines.append(f"Source Model: {soul.source_model}")
|
|
248
|
+
lines.append(f"Project: {soul.project_path}")
|
|
249
|
+
lines.append("")
|
|
250
|
+
|
|
251
|
+
# Current task
|
|
252
|
+
lines.append("--- ACTIVE TASK ---")
|
|
253
|
+
if soul.active_task:
|
|
254
|
+
lines.append(f" {soul.active_task}")
|
|
255
|
+
lines.append(f" Status: {soul.task_status}")
|
|
256
|
+
else:
|
|
257
|
+
lines.append(" (none recorded)")
|
|
258
|
+
lines.append("")
|
|
259
|
+
|
|
260
|
+
# Decisions
|
|
261
|
+
if soul.decisions:
|
|
262
|
+
lines.append("--- KEY DECISIONS ---")
|
|
263
|
+
for d in soul.decisions:
|
|
264
|
+
lines.append(f" - {d}")
|
|
265
|
+
lines.append("")
|
|
266
|
+
|
|
267
|
+
# Files
|
|
268
|
+
if soul.files_modified or soul.files_created:
|
|
269
|
+
lines.append("--- FILES CHANGED ---")
|
|
270
|
+
for f in soul.files_modified:
|
|
271
|
+
lines.append(f" M {f}")
|
|
272
|
+
for f in soul.files_created:
|
|
273
|
+
lines.append(f" + {f}")
|
|
274
|
+
lines.append("")
|
|
275
|
+
|
|
276
|
+
# Context
|
|
277
|
+
if soul.key_context:
|
|
278
|
+
lines.append("--- KEY CONTEXT ---")
|
|
279
|
+
for c in soul.key_context:
|
|
280
|
+
lines.append(f" - {c}")
|
|
281
|
+
lines.append("")
|
|
282
|
+
|
|
283
|
+
# Blockers
|
|
284
|
+
if soul.blockers:
|
|
285
|
+
lines.append("--- BLOCKERS ---")
|
|
286
|
+
for b in soul.blockers:
|
|
287
|
+
lines.append(f" ! {b}")
|
|
288
|
+
lines.append("")
|
|
289
|
+
|
|
290
|
+
# Next steps
|
|
291
|
+
if soul.next_steps:
|
|
292
|
+
lines.append("--- NEXT STEPS ---")
|
|
293
|
+
for i, s in enumerate(soul.next_steps, 1):
|
|
294
|
+
lines.append(f" {i}. {s}")
|
|
295
|
+
lines.append("")
|
|
296
|
+
|
|
297
|
+
# Git state
|
|
298
|
+
lines.append("--- GIT STATE ---")
|
|
299
|
+
lines.append(f" Branch: {soul.git_branch or '(unknown)'}")
|
|
300
|
+
lines.append(f" SHA: {soul.git_sha or '(unknown)'}")
|
|
301
|
+
lines.append(f" Uncommitted changes: {soul.uncommitted_changes}")
|
|
302
|
+
lines.append("")
|
|
303
|
+
|
|
304
|
+
# Token stats
|
|
305
|
+
if soul.tokens_used or soul.context_fullness:
|
|
306
|
+
lines.append("--- SESSION STATS ---")
|
|
307
|
+
if soul.tokens_used:
|
|
308
|
+
lines.append(f" Tokens used: ~{soul.tokens_used:,}")
|
|
309
|
+
if soul.context_fullness:
|
|
310
|
+
lines.append(f" Context fullness: {soul.context_fullness:.0%}")
|
|
311
|
+
lines.append("")
|
|
312
|
+
|
|
313
|
+
lines.append("=" * 60)
|
|
314
|
+
return "\n".join(lines)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def revive(project_path: str = "", soul_id: str = "") -> Dict[str, Any]:
|
|
318
|
+
"""Revive the latest session soul for this project.
|
|
319
|
+
|
|
320
|
+
Returns a structured dict with both the raw soul data and a
|
|
321
|
+
formatted context string that can be injected into any model.
|
|
322
|
+
"""
|
|
323
|
+
project_path = project_path or os.getcwd()
|
|
324
|
+
|
|
325
|
+
if soul_id:
|
|
326
|
+
# Search for a specific soul by ID
|
|
327
|
+
for soul in list_souls(project_path):
|
|
328
|
+
if soul.soul_id == soul_id:
|
|
329
|
+
return {
|
|
330
|
+
"status": "revived",
|
|
331
|
+
"soul": asdict(soul),
|
|
332
|
+
"context": _format_revival(soul),
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
"status": "not_found",
|
|
336
|
+
"message": f"No soul with ID '{soul_id}' found for project {project_path}",
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# Get latest
|
|
340
|
+
soul = get_latest_soul(project_path)
|
|
341
|
+
if not soul:
|
|
342
|
+
return {
|
|
343
|
+
"status": "no_souls",
|
|
344
|
+
"message": f"No session souls found for {project_path}. Nothing to revive.",
|
|
345
|
+
"hint": "Use delimit_soul_capture to save session state before ending.",
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
"status": "revived",
|
|
350
|
+
"soul": asdict(soul),
|
|
351
|
+
"context": _format_revival(soul),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def should_auto_capture(
|
|
356
|
+
context_fullness: float = 0.0,
|
|
357
|
+
session_age_minutes: int = 0,
|
|
358
|
+
last_capture_minutes_ago: int = -1,
|
|
359
|
+
) -> bool:
|
|
360
|
+
"""Determine if we should auto-capture a soul.
|
|
361
|
+
|
|
362
|
+
Triggers:
|
|
363
|
+
- Context > 70% full
|
|
364
|
+
- Session > 30 minutes old with no capture in the last 15 minutes
|
|
365
|
+
- Explicit session end (handled by caller, not this function)
|
|
366
|
+
"""
|
|
367
|
+
if context_fullness >= 0.7:
|
|
368
|
+
return True
|
|
369
|
+
if session_age_minutes >= 30 and (last_capture_minutes_ago < 0 or last_capture_minutes_ago >= 15):
|
|
370
|
+
return True
|
|
371
|
+
return False
|
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
This module requires the Delimit MCP server to be running.
|
|
4
|
-
Configure via: npx delimit-cli setup
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
# Stub — full implementation runs server-side
|
|
1
|
+
# supabase_sync — Pro module (stubbed in npm package)
|
|
2
|
+
# Full implementation available on delimit.ai server
|