Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion dashboard/agent_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,45 @@ def list_jobs(active_only: bool = False) -> list[dict]:
return [_public(j) for j in items[:30]]


def _enrich_message(message: str, world_id: str | None, attachments: list | None) -> str:
if not attachments:
return message
from integrations import object_storage
from memory import vault_documents

blocks = [message]
for att in attachments[:6]:
if not isinstance(att, dict):
continue
if att.get("type") != "vault":
continue
doc_id = att.get("doc_id") or att.get("id")
if not doc_id:
continue
try:
doc_id = int(doc_id)
except (TypeError, ValueError):
continue
doc = vault_documents.get_document(doc_id)
if not doc:
continue
if world_id and doc.get("world_id") != world_id:
continue
raw = object_storage.get_bytes(doc.get("storage_key") or "")
if not raw:
continue
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
blocks.append(f"\n\n[ATTACHED FILE: {doc.get('github_path') or doc.get('filename') or doc.get('title')} — binary, not inlined]")
continue
path = doc.get("github_path") or doc.get("filename") or doc.get("title") or "file"
if len(text) > 14000:
text = text[:14000] + "\n… (truncated)"
blocks.append(f"\n\n[ATTACHED FILE: {path}]\n```\n{text}\n```")
return "".join(blocks)


def start_job(
*,
mode: str,
Expand All @@ -114,18 +153,21 @@ def start_job(
rag_mode: str = "auto",
specialist: str = "supervisor",
session_id: str | None = None,
attachments: list | None = None,
) -> dict:
job_id = _new_id()
cancel_event = threading.Event()
enriched_message = _enrich_message(message, world_id, attachments)
job = {
"id": job_id,
"status": "queued",
"mode": mode,
"message": message,
"message": enriched_message,
"world_id": world_id,
"rag_mode": rag_mode,
"specialist": specialist,
"session_id": session_id,
"attachments": attachments or [],
"run_id": None,
"phase": "Queued…",
"events": [],
Expand Down
45 changes: 44 additions & 1 deletion dashboard/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,57 @@ def api_goals_update(gid):
@bp.route("/reminders", methods=["POST"])
def api_reminders_create():
from agent import store
from scheduler.jobs import schedule_reminder
data = request.get_json(silent=True) or {}
text = (data.get("text") or "").strip()
due_at = (data.get("due_at") or "").strip()
if not text or not due_at:
return jsonify({"error": "text and due_at are required"}), 400
rid = store.add_reminder(text=text, due_at=due_at, repeat=(data.get("repeat") or None))
_safe(lambda: schedule_reminder(rid, due_at), None)
return jsonify({"id": rid})


@bp.route("/reminders/<int:rid>", methods=["PATCH"])
def api_reminders_update(rid):
from agent import store
from scheduler.jobs import cancel_reminder_job, schedule_reminder
data = request.get_json(silent=True) or {}
status = (data.get("status") or "").strip()
if status not in ("pending", "done", "cancelled"):
return jsonify({"error": "status must be pending, done, or cancelled"}), 400
store.set_reminder_status(rid, status)
if status in ("done", "cancelled"):
_safe(lambda: cancel_reminder_job(rid), None)
elif data.get("due_at"):
due_at = (data.get("due_at") or "").strip()
store.reschedule_reminder(rid, due_at)
_safe(lambda: schedule_reminder(rid, due_at), None)
return jsonify({"ok": True, "id": rid, "status": status})


@bp.route("/nudges")
def api_nudges():
from dashboard import nudges as nudge_mod
world_id = (request.args.get("world_id") or "").strip() or None
return jsonify({"nudges": _safe(lambda: nudge_mod.collect_nudges(world_id), [])})


@bp.route("/crm/contacts/<int:cid>/followup", methods=["POST"])
def api_contact_followup(cid):
from datetime import datetime, timedelta
from memory.sql_store import get_contact, update_contact
data = request.get_json(silent=True) or {}
contact = get_contact(cid)
if not contact:
return jsonify({"error": "contact not found"}), 404
days = int(data.get("days") or 3)
days = max(1, min(days, 90))
followup_date = (datetime.now() + timedelta(days=days)).isoformat()
update_contact(cid, next_followup_at=followup_date)
return jsonify({"ok": True, "id": cid, "next_followup_at": followup_date, "days": days})


@bp.route("/crm/contacts", methods=["POST"])
def api_contacts_create():
from memory.sql_store import add_contact
Expand Down Expand Up @@ -275,7 +317,7 @@ def api_contacts_create():
def api_contacts_update(cid):
from memory.sql_store import update_contact
data = request.get_json(silent=True) or {}
allowed = {"name", "company", "role", "email", "status", "priority", "notes", "linkedin_url"}
allowed = {"name", "company", "role", "email", "status", "priority", "notes", "linkedin_url", "next_followup_at"}
payload = {k: v for k, v in data.items() if k in allowed}
if not payload:
return jsonify({"error": "no valid fields"}), 400
Expand Down Expand Up @@ -489,6 +531,7 @@ def api_chat_async():
rag_mode=(data.get("rag_mode") or "auto").strip().lower() or "auto",
specialist=specialist or "supervisor",
session_id=(data.get("session_id") or "").strip() or None,
attachments=data.get("attachments") or [],
)
return jsonify({"job": job})

Expand Down
135 changes: 135 additions & 0 deletions dashboard/nudges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Actionable nudges for the web UI — reminders, follow-ups, approvals, vault-derived prompts."""
from __future__ import annotations

import re
from datetime import datetime

from agent import store
from memory.sql_store import get_contacts_needing_followup, get_conn


def _iso_now() -> str:
return datetime.now().isoformat()


def _overdue(due_at: str | None) -> bool:
if not due_at:
return False
try:
return datetime.fromisoformat(due_at.replace("Z", "+00:00")[:26]) <= datetime.now()
except Exception:
return False


def _vault_lead_nudges(world_id: str | None = None) -> list[dict]:
"""If synced vault docs mention leads/prospects, nudge when CRM has untouched prospects."""
from memory import vault_documents

try:
conn = get_conn()
if world_id:
prospects = conn.execute(
"SELECT id, name, company, status FROM contacts WHERE status = 'prospect' LIMIT 20"
).fetchall()
docs = vault_documents.list_documents(world_id)
else:
prospects = conn.execute(
"SELECT id, name, company, status FROM contacts WHERE status = 'prospect' LIMIT 20"
).fetchall()
docs = []
for wid_row in conn.execute("SELECT DISTINCT world_id FROM vault_documents").fetchall():
docs.extend(vault_documents.list_documents(wid_row["world_id"]))
conn.close()
except Exception:
return []

if not prospects:
return []

lead_pattern = re.compile(r"\b(leads?|prospects?|outreach|pipeline|icp)\b", re.I)
lead_docs = [
d for d in docs
if d.get("source_type") == "github"
and lead_pattern.search(d.get("description") or "")
or lead_pattern.search(d.get("title") or "")
or lead_pattern.search(d.get("github_path") or "")
]
if not lead_docs:
return []

doc = lead_docs[0]
repo = doc.get("github_repo") or "vault"
path = doc.get("github_path") or doc.get("title") or "document"
count = len(prospects)
names = ", ".join(f"{p['name']}" + (f" @ {p['company']}" if p.get("company") else "") for p in prospects[:3])
extra = f" (+{count - 3} more)" if count > 3 else ""
return [{
"id": f"vault-leads-{doc.get('id')}",
"kind": "vault_leads",
"title": f"{count} prospect{'s' if count != 1 else ''} not contacted",
"body": f"Your synced doc {repo}/{path} references leads. Have you reached out to {names}{extra}?",
"action": "crm",
"priority": 2,
"meta": {
"world_id": doc.get("world_id"),
"doc_id": doc.get("id"),
"prospect_count": count,
},
}]


def collect_nudges(world_id: str | None = None) -> list[dict]:
nudges: list[dict] = []

for r in store.get_pending_reminders():
overdue = _overdue(r.get("due_at"))
nudges.append({
"id": f"reminder-{r['id']}",
"kind": "reminder",
"title": r.get("text") or "Reminder",
"body": f"Due {r.get('due_at', '')[:16].replace('T', ' ')}",
"action": "goals",
"priority": 1 if overdue else 3,
"meta": {"reminder_id": r["id"], "overdue": overdue},
})

try:
for c in get_contacts_needing_followup():
nudges.append({
"id": f"followup-{c['id']}",
"kind": "followup",
"title": f"Follow up with {c.get('name', 'contact')}",
"body": f"{c.get('company') or 'No company'} · status {c.get('status', '?')}",
"action": "crm",
"priority": 2,
"meta": {"contact_id": c["id"]},
})
except Exception:
pass

for a in store.list_pending_approvals():
nudges.append({
"id": f"approval-{a['id']}",
"kind": "approval",
"title": f"Approval needed: {a.get('tool_name', 'tool')}",
"body": (a.get("summary") or "")[:160],
"action": "approvals",
"priority": 1,
"meta": {"approval_id": a["id"]},
})

for g in store.list_goals("active")[:5]:
nudges.append({
"id": f"goal-{g['id']}",
"kind": "goal",
"title": g.get("title") or "Active goal",
"body": (g.get("detail") or "Track progress on this goal")[:120],
"action": "goals",
"priority": 4,
"meta": {"goal_id": g["id"]},
})

nudges.extend(_vault_lead_nudges(world_id))

nudges.sort(key=lambda n: (n.get("priority") or 9, n.get("title") or ""))
return nudges[:24]
Loading
Loading