personal memory agent
0
fork

Configure Feed

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

Add per-activity instructions field with UI and reset-to-default

Adds configurable detection/level instructions to each activity,
rendered inline in the LLM prompt. Includes settings UI for viewing
and editing instructions, API wiring, sparse storage with reset-to-
default (empty string reverts predefined activities to defaults),
and test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+282 -19
+3
apps/settings/routes.py
··· 1369 1369 activity_id, 1370 1370 name=name, 1371 1371 description=data.get("description"), 1372 + instructions=data.get("instructions"), 1372 1373 priority=priority, 1373 1374 icon=data.get("icon"), 1374 1375 ) ··· 1392 1393 1393 1394 Accepts JSON with optional fields: 1394 1395 description: New description 1396 + instructions: Detection/level instructions for the LLM 1395 1397 priority: "high", "normal", or "low" 1396 1398 name: New name (only for custom activities) 1397 1399 icon: New icon (only for custom activities) ··· 1421 1423 facet_name, 1422 1424 activity_id, 1423 1425 description=data.get("description"), 1426 + instructions=data.get("instructions"), 1424 1427 priority=priority, 1425 1428 name=data.get("name"), 1426 1429 icon=data.get("icon"),
+36 -13
apps/settings/workspace.html
··· 2094 2094 <label for="customActivityIcon">Icon (emoji)</label> 2095 2095 <input type="text" id="customActivityIcon" placeholder="e.g., 🔬" maxlength="4" style="max-width: 100px;"> 2096 2096 </div> 2097 + <div class="settings-field"> 2098 + <label for="customActivityInstructions">Instructions <span style="font-weight:normal;color:#888;">(optional)</span></label> 2099 + <textarea id="customActivityInstructions" rows="2" placeholder="Detection hints and level guidance for the AI agent"></textarea> 2100 + </div> 2097 2101 <button class="save-color-btn" id="saveCustomActivityBtn">Add Activity</button> 2098 2102 </div> 2099 2103 </div> ··· 4049 4053 <div class="activity-info"> 4050 4054 <div class="activity-name">${escapeHtml(a.name)}</div> 4051 4055 <div class="activity-desc activity-desc-editable" data-id="${a.id}" title="Double-click to edit">${escapeHtml(a.description) || '<em>No description</em>'}</div> 4056 + <div class="activity-instructions activity-instructions-editable" data-id="${a.id}" title="Double-click to edit instructions" style="font-size:0.8em;color:#888;margin-top:2px;">${a.instructions ? escapeHtml(a.instructions) : '<em>No instructions</em>'}</div> 4052 4057 </div> 4053 4058 <div class="activity-controls"> 4054 4059 <select class="activity-priority" data-id="${a.id}"> ··· 4116 4121 } else { 4117 4122 clearTimeout(tapTimeout); 4118 4123 tapTimeout = null; 4119 - startEditDescription(desc); 4124 + startEditField(desc, 'description'); 4125 + } 4126 + }); 4127 + }); 4128 + 4129 + // Double-click instructions to edit 4130 + document.querySelectorAll('.activity-instructions-editable').forEach(el => { 4131 + let tapTimeout = null; 4132 + el.addEventListener('click', () => { 4133 + if (!tapTimeout) { 4134 + tapTimeout = setTimeout(() => tapTimeout = null, 300); 4135 + } else { 4136 + clearTimeout(tapTimeout); 4137 + tapTimeout = null; 4138 + startEditField(el, 'instructions'); 4120 4139 } 4121 4140 }); 4122 4141 }); 4123 4142 } 4124 4143 4125 - function startEditDescription(descEl) { 4126 - const activityId = descEl.dataset.id; 4127 - const currentText = descEl.textContent.trim(); 4128 - const isPlaceholder = descEl.querySelector('em') !== null; 4144 + function startEditField(el, field) { 4145 + const activityId = el.dataset.id; 4146 + const currentText = el.textContent.trim(); 4147 + const isPlaceholder = el.querySelector('em') !== null; 4148 + const placeholderLabel = field === 'instructions' ? 'No instructions' : 'No description'; 4129 4149 4130 4150 const input = document.createElement('input'); 4131 4151 input.type = 'text'; 4132 4152 input.className = 'activity-desc-input'; 4133 4153 input.value = isPlaceholder ? '' : currentText; 4154 + input.placeholder = field === 'instructions' ? 'Detection hints and level guidance' : ''; 4134 4155 input.style.cssText = 'width:100%;padding:0.3em;font-size:0.85em;border:1px solid var(--facet-color,#667eea);border-radius:4px;'; 4135 4156 4136 - descEl.style.display = 'none'; 4137 - descEl.parentNode.insertBefore(input, descEl.nextSibling); 4157 + el.style.display = 'none'; 4158 + el.parentNode.insertBefore(input, el.nextSibling); 4138 4159 input.focus(); 4139 4160 input.select(); 4140 4161 4141 4162 const finishEdit = async () => { 4142 - const newDesc = input.value.trim(); 4163 + const newValue = input.value.trim(); 4143 4164 input.remove(); 4144 - descEl.style.display = ''; 4145 - descEl.innerHTML = newDesc ? escapeHtml(newDesc) : '<em>No description</em>'; 4165 + el.style.display = ''; 4166 + el.innerHTML = newValue ? escapeHtml(newValue) : `<em>${placeholderLabel}</em>`; 4146 4167 4147 - if (newDesc !== currentText && !(isPlaceholder && !newDesc)) { 4148 - await updateActivity(activityId, { description: newDesc }); 4168 + if (newValue !== currentText && !(isPlaceholder && !newValue)) { 4169 + await updateActivity(activityId, { [field]: newValue }); 4149 4170 } 4150 4171 }; 4151 4172 ··· 4219 4240 document.getElementById('customActivityName').value = ''; 4220 4241 document.getElementById('customActivityDesc').value = ''; 4221 4242 document.getElementById('customActivityIcon').value = ''; 4243 + document.getElementById('customActivityInstructions').value = ''; 4222 4244 document.getElementById('customActivityName').focus(); 4223 4245 }); 4224 4246 ··· 4230 4252 const name = document.getElementById('customActivityName').value.trim(); 4231 4253 const description = document.getElementById('customActivityDesc').value.trim(); 4232 4254 const icon = document.getElementById('customActivityIcon').value.trim(); 4255 + const instructions = document.getElementById('customActivityInstructions').value.trim(); 4233 4256 4234 4257 if (!name) { 4235 4258 notifyError('Validation Error', 'Name is required'); ··· 4242 4265 const response = await fetch(`/app/settings/api/facet/${window.selectedFacet}/activities`, { 4243 4266 method: 'POST', 4244 4267 headers: { 'Content-Type': 'application/json' }, 4245 - body: JSON.stringify({ name, description, icon: icon || undefined }) 4268 + body: JSON.stringify({ name, description, icon: icon || undefined, instructions: instructions || undefined }) 4246 4269 }); 4247 4270 const result = await response.json(); 4248 4271 if (result.error) throw new Error(result.error);
+4
muse/activity_state.py
··· 188 188 activity_id = activity.get("id", "") 189 189 name = activity.get("name", activity_id) 190 190 description = activity.get("description", "") 191 + instructions = activity.get("instructions", "") 191 192 priority = activity.get("priority", "normal") 192 193 193 194 if priority == "high": ··· 201 202 lines.append(f"- **{activity_id}** ({name}){priority_note}: {description}") 202 203 else: 203 204 lines.append(f"- **{activity_id}** ({name}){priority_note}") 205 + 206 + if instructions: 207 + lines.append(f" {instructions}") 204 208 205 209 return "\n".join(lines) 206 210
+133 -4
tests/test_activities.py
··· 34 34 assert "coding" in ids 35 35 assert "browsing" in ids 36 36 37 + # All defaults should have instructions 38 + for activity in defaults: 39 + assert "instructions" in activity, f"{activity['id']} missing instructions" 40 + assert isinstance(activity["instructions"], str) 41 + assert len(activity["instructions"]) > 0 42 + 37 43 38 44 def test_get_default_activities_returns_copy(): 39 45 """Test that get_default_activities returns a copy, not the original.""" ··· 97 103 {"id": "meeting", "priority": "high"}, 98 104 {"id": "coding", "description": "Custom coding description"}, 99 105 { 106 + "id": "browsing", 107 + "instructions": "Custom browsing instructions for this facet", 108 + }, 109 + { 100 110 "id": "custom_activity", 101 111 "name": "Custom", 102 112 "description": "A custom activity", 113 + "instructions": "Custom activity detection hints", 103 114 "custom": True, 104 115 }, 105 116 ] ··· 111 122 112 123 # Load and verify 113 124 loaded = get_facet_activities("test_facet") 114 - assert len(loaded) == 3 125 + assert len(loaded) == 4 115 126 116 127 # Check meeting (predefined with priority override) 117 128 meeting = next(a for a in loaded if a["id"] == "meeting") 118 129 assert meeting["priority"] == "high" 119 130 assert meeting["custom"] is False 120 131 assert "name" in meeting # Should have default name 132 + # Should have default instructions (no override) 133 + assert "instructions" in meeting 134 + assert "Levels:" in meeting["instructions"] 121 135 122 136 # Check coding (predefined with description override) 123 137 coding = next(a for a in loaded if a["id"] == "coding") 124 138 assert coding["description"] == "Custom coding description" 139 + # Should keep default instructions (only description overridden) 140 + assert "instructions" in coding 125 141 126 - # Check custom activity 142 + # Check browsing (predefined with instructions override) 143 + browsing = next(a for a in loaded if a["id"] == "browsing") 144 + assert browsing["instructions"] == "Custom browsing instructions for this facet" 145 + 146 + # Check custom activity with instructions 127 147 custom = next(a for a in loaded if a["id"] == "custom_activity") 128 148 assert custom["custom"] is True 129 149 assert custom["name"] == "Custom" 150 + assert custom["instructions"] == "Custom activity detection hints" 130 151 131 152 finally: 132 153 if original_path: ··· 162 183 activities = get_facet_activities("test_facet") 163 184 assert len(activities) == 1 164 185 186 + # Add a predefined activity with custom instructions 187 + add_activity_to_facet( 188 + "test_facet", 189 + "coding", 190 + instructions="Focus on Python and Rust only", 191 + ) 192 + coding = next( 193 + a for a in get_facet_activities("test_facet") if a["id"] == "coding" 194 + ) 195 + assert coding["instructions"] == "Focus on Python and Rust only" 196 + 197 + # Add a custom activity with instructions 198 + add_activity_to_facet( 199 + "test_facet", 200 + "3d_modeling", 201 + name="3D Modeling", 202 + description="Blender and CAD work", 203 + instructions="Detect via: Blender, FreeCAD, OpenSCAD windows", 204 + ) 205 + modeling = next( 206 + a 207 + for a in get_facet_activities("test_facet") 208 + if a["id"] == "3d_modeling" 209 + ) 210 + assert modeling["instructions"] == "Detect via: Blender, FreeCAD, OpenSCAD windows" 211 + 165 212 # Remove it 166 213 removed = remove_activity_from_facet("test_facet", "meeting") 167 214 assert removed is True 168 - activities = get_facet_activities("test_facet") 169 - assert len(activities) == 0 170 215 171 216 # Removing non-existent should return False 172 217 removed = remove_activity_from_facet("test_facet", "meeting") ··· 204 249 assert updated["priority"] == "low" 205 250 assert updated["description"] == "Updated desc" 206 251 252 + # Update instructions 253 + updated = update_activity_in_facet( 254 + "test_facet", 255 + "meeting", 256 + instructions="Only detect scheduled meetings, not ad-hoc calls", 257 + ) 258 + assert updated is not None 259 + assert updated["instructions"] == "Only detect scheduled meetings, not ad-hoc calls" 260 + # Other fields should be preserved 261 + assert updated["priority"] == "low" 262 + 207 263 # Verify via lookup 208 264 activity = get_activity_by_id("test_facet", "meeting") 209 265 assert activity["priority"] == "low" 266 + assert activity["instructions"] == "Only detect scheduled meetings, not ad-hoc calls" 267 + 268 + # Reset instructions to default via empty string 269 + from think.activities import DEFAULT_ACTIVITIES 270 + 271 + default_instructions = next( 272 + a["instructions"] for a in DEFAULT_ACTIVITIES if a["id"] == "meeting" 273 + ) 274 + updated = update_activity_in_facet( 275 + "test_facet", "meeting", instructions="" 276 + ) 277 + assert updated is not None 278 + assert updated["instructions"] == default_instructions 279 + 280 + # Reset description to default via empty string 281 + default_desc = next( 282 + a["description"] for a in DEFAULT_ACTIVITIES if a["id"] == "meeting" 283 + ) 284 + updated = update_activity_in_facet( 285 + "test_facet", "meeting", description="" 286 + ) 287 + assert updated is not None 288 + assert updated["description"] == default_desc 289 + 290 + # Reset priority to default via "normal" 291 + updated = update_activity_in_facet( 292 + "test_facet", "meeting", priority="normal" 293 + ) 294 + assert updated is not None 295 + assert updated["priority"] == "normal" 210 296 211 297 # Update non-existent should return None 212 298 result = update_activity_in_facet( 213 299 "test_facet", "nonexistent", priority="high" 214 300 ) 215 301 assert result is None 302 + 303 + finally: 304 + if original_path: 305 + os.environ["JOURNAL_PATH"] = original_path 306 + 307 + 308 + def test_format_activities_context_includes_instructions(): 309 + """Test that format_activities_context renders instructions inline.""" 310 + from think.activities import save_facet_activities 311 + 312 + with tempfile.TemporaryDirectory() as tmpdir: 313 + original_path = os.environ.get("JOURNAL_PATH") 314 + os.environ["JOURNAL_PATH"] = tmpdir 315 + 316 + facet_path = Path(tmpdir) / "facets" / "test_facet" 317 + facet_path.mkdir(parents=True) 318 + 319 + try: 320 + save_facet_activities( 321 + "test_facet", 322 + [ 323 + {"id": "coding"}, 324 + { 325 + "id": "custom_task", 326 + "name": "Custom", 327 + "description": "A custom activity", 328 + "instructions": "Detect via: specific app UI", 329 + "custom": True, 330 + }, 331 + ], 332 + ) 333 + 334 + from muse.activity_state import format_activities_context 335 + 336 + output = format_activities_context("test_facet") 337 + 338 + # Predefined coding should include its default instructions 339 + assert "**coding**" in output 340 + assert "IDE or editor open" in output # from default instructions 341 + 342 + # Custom activity should include its custom instructions 343 + assert "**custom_task**" in output 344 + assert "Detect via: specific app UI" in output 216 345 217 346 finally: 218 347 if original_path:
+106 -2
think/activities.py
··· 34 34 "name": "Meetings", 35 35 "description": "Video calls, in-person meetings, and conferences", 36 36 "icon": "📅", 37 + "instructions": ( 38 + "Levels: high=actively speaking/presenting, medium=listening attentively," 39 + " low=muted or multitasking during call." 40 + " Detect via: video call UI, multiple speakers, calendar event visible." 41 + ), 37 42 }, 38 43 { 39 44 "id": "coding", 40 45 "name": "Coding", 41 46 "description": "Programming, code review, and debugging", 42 47 "icon": "💻", 48 + "instructions": ( 49 + "Levels: high=writing or debugging code, medium=reading/reviewing code," 50 + " low=IDE or editor open but not focused." 51 + " Detect via: editors, terminals with dev tools, AI coding assistants," 52 + " git operations. Includes focused code reading and thinking." 53 + ), 43 54 }, 44 55 { 45 56 "id": "browsing", 46 57 "name": "Browsing", 47 58 "description": "Web browsing, research, and reading online", 48 59 "icon": "🌐", 60 + "instructions": ( 61 + "Levels: high=actively navigating/researching, medium=reading a page," 62 + " low=browser open but idle." 63 + " Detect via: browser tabs, URL changes, search queries." 64 + ), 49 65 }, 50 66 { 51 67 "id": "email", 52 68 "name": "Email", 53 69 "description": "Email reading and composition", 54 70 "icon": "📧", 71 + "instructions": ( 72 + "Levels: high=composing or actively reading email," 73 + " medium=scanning inbox, low=email client visible but idle." 74 + " Detect via: email client UI, inbox view, compose window." 75 + ), 55 76 }, 56 77 { 57 78 "id": "messaging", 58 79 "name": "Messaging", 59 80 "description": "Chat, Slack, Discord, and text messaging", 60 81 "icon": "💬", 82 + "instructions": ( 83 + "Levels: high=active conversation, medium=reading messages," 84 + " low=chat app visible but idle." 85 + " Detect via: chat app UI, message notifications, typing indicators." 86 + ), 61 87 }, 62 88 { 63 89 "id": "writing", 64 90 "name": "Writing", 65 91 "description": "Documents, notes, and long-form writing", 66 92 "icon": "✍️", 93 + "instructions": ( 94 + "Levels: high=actively composing text, medium=editing/revising," 95 + " low=document open but not being edited." 96 + " Detect via: document editors, note apps, text content changing." 97 + ), 67 98 }, 68 99 { 69 100 "id": "reading", 70 101 "name": "Reading", 71 102 "description": "PDFs, articles, and documentation", 72 103 "icon": "📖", 104 + "instructions": ( 105 + "Levels: high=focused reading, medium=skimming content," 106 + " low=document open but attention elsewhere." 107 + " Detect via: PDF viewers, article pages, documentation sites." 108 + " Do not use for reading code — that is coding." 109 + ), 73 110 }, 74 111 { 75 112 "id": "video", 76 113 "name": "Video", 77 114 "description": "Watching videos and streaming content", 78 115 "icon": "🎬", 116 + "instructions": ( 117 + "Levels: high=actively watching, medium=video playing while" 118 + " doing something else, low=video paused or minimized." 119 + " Detect via: video player UI, streaming sites, playback controls." 120 + ), 79 121 }, 80 122 { 81 123 "id": "gaming", 82 124 "name": "Gaming", 83 125 "description": "Games and entertainment", 84 126 "icon": "🎮", 127 + "instructions": ( 128 + "Levels: high=actively playing, medium=in menus or waiting," 129 + " low=game open but tabbed out." 130 + " Detect via: game window, controller input, game UI elements." 131 + ), 85 132 }, 86 133 { 87 134 "id": "social", 88 135 "name": "Social Media", 89 136 "description": "Social media browsing and interaction", 90 137 "icon": "📱", 138 + "instructions": ( 139 + "Levels: high=posting or actively engaging, medium=scrolling feed," 140 + " low=social app open but idle." 141 + " Detect via: social media sites/apps, feed content, post composition." 142 + ), 91 143 }, 92 144 { 93 145 "id": "productivity", 94 146 "name": "Productivity", 95 147 "description": "Spreadsheets, slides, and task management", 96 148 "icon": "📊", 149 + "instructions": ( 150 + "Levels: high=actively editing or organizing, medium=reviewing data," 151 + " low=app open but not focused." 152 + " Detect via: spreadsheet/slide editors, project management tools," 153 + " calendar apps, task boards." 154 + ), 97 155 }, 98 156 { 99 157 "id": "terminal", 100 158 "name": "Terminal", 101 159 "description": "Command line and shell sessions", 102 160 "icon": "⌨️", 161 + "instructions": ( 162 + "Levels: high=running commands or scripts, medium=reading output," 163 + " low=terminal open but idle." 164 + " Detect via: shell prompts, command output, tmux/screen sessions." 165 + " If terminal use is clearly coding-related, prefer coding instead." 166 + ), 103 167 }, 104 168 { 105 169 "id": "design", 106 170 "name": "Design", 107 171 "description": "Design tools and image editing", 108 172 "icon": "🎨", 173 + "instructions": ( 174 + "Levels: high=actively creating or editing, medium=reviewing designs," 175 + " low=design tool open but idle." 176 + " Detect via: design apps (Figma, Photoshop, etc), canvas editing." 177 + ), 109 178 }, 110 179 { 111 180 "id": "music", 112 181 "name": "Music", 113 182 "description": "Music listening and audio", 114 183 "icon": "🎵", 184 + "instructions": ( 185 + "Levels: high=actively choosing or browsing music," 186 + " medium=playlist running while working, low=ambient background audio." 187 + " Detect via: music player UI, audio playback indicators." 188 + ), 115 189 }, 116 190 ] 117 191 ··· 211 285 activity["priority"] = fa["priority"] 212 286 if "icon" in fa: 213 287 activity["icon"] = fa["icon"] 288 + if "instructions" in fa: 289 + activity["instructions"] = fa["instructions"] 214 290 215 291 # Ensure required fields have defaults 216 292 activity.setdefault("name", activity_id.replace("_", " ").title()) ··· 235 311 Optional for all: 236 312 - priority: "high", "normal", or "low" 237 313 - icon: Emoji icon 314 + - instructions: Detection/level instructions for the LLM 238 315 """ 239 316 # Build lookup for defaults to determine what needs to be stored 240 317 defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES} ··· 257 334 ): 258 335 entry["description"] = activity["description"] 259 336 337 + # Store instructions only if different from default 338 + if activity.get("instructions") and activity[ 339 + "instructions" 340 + ] != default.get("instructions"): 341 + entry["instructions"] = activity["instructions"] 342 + 260 343 # Store priority if set 261 344 if activity.get("priority") and activity["priority"] != "normal": 262 345 entry["priority"] = activity["priority"] ··· 268 351 entry["name"] = activity["name"] 269 352 if activity.get("description"): 270 353 entry["description"] = activity["description"] 354 + if activity.get("instructions"): 355 + entry["instructions"] = activity["instructions"] 271 356 if activity.get("priority"): 272 357 entry["priority"] = activity["priority"] 273 358 if activity.get("icon"): ··· 317 402 *, 318 403 name: str | None = None, 319 404 description: str | None = None, 405 + instructions: str | None = None, 320 406 priority: str = "normal", 321 407 icon: str | None = None, 322 408 ) -> dict[str, Any]: ··· 330 416 activity_id: Activity identifier 331 417 name: Display name (required for custom activities) 332 418 description: Activity description 419 + instructions: Detection/level instructions for the LLM 333 420 priority: "high", "normal", or "low" 334 421 icon: Emoji icon 335 422 ··· 351 438 activity: dict[str, Any] = {"id": activity_id} 352 439 if description: 353 440 activity["description"] = description 441 + if instructions: 442 + activity["instructions"] = instructions 354 443 if priority and priority != "normal": 355 444 activity["priority"] = priority 356 445 else: ··· 361 450 "name": name or activity_id.replace("_", " ").title(), 362 451 "description": description or "", 363 452 } 453 + if instructions: 454 + activity["instructions"] = instructions 364 455 if priority and priority != "normal": 365 456 activity["priority"] = priority 366 457 if icon: ··· 401 492 activity_id: str, 402 493 *, 403 494 description: str | None = None, 495 + instructions: str | None = None, 404 496 priority: str | None = None, 405 497 name: str | None = None, 406 498 icon: str | None = None, ··· 411 503 facet: Facet name 412 504 activity_id: Activity identifier 413 505 description: New description (None to keep existing) 506 + instructions: New detection/level instructions (None to keep existing) 414 507 priority: New priority (None to keep existing) 415 508 name: New name - only applies to custom activities 416 509 icon: New icon - only applies to custom activities ··· 427 520 found = True 428 521 429 522 if description is not None: 430 - activity["description"] = description 523 + if description == "" and activity_id in defaults_by_id: 524 + activity.pop("description", None) 525 + else: 526 + activity["description"] = description 527 + if instructions is not None: 528 + if instructions == "" and activity_id in defaults_by_id: 529 + activity.pop("instructions", None) 530 + else: 531 + activity["instructions"] = instructions 431 532 if priority is not None: 432 - activity["priority"] = priority 533 + if priority == "normal" and activity_id in defaults_by_id: 534 + activity.pop("priority", None) 535 + else: 536 + activity["priority"] = priority 433 537 434 538 # Only allow name/icon changes for custom activities 435 539 if activity.get("custom") or activity_id not in defaults_by_id: