claude-memory-agent 3.0.3 → 3.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/dashboard.html +88 -38
- package/hooks/auto-detect-response.py +3 -6
- package/hooks/detect-correction.py +8 -7
- package/hooks/grounding-hook.py +19 -3
- package/hooks/log-tool-use.py +3 -6
- package/hooks/log-user-request.py +14 -6
- package/hooks/pre-tool-decision.py +3 -6
- package/hooks/session_end_hook.py +37 -0
- package/hooks/session_start.py +10 -0
- package/hooks/stop_hook.py +123 -0
- package/install.py +139 -44
- package/main.py +133 -13
- package/mcp_proxy.py +6 -0
- package/package.json +2 -2
- package/services/agent_registry.py +260 -12
- package/services/database.py +186 -0
- package/services/soul.py +467 -0
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Agent Registry -
|
|
2
|
+
Agent Registry - Discovers real agents from disk + loads MCP/hook catalogs.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Agents are discovered from ~/.claude/agents/ (global) and
|
|
5
|
+
<project>/.claude/agents/ (per-project) by scanning .md files with YAML
|
|
6
|
+
frontmatter. Enabled agents live in the root; disabled agents live in
|
|
7
|
+
_disabled/<category>/ subdirectories.
|
|
8
|
+
|
|
9
|
+
MCP and hook metadata still comes from agent_catalog.json; dynamic loading
|
|
10
|
+
reads Claude Code settings files at runtime to check configured status.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
import json
|
|
10
14
|
import logging
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
11
17
|
from pathlib import Path
|
|
12
18
|
from typing import List, Dict, Any, Optional
|
|
13
19
|
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
14
22
|
logger = logging.getLogger(__name__)
|
|
15
23
|
|
|
16
24
|
# ---------------------------------------------------------------------------
|
|
@@ -24,8 +32,11 @@ _GLOBAL_SETTINGS_PATH = _HOME / ".claude" / "settings.json"
|
|
|
24
32
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
25
33
|
_LOCAL_SETTINGS_PATH = _PROJECT_ROOT / ".claude" / "settings.local.json"
|
|
26
34
|
|
|
35
|
+
# Global agents directory
|
|
36
|
+
_GLOBAL_AGENTS_DIR = _HOME / ".claude" / "agents"
|
|
37
|
+
|
|
27
38
|
# ---------------------------------------------------------------------------
|
|
28
|
-
# Load static catalog from JSON
|
|
39
|
+
# Load static catalog from JSON (MCPs + hooks only now)
|
|
29
40
|
# ---------------------------------------------------------------------------
|
|
30
41
|
_CATALOG_PATH = Path(__file__).resolve().parent / "agent_catalog.json"
|
|
31
42
|
|
|
@@ -42,11 +53,249 @@ def _load_catalog() -> dict:
|
|
|
42
53
|
|
|
43
54
|
_catalog = _load_catalog()
|
|
44
55
|
|
|
56
|
+
# Categories are now auto-generated from discovered agents, but keep catalog
|
|
57
|
+
# ones as fallback for MCPs/hooks
|
|
45
58
|
AGENT_CATEGORIES = _catalog.get("categories", {})
|
|
46
|
-
AVAILABLE_AGENTS = _catalog.get("agents", [])
|
|
47
59
|
AVAILABLE_MCPS = _catalog.get("mcps", [])
|
|
48
60
|
AVAILABLE_HOOKS = _catalog.get("hooks", [])
|
|
49
61
|
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Category metadata (colors + icons for known categories)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
_CATEGORY_META = {
|
|
66
|
+
"design": {"name": "Design", "color": "#f778ba", "icon": "palette"},
|
|
67
|
+
"engineering": {"name": "Engineering", "color": "#58a6ff", "icon": "code"},
|
|
68
|
+
"marketing": {"name": "Marketing", "color": "#f0883e", "icon": "bullhorn"},
|
|
69
|
+
"product": {"name": "Product", "color": "#a371f7", "icon": "lightbulb"},
|
|
70
|
+
"project-management": {"name": "Project Management", "color": "#3fb950", "icon": "tasks"},
|
|
71
|
+
"spatial-computing": {"name": "Spatial Computing", "color": "#79c0ff", "icon": "cube"},
|
|
72
|
+
"specialized": {"name": "Specialized", "color": "#d2a8ff", "icon": "star"},
|
|
73
|
+
"support": {"name": "Support", "color": "#56d364", "icon": "headset"},
|
|
74
|
+
"testing": {"name": "Testing", "color": "#e3b341", "icon": "vial"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Color palette for agent frontmatter colors
|
|
78
|
+
_COLOR_MAP = {
|
|
79
|
+
"blue": "#58a6ff",
|
|
80
|
+
"green": "#3fb950",
|
|
81
|
+
"red": "#f85149",
|
|
82
|
+
"purple": "#a371f7",
|
|
83
|
+
"orange": "#f0883e",
|
|
84
|
+
"yellow": "#e3b341",
|
|
85
|
+
"pink": "#f778ba",
|
|
86
|
+
"cyan": "#79c0ff",
|
|
87
|
+
"teal": "#2ea043",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Agent discovery from disk
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
def _parse_frontmatter(file_path: Path) -> Optional[Dict[str, Any]]:
|
|
95
|
+
"""Parse YAML frontmatter from an agent .md file."""
|
|
96
|
+
try:
|
|
97
|
+
text = file_path.read_text(encoding="utf-8")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"Could not read {file_path}: {e}")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
# Match --- delimited frontmatter
|
|
103
|
+
match = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
|
|
104
|
+
if not match:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
data = yaml.safe_load(match.group(1))
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
return None
|
|
111
|
+
return data
|
|
112
|
+
except yaml.YAMLError as e:
|
|
113
|
+
logger.warning(f"Invalid YAML frontmatter in {file_path}: {e}")
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _infer_category(filename_stem: str) -> str:
|
|
118
|
+
"""Infer category from filename prefix like 'engineering-senior-developer'."""
|
|
119
|
+
known = [
|
|
120
|
+
"design", "engineering", "marketing", "product",
|
|
121
|
+
"project-management", "spatial-computing", "specialized",
|
|
122
|
+
"support", "testing",
|
|
123
|
+
]
|
|
124
|
+
for cat in known:
|
|
125
|
+
if filename_stem.startswith(cat):
|
|
126
|
+
return cat
|
|
127
|
+
# Try first word as category
|
|
128
|
+
parts = filename_stem.split("-")
|
|
129
|
+
if parts:
|
|
130
|
+
return parts[0]
|
|
131
|
+
return "uncategorized"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_color(color_str: Optional[str]) -> str:
|
|
135
|
+
"""Resolve a color name or hex value to a hex color."""
|
|
136
|
+
if not color_str:
|
|
137
|
+
return "#8b949e"
|
|
138
|
+
if color_str.startswith("#"):
|
|
139
|
+
return color_str
|
|
140
|
+
return _COLOR_MAP.get(color_str.lower(), "#8b949e")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_agent_file(
|
|
144
|
+
file_path: Path,
|
|
145
|
+
enabled: bool,
|
|
146
|
+
scope: str,
|
|
147
|
+
category: Optional[str] = None,
|
|
148
|
+
base_dir: Optional[Path] = None,
|
|
149
|
+
) -> Optional[Dict[str, Any]]:
|
|
150
|
+
"""Parse a single agent .md file into an agent dict."""
|
|
151
|
+
fm = _parse_frontmatter(file_path)
|
|
152
|
+
if fm is None:
|
|
153
|
+
# Minimal entry for files without valid frontmatter
|
|
154
|
+
fm = {}
|
|
155
|
+
|
|
156
|
+
stem = file_path.stem
|
|
157
|
+
agent_id = stem
|
|
158
|
+
|
|
159
|
+
name = fm.get("name", stem.replace("-", " ").replace("_", " ").title())
|
|
160
|
+
description = fm.get("description", "").replace("\\n", " ").strip()
|
|
161
|
+
color = _resolve_color(fm.get("color"))
|
|
162
|
+
|
|
163
|
+
if not category:
|
|
164
|
+
category = _infer_category(stem)
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"id": agent_id,
|
|
168
|
+
"name": name,
|
|
169
|
+
"description": description,
|
|
170
|
+
"color": color,
|
|
171
|
+
"category": category,
|
|
172
|
+
"enabled": enabled,
|
|
173
|
+
"scope": scope,
|
|
174
|
+
"file_path": str(file_path),
|
|
175
|
+
"filename": file_path.name,
|
|
176
|
+
"base_dir": str(base_dir or file_path.parent),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _scan_agent_dir(base_dir: Path, scope: str) -> List[Dict[str, Any]]:
|
|
181
|
+
"""Scan an agents directory for enabled and disabled agents."""
|
|
182
|
+
agents = []
|
|
183
|
+
if not base_dir.exists():
|
|
184
|
+
return agents
|
|
185
|
+
|
|
186
|
+
# Enabled agents: .md files directly in base_dir
|
|
187
|
+
for f in sorted(base_dir.glob("*.md")):
|
|
188
|
+
if f.name.lower() in ("readme.md", "readme.txt"):
|
|
189
|
+
continue
|
|
190
|
+
agent = _parse_agent_file(
|
|
191
|
+
f, enabled=True, scope=scope, base_dir=base_dir
|
|
192
|
+
)
|
|
193
|
+
if agent:
|
|
194
|
+
agents.append(agent)
|
|
195
|
+
|
|
196
|
+
# Disabled agents: .md files in _disabled/ subdirectories
|
|
197
|
+
disabled_dir = base_dir / "_disabled"
|
|
198
|
+
if disabled_dir.exists():
|
|
199
|
+
for f in sorted(disabled_dir.rglob("*.md")):
|
|
200
|
+
if f.name.lower() in ("readme.md", "readme.txt"):
|
|
201
|
+
continue
|
|
202
|
+
# Category = parent dir name if it's a subdirectory of _disabled
|
|
203
|
+
if f.parent != disabled_dir:
|
|
204
|
+
category = f.parent.name
|
|
205
|
+
else:
|
|
206
|
+
category = _infer_category(f.stem)
|
|
207
|
+
agent = _parse_agent_file(
|
|
208
|
+
f, enabled=False, scope=scope,
|
|
209
|
+
category=category, base_dir=base_dir,
|
|
210
|
+
)
|
|
211
|
+
if agent:
|
|
212
|
+
agents.append(agent)
|
|
213
|
+
|
|
214
|
+
return agents
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def discover_agents(project_path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
218
|
+
"""Discover all agents from global and project directories.
|
|
219
|
+
|
|
220
|
+
Returns a list of agent dicts with id, name, description, color,
|
|
221
|
+
category, enabled, scope, file_path, filename, base_dir.
|
|
222
|
+
"""
|
|
223
|
+
agents = []
|
|
224
|
+
|
|
225
|
+
# 1. Global agents: ~/.claude/agents/
|
|
226
|
+
agents += _scan_agent_dir(_GLOBAL_AGENTS_DIR, scope="global")
|
|
227
|
+
|
|
228
|
+
# 2. Project agents: <project>/.claude/agents/
|
|
229
|
+
if project_path:
|
|
230
|
+
project_dir = Path(project_path) / ".claude" / "agents"
|
|
231
|
+
agents += _scan_agent_dir(project_dir, scope="project")
|
|
232
|
+
|
|
233
|
+
return agents
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def discover_categories(agents: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
|
237
|
+
"""Build categories dict from discovered agents."""
|
|
238
|
+
cats: Dict[str, Dict[str, Any]] = {}
|
|
239
|
+
for agent in agents:
|
|
240
|
+
cat_key = agent["category"]
|
|
241
|
+
if cat_key not in cats:
|
|
242
|
+
meta = _CATEGORY_META.get(cat_key, {
|
|
243
|
+
"name": cat_key.replace("-", " ").replace("_", " ").title(),
|
|
244
|
+
"color": "#8b949e",
|
|
245
|
+
"icon": "circle",
|
|
246
|
+
})
|
|
247
|
+
cats[cat_key] = meta
|
|
248
|
+
return cats
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def find_agent_by_id(
|
|
252
|
+
agent_id: str, project_path: Optional[str] = None
|
|
253
|
+
) -> Optional[Dict[str, Any]]:
|
|
254
|
+
"""Find a specific agent by ID across all directories."""
|
|
255
|
+
for agent in discover_agents(project_path):
|
|
256
|
+
if agent["id"] == agent_id:
|
|
257
|
+
return agent
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def toggle_agent(agent_id: str, enabled: bool, project_path: Optional[str] = None) -> Dict[str, Any]:
|
|
262
|
+
"""Toggle an agent between enabled and disabled state.
|
|
263
|
+
|
|
264
|
+
- Enable: move from _disabled/<category>/ to agents/ root
|
|
265
|
+
- Disable: move from agents/ root to _disabled/<category>/
|
|
266
|
+
|
|
267
|
+
Returns the updated agent dict.
|
|
268
|
+
"""
|
|
269
|
+
agent = find_agent_by_id(agent_id, project_path)
|
|
270
|
+
if not agent:
|
|
271
|
+
raise FileNotFoundError(f"Agent '{agent_id}' not found")
|
|
272
|
+
|
|
273
|
+
src = Path(agent["file_path"])
|
|
274
|
+
base = Path(agent["base_dir"])
|
|
275
|
+
|
|
276
|
+
if enabled and not agent["enabled"]:
|
|
277
|
+
# Move from _disabled/<category>/ to agents/ root
|
|
278
|
+
dest = base / agent["filename"]
|
|
279
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
shutil.move(str(src), str(dest))
|
|
281
|
+
agent["file_path"] = str(dest)
|
|
282
|
+
agent["enabled"] = True
|
|
283
|
+
elif not enabled and agent["enabled"]:
|
|
284
|
+
# Move from agents/ root to _disabled/<category>/
|
|
285
|
+
category = agent["category"]
|
|
286
|
+
category_dir = base / "_disabled" / category
|
|
287
|
+
category_dir.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
dest = category_dir / agent["filename"]
|
|
289
|
+
shutil.move(str(src), str(dest))
|
|
290
|
+
agent["file_path"] = str(dest)
|
|
291
|
+
agent["enabled"] = False
|
|
292
|
+
|
|
293
|
+
return agent
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Backward-compatible: AVAILABLE_AGENTS as lazy discovery result
|
|
297
|
+
AVAILABLE_AGENTS = discover_agents()
|
|
298
|
+
|
|
50
299
|
|
|
51
300
|
# ---------------------------------------------------------------------------
|
|
52
301
|
# Helper: derive a hook ID from its command string
|
|
@@ -249,10 +498,12 @@ def load_configured_mcps(
|
|
|
249
498
|
# Helper functions (unchanged interface)
|
|
250
499
|
# ---------------------------------------------------------------------------
|
|
251
500
|
|
|
252
|
-
def get_agents_by_category():
|
|
501
|
+
def get_agents_by_category(agents: Optional[List[Dict[str, Any]]] = None):
|
|
253
502
|
"""Group agents by category."""
|
|
503
|
+
if agents is None:
|
|
504
|
+
agents = discover_agents()
|
|
254
505
|
result = {}
|
|
255
|
-
for agent in
|
|
506
|
+
for agent in agents:
|
|
256
507
|
cat = agent["category"]
|
|
257
508
|
if cat not in result:
|
|
258
509
|
result[cat] = []
|
|
@@ -262,7 +513,4 @@ def get_agents_by_category():
|
|
|
262
513
|
|
|
263
514
|
def get_agent_by_id(agent_id: str):
|
|
264
515
|
"""Get agent by ID."""
|
|
265
|
-
|
|
266
|
-
if agent["id"] == agent_id:
|
|
267
|
-
return agent
|
|
268
|
-
return None
|
|
516
|
+
return find_agent_by_id(agent_id)
|
package/services/database.py
CHANGED
|
@@ -1234,6 +1234,43 @@ class DatabaseService:
|
|
|
1234
1234
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_activity_project ON session_activity(project_path, timestamp DESC)")
|
|
1235
1235
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_activity_session ON session_activity(session_id, timestamp DESC)")
|
|
1236
1236
|
|
|
1237
|
+
# ============================================================
|
|
1238
|
+
# SOUL LAYER TABLES
|
|
1239
|
+
# ============================================================
|
|
1240
|
+
|
|
1241
|
+
# Soul state - persistent synthesized understanding per project
|
|
1242
|
+
cursor.execute("""
|
|
1243
|
+
CREATE TABLE IF NOT EXISTS soul_state (
|
|
1244
|
+
project_path TEXT PRIMARY KEY,
|
|
1245
|
+
soul_brief TEXT DEFAULT '',
|
|
1246
|
+
user_model TEXT DEFAULT '{}',
|
|
1247
|
+
project_understanding TEXT DEFAULT '{}',
|
|
1248
|
+
success_journal TEXT DEFAULT '[]',
|
|
1249
|
+
blind_spots TEXT DEFAULT '[]',
|
|
1250
|
+
tool_preferences TEXT DEFAULT '{}',
|
|
1251
|
+
last_integrated_at TEXT,
|
|
1252
|
+
integration_count INTEGER DEFAULT 0,
|
|
1253
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
1254
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1255
|
+
)
|
|
1256
|
+
""")
|
|
1257
|
+
|
|
1258
|
+
# Soul fragments - staging area for session observations
|
|
1259
|
+
cursor.execute("""
|
|
1260
|
+
CREATE TABLE IF NOT EXISTS soul_fragments (
|
|
1261
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1262
|
+
session_id TEXT NOT NULL,
|
|
1263
|
+
project_path TEXT NOT NULL,
|
|
1264
|
+
fragment_type TEXT NOT NULL,
|
|
1265
|
+
content TEXT NOT NULL,
|
|
1266
|
+
raw_source TEXT DEFAULT '',
|
|
1267
|
+
captured_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
1268
|
+
integrated BOOLEAN DEFAULT 0
|
|
1269
|
+
)
|
|
1270
|
+
""")
|
|
1271
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_soul_fragments_session ON soul_fragments(session_id)")
|
|
1272
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_soul_fragments_project ON soul_fragments(project_path, integrated)")
|
|
1273
|
+
|
|
1237
1274
|
self.conn.commit()
|
|
1238
1275
|
|
|
1239
1276
|
def _serialize_embedding(self, embedding: List[float]) -> str:
|
|
@@ -3347,6 +3384,155 @@ class DatabaseService:
|
|
|
3347
3384
|
for row in cursor.fetchall()
|
|
3348
3385
|
]
|
|
3349
3386
|
|
|
3387
|
+
# ============================================================
|
|
3388
|
+
# SOUL LAYER METHODS
|
|
3389
|
+
# ============================================================
|
|
3390
|
+
|
|
3391
|
+
async def get_soul_state(self, project_path: str) -> Optional[Dict[str, Any]]:
|
|
3392
|
+
"""Get the soul state for a project. Returns None if no state exists."""
|
|
3393
|
+
try:
|
|
3394
|
+
with self.get_connection() as conn:
|
|
3395
|
+
cursor = conn.cursor()
|
|
3396
|
+
cursor.execute(
|
|
3397
|
+
"SELECT * FROM soul_state WHERE project_path = ?",
|
|
3398
|
+
(project_path,)
|
|
3399
|
+
)
|
|
3400
|
+
row = cursor.fetchone()
|
|
3401
|
+
return dict(row) if row else None
|
|
3402
|
+
except Exception as e:
|
|
3403
|
+
logger.warning(f"Failed to get soul state: {e}")
|
|
3404
|
+
return None
|
|
3405
|
+
|
|
3406
|
+
async def upsert_soul_state(self, project_path: str, updates: Dict[str, Any]) -> bool:
|
|
3407
|
+
"""Create or update the soul state for a project.
|
|
3408
|
+
|
|
3409
|
+
Args:
|
|
3410
|
+
project_path: Project path (primary key)
|
|
3411
|
+
updates: Dict of column->value pairs to set
|
|
3412
|
+
"""
|
|
3413
|
+
try:
|
|
3414
|
+
with self.get_connection() as conn:
|
|
3415
|
+
cursor = conn.cursor()
|
|
3416
|
+
# Check if exists
|
|
3417
|
+
cursor.execute(
|
|
3418
|
+
"SELECT 1 FROM soul_state WHERE project_path = ?",
|
|
3419
|
+
(project_path,)
|
|
3420
|
+
)
|
|
3421
|
+
exists = cursor.fetchone() is not None
|
|
3422
|
+
|
|
3423
|
+
if exists:
|
|
3424
|
+
# Build UPDATE dynamically from updates dict
|
|
3425
|
+
allowed_cols = {
|
|
3426
|
+
'soul_brief', 'user_model', 'project_understanding',
|
|
3427
|
+
'success_journal', 'blind_spots', 'tool_preferences',
|
|
3428
|
+
'last_integrated_at', 'integration_count'
|
|
3429
|
+
}
|
|
3430
|
+
set_parts = []
|
|
3431
|
+
values = []
|
|
3432
|
+
for col, val in updates.items():
|
|
3433
|
+
if col in allowed_cols:
|
|
3434
|
+
set_parts.append(f"{col} = ?")
|
|
3435
|
+
values.append(val)
|
|
3436
|
+
if not set_parts:
|
|
3437
|
+
return True
|
|
3438
|
+
set_parts.append("updated_at = CURRENT_TIMESTAMP")
|
|
3439
|
+
values.append(project_path)
|
|
3440
|
+
sql = f"UPDATE soul_state SET {', '.join(set_parts)} WHERE project_path = ?"
|
|
3441
|
+
cursor.execute(sql, values)
|
|
3442
|
+
else:
|
|
3443
|
+
# INSERT with defaults + any provided updates
|
|
3444
|
+
cols = ['project_path']
|
|
3445
|
+
vals = [project_path]
|
|
3446
|
+
allowed_cols = {
|
|
3447
|
+
'soul_brief', 'user_model', 'project_understanding',
|
|
3448
|
+
'success_journal', 'blind_spots', 'tool_preferences',
|
|
3449
|
+
'last_integrated_at', 'integration_count'
|
|
3450
|
+
}
|
|
3451
|
+
for col, val in updates.items():
|
|
3452
|
+
if col in allowed_cols:
|
|
3453
|
+
cols.append(col)
|
|
3454
|
+
vals.append(val)
|
|
3455
|
+
placeholders = ', '.join(['?'] * len(cols))
|
|
3456
|
+
sql = f"INSERT INTO soul_state ({', '.join(cols)}) VALUES ({placeholders})"
|
|
3457
|
+
cursor.execute(sql, vals)
|
|
3458
|
+
|
|
3459
|
+
conn.commit()
|
|
3460
|
+
return True
|
|
3461
|
+
except Exception as e:
|
|
3462
|
+
logger.error(f"Failed to upsert soul state: {e}")
|
|
3463
|
+
return False
|
|
3464
|
+
|
|
3465
|
+
async def insert_soul_fragment(
|
|
3466
|
+
self, session_id: str, project_path: str,
|
|
3467
|
+
fragment_type: str, content: str, raw_source: str = ""
|
|
3468
|
+
) -> Optional[int]:
|
|
3469
|
+
"""Insert a soul fragment into the staging table."""
|
|
3470
|
+
try:
|
|
3471
|
+
with self.get_connection() as conn:
|
|
3472
|
+
cursor = conn.cursor()
|
|
3473
|
+
cursor.execute(
|
|
3474
|
+
"""INSERT INTO soul_fragments
|
|
3475
|
+
(session_id, project_path, fragment_type, content, raw_source)
|
|
3476
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
3477
|
+
(session_id, project_path, fragment_type, content, raw_source)
|
|
3478
|
+
)
|
|
3479
|
+
conn.commit()
|
|
3480
|
+
return cursor.lastrowid
|
|
3481
|
+
except Exception as e:
|
|
3482
|
+
logger.warning(f"Failed to insert soul fragment: {e}")
|
|
3483
|
+
return None
|
|
3484
|
+
|
|
3485
|
+
async def get_session_fragments(
|
|
3486
|
+
self, session_id: str, integrated: Optional[bool] = None
|
|
3487
|
+
) -> List[Dict[str, Any]]:
|
|
3488
|
+
"""Get all soul fragments for a session."""
|
|
3489
|
+
try:
|
|
3490
|
+
with self.get_connection() as conn:
|
|
3491
|
+
cursor = conn.cursor()
|
|
3492
|
+
if integrated is not None:
|
|
3493
|
+
cursor.execute(
|
|
3494
|
+
"SELECT * FROM soul_fragments WHERE session_id = ? AND integrated = ? ORDER BY captured_at",
|
|
3495
|
+
(session_id, int(integrated))
|
|
3496
|
+
)
|
|
3497
|
+
else:
|
|
3498
|
+
cursor.execute(
|
|
3499
|
+
"SELECT * FROM soul_fragments WHERE session_id = ? ORDER BY captured_at",
|
|
3500
|
+
(session_id,)
|
|
3501
|
+
)
|
|
3502
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
3503
|
+
except Exception as e:
|
|
3504
|
+
logger.warning(f"Failed to get session fragments: {e}")
|
|
3505
|
+
return []
|
|
3506
|
+
|
|
3507
|
+
async def get_unintegrated_fragments(self, project_path: str) -> List[Dict[str, Any]]:
|
|
3508
|
+
"""Get all unintegrated soul fragments for a project."""
|
|
3509
|
+
try:
|
|
3510
|
+
with self.get_connection() as conn:
|
|
3511
|
+
cursor = conn.cursor()
|
|
3512
|
+
cursor.execute(
|
|
3513
|
+
"SELECT * FROM soul_fragments WHERE project_path = ? AND integrated = 0 ORDER BY captured_at",
|
|
3514
|
+
(project_path,)
|
|
3515
|
+
)
|
|
3516
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
3517
|
+
except Exception as e:
|
|
3518
|
+
logger.warning(f"Failed to get unintegrated fragments: {e}")
|
|
3519
|
+
return []
|
|
3520
|
+
|
|
3521
|
+
async def mark_fragments_integrated(self, session_id: str) -> int:
|
|
3522
|
+
"""Mark all fragments for a session as integrated. Returns count."""
|
|
3523
|
+
try:
|
|
3524
|
+
with self.get_connection() as conn:
|
|
3525
|
+
cursor = conn.cursor()
|
|
3526
|
+
cursor.execute(
|
|
3527
|
+
"UPDATE soul_fragments SET integrated = 1 WHERE session_id = ? AND integrated = 0",
|
|
3528
|
+
(session_id,)
|
|
3529
|
+
)
|
|
3530
|
+
conn.commit()
|
|
3531
|
+
return cursor.rowcount
|
|
3532
|
+
except Exception as e:
|
|
3533
|
+
logger.warning(f"Failed to mark fragments integrated: {e}")
|
|
3534
|
+
return 0
|
|
3535
|
+
|
|
3350
3536
|
# ============================================================
|
|
3351
3537
|
# GENERIC QUERY METHOD
|
|
3352
3538
|
# ============================================================
|