fusesell 1.3.42__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. fusesell-1.3.42.dist-info/METADATA +873 -0
  2. fusesell-1.3.42.dist-info/RECORD +35 -0
  3. fusesell-1.3.42.dist-info/WHEEL +5 -0
  4. fusesell-1.3.42.dist-info/entry_points.txt +2 -0
  5. fusesell-1.3.42.dist-info/licenses/LICENSE +21 -0
  6. fusesell-1.3.42.dist-info/top_level.txt +2 -0
  7. fusesell.py +20 -0
  8. fusesell_local/__init__.py +37 -0
  9. fusesell_local/api.py +343 -0
  10. fusesell_local/cli.py +1480 -0
  11. fusesell_local/config/__init__.py +11 -0
  12. fusesell_local/config/default_email_templates.json +34 -0
  13. fusesell_local/config/default_prompts.json +19 -0
  14. fusesell_local/config/default_scoring_criteria.json +154 -0
  15. fusesell_local/config/prompts.py +245 -0
  16. fusesell_local/config/settings.py +277 -0
  17. fusesell_local/pipeline.py +978 -0
  18. fusesell_local/stages/__init__.py +19 -0
  19. fusesell_local/stages/base_stage.py +603 -0
  20. fusesell_local/stages/data_acquisition.py +1820 -0
  21. fusesell_local/stages/data_preparation.py +1238 -0
  22. fusesell_local/stages/follow_up.py +1728 -0
  23. fusesell_local/stages/initial_outreach.py +2972 -0
  24. fusesell_local/stages/lead_scoring.py +1452 -0
  25. fusesell_local/utils/__init__.py +36 -0
  26. fusesell_local/utils/agent_context.py +552 -0
  27. fusesell_local/utils/auto_setup.py +361 -0
  28. fusesell_local/utils/birthday_email_manager.py +467 -0
  29. fusesell_local/utils/data_manager.py +4857 -0
  30. fusesell_local/utils/event_scheduler.py +959 -0
  31. fusesell_local/utils/llm_client.py +342 -0
  32. fusesell_local/utils/logger.py +203 -0
  33. fusesell_local/utils/output_helpers.py +2443 -0
  34. fusesell_local/utils/timezone_detector.py +914 -0
  35. fusesell_local/utils/validators.py +436 -0
@@ -0,0 +1,36 @@
1
+ """
2
+ FuseSell Utilities - Common utilities and helper functions
3
+ """
4
+
5
+ from .data_manager import LocalDataManager
6
+ from .llm_client import LLMClient, normalize_llm_base_url
7
+ from .validators import InputValidator
8
+ from .logger import setup_logging
9
+ from .output_helpers import write_full_output_html
10
+ from .auto_setup import (
11
+ check_settings_completion,
12
+ auto_initialize_auto_interaction,
13
+ auto_populate_from_email,
14
+ generate_agent_context,
15
+ should_update_agent_context,
16
+ get_gmail_email_safe,
17
+ )
18
+ from .agent_context import notify_action_completed, write_agent_md, get_agent_md_path
19
+
20
+ __all__ = [
21
+ 'LocalDataManager',
22
+ 'LLMClient',
23
+ 'normalize_llm_base_url',
24
+ 'InputValidator',
25
+ 'setup_logging',
26
+ 'write_full_output_html',
27
+ 'check_settings_completion',
28
+ 'auto_initialize_auto_interaction',
29
+ 'auto_populate_from_email',
30
+ 'generate_agent_context',
31
+ 'should_update_agent_context',
32
+ 'get_gmail_email_safe',
33
+ 'notify_action_completed',
34
+ 'write_agent_md',
35
+ 'get_agent_md_path',
36
+ ]
@@ -0,0 +1,552 @@
1
+ """
2
+ Agent context helpers for FuseSell.
3
+
4
+ Provides a lightweight agent.md writer and an action hook that can be used by
5
+ RealtimeX flows (or other consumers) to refresh agent context after critical
6
+ mutations. The helpers are resilient to missing realtimex_toolkit; when the
7
+ toolkit is unavailable, agent.md is written directly to the expected path.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ import time
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .auto_setup import generate_agent_context, should_update_agent_context
18
+ from .data_manager import LocalDataManager
19
+
20
+ # Default action priorities, aligned with the RealtimeX flows.
21
+ CRITICAL_ACTIONS = [
22
+ 'product_create', 'product_update', 'product_delete', 'product_status_change', 'product_bulk_import',
23
+ 'process_create',
24
+ 'team_create', 'team_update',
25
+ 'gs_organization', 'sales_rep', 'product_team', 'auto_interaction',
26
+ 'initial_outreach', 'follow_up', 'schedule_time', 'followup_schedule_time',
27
+ ]
28
+
29
+ SKIP_ACTIONS = [
30
+ 'product_view', 'product_list', 'team_view', 'team_list', 'settings_view',
31
+ 'process_query', 'process_status_change', 'draft_list', 'event_list', 'event_update',
32
+ 'knowledge_query', 'proposal_generate',
33
+ ]
34
+
35
+
36
+ def get_agent_md_path(workspace_slug: str, agent_id: str) -> Path:
37
+ """
38
+ Construct the standardized agent.md path used by RealtimeX.
39
+ """
40
+ home_dir = Path.home()
41
+ return (
42
+ home_dir
43
+ / ".realtimex.ai"
44
+ / "Resources"
45
+ / "agent-skills"
46
+ / "workspaces"
47
+ / workspace_slug
48
+ / agent_id
49
+ / "agent.md"
50
+ )
51
+
52
+
53
+ def _format_products(products: List[Dict[str, Any]], limit: int = 5) -> str:
54
+ if not products:
55
+ return "None configured"
56
+
57
+ lines: List[str] = []
58
+ for product in products[:limit]:
59
+ name = product.get('product_name') or product.get('name') or 'Unknown product'
60
+ lines.append(f"- {name}")
61
+ remaining = len(products) - limit
62
+ if remaining > 0:
63
+ lines.append(f"... ({remaining} more)")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def _format_processes(processes: List[Dict[str, Any]], limit: int = 5, detail_limit: int = 160) -> str:
68
+ if not processes:
69
+ return "None"
70
+
71
+ lines: List[str] = []
72
+ for process in processes[:limit]:
73
+ task_id = process.get('task_id') or process.get('id') or 'unknown'
74
+ customer = process.get('customer_name') or process.get('customer_company') or 'unknown customer'
75
+ status = process.get('status') or 'unknown'
76
+ notes = process.get('notes') or ''
77
+ if notes and len(notes) > detail_limit:
78
+ notes = notes[:detail_limit] + '...'
79
+ lines.append(f"- {task_id}: {customer} [{status}]")
80
+ if notes:
81
+ lines.append(f" - notes: {notes}")
82
+ if len(processes) > limit:
83
+ lines.append(f"... ({len(processes) - limit} more)")
84
+ return "\n".join(lines)
85
+
86
+
87
+ def _render_agent_markdown(context: Dict[str, Any]) -> str:
88
+ """
89
+ Render the structured context returned by generate_agent_context into markdown.
90
+ """
91
+ stats = context.get('statistics') or {}
92
+ team_settings = context.get('team_settings') or {}
93
+
94
+ settings_completion = stats.get('settings_completion') or {}
95
+ products = context.get('products') or []
96
+ active_processes = context.get('active_processes') or []
97
+
98
+ product_by_id = {p.get('product_id'): p for p in products if p.get('product_id')}
99
+
100
+ # Product counts
101
+ active_products = [p for p in products if (p.get('status') or '').lower() == 'active']
102
+ inactive_products = [p for p in products if (p.get('status') or '').lower() != 'active']
103
+ total_products = stats.get('total_products', len(products))
104
+
105
+ # Process counts (best effort)
106
+ active_processes_count = stats.get('active_processes', len(active_processes))
107
+ total_processes = stats.get('total_processes', active_processes_count)
108
+
109
+ # Quick settings flags (condensed)
110
+ org_profile_configured = bool(team_settings.get('gs_team_organization'))
111
+ reps_configured = bool(team_settings.get('gs_team_rep'))
112
+ linked_products_data = team_settings.get('gs_team_product') if isinstance(team_settings, dict) else None
113
+ products_linked = bool(linked_products_data)
114
+ auto_interaction_configured = bool(team_settings.get('gs_team_auto_interaction'))
115
+ follow_up_configured = bool(team_settings.get('gs_team_follow_up'))
116
+
117
+ completion_lines = [
118
+ f"- auto_interaction: {bool(settings_completion.get('auto_interaction_completed'))}",
119
+ f"- rep: {bool(settings_completion.get('rep_completed'))}",
120
+ f"- product: {bool(settings_completion.get('product_completed'))}",
121
+ f"- organization: {bool(settings_completion.get('organization_completed'))}",
122
+ f"- follow_up: {bool(settings_completion.get('follow_up_completed'))}",
123
+ ]
124
+
125
+ workspace_slug = context.get('workspace_slug', 'workspace-default')
126
+ org_id = context.get('org_id', 'unknown@example.com')
127
+ team_id = context.get('team_id', 'N/A')
128
+ team_name = context.get('team_name', 'Workspace Team')
129
+ team_created_at = context.get('team_created_at', 'N/A')
130
+
131
+ # Recent activity (best effort) capped at 5
132
+ recent_activity: List[str] = []
133
+ seven_days_ago = datetime.utcnow() - timedelta(days=7)
134
+ if team_created_at and team_created_at != 'N/A':
135
+ recent_activity.append(f"- Team created: {team_created_at}")
136
+ for product in sorted(products, key=lambda p: p.get('created_at') or '', reverse=True):
137
+ created = product.get('created_at')
138
+ if not created:
139
+ continue
140
+ try:
141
+ created_dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
142
+ if created_dt >= seven_days_ago:
143
+ recent_activity.append(
144
+ f"- Product added: {product.get('product_name', 'Unknown')} ({created_dt.date()})"
145
+ )
146
+ except Exception:
147
+ continue
148
+ if not recent_activity:
149
+ recent_activity.append("None")
150
+ if len(recent_activity) > 5:
151
+ extra = len(recent_activity) - 5
152
+ recent_activity = recent_activity[:5] + [f"... ({extra} more)"]
153
+
154
+ # Sales reps
155
+ reps_data = team_settings.get('gs_team_rep') if isinstance(team_settings, dict) else None
156
+ reps_list: List[str] = []
157
+ sales_poc = "Not configured"
158
+ if isinstance(reps_data, list):
159
+ for rep in reps_data:
160
+ if not isinstance(rep, dict):
161
+ continue
162
+ name = rep.get('name') or 'Unnamed'
163
+ email = rep.get('email') or ''
164
+ title = rep.get('position') or rep.get('title') or ''
165
+ line = f"- {name}"
166
+ if title:
167
+ line += f" ({title})"
168
+ if email:
169
+ line += f" - {email}"
170
+ reps_list.append(line)
171
+ if reps_list:
172
+ first_rep = reps_data[0]
173
+ sales_poc = f"{first_rep.get('name', 'Unknown')} ({first_rep.get('email', '')})".strip()
174
+ if not reps_list:
175
+ reps_list.append("No sales reps configured")
176
+ if len(reps_list) > 5:
177
+ extra = len(reps_list) - 5
178
+ reps_list = reps_list[:5] + [f"... ({extra} more)"]
179
+
180
+ # Linked products
181
+ linked_products_list: List[str] = []
182
+ if isinstance(linked_products_data, list):
183
+ for link in linked_products_data:
184
+ if not isinstance(link, dict):
185
+ continue
186
+ pid = link.get('product_id')
187
+ if not pid:
188
+ continue
189
+ product = product_by_id.get(pid)
190
+ linked_products_list.append(f"- {product.get('product_name', pid) if product else pid}")
191
+ if not linked_products_list:
192
+ linked_products_list.append("No products linked to workspace")
193
+ if len(linked_products_list) > 5:
194
+ extra = len(linked_products_list) - 5
195
+ linked_products_list = linked_products_list[:5] + [f"... ({extra} more)"]
196
+
197
+ # Org profile
198
+ org_profile_data = team_settings.get('gs_team_organization') if isinstance(team_settings, dict) else None
199
+ org_profile_lines: List[str] = []
200
+ if isinstance(org_profile_data, list):
201
+ org_profile_data = org_profile_data[0] if org_profile_data else {}
202
+ if isinstance(org_profile_data, dict):
203
+ if org_profile_data.get('legal_name'):
204
+ org_profile_lines.append(f"- Org Name: {org_profile_data['legal_name']}")
205
+ if org_profile_data.get('primary_email'):
206
+ org_profile_lines.append(f"- Primary Email: {org_profile_data['primary_email']}")
207
+ if org_profile_data.get('address'):
208
+ org_profile_lines.append(f"- Address: {org_profile_data['address']}")
209
+ if not org_profile_lines:
210
+ org_profile_lines.append("Not configured")
211
+
212
+ # Required settings readiness
213
+ required_settings = {
214
+ "Organization Profile": org_profile_configured,
215
+ "Sales Representatives": reps_configured,
216
+ "Product Catalog": bool(linked_products_data),
217
+ "Email Automation": auto_interaction_configured,
218
+ }
219
+ required_settings_count = sum(1 for val in required_settings.values() if val)
220
+ total_required = len(required_settings)
221
+ if required_settings_count >= total_required:
222
+ sales_ready_status = "Fully Ready"
223
+ elif required_settings_count >= total_required - 1:
224
+ sales_ready_status = "Almost Ready (1 setting remaining)"
225
+ elif required_settings_count >= 2:
226
+ sales_ready_status = "Partially Ready"
227
+ else:
228
+ sales_ready_status = "Not Ready"
229
+ configured_settings_text = "\n".join(
230
+ [f"- {name}" for name, done in required_settings.items() if done]
231
+ ) or "None configured"
232
+ missing_settings_text = "\n".join(
233
+ [f"- {name}" for name, done in required_settings.items() if not done]
234
+ ) or "None"
235
+ ready_to_start = (
236
+ f"**Status**: Yes, ready to go\n\n"
237
+ f"- Product: {(active_products[0].get('product_name') if active_products else 'N/A')}\n"
238
+ f"- Sales Rep: {sales_poc}\n"
239
+ f"- Method: Email outreach"
240
+ if required_settings_count >= total_required
241
+ else f"**Status**: Not yet\n\nComplete these required settings first:\n{missing_settings_text}"
242
+ )
243
+
244
+ primary_product = active_products[0].get('product_name', 'No products configured') if active_products else 'No products configured'
245
+ target_market = active_products[0].get('short_description', 'Not specified') if active_products else 'Not specified'
246
+
247
+ # Process details
248
+ recently_completed_processes = "None"
249
+
250
+ # Auto interaction detail
251
+ auto_interaction_details = "Not configured"
252
+ auto_data = team_settings.get('gs_team_auto_interaction') if isinstance(team_settings, dict) else None
253
+ if isinstance(auto_data, list) and auto_data:
254
+ entry = auto_data[0]
255
+ if isinstance(entry, dict):
256
+ from_email = ""
257
+ from_block = entry.get('from') or {}
258
+ if isinstance(from_block, dict):
259
+ from_email = from_block.get('email') or ""
260
+ if entry.get('from_email'):
261
+ from_email = entry.get('from_email')
262
+ tool = entry.get('tool') or entry.get('tool_type') or 'Unknown tool'
263
+ auto_interaction_details = f"- Tool: {tool}"
264
+ if from_email:
265
+ auto_interaction_details += f"\n- From email: {from_email}"
266
+ elif isinstance(auto_data, dict) and auto_data:
267
+ from_email = auto_data.get('from_email') or ''
268
+ tool = auto_data.get('tool') or auto_data.get('tool_type') or 'Unknown tool'
269
+ auto_interaction_details = f"- Tool: {tool}"
270
+ if from_email:
271
+ auto_interaction_details += f"\n- From email: {from_email}"
272
+
273
+ return f"""# Workspace Context: {team_name}
274
+
275
+ > Auto-generated workspace context for FuseSell agent
276
+ > Last Updated: {context.get('last_updated', 'unknown')}
277
+
278
+ ## Workspace Identity
279
+
280
+ - **Workspace ID**: `{workspace_slug}`
281
+ - **Primary User (Org ID)**: {org_id}
282
+
283
+ ## Hidden Team (Single Team Model)
284
+
285
+ - **Team ID**: `{team_id}`
286
+ - **Team Name**: {team_name}
287
+ - **Created**: {team_created_at}
288
+
289
+ ## Product Catalog
290
+
291
+ **Total Products**: {total_products} ({len(active_products)} active, {len(inactive_products)} inactive)
292
+
293
+ ### Active Products
294
+ {_format_products(active_products)}
295
+
296
+ ### Draft/Inactive Products
297
+ {_format_products(inactive_products)}
298
+
299
+ ### Recent Activity (last 7 days)
300
+ {chr(10).join(recent_activity)}
301
+
302
+ ## Active Sales Processes
303
+
304
+ **Total Active**: {active_processes_count}
305
+ **Total Processes**: {total_processes}
306
+
307
+ ### In Progress
308
+ {_format_processes(active_processes)}
309
+
310
+ ### Recently Completed
311
+ {recently_completed_processes}
312
+
313
+ ## Sales Settings Configuration
314
+
315
+ ### Organization Profile (`gs_team_organization`)
316
+ {chr(10).join(org_profile_lines)}
317
+
318
+ ### Sales Representatives (`gs_team_rep`)
319
+ **Configured Reps**: {len(reps_data) if isinstance(reps_data, list) else 0}
320
+ {chr(10).join(reps_list)}
321
+
322
+ ### Product-Team Links (`gs_team_product`)
323
+ **Linked Products**: {len(linked_products_data) if isinstance(linked_products_data, list) else 0}
324
+ {chr(10).join(linked_products_list)}
325
+
326
+ ### Initial Outreach (`gs_team_initial_outreach`)
327
+ {"Configured" if team_settings.get('gs_team_initial_outreach') else "Not configured"}
328
+
329
+ ### Follow-up Configuration (`gs_team_follow_up`)
330
+ {"Configured" if follow_up_configured else "Not configured"}
331
+
332
+ ### Schedule Windows
333
+ **Initial Outreach** (`gs_team_schedule_time`):
334
+ {"Configured" if team_settings.get('gs_team_schedule_time') else "Not configured"}
335
+
336
+ **Follow-up** (`gs_team_followup_schedule_time`):
337
+ {"Configured" if team_settings.get('gs_team_followup_schedule_time') else "Not configured"}
338
+
339
+ ### Automation Settings (`gs_team_auto_interaction`)
340
+ {auto_interaction_details}
341
+
342
+ ### Birthday Email (`gs_team_birthday_email`)
343
+ {"Configured" if team_settings.get('gs_team_birthday_email') else "Not configured"}
344
+
345
+ ## Configuration Readiness
346
+
347
+ - **Sales Ready**: {sales_ready_status}
348
+ - **Setup Completion**: {int((required_settings_count/total_required)*100)}% ({required_settings_count} of {total_required} required settings configured)
349
+
350
+ ### Required Configuration Status
351
+
352
+ #### Configured Settings
353
+ {configured_settings_text}
354
+
355
+ ### Ready to Start Selling?
356
+
357
+ {ready_to_start}
358
+
359
+ ### Quick Reference
360
+
361
+ **Primary Product**: {primary_product}
362
+ **Target Market**: {target_market}
363
+ **Sales Point of Contact**: {sales_poc}
364
+ """
365
+
366
+
367
+ def write_agent_md(
368
+ workspace_slug: Optional[str] = None,
369
+ force: bool = False,
370
+ *,
371
+ manager: Optional[LocalDataManager] = None,
372
+ org_id: Optional[str] = None,
373
+ data_dir: Optional[str] = None,
374
+ detail_limit: int = 180,
375
+ ) -> Optional[Path]:
376
+ """
377
+ Generate and write agent.md using package helpers, preserving agent-written content.
378
+
379
+ If realtimex_toolkit is available, uses save_agent_memory to write to the
380
+ standardized path. Otherwise writes directly to the expected path on disk.
381
+ """
382
+ try:
383
+ from realtimex_toolkit import ( # type: ignore
384
+ get_flow_variable,
385
+ get_workspace_slug,
386
+ get_workspace_data_dir,
387
+ get_agent_id,
388
+ save_agent_memory,
389
+ )
390
+ except Exception: # noqa: BLE001
391
+ get_flow_variable = None
392
+ get_workspace_slug = None
393
+ get_workspace_data_dir = None
394
+ get_agent_id = None
395
+ save_agent_memory = None
396
+
397
+ # Resolve workspace slug from provided arg, toolkit, or flow variables
398
+ if workspace_slug is None:
399
+ resolved_slug = None
400
+ if get_flow_variable:
401
+ try:
402
+ resolved_slug = get_flow_variable("workspace_slug", default_value=None)
403
+ if not resolved_slug:
404
+ resolved_slug = get_flow_variable("workspace", default_value=None)
405
+ except Exception:
406
+ resolved_slug = None
407
+ if not resolved_slug and get_workspace_slug:
408
+ resolved_slug = get_workspace_slug(default_value="workspace-default")
409
+ workspace_slug = resolved_slug or "workspace-default"
410
+
411
+ if org_id is None and get_flow_variable:
412
+ user = get_flow_variable("user", default_value={'email': 'unknown@example.com'})
413
+ org_id = user.get('email', 'unknown@example.com') if isinstance(user, dict) else 'unknown@example.com'
414
+ if org_id is None:
415
+ org_id = "unknown@example.com"
416
+
417
+ agent_id = "agent-default"
418
+ if get_agent_id:
419
+ agent_id = get_agent_id(default_value="ef0f035a-84c1-4b88-9050-cd7f6fa40ed6")
420
+
421
+ # Resolve workspace and data directories
422
+ workspace_dir = None
423
+ if get_workspace_data_dir:
424
+ try:
425
+ workspace_dir = Path(get_workspace_data_dir(default_workspace_slug=workspace_slug)).expanduser()
426
+ except Exception:
427
+ workspace_dir = None
428
+ if workspace_dir is None:
429
+ workspace_dir = Path.home() / ".realtimex.ai" / "Workspaces" / workspace_slug
430
+
431
+ data_dir_path = Path(data_dir).expanduser() if data_dir else workspace_dir / "fusesell_data"
432
+
433
+ agent_md_path = get_agent_md_path(workspace_slug, agent_id)
434
+
435
+ # TTL check (5 minutes)
436
+ if not force and agent_md_path.exists():
437
+ age_minutes = (time.time() - agent_md_path.stat().st_mtime) / 60
438
+ if age_minutes < 5:
439
+ return agent_md_path
440
+
441
+ agent_section = ""
442
+ if agent_md_path.exists():
443
+ try:
444
+ existing_content = agent_md_path.read_text(encoding='utf-8')
445
+ if "<!-- END AUTO-GENERATED -->" in existing_content:
446
+ parts = existing_content.split("<!-- END AUTO-GENERATED -->", 1)
447
+ if len(parts) > 1:
448
+ agent_section = parts[1].strip()
449
+ except Exception as exc: # noqa: BLE001
450
+ print(f"[WARNING] Could not read existing agent.md: {exc}", file=sys.stderr)
451
+
452
+ try:
453
+ dm = manager or LocalDataManager(data_dir=str(data_dir_path))
454
+ context_data = generate_agent_context(
455
+ manager=dm,
456
+ org_id=org_id,
457
+ detail_limit=detail_limit,
458
+ )
459
+ # Inject workspace metadata for rendering
460
+ context_data['workspace_slug'] = workspace_slug
461
+ context_data['org_id'] = org_id
462
+ if context_data.get('team_name') is None:
463
+ context_data['team_name'] = workspace_slug
464
+ if context_data.get('team_id') is None:
465
+ context_data['team_id'] = 'N/A'
466
+ if context_data.get('team_created_at') is None:
467
+ context_data['team_created_at'] = 'N/A'
468
+ auto_generated_content = _render_agent_markdown(context_data)
469
+
470
+ timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
471
+ complete_content = f"""<!-- AUTO-GENERATED SECTION -->
472
+ <!-- Last Updated: {timestamp} -->
473
+ <!-- WARNING: Do not edit content between AUTO-GENERATED and END AUTO-GENERATED markers -->
474
+ <!-- This section is automatically regenerated from database on critical actions -->
475
+
476
+ {auto_generated_content}
477
+
478
+ <!-- END AUTO-GENERATED -->"""
479
+
480
+ if save_agent_memory:
481
+ new_agent_path = save_agent_memory(
482
+ workspace_slug=workspace_slug,
483
+ agent_id=agent_id,
484
+ content=complete_content,
485
+ mode="overwrite",
486
+ )
487
+ return Path(new_agent_path)
488
+
489
+ agent_md_path.parent.mkdir(parents=True, exist_ok=True)
490
+ agent_md_path.write_text(complete_content, encoding='utf-8')
491
+ return agent_md_path
492
+
493
+ except Exception as exc: # noqa: BLE001
494
+ print(f"[ERROR] Failed to generate agent.md: {exc}", file=sys.stderr)
495
+ import traceback
496
+ traceback.print_exc()
497
+ return None
498
+
499
+
500
+ def notify_action_completed(
501
+ action: str,
502
+ *,
503
+ workspace_slug: Optional[str] = None,
504
+ force: bool = False,
505
+ manager: Optional[LocalDataManager] = None,
506
+ org_id: Optional[str] = None,
507
+ data_dir: Optional[str] = None,
508
+ ) -> Dict[str, Any]:
509
+ """
510
+ Update agent.md if action requires it. Returns a status dictionary.
511
+ """
512
+ if not should_update_agent_context(
513
+ action=action,
514
+ critical_actions=CRITICAL_ACTIONS,
515
+ skip_actions=SKIP_ACTIONS,
516
+ ):
517
+ return {
518
+ 'status': 'skipped',
519
+ 'message': 'Agent context skipped (read-only action)',
520
+ 'updated': False,
521
+ }
522
+
523
+ should_force = force or action in CRITICAL_ACTIONS
524
+
525
+ agent_md_path = write_agent_md(
526
+ workspace_slug=workspace_slug,
527
+ force=should_force,
528
+ manager=manager,
529
+ org_id=org_id,
530
+ data_dir=data_dir,
531
+ )
532
+
533
+ if agent_md_path:
534
+ return {
535
+ 'status': 'success',
536
+ 'message': 'Agent context updated',
537
+ 'updated': True,
538
+ 'path': str(agent_md_path),
539
+ }
540
+
541
+ return {
542
+ 'status': 'error',
543
+ 'message': 'Failed to update agent context',
544
+ 'updated': False,
545
+ }
546
+
547
+
548
+ __all__ = [
549
+ 'notify_action_completed',
550
+ 'write_agent_md',
551
+ 'get_agent_md_path',
552
+ ]