diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py index e4e8ce759..3d99bf738 100644 --- a/routes/contacts_routes.py +++ b/routes/contacts_routes.py @@ -86,11 +86,13 @@ def _normalize_contact(contact: Dict) -> Dict: name = str(contact.get("name") or "").strip() if not name and emails: name = emails[0].split("@")[0] + address = str(contact.get("address") or "").strip() return { "uid": str(contact.get("uid") or uuid.uuid4()), "name": name, "emails": emails, "phones": phones, + "address": address, } @@ -146,7 +148,7 @@ def _parse_vcards(text: str) -> List[Dict]: for block in re.split(r"BEGIN:VCARD", text): if not block.strip(): continue - contact = {"name": "", "emails": [], "phones": [], "uid": ""} + contact = {"name": "", "emails": [], "phones": [], "uid": "", "address": ""} for line in block.split("\n"): line = line.strip() # Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...") @@ -169,6 +171,15 @@ def _parse_vcards(text: str) -> List[Dict]: phone = _vunesc(name_part.split(":", 1)[1]) if phone and phone not in contact["phones"]: contact["phones"].append(phone) + elif name_part.startswith("ADR"): + # vCard ADR is 7 semicolon-separated components: + # post-office-box;extended-address;street;locality;region;postal-code;country. + # Recover a human-readable string by joining non-empty + # components with ", ". + if ":" in name_part: + raw = name_part.split(":", 1)[1] + parts = [_vunesc(p).strip() for p in raw.split(";")] + contact["address"] = ", ".join(p for p in parts if p) elif name_part.startswith("UID:"): contact["uid"] = _vunesc(name_part[4:]) if contact["name"] or contact["emails"]: @@ -193,7 +204,8 @@ def _vesc(value: str) -> str: def _build_vcard(name: str, email: str, uid: Optional[str] = None, emails: Optional[List[str]] = None, - phones: Optional[List[str]] = None) -> str: + phones: Optional[List[str]] = None, + address: Optional[str] = None) -> str: """Build a vCard. Accepts either a single `email` (legacy callers) or full `emails`/`phones` lists (edit path). The first email is marked PREF=1. All values are RFC-6350-escaped.""" @@ -226,6 +238,12 @@ def _build_vcard(name: str, email: str, uid: Optional[str] = None, lines.append(f"EMAIL;PREF=1:{_vesc(em)}" if i == 0 else f"EMAIL:{_vesc(em)}") for ph in phone_list: lines.append(f"TEL:{_vesc(ph)}") + # Address: stuff the whole human-readable string into the street + # component of ADR. vCard ADR has 7 semicolon-separated components: + # post-office-box;extended-address;street;locality;region;postal-code;country. + addr = (address or "").strip() + if addr: + lines.append(f"ADR:;;{_vesc(addr)};;;;") lines.append("END:VCARD") return "\r\n".join(lines) + "\r\n" @@ -362,7 +380,7 @@ def _resolve_resource_url(uid: str) -> str: return _lookup() or _vcard_url(uid) -def _create_contact(name: str, email: str) -> bool: +def _create_contact(name: str, email: str, address: str = "") -> bool: """Add a new contact via CardDAV or local contacts.""" cfg = _get_carddav_config() if not _carddav_configured(cfg): @@ -371,12 +389,12 @@ def _create_contact(name: str, email: str) -> bool: for c in contacts: if email_l and email_l in [e.lower() for e in c.get("emails", [])]: return True - contacts.append(_normalize_contact({"name": name, "emails": [email]})) + contacts.append(_normalize_contact({"name": name, "emails": [email], "address": address})) _save_local_contacts(contacts) return True contact_uid = str(uuid.uuid4()) - vcard = _build_vcard(name, email, contact_uid) + vcard = _build_vcard(name, email, contact_uid, address=address) try: url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf" auth = None @@ -609,7 +627,7 @@ def _contacts_to_csv(contacts: List[Dict]) -> str: return out.getvalue() -def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -> bool: +def _update_contact(uid: str, name: str, emails: List[str], phones: List[str], address: str = "") -> bool: """Rewrite an existing contact via CardDAV or local contacts.""" cfg = _get_carddav_config() if not _carddav_configured(cfg): @@ -618,16 +636,19 @@ def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) - out = [] for c in contacts: if c.get("uid") == uid: - out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones})) + # Preserve existing address when caller passes "" (only + # updating name/emails/phones, not touching address). + addr = address if address else c.get("address", "") + out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": addr})) found = True else: out.append(c) if not found: - out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones})) + out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": address})) _save_local_contacts(out) return True - vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones) + vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones, address=address) # Use the real resource href (handles externally-created contacts whose # filename != UID); falls back to the .vcf guess. try: diff --git a/src/agent_loop.py b/src/agent_loop.py index f884df421..b268b9cac 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -441,7 +441,7 @@ Bulk delete/archive/mark emails. Use this for "delete all those" after listing e "archive_email": "- ```archive_email``` — Archive one email by UID. Args (JSON): {\"uid\":\"...\", \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.", "mark_email_read": "- ```mark_email_read``` — Mark one email read/unread. Args (JSON): {\"uid\":\"...\", \"read\":true, \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.", "resolve_contact": "- ```resolve_contact``` — Look up a contact's email by name. Searches CardDAV address book + sent email history. Args (JSON): {\"name\": \"...\"}. Use BEFORE send_email when the user gives only a name.", - "manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"uid\": \"...\"}. Use only for explicit address-book/contact requests with contact details. Do NOT use for user identity facts like 'my name is '; save those with manage_memory. For update/delete, call action=list first to get the uid.", + "manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"phones\": [...], \"address\": \"...\", \"uid\": \"...\"}. Use for info about another person: email, phone, postal address. For 'save this for ' / address paste / phone next to a name, use this — NOT manage_memory. Do NOT use for user identity facts ('my name is X'); those are manage_memory. For update/delete, call action=list first for the uid.", "manage_calendar": """\ ```manage_calendar {"action": "create_event", "summary": "", "dtstart": ""} diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 86bca6b9e..9c9e45058 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -4397,16 +4397,24 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict: if action == "add": email = (args.get("email") or "").strip() - if not email: - return {"error": "email is required for add", "exit_code": 1} - name = (args.get("name") or "").strip() or email.split("@")[0] - # Dedupe by email (same as the /add route). - existing = await asyncio.to_thread(cc._fetch_contacts) - for c in existing: - if email.lower() in [e.lower() for e in c.get("emails", [])]: - return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0} - ok = await asyncio.to_thread(cc._create_contact, name, email) - return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1} + name = (args.get("name") or "").strip() or (email.split("@")[0] if email else "") + address = (args.get("address") or "").strip() + # Need at least one identifying field. Address-only (e.g. a + # business location with no email) is fine as long as there's + # a name. + if not email and not name: + return {"error": "Provide at least name+address or email for add", "exit_code": 1} + # Dedupe by email when one is given. + if email: + existing = await asyncio.to_thread(cc._fetch_contacts) + for c in existing: + if email.lower() in [e.lower() for e in c.get("emails", [])]: + return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0} + ok = await asyncio.to_thread(cc._create_contact, name, email, address) + tail = f" <{email}>" if email else "" + if address: + tail += f" — {address}" + return {"output": f"{'Added' if ok else 'Failed to add'} {name}{tail}.", "exit_code": 0 if ok else 1} if action in ("update", "edit"): uid = (args.get("uid") or "").strip() @@ -4418,11 +4426,12 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict: emails = [args["email"]] emails = [e.strip() for e in (emails or []) if e and e.strip()] phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()] - if not name and not emails: - return {"error": "Provide a name or emails to update", "exit_code": 1} + address = (args.get("address") or "").strip() + if not name and not emails and not address: + return {"error": "Provide a name, emails, or address to update", "exit_code": 1} if not name and emails: name = emails[0].split("@")[0] - ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones) + ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones, address) return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1} if action == "delete": diff --git a/src/tool_index.py b/src/tool_index.py index ea6bd323a..a71e60264 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -114,7 +114,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.", "bulk_email": "Perform one action on many emails at once. Use for delete all those, archive these, mark all read, move spam to junk. Takes explicit UIDs from list_emails or all_unread=true. Always pass account for Gmail/work/custom mailbox results.", "resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.", - "manage_contact": "Save / update / delete / list address-book contacts (CardDAV). This is the right tool whenever the user is storing info about a specific OTHER person — their email, phone number, postal/mailing/street address, birthday, role at a company. Examples of phrasings that should land here: 'save this for ', 'save it for ', 'remember 's address / phone / email', 'add to contacts', 'update 's email', 'delete from contacts'. If the message contains a postal address, street, city, ZIP/postal code, or phone number alongside a person's name, use manage_contact — NOT manage_memory. Action=list returns the uid needed for update/delete. Do NOT use for facts about the USER themselves ('my name is X', 'I live at Y'); those are manage_memory.", + "manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for ' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.", "manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.", "manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.", "download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index e0d01f008..9ad4a5003 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -1014,7 +1014,7 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "manage_contact", - "description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact ('save Jonathan's email jon@x.com'), update an existing one ('change Maria's number'), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.", + "description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.", "parameters": { "type": "object", "properties": { @@ -1025,6 +1025,7 @@ FUNCTION_TOOL_SCHEMAS = [ "email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."}, "emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."}, "phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."}, + "address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."}, }, "required": ["action"] }