claude-memory-agent 3.0.2 → 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 CHANGED
@@ -466,6 +466,31 @@
466
466
  top: 0; left: 0; right: 0;
467
467
  height: 3px;
468
468
  }
469
+ .agent-badges {
470
+ display: flex;
471
+ flex-wrap: wrap;
472
+ gap: 6px;
473
+ margin-bottom: 12px;
474
+ }
475
+ .agent-badge {
476
+ padding: 3px 10px;
477
+ border-radius: 12px;
478
+ font-size: 11px;
479
+ font-weight: 600;
480
+ letter-spacing: 0.3px;
481
+ }
482
+ .agent-badge.scope-global {
483
+ background: rgba(88, 166, 255, 0.15);
484
+ color: #58a6ff;
485
+ }
486
+ .agent-badge.scope-project {
487
+ background: rgba(63, 185, 80, 0.15);
488
+ color: #3fb950;
489
+ }
490
+ .agent-badge.category-badge {
491
+ background: var(--bg-tertiary);
492
+ color: var(--text-secondary);
493
+ }
469
494
  /* Config Cards */
470
495
  .config-card {
471
496
  background: var(--bg-card);
@@ -2205,6 +2230,10 @@
2205
2230
 
2206
2231
  <!-- Agents Tab -->
2207
2232
  <div class="tab-content active" id="agents-tab">
2233
+ <div style="background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.2); border-radius: 12px; padding: 12px 20px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--text-secondary);">
2234
+ <i class="fas fa-info-circle" style="color: #58a6ff; font-size: 16px;"></i>
2235
+ <span>Agents discovered from <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">~/.claude/agents/</code>. Toggle moves files between <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">agents/</code> and <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">agents/_disabled/</code>. Global agents are available across all projects.</span>
2236
+ </div>
2208
2237
  <div class="section-header">
2209
2238
  <div class="search-bar">
2210
2239
  <i class="fas fa-search"></i>
@@ -3056,8 +3085,9 @@
3056
3085
 
3057
3086
  async function loadAgentData() {
3058
3087
  try {
3088
+ const agentParams = currentProject ? `?project_path=${encodeURIComponent(currentProject)}` : '';
3059
3089
  const [agentsRes, mcpsRes, hooksRes] = await Promise.all([
3060
- fetch(`${API_URL}/api/agents`),
3090
+ fetch(`${API_URL}/api/agents${agentParams}`),
3061
3091
  fetch(`${API_URL}/api/mcps`),
3062
3092
  fetch(`${API_URL}/api/hooks`)
3063
3093
  ]);
@@ -3071,12 +3101,14 @@
3071
3101
  document.getElementById('agentsTabCount').textContent = allAgents.length;
3072
3102
  document.getElementById('mcpsTabCount').textContent = allMcps.length;
3073
3103
  document.getElementById('hooksTabCount').textContent = allHooks.length;
3104
+ // Update agent stats from discovery
3105
+ const enabledCount = allAgents.filter(a => a.enabled).length;
3106
+ document.getElementById('enabledAgentsCount').textContent = enabledCount;
3107
+ document.getElementById('agentsTotalCount').textContent = `/ ${allAgents.length} total`;
3074
3108
  renderCategoryFilter();
3075
- if (projectConfig) {
3076
- renderAgents();
3077
- renderMcps();
3078
- renderHooks();
3079
- }
3109
+ renderAgents();
3110
+ renderMcps();
3111
+ renderHooks();
3080
3112
  } catch (e) {
3081
3113
  console.error('Error loading agent data:', e);
3082
3114
  }
@@ -3107,21 +3139,26 @@
3107
3139
  // Rendering
3108
3140
  function renderCategoryFilter() {
3109
3141
  const container = document.getElementById('categoryFilter');
3110
- container.innerHTML = `<div class="category-chip active" data-category="all" onclick="filterByCategory('all')"><i class="fas fa-th"></i> All</div>`;
3142
+ const allCount = allAgents.length;
3143
+ container.innerHTML = `<div class="category-chip active" data-category="all" onclick="filterByCategory('all')"><i class="fas fa-th"></i> All (${allCount})</div>`;
3144
+ // Count agents per category
3145
+ const catCounts = {};
3146
+ allAgents.forEach(a => { catCounts[a.category] = (catCounts[a.category] || 0) + 1; });
3111
3147
  Object.entries(categories).forEach(([key, cat]) => {
3148
+ const count = catCounts[key] || 0;
3112
3149
  const chip = document.createElement('div');
3113
3150
  chip.className = 'category-chip';
3114
3151
  chip.dataset.category = key;
3115
- chip.style.setProperty('--chip-color', cat.color);
3152
+ chip.style.setProperty('--chip-color', cat.color || '#8b949e');
3116
3153
  chip.onclick = () => filterByCategory(key);
3117
- chip.innerHTML = `<i class="fas fa-${getIconForCategory(cat.icon)}"></i> ${cat.name}`;
3154
+ chip.innerHTML = `<i class="fas fa-${getIconForCategory(cat.icon)}"></i> ${cat.name} (${count})`;
3118
3155
  container.appendChild(chip);
3119
3156
  });
3120
3157
  }
3121
3158
 
3122
3159
  function getIconForCategory(icon) {
3123
- const iconMap = { 'code': 'code', 'shield-check': 'shield-alt', 'palette': 'palette', 'lightbulb': 'lightbulb', 'megaphone': 'bullhorn', 'settings': 'cog', 'chart-bar': 'chart-bar', 'cube': 'cube', 'search': 'search' };
3124
- return iconMap[icon] || 'circle';
3160
+ const iconMap = { 'code': 'code', 'shield-check': 'shield-alt', 'palette': 'palette', 'lightbulb': 'lightbulb', 'megaphone': 'bullhorn', 'settings': 'cog', 'chart-bar': 'chart-bar', 'cube': 'cube', 'search': 'search', 'tasks': 'tasks', 'star': 'star', 'headset': 'headset', 'vial': 'vial', 'bullhorn': 'bullhorn', 'circle': 'circle' };
3161
+ return iconMap[icon] || icon || 'circle';
3125
3162
  }
3126
3163
 
3127
3164
  function renderAgents() {
@@ -3129,25 +3166,31 @@
3129
3166
  const searchTerm = document.getElementById('agentSearch').value.toLowerCase();
3130
3167
  let filteredAgents = allAgents;
3131
3168
  if (activeCategory !== 'all') filteredAgents = filteredAgents.filter(a => a.category === activeCategory);
3132
- if (searchTerm) filteredAgents = filteredAgents.filter(a => a.name.toLowerCase().includes(searchTerm) || a.description.toLowerCase().includes(searchTerm) || a.tags.some(t => t.includes(searchTerm)));
3169
+ if (searchTerm) filteredAgents = filteredAgents.filter(a => a.name.toLowerCase().includes(searchTerm) || (a.description || '').toLowerCase().includes(searchTerm) || a.category.includes(searchTerm));
3133
3170
  if (!filteredAgents.length) {
3134
- grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 60px; color: var(--text-secondary);">No agents found</div>';
3171
+ grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 60px; color: var(--text-secondary);"><i class="fas fa-robot" style="font-size: 48px; margin-bottom: 16px; display: block; opacity: 0.3;"></i>No agents found</div>';
3135
3172
  return;
3136
3173
  }
3137
3174
  grid.innerHTML = filteredAgents.map(agent => {
3138
- const config = projectConfig?.agents?.[agent.id] || { enabled: agent.default_enabled };
3139
- const category = categories[agent.category] || {};
3140
- const isEnabled = config.enabled;
3175
+ const isEnabled = agent.enabled;
3176
+ const catMeta = categories[agent.category] || {};
3177
+ const catColor = catMeta.color || agent.color || '#8b949e';
3178
+ const scopeClass = agent.scope === 'global' ? 'scope-global' : 'scope-project';
3179
+ const scopeLabel = agent.scope === 'global' ? 'Global' : 'Project';
3180
+ const catLabel = (catMeta.name || agent.category || '').replace(/-/g, ' ');
3141
3181
  return `
3142
3182
  <div class="agent-card ${isEnabled ? '' : 'disabled'}">
3143
- <div class="agent-category-label" style="background: ${category.color || '#58a6ff'}"></div>
3183
+ <div class="agent-category-label" style="background: ${catColor}"></div>
3144
3184
  <div class="agent-card-header">
3145
- <div class="agent-icon" style="background: ${category.color}20; color: ${category.color}"><i class="fas fa-${getIconForCategory(category.icon)}"></i></div>
3185
+ <div class="agent-icon" style="background: ${catColor}20; color: ${catColor}"><i class="fas fa-${getIconForCategory(catMeta.icon)}"></i></div>
3146
3186
  <div class="agent-toggle ${isEnabled ? 'active' : ''}" onclick="toggleAgent('${agent.id}', ${!isEnabled})"></div>
3147
3187
  </div>
3148
3188
  <div class="agent-name">${agent.name}</div>
3149
- <div class="agent-description">${agent.description}</div>
3150
- <div class="agent-tags">${agent.tags.slice(0, 4).map(tag => `<span class="agent-tag">${tag}</span>`).join('')}</div>
3189
+ <div class="agent-description">${agent.description || 'No description'}</div>
3190
+ <div class="agent-badges">
3191
+ <span class="agent-badge ${scopeClass}">${scopeLabel}</span>
3192
+ <span class="agent-badge category-badge">${catLabel}</span>
3193
+ </div>
3151
3194
  </div>
3152
3195
  `;
3153
3196
  }).join('');
@@ -3197,18 +3240,18 @@
3197
3240
 
3198
3241
  // Toggle actions
3199
3242
  async function toggleAgent(agentId, enabled) {
3200
- if (!currentProject) return;
3201
3243
  try {
3202
- const response = await fetch(`${API_URL}/api/project/${encodeURIComponent(currentProject)}/agent/${agentId}`, {
3244
+ const response = await fetch(`${API_URL}/api/agents/${agentId}/toggle`, {
3203
3245
  method: 'POST',
3204
3246
  headers: { 'Content-Type': 'application/json' },
3205
- body: JSON.stringify({ enabled })
3247
+ body: JSON.stringify({ enabled, project_path: currentProject || undefined })
3206
3248
  });
3207
3249
  const result = await response.json();
3208
3250
  if (result.success) {
3209
- await loadProjectConfig();
3210
- renderAgents();
3251
+ await loadAgentData();
3211
3252
  showToast(`${agentId} ${enabled ? 'enabled' : 'disabled'}`, 'success');
3253
+ } else {
3254
+ showToast(result.error || 'Failed to toggle agent', 'error');
3212
3255
  }
3213
3256
  } catch (e) {
3214
3257
  showToast('Failed to update agent', 'error');
@@ -3254,21 +3297,28 @@
3254
3297
  }
3255
3298
 
3256
3299
  async function enableAllAgents(enabled) {
3257
- if (!currentProject) return;
3258
- const updates = {};
3259
- allAgents.forEach(agent => { updates[agent.id] = enabled; });
3300
+ // Filter to only agents that need toggling
3301
+ const toToggle = allAgents.filter(a => a.enabled !== enabled);
3302
+ if (!toToggle.length) {
3303
+ showToast(`All agents already ${enabled ? 'enabled' : 'disabled'}`, 'info');
3304
+ return;
3305
+ }
3260
3306
  try {
3261
- const response = await fetch(`${API_URL}/api/project/${encodeURIComponent(currentProject)}/agents/bulk`, {
3262
- method: 'POST',
3263
- headers: { 'Content-Type': 'application/json' },
3264
- body: JSON.stringify({ updates })
3265
- });
3266
- const result = await response.json();
3267
- if (result.success) {
3268
- await loadProjectConfig();
3269
- renderAgents();
3270
- showToast(`All agents ${enabled ? 'enabled' : 'disabled'}`, 'success');
3307
+ let succeeded = 0;
3308
+ // Toggle in batches of 5 for reasonable concurrency
3309
+ for (let i = 0; i < toToggle.length; i += 5) {
3310
+ const batch = toToggle.slice(i, i + 5);
3311
+ const results = await Promise.all(batch.map(agent =>
3312
+ fetch(`${API_URL}/api/agents/${agent.id}/toggle`, {
3313
+ method: 'POST',
3314
+ headers: { 'Content-Type': 'application/json' },
3315
+ body: JSON.stringify({ enabled, project_path: currentProject || undefined })
3316
+ }).then(r => r.json())
3317
+ ));
3318
+ succeeded += results.filter(r => r.success).length;
3271
3319
  }
3320
+ await loadAgentData();
3321
+ showToast(`${succeeded} agents ${enabled ? 'enabled' : 'disabled'}`, 'success');
3272
3322
  } catch (e) {
3273
3323
  showToast('Failed to update agents', 'error');
3274
3324
  }
@@ -261,12 +261,9 @@ def main():
261
261
  if not response_text or len(response_text) < 50:
262
262
  sys.exit(0)
263
263
 
264
- # Load session data (includes current_request_id for causal chain)
265
- session_data = load_session_data()
266
- if not session_data:
267
- sys.exit(0)
268
-
269
- session_id = session_data.get("session_id")
264
+ # Load session data, prefer session_id from stdin
265
+ session_data = load_session_data() or {}
266
+ session_id = hook_input.get("session_id") or session_data.get("session_id")
270
267
  if not session_id:
271
268
  sys.exit(0)
272
269
 
@@ -110,8 +110,8 @@ def main():
110
110
  except (json.JSONDecodeError, ValueError, EOFError):
111
111
  sys.exit(0)
112
112
 
113
- # Get user message
114
- user_message = hook_input.get("user_prompt", "")
113
+ # Get user message (Claude Code sends "prompt", legacy uses "user_prompt")
114
+ user_message = hook_input.get("prompt", "") or hook_input.get("user_prompt", "")
115
115
  if not user_message:
116
116
  session_messages = hook_input.get("session_messages", [])
117
117
  if session_messages:
@@ -128,14 +128,15 @@ def main():
128
128
  if not is_correction:
129
129
  sys.exit(0)
130
130
 
131
- # Load session data (includes current_request_id for causal chain)
132
- session_data = load_session_data()
133
- if not session_data:
131
+ # Load session data, prefer session_id from stdin
132
+ session_data = load_session_data() or {}
133
+ stdin_session_id = hook_input.get("session_id")
134
+ if stdin_session_id and session_data.get("session_id") != stdin_session_id:
135
+ session_data["session_id"] = stdin_session_id
136
+ if not session_data.get("session_id"):
134
137
  sys.exit(0)
135
138
 
136
139
  session_id = session_data.get("session_id")
137
- if not session_id:
138
- sys.exit(0)
139
140
 
140
141
  # Get the current request ID for causal chain linking
141
142
  root_event_id = session_data.get("current_request_id")
@@ -403,8 +403,24 @@ def check_and_trigger_flush(session_id: str, project_path: str):
403
403
 
404
404
  def main():
405
405
  """Main entry point for the hook."""
406
- project_path = get_project_path()
407
- session_id = get_session_id()
406
+ # Read hook input from stdin (Claude Code sends session_id, cwd, prompt)
407
+ hook_input = {}
408
+ try:
409
+ hook_input = json.load(sys.stdin)
410
+ except Exception:
411
+ pass
412
+
413
+ project_path = hook_input.get("cwd") or get_project_path()
414
+
415
+ # Get session_id: stdin JSON > env var > .claude_session file
416
+ session_id = hook_input.get("session_id") or get_session_id()
417
+
418
+ # Ensure .claude_session file exists for sibling hooks
419
+ if session_id:
420
+ session_data = load_session_data() or {}
421
+ if session_data.get("session_id") != session_id:
422
+ session_data["session_id"] = session_id
423
+ save_session_data(session_data)
408
424
 
409
425
  # If no session, try to init one
410
426
  if not session_id:
@@ -475,7 +491,7 @@ def main():
475
491
  curator_status = None
476
492
 
477
493
  # Only fetch curator context if there's user input to contextualize
478
- user_input = os.getenv("CLAUDE_USER_INPUT", "")
494
+ user_input = hook_input.get("prompt", "") or hook_input.get("user_prompt", "") or os.getenv("CLAUDE_USER_INPUT", "")
479
495
  if user_input and len(user_input) > 10:
480
496
  curator_summary = call_memory_agent("curator_get_summary", {
481
497
  "query": user_input[:500], # Limit query length
@@ -150,12 +150,9 @@ def main():
150
150
  if tool_name not in TRACKABLE_TOOLS:
151
151
  sys.exit(0)
152
152
 
153
- # Load session data (includes current_request_id for causal chain)
154
- session_data = load_session_data()
155
- if not session_data:
156
- sys.exit(0)
157
-
158
- session_id = session_data.get("session_id")
153
+ # Load session data, prefer session_id from stdin
154
+ session_data = load_session_data() or {}
155
+ session_id = hook_input.get("session_id") or session_data.get("session_id")
159
156
  if not session_id:
160
157
  sys.exit(0)
161
158
 
@@ -143,7 +143,8 @@ def main():
143
143
  sys.exit(0)
144
144
 
145
145
  # Get user message from hook input
146
- user_message = hook_input.get("user_prompt", "")
146
+ # Claude Code sends "prompt", legacy format uses "user_prompt"
147
+ user_message = hook_input.get("prompt", "") or hook_input.get("user_prompt", "")
147
148
  if not user_message:
148
149
  # Try alternative format
149
150
  session_messages = hook_input.get("session_messages", [])
@@ -155,15 +156,22 @@ def main():
155
156
  if not user_message:
156
157
  sys.exit(0)
157
158
 
158
- # Load session data
159
- session_data = load_session_data()
160
- if not session_data:
161
- sys.exit(0)
159
+ # Get session_id: stdin JSON > env var > .claude_session file
160
+ session_id = hook_input.get("session_id") or os.getenv("CLAUDE_SESSION_ID")
161
+ session_data = load_session_data() or {}
162
+
163
+ if not session_id:
164
+ session_id = session_data.get("session_id")
162
165
 
163
- session_id = session_data.get("session_id")
164
166
  if not session_id:
165
167
  sys.exit(0)
166
168
 
169
+ # Ensure .claude_session file exists for sibling hooks
170
+ if not session_data or session_data.get("session_id") != session_id:
171
+ session_data = session_data or {}
172
+ session_data["session_id"] = session_id
173
+ save_session_data(session_data)
174
+
167
175
  # Truncate long messages
168
176
  summary = user_message[:200]
169
177
  if len(user_message) > 200:
@@ -161,12 +161,9 @@ def main():
161
161
  if tool_name not in DECISION_WORTHY_TOOLS:
162
162
  sys.exit(0)
163
163
 
164
- # Load session data
165
- session_data = load_session_data()
166
- if not session_data:
167
- sys.exit(0)
168
-
169
- session_id = session_data.get("session_id")
164
+ # Load session data, prefer session_id from stdin
165
+ session_data = load_session_data() or {}
166
+ session_id = hook_input.get("session_id") or session_data.get("session_id")
170
167
  if not session_id:
171
168
  sys.exit(0)
172
169
 
@@ -74,6 +74,15 @@ def main():
74
74
  except ImportError:
75
75
  pass
76
76
 
77
+ # ---------------------------------------------------------------
78
+ # Step 1.7: Trigger soul integration (merge fragments → soul_state)
79
+ # ---------------------------------------------------------------
80
+ if session_id and project_path:
81
+ try:
82
+ _trigger_soul_integration(session_id, project_path, timeout=5.0)
83
+ except Exception as e:
84
+ print(f"[SessionEnd] Soul integration failed (non-fatal): {e}", file=sys.stderr)
85
+
77
86
  # ---------------------------------------------------------------
78
87
  # Step 1.5: Deregister from cross-session awareness
79
88
  # ---------------------------------------------------------------
@@ -180,5 +189,33 @@ def _trigger_session_wrapup(session_id: str, project_path: str, timeout: float =
180
189
  print(f"[SessionEnd] Flush API call failed: {e}", file=sys.stderr)
181
190
 
182
191
 
192
+ def _trigger_soul_integration(session_id: str, project_path: str, timeout: float = 5.0):
193
+ """Trigger soul integration — merges session fragments into persistent soul_state."""
194
+ import urllib.request
195
+ import urllib.error
196
+
197
+ memory_agent_url = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
198
+
199
+ payload = json.dumps({
200
+ "session_id": session_id,
201
+ "project_path": project_path,
202
+ }).encode("utf-8")
203
+
204
+ try:
205
+ req = urllib.request.Request(
206
+ f"{memory_agent_url}/api/soul/integrate",
207
+ data=payload,
208
+ headers={"Content-Type": "application/json"},
209
+ method="POST"
210
+ )
211
+ with urllib.request.urlopen(req, timeout=min(timeout, 5.0)) as resp:
212
+ if resp.status == 200:
213
+ result = json.loads(resp.read().decode("utf-8"))
214
+ integrated = result.get("integrated", 0)
215
+ print(f"[SessionEnd] Soul integration complete: {integrated} fragments integrated.", file=sys.stderr)
216
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as e:
217
+ print(f"[SessionEnd] Soul integration API call failed: {e}", file=sys.stderr)
218
+
219
+
183
220
  if __name__ == "__main__":
184
221
  main()
@@ -86,6 +86,16 @@ async def load_session_context(project_path: str) -> str:
86
86
  """Load all relevant context for a session start."""
87
87
  context_parts = []
88
88
 
89
+ # ============================================================
90
+ # SOUL LAYER: Load soul brief (personality + learning context)
91
+ # ============================================================
92
+ soul_brief = await call_rest_api("GET", "/api/soul/brief", params={
93
+ "project_path": project_path,
94
+ })
95
+
96
+ if soul_brief and soul_brief.get("success") and soul_brief.get("brief"):
97
+ context_parts.append(soul_brief["brief"])
98
+
89
99
  # ============================================================
90
100
  # CROSS-SESSION AWARENESS: Register this session + catch-up
91
101
  # ============================================================
@@ -305,6 +305,122 @@ def _store_memory(extraction: Dict[str, Any], project_path: Optional[str] = None
305
305
  return False
306
306
 
307
307
 
308
+ # ---------------------------------------------------------------------------
309
+ # Soul fragment capture (lightweight regex → HTTP POST)
310
+ # ---------------------------------------------------------------------------
311
+
312
+ SOUL_PATTERNS = {
313
+ "decision_made": [
314
+ re.compile(
315
+ r"(?:let's|we'll|going to|chose to|decided to)\s+(?:use|go with|implement|try)\s+(.+)",
316
+ re.IGNORECASE,
317
+ ),
318
+ re.compile(
319
+ r"(?:using|choosing|picked)\s+(\S+)\s+(?:because|since|for)",
320
+ re.IGNORECASE,
321
+ ),
322
+ ],
323
+ "preference_expressed": [
324
+ re.compile(
325
+ r"(?:I prefer|you should always|always use|never use|don't use|I like to)\s+(.+)",
326
+ re.IGNORECASE,
327
+ ),
328
+ re.compile(
329
+ r"(?:remember to|make sure to|don't forget to)\s+(.+)",
330
+ re.IGNORECASE,
331
+ ),
332
+ ],
333
+ "error_resolved": [
334
+ re.compile(
335
+ r"(?:fixed|resolved|solved|the issue was|root cause)\s*:?\s*(.+)",
336
+ re.IGNORECASE,
337
+ ),
338
+ re.compile(
339
+ r"(?:the (?:fix|solution) (?:was|is))\s+(.+)",
340
+ re.IGNORECASE,
341
+ ),
342
+ ],
343
+ "pattern_used": [
344
+ re.compile(
345
+ r"(?:same (?:approach|pattern|method) as)\s+(.+)",
346
+ re.IGNORECASE,
347
+ ),
348
+ re.compile(
349
+ r"(?:like we did (?:for|in|with))\s+(.+)",
350
+ re.IGNORECASE,
351
+ ),
352
+ ],
353
+ "correction_received": [
354
+ re.compile(
355
+ r"(?:no,?\s+(?:actually|that's wrong|not like that)|(?:don't|stop)\s+(?:do|doing)\s+that)\s*[,:]?\s*(.+)",
356
+ re.IGNORECASE,
357
+ ),
358
+ ],
359
+ }
360
+
361
+
362
+ def _capture_soul_fragments(
363
+ text: str, session_id: str, project_path: str
364
+ ):
365
+ """Extract and POST soul fragments from response text.
366
+
367
+ Runs regex extraction, then fires HTTP POST for each fragment.
368
+ Budget: < 200ms total. Non-blocking — failures are silent.
369
+ """
370
+ import urllib.request
371
+ import urllib.error
372
+
373
+ fragments = []
374
+ seen = set()
375
+
376
+ for fragment_type, patterns in SOUL_PATTERNS.items():
377
+ for pattern in patterns:
378
+ for match in pattern.finditer(text):
379
+ captured = match.group(1).strip() if match.lastindex else match.group(0).strip()
380
+ if len(captured) < 10 or len(captured) > 300:
381
+ continue
382
+ key = captured[:50].lower()
383
+ if key in seen:
384
+ continue
385
+ seen.add(key)
386
+ fragments.append({
387
+ "fragment_type": fragment_type,
388
+ "content": captured[:300],
389
+ })
390
+
391
+ if not fragments:
392
+ return
393
+
394
+ # POST each fragment (fire-and-forget, tight timeout)
395
+ captured_count = 0
396
+ for frag in fragments[:5]: # Cap at 5 fragments per response
397
+ try:
398
+ payload = json.dumps({
399
+ "session_id": session_id,
400
+ "project_path": project_path,
401
+ "fragment_type": frag["fragment_type"],
402
+ "content": frag["content"],
403
+ }).encode("utf-8")
404
+
405
+ req = urllib.request.Request(
406
+ f"{MEMORY_AGENT_URL}/api/soul/capture",
407
+ data=payload,
408
+ headers={"Content-Type": "application/json"},
409
+ method="POST",
410
+ )
411
+ with urllib.request.urlopen(req, timeout=0.5) as resp:
412
+ if resp.status == 200:
413
+ captured_count += 1
414
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
415
+ pass # Silent failure — don't block the hook
416
+
417
+ if captured_count > 0:
418
+ print(
419
+ f"[Stop] Soul fragments captured: {captured_count}/{len(fragments)}",
420
+ file=sys.stderr,
421
+ )
422
+
423
+
308
424
  # ---------------------------------------------------------------------------
309
425
  # Main
310
426
  # ---------------------------------------------------------------------------
@@ -353,6 +469,13 @@ def main():
353
469
  if stored_hashes:
354
470
  _save_cursor_hashes(session_id, stored_hashes)
355
471
 
472
+ # --- Soul fragment capture (lightweight, adds ~100ms) ---
473
+ elapsed = time.time() - start
474
+ if elapsed < TOTAL_TIME_BUDGET - 0.2:
475
+ _capture_soul_fragments(
476
+ response_text, session_id, project_path
477
+ )
478
+
356
479
  elapsed_total = round(time.time() - start, 3)
357
480
  print(
358
481
  f"[Stop] session={session_id} "