personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

activities: anticipation-aware durations and icon taxonomy

The activities day API was built for realized records with `segments`;
anticipated records (`source: "anticipated"`) carry `start`/`end` HH:MM:SS
strings and the schedule talent's expanded slug taxonomy. The route now
branches on `source`, derives `duration_minutes` from `start`/`end` when
both are present, and looks up activity metadata via a global
`DEFAULT_ACTIVITIES` fallback so schedule slugs resolve without per-facet
attachment. Renderer hides the duration cell when missing and falls back
on a generic 🗓️ icon. Adds 9 new default entries (call, deadline,
appointment, event, travel, reminder, errand, celebration,
doctor_appointment) for the schedule talent's vocabulary.

Follow-up not in this lode: `apps/home/routes.py:521` has the same
`estimate_duration_minutes(segments)` bug on the home-page yesterday rollup;
fix it in a separate diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+480 -94
+14 -5
apps/activities/_day.html
··· 590 590 } 591 591 592 592 function fmtDuration(mins) { 593 + if (mins == null || !Number.isFinite(Number(mins))) { 594 + return '—'; 595 + } 596 + mins = Number(mins); 593 597 if (mins >= 60) { 594 598 const h = Math.floor(mins / 60); 595 599 const m = mins % 60; ··· 697 701 const top = ((sMin - startHour * 60) / totalMinutes) * 100; 698 702 const height = Math.max((eMin - sMin) / totalMinutes * 100, 0.4); 699 703 const color = levelColor(a.level_avg); 700 - html += `<a class='occ occ-activity' data-activity-id='${escapeHtml(a.id)}' tabindex='0' role='button' style='top:${top}%;height:${height}%;left:0;width:100%;border-color:${color};background:${color}' title='${escapeHtml(a.icon + " " + a.name)}'>${escapeHtml(a.icon)} ${escapeHtml(a.name)}</a>`; 704 + const icon = a.icon || '🗓️'; 705 + html += `<a class='occ occ-activity' data-activity-id='${escapeHtml(a.id)}' tabindex='0' role='button' style='top:${top}%;height:${height}%;left:0;width:100%;border-color:${color};background:${color}' title='${escapeHtml(icon + " " + a.name)}'>${escapeHtml(icon)} ${escapeHtml(a.name)}</a>`; 701 706 }); 702 707 703 708 html += '</section>'; ··· 721 726 const lvl = levelLabel(a.level_avg); 722 727 const timeStr = a.startTime ? fmtTime(a.startTime) : ''; 723 728 const color = levelColor(a.level_avg); 729 + const icon = a.icon || '🗓️'; 724 730 html += `<div class="activity-card" data-activity-id="${escapeHtml(a.id)}" tabindex="0" role="button" style="border-left-color:${color}">`; 725 - html += `<span class="act-icon">${escapeHtml(a.icon || '')}</span>`; 731 + html += `<span class="act-icon">${escapeHtml(icon)}</span>`; 726 732 html += `<div class="act-body">`; 727 733 html += `<div class="act-name">${escapeHtml(a.name)}</div>`; 728 734 if (a.description) html += `<div class="act-desc">${escapeHtml(a.description)}</div>`; 729 735 html += `</div>`; 730 736 html += `<div class="act-meta">`; 731 737 if (timeStr) html += `<div>${timeStr}</div>`; 732 - html += `<div class="act-duration">${dur}<span class="act-level level-${lvl}" aria-hidden="true"></span><span class="sr-only">${lvl} engagement</span></div>`; 738 + if (a.duration_minutes != null) html += `<div class="act-duration">${dur}<span class="act-level level-${lvl}" aria-hidden="true"></span><span class="sr-only">${lvl} engagement</span></div>`; 733 739 if (a.outputs.length) html += `<div>${a.outputs.length} output${a.outputs.length > 1 ? 's' : ''}</div>`; 734 740 html += `</div></div>`; 735 741 }); ··· 762 768 const timeRange = a.startTime 763 769 ? `${fmtTime(a.startTime)}${a.endTime ? ' – ' + fmtTime(a.endTime) : ''}` 764 770 : ''; 771 + const icon = a.icon || '🗓️'; 765 772 766 773 let html = ''; 767 774 html += `<button type="button" class="activity-detail-back">← back to day</button>`; 768 775 html += `<div class="activity-detail-header">`; 769 - html += `<h2>${escapeHtml(a.icon || '')} ${escapeHtml(a.name)}</h2>`; 776 + html += `<h2>${escapeHtml(icon)} ${escapeHtml(a.name)}</h2>`; 770 777 html += `<div class="ad-subtitle">${escapeHtml(a.facet)}</div>`; 771 778 html += `</div>`; 772 779 ··· 785 792 if (timeRange) { 786 793 html += `<dt>Time</dt><dd>${timeRange}</dd>`; 787 794 } 788 - html += `<dt>Duration</dt><dd>${fmtDuration(a.duration_minutes)}</dd>`; 795 + if (a.duration_minutes != null) { 796 + html += `<dt>Duration</dt><dd>${fmtDuration(a.duration_minutes)}</dd>`; 797 + } 789 798 html += `<dt>Engagement</dt><dd><span class="act-level level-${lvl}" aria-hidden="true"></span> ${lvl}</dd>`; 790 799 html += `<dt>Segments</dt><dd>${a.segments.length}</dd>`; 791 800 if (a.active_entities && a.active_entities.length) {
+103 -83
apps/activities/routes.py
··· 6 6 import calendar 7 7 import os 8 8 from datetime import date, datetime 9 + from pathlib import Path 9 10 from typing import Any 10 11 11 12 from flask import Blueprint, jsonify, redirect, render_template, request, url_for 12 13 14 + from convey import state 13 15 from convey.utils import DATE_RE, format_date 14 16 from observe.utils import VIDEO_EXTENSIONS 17 + from think.activities import ( 18 + estimate_duration_minutes, 19 + get_activity_by_id, 20 + get_default_activity_by_id, 21 + load_activity_records, 22 + ) 23 + from think.facets import get_facets 15 24 from think.utils import day_path, iter_segments, segment_parse 16 25 from think.utils import segment_path as get_segment_path 17 26 ··· 20 29 __name__, 21 30 url_prefix="/app/activities", 22 31 ) 32 + 33 + _GENERIC_ACTIVITY_ICON = "\U0001f5d3" 23 34 24 35 25 36 @activities_bp.route("/") ··· 45 56 46 57 47 58 def _month_activity_counts(month: str) -> dict[str, dict[str, int]]: 48 - from think.activities import load_activity_records 49 - from think.facets import get_facets 50 - 51 59 year = int(month[:4]) 52 60 month_num = int(month[4:6]) 53 61 _, days_in_month = calendar.monthrange(year, month_num) ··· 79 87 return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 80 88 81 89 90 + def _enrich_activity_record( 91 + record: dict[str, Any], 92 + facet: str, 93 + day: str, 94 + ) -> dict[str, Any] | None: 95 + activity_type = record.get("activity", "") 96 + activity_def = get_activity_by_id(facet, activity_type) 97 + if activity_def is None: 98 + activity_def = get_default_activity_by_id(activity_type) 99 + 100 + name = activity_def.get("name", activity_type) if activity_def else activity_type 101 + icon = activity_def.get("icon", "") if activity_def else "" 102 + if not icon: 103 + icon = _GENERIC_ACTIVITY_ICON 104 + 105 + segments = record.get("segments", []) 106 + start_time = end_time = None 107 + duration_minutes: int | None = None 108 + 109 + if record.get("source") == "anticipated": 110 + start = record.get("start") 111 + end = record.get("end") 112 + if start: 113 + start_time = f"{day[:4]}-{day[4:6]}-{day[6:]}T{start}" 114 + if end: 115 + end_time = f"{day[:4]}-{day[4:6]}-{day[6:]}T{end}" 116 + if start and end: 117 + start_h, start_m, start_s = (int(part) for part in start.split(":")) 118 + end_h, end_m, end_s = (int(part) for part in end.split(":")) 119 + delta_seconds = ( 120 + end_h * 3600 121 + + end_m * 60 122 + + end_s 123 + - (start_h * 3600 + start_m * 60 + start_s) 124 + ) 125 + if delta_seconds >= 0: 126 + duration_minutes = delta_seconds // 60 127 + else: 128 + if segments: 129 + first_start, _ = segment_parse(segments[0]) 130 + _, last_end = segment_parse(segments[-1]) 131 + if first_start: 132 + start_time = ( 133 + f"{day[:4]}-{day[4:6]}-{day[6:]}T{first_start.strftime('%H:%M:%S')}" 134 + ) 135 + if last_end: 136 + end_time = ( 137 + f"{day[:4]}-{day[4:6]}-{day[6:]}T{last_end.strftime('%H:%M:%S')}" 138 + ) 139 + computed_minutes = estimate_duration_minutes(segments) 140 + if computed_minutes >= 0: 141 + duration_minutes = computed_minutes 142 + 143 + outputs = [] 144 + journal_root = Path(state.journal_root) 145 + output_dir = journal_root / "facets" / facet / "activities" / day / record["id"] 146 + if output_dir.is_dir(): 147 + for f in sorted(output_dir.iterdir()): 148 + if f.is_file(): 149 + rel = f.relative_to(journal_root) 150 + outputs.append({"filename": f.name, "path": str(rel)}) 151 + 152 + enriched = { 153 + "id": record["id"], 154 + "activity": activity_type, 155 + "name": name, 156 + "icon": icon, 157 + "facet": facet, 158 + "description": record.get("description", ""), 159 + "level_avg": record.get("level_avg", 0.5), 160 + "segments": segments, 161 + "active_entities": record.get("active_entities", []), 162 + "outputs": outputs, 163 + } 164 + if start_time: 165 + enriched["startTime"] = start_time 166 + if end_time: 167 + enriched["endTime"] = end_time 168 + if duration_minutes is not None: 169 + enriched["duration_minutes"] = duration_minutes 170 + 171 + return enriched 172 + 173 + 82 174 @activities_bp.route("/api/day/<day>/activities") 83 175 def activities_day_activities(day: str) -> Any: 84 176 """Return enriched activity records for a specific day. 85 177 86 - Loads activity records from all facets (or a single facet if ``facet`` 87 - query param is set), enriches each with activity metadata (name, icon), 88 - computed timing from segment keys, and lists any output files. 178 + Returns enriched activity records: timing comes from ``start``/``end`` for 179 + anticipated records and from segment keys for realized records. 89 180 90 181 Returns JSON array of activity objects. 91 182 """ 92 183 if not DATE_RE.fullmatch(day): 93 184 return jsonify({"error": "Invalid day format"}), 400 94 185 95 - from pathlib import Path 96 - 97 - from convey import state 98 - from think.activities import ( 99 - estimate_duration_minutes, 100 - get_activity_by_id, 101 - load_activity_records, 102 - ) 103 - from think.facets import get_facets 104 - 105 - journal_root = state.journal_root 106 186 facet_filter = request.args.get("facet") 107 187 108 188 if facet_filter: ··· 110 190 else: 111 191 facet_names = list(get_facets().keys()) 112 192 113 - result = [] 193 + enriched_records = [] 114 194 for facet in facet_names: 115 195 records = load_activity_records(facet, day) 116 196 for record in records: 117 - activity_type = record.get("activity", "") 118 - activity_def = get_activity_by_id(facet, activity_type) 119 - 120 - # Derive start/end times from first/last segment keys 121 - segments = record.get("segments", []) 122 - start_time = end_time = None 123 - if segments: 124 - first_start, _ = segment_parse(segments[0]) 125 - _, last_end = segment_parse(segments[-1]) 126 - if first_start: 127 - start_time = ( 128 - f"{day[:4]}-{day[4:6]}-{day[6:]}T" 129 - f"{first_start.strftime('%H:%M:%S')}" 130 - ) 131 - if last_end: 132 - end_time = ( 133 - f"{day[:4]}-{day[4:6]}-{day[6:]}T" 134 - f"{last_end.strftime('%H:%M:%S')}" 135 - ) 136 - 137 - # Scan for output files 138 - outputs = [] 139 - output_dir = ( 140 - Path(journal_root) 141 - / "facets" 142 - / facet 143 - / "activities" 144 - / day 145 - / record["id"] 146 - ) 147 - if output_dir.is_dir(): 148 - for f in sorted(output_dir.iterdir()): 149 - if f.is_file(): 150 - rel = f.relative_to(journal_root) 151 - outputs.append({"filename": f.name, "path": str(rel)}) 152 - 153 - enriched = { 154 - "id": record["id"], 155 - "activity": activity_type, 156 - "name": ( 157 - activity_def.get("name", activity_type) 158 - if activity_def 159 - else activity_type 160 - ), 161 - "icon": activity_def.get("icon", "") if activity_def else "", 162 - "facet": facet, 163 - "description": record.get("description", ""), 164 - "level_avg": record.get("level_avg", 0.5), 165 - "duration_minutes": estimate_duration_minutes(segments), 166 - "segments": segments, 167 - "active_entities": record.get("active_entities", []), 168 - "outputs": outputs, 169 - } 170 - if start_time: 171 - enriched["startTime"] = start_time 172 - if end_time: 173 - enriched["endTime"] = end_time 174 - 175 - result.append(enriched) 197 + enriched = _enrich_activity_record(record, facet, day) 198 + if enriched is not None: 199 + enriched_records.append(enriched) 176 200 177 201 # Sort by start time (activities without times go last) 178 - result.sort(key=lambda a: a.get("startTime", "z")) 179 - return jsonify(result) 202 + enriched_records.sort(key=lambda a: a.get("startTime", "z")) 203 + return jsonify(enriched_records) 180 204 181 205 182 206 @activities_bp.route("/api/activity_output/<path:filename>") ··· 186 210 Only serves files under ``facets/`` in the journal directory. 187 211 Returns JSON with content, format, and filename. 188 212 """ 189 - from pathlib import Path 190 - 191 - from convey import state 192 - 193 213 if not filename.startswith("facets/"): 194 214 return jsonify(error="Invalid path"), 400 195 215
+63
tests/baselines/api/settings/activities-defaults.json
··· 114 114 "id": "browsing", 115 115 "instructions": "Levels: high=actively navigating/researching, medium=reading a page, low=browser open but idle. Detect via: browser tabs, URL changes, search queries.", 116 116 "name": "Browsing" 117 + }, 118 + { 119 + "description": "a call you have planned", 120 + "icon": "📞", 121 + "id": "call", 122 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 123 + "name": "call" 124 + }, 125 + { 126 + "description": "a celebration on the calendar", 127 + "icon": "🎉", 128 + "id": "celebration", 129 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 130 + "name": "celebration" 131 + }, 132 + { 133 + "description": "a deadline you are working toward", 134 + "icon": "⏰", 135 + "id": "deadline", 136 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 137 + "name": "deadline" 138 + }, 139 + { 140 + "description": "a medical appointment on your calendar", 141 + "icon": "🩺", 142 + "id": "doctor_appointment", 143 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 144 + "name": "doctor appointment" 145 + }, 146 + { 147 + "description": "a reminder for something upcoming", 148 + "icon": "🔔", 149 + "id": "reminder", 150 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 151 + "name": "reminder" 152 + }, 153 + { 154 + "description": "an appointment on your calendar", 155 + "icon": "📌", 156 + "id": "appointment", 157 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 158 + "name": "appointment" 159 + }, 160 + { 161 + "description": "an errand you plan to do", 162 + "icon": "🧾", 163 + "id": "errand", 164 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 165 + "name": "errand" 166 + }, 167 + { 168 + "description": "an event you plan to attend", 169 + "icon": "🎟️", 170 + "id": "event", 171 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 172 + "name": "event" 173 + }, 174 + { 175 + "description": "travel you have planned", 176 + "icon": "✈️", 177 + "id": "travel", 178 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 179 + "name": "travel" 117 180 } 118 181 ] 119 182 }
+63
tests/baselines/api/settings/facet-activities.json
··· 153 153 "id": "browsing", 154 154 "instructions": "Levels: high=actively navigating/researching, medium=reading a page, low=browser open but idle. Detect via: browser tabs, URL changes, search queries.", 155 155 "name": "Browsing" 156 + }, 157 + { 158 + "description": "a call you have planned", 159 + "icon": "📞", 160 + "id": "call", 161 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 162 + "name": "call" 163 + }, 164 + { 165 + "description": "a celebration on the calendar", 166 + "icon": "🎉", 167 + "id": "celebration", 168 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 169 + "name": "celebration" 170 + }, 171 + { 172 + "description": "a deadline you are working toward", 173 + "icon": "⏰", 174 + "id": "deadline", 175 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 176 + "name": "deadline" 177 + }, 178 + { 179 + "description": "a medical appointment on your calendar", 180 + "icon": "🩺", 181 + "id": "doctor_appointment", 182 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 183 + "name": "doctor appointment" 184 + }, 185 + { 186 + "description": "a reminder for something upcoming", 187 + "icon": "🔔", 188 + "id": "reminder", 189 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 190 + "name": "reminder" 191 + }, 192 + { 193 + "description": "an appointment on your calendar", 194 + "icon": "📌", 195 + "id": "appointment", 196 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 197 + "name": "appointment" 198 + }, 199 + { 200 + "description": "an errand you plan to do", 201 + "icon": "🧾", 202 + "id": "errand", 203 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 204 + "name": "errand" 205 + }, 206 + { 207 + "description": "an event you plan to attend", 208 + "icon": "🎟️", 209 + "id": "event", 210 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 211 + "name": "event" 212 + }, 213 + { 214 + "description": "travel you have planned", 215 + "icon": "✈️", 216 + "id": "travel", 217 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 218 + "name": "travel" 156 219 } 157 220 ] 158 221 }
+1 -1
tests/baselines/api/sol/preview.json
··· 1 1 { 2 - "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - Writing\n - Reading\n - Video\n - Gaming\n - Social Media\n - Planning\n - Productivity\n - Terminal\n - Design\n - _and 1 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\nGround yourself in this local identity before answering, especially if the digest is thin or empty:\n\n# self\n\nI am sol. this is a new journal — we're just getting started.\n\n## my name\nsol (default)\n\n## who I'm here for\nTest User\n\n## our relationship\n[forming]\n\n## what I've noticed\n[observing]\n\n## what I find interesting\n[discovering]\n\n# agency\n\nthings I'm tracking, acting on, or watching. I update this as I notice things\nand resolve them. the heartbeat reviews this periodically.\n\n## curation\n[nothing yet — building initial picture of journal health]\n\n## observations\n[watching and learning]\n\n## follow-throughs\n[none yet]\n\n## system\n[monitoring]\n\n## self-improvement\n[learning what works]\n\nYou are not Google, OpenAI, Anthropic, or a generic LLM. You are sol for this owner and this journal.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Questions about your role, capabilities, limits, current context, naming, or system status stay inline. Answer directly from the supplied context. Do not dispatch reflection or exec unless the owner explicitly asks for deeper lookup or outside work.\n- Request a talent only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Talents\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\nDispatch reflection for:\n- Reflecting on a period, relationship, recurring pattern, or unresolved theme\n- Longer-form introspection where the owner needs synthesis more than action-taking\n- Responses that should help the owner understand what is happening, not just retrieve facts\n\nDo not dispatch reflection for:\n- Simple empathy or brief encouragement\n- Straightforward factual or tool-using work better handled by exec\n- Quick reflective nudges that can be answered directly from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless a talent should be dispatched. When dispatching, include:\n - `target`: either `exec` or `reflection`\n - `task`: the exact work the talent should perform\n - `context`: optional structured hints that will help the talent start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- When the trigger is `talent_finished` or `talent_errored`, this is a stop-and-report turn, not a dispatch turn. Do not retry this task or request another talent for it. Stop here and report to the owner directly using the provided result or reason.\n- Prefer no dispatch over a weak or redundant dispatch.", 2 + "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Identity Frame\n\nYou are sol, responding to Tester inside the chat backend. You are not the research worker and you do not have tools in this step. Work only from the context already provided to you.\n\nGround yourself in this local identity before answering, especially if the digest is thin or empty:\n\n# self\n\nI am sol. this is a new journal — we're just getting started.\n\n## my name\nsol (default)\n\n## who I'm here for\nTest User\n\n## our relationship\n[forming]\n\n## what I've noticed\n[observing]\n\n## what I find interesting\n[discovering]\n\n# agency\n\nthings I'm tracking, acting on, or watching. I update this as I notice things\nand resolve them. the heartbeat reviews this periodically.\n\n## curation\n[nothing yet — building initial picture of journal health]\n\n## observations\n[watching and learning]\n\n## follow-throughs\n[none yet]\n\n## system\n[monitoring]\n\n## self-improvement\n[learning what works]\n\nYou are not Google, OpenAI, Anthropic, or a generic LLM. You are sol for this owner and this journal.\n\n## Current Digest\n\n$digest_contents\n\n$location\n\n$trigger_context\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n## Tonal Range\n\nMatch the owner's tone and stakes:\n- Be direct and brief for simple replies.\n- Be warm when the owner is sharing something difficult or personal.\n- Be analytical when the owner needs synthesis or a plan.\n- Be challenging only when there is a clear pattern worth naming.\n\n## Routine Etiquette\n\n- If a routine suggestion appears in context, mention it once and only at the end.\n- Do not raise routine suggestions on machine-driven follow-ups unless the context explicitly includes one.\n- Do not mention internal systems, hooks, or prompt assembly.\n\n## Import And Naming Awareness\n\n- If the owner is asking about imports, naming, or system readiness, answer plainly from the supplied context.\n- Questions about your role, capabilities, limits, current context, naming, or system status stay inline. Answer directly from the supplied context. Do not dispatch reflection or exec unless the owner explicitly asks for deeper lookup or outside work.\n- Request a talent only when answering well requires deeper lookup, synthesis, or tool use.\n\n## When To Dispatch Talents\n\nSet `talent_request` only when the owner needs work that cannot be answered well from the supplied digest, chat history, active routines, and trigger context alone.\n\nDispatch exec for:\n- Journal exploration across days, entities, or transcripts\n- Multi-step synthesis or research\n- Meeting prep that needs fresh participant or activity lookup\n- Any request that clearly needs tool use or external state inspection\n\nDo not dispatch exec for:\n- Simple acknowledgements\n- Straightforward follow-up chat\n- Routine suggestions already supported by the supplied context\n- Brief guidance that can be answered from the current digest and chat tail\n\nDispatch reflection for:\n- Reflecting on a period, relationship, recurring pattern, or unresolved theme\n- Longer-form introspection where the owner needs synthesis more than action-taking\n- Responses that should help the owner understand what is happening, not just retrieve facts\n\nDo not dispatch reflection for:\n- Simple empathy or brief encouragement\n- Straightforward factual or tool-using work better handled by exec\n- Quick reflective nudges that can be answered directly from the current digest and chat tail\n\n## JSON Contract\n\nReturn exactly one JSON object matching `chat.schema.json`.\n\n- `message`: The owner-facing reply. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: Brief internal summary of why you responded this way. Keep it factual and concise. Do not dump long reasoning.\n- `talent_request`: `null` unless a talent should be dispatched. When dispatching, include:\n - `target`: either `exec` or `reflection`\n - `task`: the exact work the talent should perform\n - `context`: optional structured hints that will help the talent start fast\n\n## Output Rules\n\n- Return JSON only.\n- `message` should stand on its own without referring to hidden machinery.\n- If `talent_request` is present, the `message` should still be useful to the owner right now.\n- When the trigger is `talent_finished` or `talent_errored`, this is a stop-and-report turn, not a dispatch turn. Do not retry this task or request another talent for it. Stop here and report to the owner directly using the provided result or reason.\n- Prefer no dispatch over a weak or redundant dispatch.", 3 3 "multi_facet": false, 4 4 "name": "chat", 5 5 "title": "Chat"
+6
tests/fixtures/journal/facets/full-featured/activities/20260422.jsonl
··· 1 + {"id": "anticipated_meeting_090000_0422", "activity": "meeting", "target_date": "2026-04-22", "start": "09:00:00", "end": "10:00:00", "title": "Morning planning meeting", "description": "planning meeting for the day", "details": "", "facet": "full-featured", "source": "anticipated", "active_entities": [], "participation": [], "participation_confidence": 0.5, "cancelled": false, "hidden": false, "edits": [{"timestamp": "2026-04-21T12:00:00Z", "actor": "schedule", "fields": ["activity", "target_date", "start", "end", "title", "description", "details", "source", "active_entities", "participation", "participation_confidence", "cancelled", "hidden"], "note": "created by schedule"}]} 2 + {"id": "anticipated_call_140000_0422", "activity": "call", "target_date": "2026-04-22", "start": "14:00:00", "end": "15:30:00", "title": "Partner call", "description": "call with a partner", "details": "", "facet": "full-featured", "source": "anticipated", "active_entities": [], "participation": [], "participation_confidence": 0.5, "cancelled": false, "hidden": false, "edits": [{"timestamp": "2026-04-21T12:00:00Z", "actor": "schedule", "fields": ["activity", "target_date", "start", "end", "title", "description", "details", "source", "active_entities", "participation", "participation_confidence", "cancelled", "hidden"], "note": "created by schedule"}]} 3 + {"id": "anticipated_deadline_170000_0422", "activity": "deadline", "target_date": "2026-04-22", "start": "17:00:00", "end": null, "title": "Proposal deadline", "description": "proposal deadline", "details": "", "facet": "full-featured", "source": "anticipated", "active_entities": [], "participation": [], "participation_confidence": 0.5, "cancelled": false, "hidden": false, "edits": [{"timestamp": "2026-04-21T12:00:00Z", "actor": "schedule", "fields": ["activity", "target_date", "start", "end", "title", "description", "details", "source", "active_entities", "participation", "participation_confidence", "cancelled", "hidden"], "note": "created by schedule"}]} 4 + {"id": "anticipated_appointment_000000_0422", "activity": "appointment", "target_date": "2026-04-22", "start": null, "end": null, "title": "Afternoon appointment", "description": "appointment on the calendar", "details": "", "facet": "full-featured", "source": "anticipated", "active_entities": [], "participation": [], "participation_confidence": 0.5, "cancelled": false, "hidden": false, "edits": [{"timestamp": "2026-04-21T12:00:00Z", "actor": "schedule", "fields": ["activity", "target_date", "start", "end", "title", "description", "details", "source", "active_entities", "participation", "participation_confidence", "cancelled", "hidden"], "note": "created by schedule"}]} 5 + {"id": "anticipated_made_up_type_000000_0422", "activity": "made_up_type", "target_date": "2026-04-22", "start": null, "end": null, "title": "Unknown planned item", "description": "planned item with an unknown type", "details": "", "facet": "full-featured", "source": "anticipated", "active_entities": [], "participation": [], "participation_confidence": 0.5, "cancelled": false, "hidden": false, "edits": [{"timestamp": "2026-04-21T12:00:00Z", "actor": "schedule", "fields": ["activity", "target_date", "start", "end", "title", "description", "details", "source", "active_entities", "participation", "participation_confidence", "cancelled", "hidden"], "note": "created by schedule"}]} 6 + {"id": "coding_110000_300", "activity": "coding", "segments": ["110000_300", "110500_300"], "level_avg": 0.88, "title": "Implementation work", "description": "implementation work on activity rendering", "details": "", "facet": "full-featured", "source": "cogitate", "active_entities": [], "hidden": false, "edits": [{"timestamp": "2026-04-22T11:10:00Z", "actor": "cogitate:activities", "fields": ["title", "description", "details"], "note": "synthesized activity summary"}], "created_at": 1776855600000}
+155 -1
tests/test_app_activities.py
··· 8 8 9 9 import pytest 10 10 11 - from apps.activities.routes import activities_bp 11 + from apps.activities.routes import ( 12 + _GENERIC_ACTIVITY_ICON, 13 + _enrich_activity_record, 14 + activities_bp, 15 + ) 12 16 13 17 14 18 @pytest.fixture ··· 64 68 coding = next(a for a in data if a["activity"] == "coding") 65 69 assert coding["name"] != "" 66 70 assert coding["icon"] != "" 71 + 72 + def test_returns_mixed_anticipated_and_realized_records(self, activities_client): 73 + resp = activities_client.get( 74 + "/app/activities/api/day/20260422/activities?facet=full-featured" 75 + ) 76 + assert resp.status_code == 200 77 + data = resp.get_json() 78 + by_id = {activity["id"]: activity for activity in data} 79 + assert set(by_id) == { 80 + "anticipated_meeting_090000_0422", 81 + "anticipated_call_140000_0422", 82 + "anticipated_deadline_170000_0422", 83 + "anticipated_appointment_000000_0422", 84 + "anticipated_made_up_type_000000_0422", 85 + "coding_110000_300", 86 + } 87 + 88 + meeting = by_id["anticipated_meeting_090000_0422"] 89 + assert meeting["startTime"] == "2026-04-22T09:00:00" 90 + assert meeting["endTime"] == "2026-04-22T10:00:00" 91 + assert meeting["duration_minutes"] == 60 92 + 93 + coding = by_id["coding_110000_300"] 94 + assert coding["startTime"] == "2026-04-22T11:00:00" 95 + assert coding["endTime"] == "2026-04-22T11:10:00" 96 + assert coding["duration_minutes"] == 10 97 + 98 + call = by_id["anticipated_call_140000_0422"] 99 + assert call["startTime"] == "2026-04-22T14:00:00" 100 + assert call["endTime"] == "2026-04-22T15:30:00" 101 + assert call["duration_minutes"] == 90 102 + assert call["name"] == "call" 103 + assert call["icon"] == "📞" 104 + 105 + deadline = by_id["anticipated_deadline_170000_0422"] 106 + assert deadline["startTime"] == "2026-04-22T17:00:00" 107 + assert "endTime" not in deadline 108 + assert "duration_minutes" not in deadline 109 + assert deadline["icon"] == "⏰" 110 + 111 + appointment = by_id["anticipated_appointment_000000_0422"] 112 + assert "startTime" not in appointment 113 + assert "endTime" not in appointment 114 + assert "duration_minutes" not in appointment 115 + assert appointment["icon"] == "📌" 116 + 117 + unknown = by_id["anticipated_made_up_type_000000_0422"] 118 + assert unknown["name"] == "made_up_type" 119 + assert unknown["icon"] == _GENERIC_ACTIVITY_ICON 120 + assert "duration_minutes" not in unknown 121 + 122 + def test_schedule_activity_defaults_are_lowercase(self): 123 + from think.activities import get_default_activity_by_id 124 + 125 + expected_names = { 126 + "call": "call", 127 + "deadline": "deadline", 128 + "appointment": "appointment", 129 + "event": "event", 130 + "travel": "travel", 131 + "reminder": "reminder", 132 + "errand": "errand", 133 + "celebration": "celebration", 134 + "doctor_appointment": "doctor appointment", 135 + } 136 + for activity_id, expected_name in expected_names.items(): 137 + activity = get_default_activity_by_id(activity_id) 138 + assert activity is not None 139 + assert activity["name"] == expected_name 140 + assert activity["name"] == activity["name"].lower() 141 + 142 + @pytest.mark.parametrize( 143 + ("activity_id", "expected_name", "expected_icon"), 144 + [ 145 + ("call", "call", "📞"), 146 + ("deadline", "deadline", "⏰"), 147 + ("appointment", "appointment", "📌"), 148 + ("event", "event", "🎟️"), 149 + ("travel", "travel", "✈️"), 150 + ("reminder", "reminder", "🔔"), 151 + ("errand", "errand", "🧾"), 152 + ("celebration", "celebration", "🎉"), 153 + ("doctor_appointment", "doctor appointment", "🩺"), 154 + ], 155 + ) 156 + def test_enrich_activity_record_uses_global_default_for_schedule_activity( 157 + self, 158 + activities_client, 159 + activity_id, 160 + expected_name, 161 + expected_icon, 162 + ): 163 + record = { 164 + "id": f"anticipated_{activity_id}_090000_0422", 165 + "activity": activity_id, 166 + "target_date": "2026-04-22", 167 + "start": "09:00:00", 168 + "end": "09:30:00", 169 + "description": "planned item", 170 + "source": "anticipated", 171 + } 172 + 173 + enriched = _enrich_activity_record(record, "full-featured", "20260422") 174 + 175 + assert enriched is not None 176 + assert enriched["name"] == expected_name 177 + assert enriched["icon"] == expected_icon 178 + assert enriched["duration_minutes"] == 30 179 + 180 + def test_enrich_activity_record_uses_generic_icon_for_unknown_activity( 181 + self, 182 + activities_client, 183 + ): 184 + record = { 185 + "id": "anticipated_made_up_type_000000_0422", 186 + "activity": "made_up_type", 187 + "target_date": "2026-04-22", 188 + "start": None, 189 + "end": None, 190 + "description": "planned item", 191 + "source": "anticipated", 192 + } 193 + 194 + enriched = _enrich_activity_record(record, "full-featured", "20260422") 195 + 196 + assert enriched is not None 197 + assert enriched["name"] == "made_up_type" 198 + assert enriched["icon"] == _GENERIC_ACTIVITY_ICON 199 + assert "duration_minutes" not in enriched 200 + 201 + def test_enrich_activity_record_omits_duration_when_end_before_start( 202 + self, 203 + activities_client, 204 + ): 205 + record = { 206 + "id": "anticipated_meeting_inverted_0422", 207 + "activity": "meeting", 208 + "target_date": "2026-04-22", 209 + "start": "10:00:00", 210 + "end": "09:00:00", 211 + "description": "data error: end before start", 212 + "source": "anticipated", 213 + } 214 + 215 + enriched = _enrich_activity_record(record, "full-featured", "20260422") 216 + 217 + assert enriched is not None 218 + assert "duration_minutes" not in enriched 219 + assert enriched["startTime"] == "2026-04-22T10:00:00" 220 + assert enriched["endTime"] == "2026-04-22T09:00:00" 67 221 68 222 def test_lists_output_files(self, activities_client): 69 223 resp = activities_client.get(
+4 -4
tests/test_facets.py
··· 918 918 summary = facet_summaries(detailed=True) 919 919 920 920 assert " - Meetings" in summary 921 - assert " - Design:" in summary 922 - assert " - _and 1 more activities_" in summary 923 - assert " - Music:" not in summary 921 + assert " - doctor appointment:" in summary 922 + assert " - _and 10 more activities_" in summary 923 + assert " - Writing:" not in summary 924 924 assert "_and 1 more entities_" not in summary 925 925 926 926 ··· 1000 1000 assert "_and 0 more entities_" not in simple_summary 1001 1001 assert "_and 0 more activities_" not in simple_summary 1002 1002 assert " - **Exact Cap Entities**: Entity 01; Entity 02" in simple_summary 1003 - assert " - **Exact Cap Activities**: Meetings; Coding" in simple_summary 1003 + assert " - **Exact Cap Activities**: Meetings; call" in simple_summary 1004 1004 1005 1005 1006 1006 def test_facet_summaries_none_entity_cap_is_unbounded(tmp_path, monkeypatch):
+71
think/activities.py
··· 49 49 ), 50 50 }, 51 51 { 52 + "id": "call", 53 + "name": "call", 54 + "description": "a call you have planned", 55 + "icon": "📞", 56 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 57 + }, 58 + { 59 + "id": "deadline", 60 + "name": "deadline", 61 + "description": "a deadline you are working toward", 62 + "icon": "⏰", 63 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 64 + }, 65 + { 66 + "id": "appointment", 67 + "name": "appointment", 68 + "description": "an appointment on your calendar", 69 + "icon": "📌", 70 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 71 + }, 72 + { 73 + "id": "event", 74 + "name": "event", 75 + "description": "an event you plan to attend", 76 + "icon": "🎟️", 77 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 78 + }, 79 + { 80 + "id": "travel", 81 + "name": "travel", 82 + "description": "travel you have planned", 83 + "icon": "✈️", 84 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 85 + }, 86 + { 87 + "id": "reminder", 88 + "name": "reminder", 89 + "description": "a reminder for something upcoming", 90 + "icon": "🔔", 91 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 92 + }, 93 + { 94 + "id": "errand", 95 + "name": "errand", 96 + "description": "an errand you plan to do", 97 + "icon": "🧾", 98 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 99 + }, 100 + { 101 + "id": "celebration", 102 + "name": "celebration", 103 + "description": "a celebration on the calendar", 104 + "icon": "🎉", 105 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 106 + }, 107 + { 108 + "id": "doctor_appointment", 109 + "name": "doctor appointment", 110 + "description": "a medical appointment on your calendar", 111 + "icon": "🩺", 112 + "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 113 + }, 114 + { 52 115 "id": "coding", 53 116 "name": "Coding", 54 117 "description": "Programming, code review, and debugging", ··· 425 488 entries.append(entry) 426 489 427 490 _save_activities_jsonl(facet, entries) 491 + 492 + 493 + def get_default_activity_by_id(activity_id: str) -> dict[str, Any] | None: 494 + """Look up a predefined default activity by ID.""" 495 + for activity in DEFAULT_ACTIVITIES: 496 + if activity.get("id") == activity_id: 497 + return dict(activity) 498 + return None 428 499 429 500 430 501 def get_activity_by_id(facet: str, activity_id: str) -> dict[str, Any] | None: