personal memory agent
0
fork

Configure Feed

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

Add segment-level entity extraction insight

Introduces a new "entities" insight that extracts people, companies,
projects, and tools from segment content using Gemini flash-lite.

Changes:
- Add insight.entities.* context default with TIER_LITE in models.py
- Extend send_insight() to accept thinking_budget and max_output_tokens
from insight frontmatter (with backwards-compatible defaults)
- Create entities.md insight prompt with segment frequency
- Create entities.py hook that parses markdown list output, deduplicates
by (type, name), and writes entities.jsonl to segment directory

Usage: sol insight YYYYMMDD --segment HHMMSS_LEN -f entities

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

+204 -6
+11 -2
muse/models.py
··· 170 170 "group": "Import", 171 171 }, 172 172 # Insight pipeline - daily analysis and summaries 173 + "insight.entities.*": { 174 + "tier": TIER_LITE, 175 + "label": "Entity Extraction", 176 + "group": "Think", 177 + }, 173 178 "insight.*": { 174 179 "tier": TIER_FLASH, 175 180 "label": "Daily Insights", ··· 230 235 for md_path in agents_dir.glob("*.md"): 231 236 agent_name = md_path.stem 232 237 try: 233 - post = frontmatter.load(md_path, ) 238 + post = frontmatter.load( 239 + md_path, 240 + ) 234 241 config = post.metadata if post.metadata else {} 235 242 236 243 context = f"agent.system.{agent_name}" ··· 255 262 for md_path in agents_subdir.glob("*.md"): 256 263 agent_name = md_path.stem 257 264 try: 258 - post = frontmatter.load(md_path, ) 265 + post = frontmatter.load( 266 + md_path, 267 + ) 259 268 config = post.metadata if post.metadata else {} 260 269 261 270 context = f"agent.{app_name}.{agent_name}"
+22 -4
think/insight.py
··· 140 140 insight_key: str | None = None, 141 141 json_output: bool = False, 142 142 system_instruction: str | None = None, 143 + thinking_budget: int | None = None, 144 + max_output_tokens: int | None = None, 143 145 ) -> str: 144 146 """Send clustered transcript to LLM for insight generation. 145 147 ··· 153 155 json_output: If True, request JSON response format. 154 156 system_instruction: System instruction text. If None, loads default 155 157 from journal.md via compose_instructions(). 158 + thinking_budget: Token budget for model thinking. If None, uses default. 159 + max_output_tokens: Maximum output tokens. If None, uses default. 156 160 157 161 Returns: 158 162 Generated insight content (markdown or JSON string). ··· 162 166 instructions = compose_instructions(include_datetime=False) 163 167 system_instruction = instructions["system_instruction"] 164 168 169 + # Use defaults if not specified 170 + if thinking_budget is None: 171 + thinking_budget = 8192 * 3 172 + if max_output_tokens is None: 173 + max_output_tokens = 8192 * 6 174 + 165 175 # Build context for provider routing and token logging 166 176 output_type = "json" if json_output else "markdown" 167 177 context = ( ··· 189 199 contents=[prompt], 190 200 context=context, 191 201 temperature=0.3, 192 - max_output_tokens=8192 * 6, 193 - thinking_budget=8192 * 3, 202 + max_output_tokens=max_output_tokens, 203 + thinking_budget=thinking_budget, 194 204 model=model, 195 205 cached_content=cache_name, 196 206 client=client, ··· 202 212 contents=[transcript, prompt], 203 213 context=context, 204 214 temperature=0.3, 205 - max_output_tokens=8192 * 6, 206 - thinking_budget=8192 * 3, 215 + max_output_tokens=max_output_tokens, 216 + thinking_budget=thinking_budget, 207 217 system_instruction=system_instruction, 208 218 json_output=json_output, 209 219 ) ··· 462 472 # Check if output file already exists 463 473 output_exists = output_path.exists() and output_path.stat().st_size > 0 464 474 475 + # Extract optional generation parameters from insight metadata 476 + meta_thinking_budget = insight_meta.get("thinking_budget") 477 + meta_max_output_tokens = insight_meta.get("max_output_tokens") 478 + 465 479 if output_exists and not args.force: 466 480 print( 467 481 f"Output file already exists: {output_path}. Loading existing content." ··· 478 492 insight_key=insight_key, 479 493 json_output=is_json_output, 480 494 system_instruction=system_instruction, 495 + thinking_budget=meta_thinking_budget, 496 + max_output_tokens=meta_max_output_tokens, 481 497 ) 482 498 else: 483 499 result = send_insight( ··· 488 504 insight_key=insight_key, 489 505 json_output=is_json_output, 490 506 system_instruction=system_instruction, 507 + thinking_budget=meta_thinking_budget, 508 + max_output_tokens=meta_max_output_tokens, 491 509 ) 492 510 493 511 # Check if we got a valid response
+31
think/insights/entities.md
··· 1 + { 2 + 3 + "title": "Entity Extraction", 4 + "description": "Extracts people, companies, projects, and tools from segment content", 5 + "frequency": "segment", 6 + "hook": "entities", 7 + "thinking_budget": 4096, 8 + "max_output_tokens": 1024, 9 + "instructions": { 10 + "system": "journal", 11 + "facets": "none", 12 + "sources": {"audio": true, "screen": true, "insights": false} 13 + } 14 + 15 + } 16 + 17 + $segment_insight 18 + 19 + Extract named entities and descriptions from the given segment transcription document. 20 + 21 + Focus on only these four types of entities: 22 + - Person: individual people by name 23 + - Company: businesses and organizations 24 + - Project: named projects, products, or codebases 25 + - Tool: software applications and services 26 + 27 + Skip entities that don't fit one of these four types. 28 + Skip any url, domain, filename, or path. 29 + 30 + Output as a simple markdown list of detected entities with all three elements: the chosen type, the detected entity name, and the description of how the entity occurred in the document: 31 + * type: name - description of how they appear across the segment (transcript or screen? which app? what do we learn about them?)
+140
think/insights/entities.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Hook for extracting entities from insight results and writing to JSONL. 5 + 6 + This hook is invoked via "hook": "entities" in insight frontmatter. 7 + It parses the markdown entity list and writes deduplicated entities 8 + to a JSONL file in the segment directory. 9 + """ 10 + 11 + import json 12 + import logging 13 + import re 14 + from pathlib import Path 15 + 16 + # Pattern to match: * Type: Name - Description 17 + # Requires whitespace around " - " to allow dashes in names (e.g., DO-260C) 18 + ENTITY_PATTERN = re.compile(r"^\*\s*([^:]+):\s*(.+?)\s+-\s+(.+)$") 19 + 20 + # Alternate pattern: * type: Tool - name: make - description: ... 21 + LABELED_PATTERN = re.compile( 22 + r"^\*\s*type:\s*(.+?)\s+-\s*name:\s*(.+?)\s+-\s*description:\s*(.+)$", 23 + re.IGNORECASE, 24 + ) 25 + 26 + 27 + def _strip_bold(text: str) -> str: 28 + """Remove markdown bold formatting from text.""" 29 + return text.replace("**", "").strip() 30 + 31 + 32 + def _parse_entity_line(line: str) -> dict | None: 33 + """Parse a markdown entity line into structured data. 34 + 35 + Expected formats: 36 + - * Type: Name - Description sentence 37 + - * type: Tool - name: make - description: A build tool. 38 + 39 + Returns: 40 + Dict with type, name, description keys or None if line doesn't match. 41 + """ 42 + line = line.strip() 43 + if not line: 44 + return None 45 + 46 + # Try standard format first: * Type: Name - Description 47 + match = ENTITY_PATTERN.match(line) 48 + if match: 49 + return { 50 + "type": _strip_bold(match.group(1)), 51 + "name": _strip_bold(match.group(2)), 52 + "description": match.group(3).strip(), 53 + } 54 + 55 + # Try labeled format: * type: X - name: Y - description: Z 56 + match = LABELED_PATTERN.match(line) 57 + if match: 58 + return { 59 + "type": _strip_bold(match.group(1)), 60 + "name": _strip_bold(match.group(2)), 61 + "description": match.group(3).strip(), 62 + } 63 + 64 + return None 65 + 66 + 67 + def process(result: str, context: dict) -> str | None: 68 + """Parse entity list and write to segment JSONL file. 69 + 70 + Args: 71 + result: The generated insight content (markdown entity list). 72 + context: Hook context with keys: 73 + - day: YYYYMMDD string 74 + - segment: segment key (HHMMSS_LEN) 75 + - insight_key: e.g., "entities" 76 + - output_path: absolute path to output file 77 + - insight_meta: dict with frontmatter 78 + - transcript: the clustered transcript markdown 79 + 80 + Returns: 81 + None - this hook does not modify the insight result. 82 + """ 83 + segment = context.get("segment") 84 + if not segment: 85 + logging.warning("entities hook requires segment mode") 86 + return None 87 + 88 + # Parse entities from result 89 + entities = [] 90 + unparsed = [] 91 + for line in result.strip().split("\n"): 92 + line = line.strip() 93 + if not line: 94 + continue 95 + entity = _parse_entity_line(line) 96 + if entity: 97 + entities.append(entity) 98 + elif line.startswith("*"): 99 + unparsed.append(line) 100 + 101 + if unparsed: 102 + logging.warning("entities hook: %d unparsed lines", len(unparsed)) 103 + for line in unparsed[:5]: # Log first 5 104 + logging.debug(" unparsed: %s", line) 105 + 106 + if not entities: 107 + logging.info("entities hook: no entities extracted") 108 + return None 109 + 110 + # Deduplicate by (type, name) - keep first occurrence 111 + seen = set() 112 + unique_entities = [] 113 + for entity in entities: 114 + key = (entity["type"].lower(), entity["name"].lower()) 115 + if key not in seen: 116 + seen.add(key) 117 + unique_entities.append(entity) 118 + 119 + if len(unique_entities) < len(entities): 120 + logging.info( 121 + "entities hook: deduplicated %d -> %d entities", 122 + len(entities), 123 + len(unique_entities), 124 + ) 125 + 126 + # Determine output path: segment_dir/entities.jsonl 127 + output_path = Path(context.get("output_path", "")) 128 + segment_dir = output_path.parent 129 + jsonl_path = segment_dir / "entities.jsonl" 130 + 131 + # Write JSONL file 132 + try: 133 + with open(jsonl_path, "w") as f: 134 + for entity in unique_entities: 135 + f.write(json.dumps(entity) + "\n") 136 + print(f"Entities written to: {jsonl_path} ({len(unique_entities)} entities)") 137 + except Exception as e: 138 + logging.error("entities hook: failed to write JSONL: %s", e) 139 + 140 + return None # Don't modify insight result