personal memory agent
0

Configure Feed

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

entities: retire per-segment extractor; index + show `sense` per-segment

Delete the redundant per-segment Entity Extraction talent (entities.md + entities.py hook + entities.schema.json) and the now-unreachable consolidate chain (consolidation.py, POST /api/consolidate, the `entities consolidate` CLI verb, and the orphan is_noise_entity helper).

Make talents/sense.json — the richest per-segment artifact — the indexed, transcripts-visible per-segment output: add format_sense, register */*/*/talents/sense.json (indexed) in FORMATTERS in place of the dropped entities.jsonl entry, and render it as the single transcripts "sense" tab (popping the stale rglob-collected sense.md key first; sense.md stays on disk for downstream consumers). Drop "entities" from SEGMENT_FLOOR_TALENTS so segment completion is gated by sensed + floor:documents.

Detection (entities:detection) and the detected entity store are untouched.

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

+329 -1051
+1 -1
AGENTS.md
··· 176 176 177 177 | Domain | Write-owning module(s) | 178 178 |--------|------------------------| 179 - | Entities (`entities/*/entity.json`, `entities/*/*.npz`) | `solstone/think/entities/journal.py` + `solstone/think/entities/relationships.py` + `solstone/think/entities/consolidation.py` + `solstone/think/entities/saving.py` + `solstone/think/entities/merge.py` + `solstone/think/entities/voiceprints.py` + `solstone/apps/speakers/owner.py` + `solstone/apps/speakers/routes.py` | 179 + | Entities (`entities/*/entity.json`, `entities/*/*.npz`) | `solstone/think/entities/journal.py` + `solstone/think/entities/relationships.py` + `solstone/think/entities/saving.py` + `solstone/think/entities/merge.py` + `solstone/think/entities/voiceprints.py` + `solstone/apps/speakers/owner.py` + `solstone/apps/speakers/routes.py` | 180 180 | Owner voice candidate (`awareness/owner_candidate.npz`) | `solstone/apps/speakers/owner.py` | 181 181 | Speaker discovery clusters (`awareness/discovery_clusters.json`, `awareness/discovery_clusters.resolved.json`) | `solstone/apps/speakers/discovery.py` | 182 182 | Speaker candidate pool (`awareness/speaker_candidates.json`) | `solstone/apps/speakers/candidate_tracker.py` |
+1 -1
docs/APPS.md
··· 272 272 - Discovery logic: `solstone/think/call.py` - `_discover_app_calls()` function 273 273 - App CLI example: `solstone/apps/entities/call.py` - Entity search command 274 274 275 - **Entities app reference:** `solstone/apps/entities/call.py` is the current pattern for a data-backed app CLI. It exposes `sol call entities list|detect|attach|update|aka|merge|observe|observations|consolidate|move`, and — like every journal-data `call.py` — reaches the journal only over HTTP via the Convey client (`solstone.think.convey_client`), importing no journal/domain module and doing no filesystem I/O of its own. The think-side write-owners it ultimately drives live under `solstone/think/entities/` (e.g. `journal.py`, `saving.py`, `merge.py`, `relationships.py`, `consolidation.py`), which own `journal/entities/<slug>/entity.json` and the per-entity `.npz` embedding files. 275 + **Entities app reference:** `solstone/apps/entities/call.py` is the current pattern for a data-backed app CLI. It exposes `sol call entities list|detect|attach|update|aka|merge|observe|observations|move`, and — like every journal-data `call.py` — reaches the journal only over HTTP via the Convey client (`solstone.think.convey_client`), importing no journal/domain module and doing no filesystem I/O of its own. The think-side write-owners it ultimately drives live under `solstone/think/entities/` (e.g. `journal.py`, `saving.py`, `merge.py`, `relationships.py`), which own `journal/entities/<slug>/entity.json` and the per-entity `.npz` embedding files. 276 276 277 277 --- 278 278
-1
scripts/check_journal_io_access.py
··· 99 99 "solstone/think/activities.py", 100 100 "solstone/think/awareness.py", 101 101 "solstone/think/day_accumulator.py", 102 - "solstone/think/entities/consolidation.py", 103 102 "solstone/think/entities/journal.py", 104 103 "solstone/think/entities/merge.py", 105 104 "solstone/think/entities/observations.py",
-17
solstone/apps/entities/call.py
··· 428 428 typer.echo(f"Added alias '{aka_value}' to '{resolved_name}'.") 429 429 430 430 431 - @app.command() 432 - def consolidate( 433 - full: bool = typer.Option(False, "--full", help="Scan all days, not just today."), 434 - ) -> None: 435 - """Consolidate segment-detected entities into journal identities.""" 436 - try: 437 - body = _request( 438 - "POST", 439 - "/app/entities/api/consolidate", 440 - json_body={"full": full}, 441 - ) 442 - except ConveyClientError as err: 443 - _handle_entity_error(err) 444 - count = body.get("count", 0) if isinstance(body, dict) else 0 445 - typer.echo(f"Wrote {count} new entities.") 446 - 447 - 448 431 @app.command("record-merge-candidate") 449 432 def record_merge_candidate( 450 433 source: str = typer.Argument(
-13
solstone/apps/entities/routes.py
··· 83 83 update_facet_entity_description, 84 84 update_facet_entity_identity, 85 85 ) 86 - from solstone.think.entities.consolidation import consolidate_detected_entities 87 86 from solstone.think.entities.journal import delete_journal_entity, load_journal_entity 88 87 from solstone.think.entities.relationships import move_facet_entity 89 88 from solstone.think.entities.review_candidates import ( ··· 537 536 params={"entity": original_query, "name": exclude_name, "aka": aka}, 538 537 ) 539 538 return success_response({"aka": aka_list}) 540 - 541 - 542 - @entities_bp.route("/api/consolidate", methods=["POST"]) 543 - def consolidate_entities_for_call() -> Any: 544 - """Consolidate detected entities for the CLI.""" 545 - data = _json_body() 546 - full = _body_bool(data, "full") 547 - try: 548 - count = consolidate_detected_entities(state.journal_root, full=full) 549 - except Exception as exc: 550 - return error_response(ENTITY_OPERATION_FAILED, detail=str(exc)) 551 - return success_response({"count": count}) 552 539 553 540 554 541 @entities_bp.route("/api/record-merge-candidate", methods=["POST"])
+2 -26
solstone/apps/entities/talent/entities/SKILL.md
··· 2 2 name: entities 3 3 description: > 4 4 Tracked entities — people, companies, projects, tools — within facets. 5 - Detect, attach, move, merge, consolidate, update, alias, search. 5 + Detect, attach, move, merge, update, alias, search. 6 6 TRIGGER: entity, person, company, relationship, who is, contact, sol call 7 7 entities detect/attach/merge/search. 8 8 --- ··· 256 256 sol call entities search --since 20260115 257 257 ``` 258 258 259 - ## consolidate 260 - 261 - ```bash 262 - sol call entities consolidate [--full] 263 - ``` 264 - 265 - Roll up segment-level entity detections into journal-level entity records. 266 - 267 - - `--full`: scan all days. Default scans today only. 268 - 269 - Behavior notes: 270 - 271 - - Uses 85% fuzzy-name matching to merge duplicates. Threshold is not configurable. 272 - - Noise-filter entities are skipped. 273 - - Reports the number of new entities written; existing entities are updated in place with richer descriptions. 274 - 275 - Example: 276 - 277 - ```bash 278 - sol call entities consolidate 279 - sol call entities consolidate --full 280 - ``` 281 - 282 259 ## merge 283 260 284 261 ```bash 285 262 sol call entities merge SOURCE_SLUG TARGET_SLUG [--commit/--no-commit] [--keep-source-as-aka/--no-keep-source-as-aka] 286 263 ``` 287 264 288 - Plan or execute a merge of two journal entities (e.g., collapse duplicates after consolidation). 265 + Plan or execute a merge of two journal entities (e.g., collapse duplicate records after review). 289 266 290 267 - `SOURCE_SLUG`: entity slug to merge from (removed on `--commit`). 291 268 - `TARGET_SLUG`: entity slug to merge into (canonical). ··· 308 285 ## Gotchas 309 286 310 287 - **`merge` previews by default.** Default is `--no-commit`: it emits a JSON plan without mutating anything. Pass `--commit` when you actually want the merge to happen. 311 - - **`consolidate` auto-merges at 85%.** Fuzzy-name matching runs unattended; review the output whenever it reports new entities to catch unexpected consolidations. 312 288 - **`detect` requires TYPE ≥ 3 chars.** Shorter types are silently rejected. 313 289 - **`observe` is for durable traits, `detect` is for day-scoped sightings.** Mixing them skews future entity context.
+6 -5
solstone/apps/transcripts/routes.py
··· 926 926 if "audio" in data_state: 927 927 md_files.pop("audio", None) 928 928 929 - entities_jsonl = talents_dir / "entities.jsonl" 930 - if entities_jsonl.is_file(): 929 + md_files.pop("sense", None) 930 + sense_json = talents_dir / "sense.json" 931 + if sense_json.is_file(): 931 932 try: 932 - chunks, _meta = format_file(entities_jsonl) 933 + chunks, _meta = format_file(sense_json) 933 934 rendered = "\n".join( 934 935 chunk["markdown"] for chunk in chunks if chunk.get("markdown") 935 936 ) 936 937 if rendered: 937 - md_files["entities"] = rendered 938 + md_files["sense"] = rendered 938 939 except Exception: 939 940 logger.warning( 940 - "segment detail: failed to render entities.jsonl", 941 + "segment detail: failed to render sense.json", 941 942 exc_info=True, 942 943 ) 943 944
+40 -21
solstone/apps/transcripts/tests/test_segment_routes.py
··· 543 543 assert any(chunk["type"] == "screen" for chunk in data["chunks"]) 544 544 545 545 546 - def test_segment_content_renders_entities_jsonl_over_stale_markdown( 547 - client, journal_copy 548 - ): 546 + def test_segment_content_renders_sense_json_over_stale_markdown(client, journal_copy): 549 547 day = "20990116" 550 548 stream = "default" 551 549 segment = "090000_300" 552 550 _write_segment(journal_copy, day, stream, segment, audio=False, screen=False) 553 551 talents_dir = journal_copy / "chronicle" / day / stream / segment / "talents" 554 552 talents_dir.mkdir(parents=True, exist_ok=True) 555 - _write_jsonl( 556 - talents_dir / "entities.jsonl", 557 - [ 553 + (talents_dir / "sense.json").write_text( 554 + json.dumps( 558 555 { 559 - "type": "Person", 560 - "name": "Alice Smith", 561 - "description": "Discussed the timeline", 562 - }, 563 - { 564 - "type": "Tool", 565 - "name": "Grafana", 566 - "description": "Used for dashboards", 567 - }, 568 - ], 556 + "density": "active", 557 + "content_type": "meeting", 558 + "activity_summary": "Discussed the timeline for the launch.", 559 + "entities": [ 560 + { 561 + "type": "Person", 562 + "name": "Alice Smith", 563 + "role": "attendee", 564 + "source": "voice", 565 + "context": "Owned timeline follow-up.", 566 + }, 567 + { 568 + "type": "Tool", 569 + "name": "Grafana", 570 + "role": "mentioned", 571 + "source": "screen", 572 + "context": "Used for dashboards.", 573 + }, 574 + ], 575 + "facets": [ 576 + {"facet": "work", "activity": "launch planning", "level": "high"} 577 + ], 578 + "speculative_facet": None, 579 + "meeting_detected": True, 580 + "speakers": ["Alice Smith", "Bob Chen"], 581 + "recommend": {"screen_record": True, "speaker_attribution": True}, 582 + "emotional_register": "collaborative", 583 + } 584 + ), 585 + encoding="utf-8", 569 586 ) 570 - (talents_dir / "entities.md").write_text("STALE MD", encoding="utf-8") 587 + (talents_dir / "sense.md").write_text("STALE MD", encoding="utf-8") 571 588 572 589 response = client.get(f"/app/transcripts/api/segment/{day}/{stream}/{segment}") 573 590 574 591 assert response.status_code == 200 575 592 md_files = response.get_json()["md_files"] 576 - assert list(md_files).count("entities") == 1 577 - assert "Alice Smith" in md_files["entities"] 578 - assert " — " in md_files["entities"] 579 - assert "STALE MD" not in md_files["entities"] 593 + assert "entities" not in md_files 594 + assert list(md_files).count("sense") == 1 595 + assert "Alice Smith" in md_files["sense"] 596 + assert "Owned timeline follow-up." in md_files["sense"] 597 + assert "Discussed the timeline for the launch." in md_files["sense"] 598 + assert "STALE MD" not in md_files["sense"] 580 599 581 600 582 601 def test_segment_content_strips_duplicate_audio_markdown_timestamp(
-57
solstone/talent/entities.md
··· 1 - { 2 - "type": "generate", 3 - 4 - "title": "Entity Extraction", 5 - "description": "Extracts people, companies, projects, and tools from segment content", 6 - "color": "#2e7d32", 7 - "schedule": "segment", 8 - "priority": 10, 9 - "hook": {"post": "entities"}, 10 - "thinking_budget": 4096, 11 - "max_output_tokens": 1024, 12 - "output": "json", 13 - "schema": "entities.schema.json", 14 - "load": {"transcripts": true, "percepts": true, "talents": false} 15 - 16 - } 17 - 18 - $segment_preamble 19 - 20 - Extract named entities and descriptions from the given segment transcription document. 21 - 22 - Focus on only these four types of entities: 23 - - Person: individual people by name 24 - - Company: businesses and organizations 25 - - Project: named projects, products, or codebases 26 - - Tool: software applications and services 27 - 28 - Skip entities that don't fit one of these four types. 29 - Skip any url, domain, filename, or path. 30 - 31 - ## Name Resolution 32 - 33 - - Prefer full names over nicknames or abbreviations when context makes identity clear (e.g., "Dens" → "Dennis Crowley" if the surrounding conversation confirms identity). 34 - - Use the first full name encountered for people (e.g., if "John B." and "John Borthwick" appear, use "John Borthwick"). 35 - - Only extract first-name-only references when identity is unambiguous (one "Sarah" in the conversation). Skip ambiguous first-name references rather than guessing. 36 - - Use the official or most common name for companies (e.g., "MS", "Microsoft", "MSFT" → "Microsoft"). 37 - 38 - ## What to return 39 - 40 - Return a single JSON object: `{"entities": [ ... ]}`. Each entry has exactly three fields: `type` (one of Person, Company, Project, Tool), `name`, and `description`. If no entities qualify, return `{"entities": []}`. 41 - 42 - Example: 43 - 44 - { 45 - "entities": [ 46 - { 47 - "type": "Person", 48 - "name": "Alice Smith", 49 - "description": "Mentioned in discussion about the project timeline" 50 - }, 51 - { 52 - "type": "Tool", 53 - "name": "Grafana", 54 - "description": "Referenced for monitoring metrics dashboards" 55 - } 56 - ] 57 - }
-130
solstone/talent/entities.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Hook for extracting entities from model JSON output and writing to JSONL. 5 - 6 - This hook is invoked via "hook": {"post": "entities"} in generator frontmatter. 7 - It parses the structured entity payload and writes deduplicated entities to a 8 - JSONL file next to the agent output. 9 - """ 10 - 11 - import json 12 - import logging 13 - from pathlib import Path 14 - 15 - from solstone.think.indexer.journal import index_file 16 - from solstone.think.utils import get_journal 17 - 18 - logger = logging.getLogger(__name__) 19 - ENTITY_TYPES = {"Person", "Company", "Project", "Tool"} 20 - 21 - 22 - def post_process(result: str, context: dict) -> str | None: 23 - """Parse entity JSON and write to an adjacent JSONL file. 24 - 25 - Args: 26 - result: The generated output content (JSON entity object). 27 - context: HookContext with keys including day, segment, name, 28 - output_path, meta, transcript. 29 - 30 - Returns: 31 - None - this hook does not modify the output result. 32 - """ 33 - try: 34 - data = json.loads(result) 35 - except json.JSONDecodeError: 36 - logger.warning("entities hook: malformed payload (invalid JSON)") 37 - return None 38 - 39 - if not isinstance(data, dict) or not isinstance(data.get("entities"), list): 40 - logger.warning("entities hook: malformed payload (bad shape)") 41 - return None 42 - 43 - entities = [] 44 - dropped = 0 45 - for item in data["entities"]: 46 - if not isinstance(item, dict): 47 - dropped += 1 48 - continue 49 - 50 - etype = item.get("type") 51 - name = item.get("name") 52 - description = item.get("description") 53 - if ( 54 - not isinstance(etype, str) 55 - or not isinstance(name, str) 56 - or not isinstance(description, str) 57 - ): 58 - dropped += 1 59 - continue 60 - 61 - entity = { 62 - "type": etype.strip(), 63 - "name": name.strip(), 64 - "description": description.strip(), 65 - } 66 - if ( 67 - not entity["type"] 68 - or not entity["name"] 69 - or not entity["description"] 70 - or entity["type"] not in ENTITY_TYPES 71 - ): 72 - dropped += 1 73 - continue 74 - 75 - entities.append(entity) 76 - 77 - if dropped: 78 - logger.warning("entities hook: dropped %d malformed entity items", dropped) 79 - 80 - if not entities: 81 - logger.info("entities hook: no entities extracted") 82 - return None 83 - 84 - # Deduplicate by (type, name) - keep first occurrence 85 - seen = set() 86 - unique_entities = [] 87 - for entity in entities: 88 - key = (entity["type"].lower(), entity["name"].lower()) 89 - if key not in seen: 90 - seen.add(key) 91 - unique_entities.append(entity) 92 - 93 - if len(unique_entities) < len(entities): 94 - logger.info( 95 - "entities hook: deduplicated %d -> %d entities", 96 - len(entities), 97 - len(unique_entities), 98 - ) 99 - 100 - # Write entities.jsonl alongside the agent output in the talents/ directory 101 - output_path_value = context.get("output_path") 102 - if not output_path_value: 103 - logger.error("entities hook: missing output_path in context") 104 - return None 105 - 106 - output_path = Path(output_path_value) 107 - agents_dir = output_path.parent 108 - jsonl_path = agents_dir / "entities.jsonl" 109 - 110 - # Write JSONL file 111 - try: 112 - jsonl_path.parent.mkdir(parents=True, exist_ok=True) 113 - with open(jsonl_path, "w", encoding="utf-8") as f: 114 - for entity in unique_entities: 115 - f.write(json.dumps(entity) + "\n") 116 - logger.info( 117 - "entities hook: wrote %d entities to %s", 118 - len(unique_entities), 119 - jsonl_path, 120 - ) 121 - except Exception as e: 122 - logger.error("entities hook: failed to write JSONL: %s", e) 123 - return None 124 - 125 - try: 126 - index_file(get_journal(), str(jsonl_path)) 127 - except Exception: 128 - logger.warning("entities hook: index failed for %s", jsonl_path, exc_info=True) 129 - 130 - return None # Don't modify insight result
-23
solstone/talent/entities.schema.json
··· 1 - { 2 - "type": "object", 3 - "additionalProperties": false, 4 - "required": ["entities"], 5 - "properties": { 6 - "entities": { 7 - "type": "array", 8 - "items": { 9 - "type": "object", 10 - "additionalProperties": false, 11 - "required": ["type", "name", "description"], 12 - "properties": { 13 - "type": { 14 - "type": "string", 15 - "enum": ["Person", "Company", "Project", "Tool"] 16 - }, 17 - "name": {"type": "string"}, 18 - "description": {"type": "string"} 19 - } 20 - } 21 - } 22 - } 23 - }
+1 -1
solstone/talent/sol/references/commands.md
··· 22 22 23 23 Read: `list`, `search` 24 24 25 - Write: `accept-merge-candidate`, `attach`, `consolidate`, `dismiss-merge-candidate`, `merge`, `merge-candidates`, `move`, `record-merge-candidate`, `update` 25 + Write: `accept-merge-candidate`, `attach`, `dismiss-merge-candidate`, `merge`, `merge-candidates`, `move`, `record-merge-candidate`, `update` 26 26 27 27 Other: `aka`, `detect`, `observations`, `observe` 28 28
-158
solstone/think/entities/consolidation.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Consolidate segment-detected entities into journal identities.""" 5 - 6 - import json 7 - import logging 8 - from datetime import datetime 9 - from pathlib import Path 10 - 11 - from solstone.think.entities.core import entity_slug, is_noise_entity 12 - from solstone.think.entities.journal import ( 13 - load_all_journal_entities, 14 - save_journal_entity, 15 - ) 16 - from solstone.think.entities.matching import MatchTier, find_matching_entity 17 - from solstone.think.utils import CHRONICLE_DIR, DATE_RE, now_ms 18 - 19 - logger = logging.getLogger(__name__) 20 - 21 - 22 - def consolidate_detected_entities( 23 - journal: str, 24 - full: bool = False, 25 - fuzzy_threshold: int = 85, 26 - ) -> int: 27 - """Consolidate segment-detected entities into journal identities.""" 28 - journal_path = Path(journal) 29 - day_root = ( 30 - journal_path / CHRONICLE_DIR 31 - if (journal_path / CHRONICLE_DIR).is_dir() 32 - else journal_path 33 - ) 34 - today = datetime.now().strftime("%Y%m%d") 35 - 36 - segment_files = [] 37 - for path in day_root.glob("**/talents/entities.jsonl"): 38 - if not path.is_file(): 39 - continue 40 - try: 41 - day = path.relative_to(day_root).parts[0] 42 - except (ValueError, IndexError): 43 - continue 44 - if not DATE_RE.fullmatch(day): 45 - continue 46 - if full or day == today: 47 - segment_files.append(path) 48 - 49 - seen: dict[tuple[str, str], dict[str, str]] = {} 50 - for seg_file in segment_files: 51 - try: 52 - with open(seg_file, encoding="utf-8") as f: 53 - for raw in f: 54 - raw = raw.strip() 55 - if not raw: 56 - continue 57 - try: 58 - data = json.loads(raw) 59 - except json.JSONDecodeError as e: 60 - logger.warning( 61 - "Skipping malformed JSONL in %s: %s", seg_file, e 62 - ) 63 - continue 64 - 65 - name = (data.get("name") or "").strip() 66 - etype = (data.get("type") or "").strip() 67 - description = (data.get("description") or "").strip() 68 - 69 - if not name or not etype or is_noise_entity(name): 70 - continue 71 - 72 - key = (name.lower(), etype.lower()) 73 - if key not in seen: 74 - seen[key] = { 75 - "name": name, 76 - "type": etype, 77 - "description": description, 78 - } 79 - elif len(description) > len(seen[key]["description"]): 80 - seen[key]["description"] = description 81 - except OSError as e: 82 - logger.warning("Skipping %s: %s", seg_file, e) 83 - 84 - entities_list = list(load_all_journal_entities().values()) 85 - total_detections = len(seen) 86 - fuzzy_count = 0 87 - exact_count = 0 88 - new_count = 0 89 - ts = now_ms() 90 - 91 - for (name_lower, _type_lower), data in seen.items(): 92 - name = data["name"] 93 - etype = data["type"] 94 - description = data["description"] 95 - 96 - match = find_matching_entity( 97 - name, entities_list, fuzzy_threshold=fuzzy_threshold 98 - ) 99 - if match: 100 - if match.tier == MatchTier.FUZZY: 101 - fuzzy_count += 1 102 - else: 103 - exact_count += 1 104 - continue 105 - 106 - base_slug = entity_slug(name) 107 - if not base_slug: 108 - continue 109 - 110 - final_slug = None 111 - for attempt in range(1, 102): 112 - candidate = base_slug if attempt == 1 else f"{base_slug}_{attempt}" 113 - candidate_path = journal_path / "entities" / candidate / "entity.json" 114 - 115 - if not candidate_path.exists(): 116 - final_slug = candidate 117 - break 118 - 119 - try: 120 - with open(candidate_path, encoding="utf-8") as f: 121 - existing = json.load(f) 122 - if (existing.get("name") or "").lower().strip() == name_lower: 123 - break 124 - except (json.JSONDecodeError, OSError): 125 - continue 126 - else: 127 - logger.warning("Too many slug collisions for '%s', skipping", name) 128 - continue 129 - 130 - if final_slug is None: 131 - continue 132 - 133 - entity = { 134 - "id": final_slug, 135 - "name": name, 136 - "type": etype, 137 - "source": "detected", 138 - "created_at": ts, 139 - "updated_at": ts, 140 - } 141 - if description: 142 - entity["description"] = description 143 - 144 - try: 145 - save_journal_entity(entity) 146 - new_count += 1 147 - except OSError as e: 148 - logger.warning("Failed to write entity %s: %s", final_slug, e) 149 - 150 - logger.info( 151 - "consolidate: %d detections, %d matched-skipped (%d fuzzy, %d exact), %d new entities", 152 - total_detections, 153 - fuzzy_count + exact_count, 154 - fuzzy_count, 155 - exact_count, 156 - new_count, 157 - ) 158 - return new_count
-14
solstone/think/entities/core.py
··· 39 39 # Maximum length for entity slug before truncation 40 40 MAX_ENTITY_SLUG_LENGTH = 200 41 41 42 - # Noise entity patterns — transcript artifacts that should not be indexed. 43 - # Matches "Speaker N", "Unknown/Unidentified <word>" (single-word role). 44 - # Multi-word patterns ("Speaker Diarization") and bare "Unknown" are kept. 45 - _NOISE_ENTITY_RE = re.compile( 46 - r"^Speaker \d+(?:\s*\(.*\))?$" 47 - r"|^(?:Unknown|Unidentified) \w+(?:\s*\d+)?(?:\s*\(.*\))?$", 48 - re.IGNORECASE, 49 - ) 50 - 51 42 52 43 def get_identity_names() -> list[str]: 53 44 """Get all names/aliases for the journal principal from identity config. ··· 171 162 return bool( 172 163 re.match(r"^[A-Za-z0-9 ]+$", etype) and re.search(r"[A-Za-z0-9]", etype) 173 164 ) 174 - 175 - 176 - def is_noise_entity(name: str) -> bool: 177 - """Return True if the entity name is a transcript artifact.""" 178 - return bool(_NOISE_ENTITY_RE.match(name)) 179 165 180 166 181 167 def entity_slug(name: str) -> str:
+87 -20
solstone/think/entities/formatting.py
··· 238 238 return chunks, meta 239 239 240 240 241 - def format_segment_entities( 241 + def format_sense( 242 242 entries: list[dict[str, Any]], 243 243 context: dict[str, Any] | None = None, 244 244 ) -> tuple[list[dict[str, Any]], dict[str, Any]]: 245 - """Render the per-segment canonical talents/entities.jsonl artifact.""" 245 + """Render the per-segment talents/sense.json artifact.""" 246 246 _ = context 247 - chunks: list[dict[str, Any]] = [] 248 - for entry in entries: 249 - etype = str(entry.get("type", "")).strip() 250 - name = str(entry.get("name", "")).strip() 251 - description = str(entry.get("description", "")).strip() 252 - if not name: 253 - continue 247 + meta = {"indexer": {"agent": "sense"}} 248 + if not entries: 249 + return [], meta 250 + 251 + sense_obj = entries[0] 252 + if not isinstance(sense_obj, dict): 253 + return [], meta 254 + 255 + lines: list[str] = [] 256 + content_type = str(sense_obj.get("content_type") or "").strip() 257 + emotional_register = str(sense_obj.get("emotional_register") or "").strip() 258 + heading_parts = [part for part in (content_type, emotional_register) if part] 259 + if heading_parts: 260 + lines.append(f"## Sense: {' · '.join(heading_parts)}") 261 + 262 + activity_summary = str(sense_obj.get("activity_summary") or "").strip() 263 + if activity_summary: 264 + if lines: 265 + lines.append("") 266 + lines.append(activity_summary) 267 + 268 + entities = sense_obj.get("entities") or [] 269 + entity_lines: list[str] = [] 270 + if isinstance(entities, list): 271 + for entity in entities: 272 + if not isinstance(entity, dict): 273 + continue 274 + name = str(entity.get("name") or "").strip() 275 + if not name: 276 + continue 277 + etype = str(entity.get("type") or "").strip() 278 + context_text = str(entity.get("context") or "").strip() 279 + prefix = f"{etype}: " if etype else "" 280 + line = f"- {prefix}{name}" 281 + if context_text: 282 + line += f" — {context_text}" 283 + entity_lines.append(line) 284 + if entity_lines: 285 + if lines: 286 + lines.append("") 287 + lines.append("### Entities") 288 + lines.extend(entity_lines) 289 + 290 + facets = sense_obj.get("facets") or [] 291 + facet_lines: list[str] = [] 292 + if isinstance(facets, list): 293 + for facet in facets: 294 + if not isinstance(facet, dict): 295 + continue 296 + facet_name = str(facet.get("facet") or "").strip() 297 + activity = str(facet.get("activity") or "").strip() 298 + level = str(facet.get("level") or "").strip() 299 + if not any((facet_name, activity, level)): 300 + continue 301 + if facet_name and activity: 302 + text = f"{facet_name}: {activity}" 303 + elif facet_name: 304 + text = facet_name 305 + elif activity: 306 + text = activity 307 + else: 308 + text = f"({level})" 309 + level = "" 310 + if level: 311 + text += f" ({level})" 312 + facet_lines.append(f"- {text}") 313 + if facet_lines: 314 + if lines: 315 + lines.append("") 316 + lines.append("### Facets") 317 + lines.extend(facet_lines) 318 + 319 + speakers = sense_obj.get("speakers") or [] 320 + if sense_obj.get("meeting_detected") and isinstance(speakers, list): 321 + speaker_names = [ 322 + str(speaker).strip() for speaker in speakers if str(speaker).strip() 323 + ] 324 + if speaker_names: 325 + if lines: 326 + lines.append("") 327 + lines.append(f"**Speakers:** {', '.join(speaker_names)}") 254 328 255 - markdown = ( 256 - f"{etype}: {name} — {description}" if description else f"{etype}: {name}" 257 - ) 258 - chunks.append( 259 - { 260 - "timestamp": 0, 261 - "markdown": markdown, 262 - "source": entry, 263 - } 264 - ) 329 + markdown = "\n".join(lines).strip() 330 + if not markdown: 331 + return [], meta 265 332 266 - return chunks, {"indexer": {"agent": "entities"}} 333 + return [{"markdown": markdown, "timestamp": 0, "source": sense_obj}], meta 267 334 268 335 269 336 def format_observations(
+2 -2
solstone/think/formatters.py
··· 218 218 "*/*/*/screen.jsonl": ("solstone.observe.screen", "format_screen", False), 219 219 "*/*/*/*_screen.jsonl": ("solstone.observe.screen", "format_screen", False), 220 220 "*/chat/*/chat.jsonl": ("solstone.think.chat_formatter", "format_chat", True), 221 - "*/*/*/talents/entities.jsonl": ( 221 + "*/*/*/talents/sense.json": ( 222 222 "solstone.think.entities.formatting", 223 - "format_segment_entities", 223 + "format_sense", 224 224 True, 225 225 ), 226 226 "*/talents/*.jsonl": (
+1 -1
solstone/think/pipeline_health.py
··· 32 32 33 33 _MODES = ("segment", "daily", "activity", "weekly", "flush", "cadence") 34 34 _FAILED_LIST_CAP = 20 35 - SEGMENT_FLOOR_TALENTS: tuple[str, ...] = ("entities", "documents") 35 + SEGMENT_FLOOR_TALENTS: tuple[str, ...] = ("documents",) 36 36 SEGMENT_NONGATING_TALENTS: tuple[str, ...] = ("entities:detection",) 37 37 STUCK_FAIL_THRESHOLD = 3 38 38 BACKLOG_DEFAULT_WINDOW = 30
-11
tests/baselines/api/sol/talents-day.json
··· 82 82 "title": "Document Analysis", 83 83 "type": "generate" 84 84 }, 85 - "entities": { 86 - "app": null, 87 - "color": "#2e7d32", 88 - "description": "Extracts people, companies, projects, and tools from segment content", 89 - "multi_facet": false, 90 - "output_format": "json", 91 - "schedule": "segment", 92 - "source": "system", 93 - "title": "Entity Extraction", 94 - "type": "generate" 95 - }, 96 85 "entities:detection": { 97 86 "app": "entities", 98 87 "color": "#00695c",
-23
tests/baselines/api/stats/stats.json
··· 93 93 "title": "Document Analysis", 94 94 "type": "generate" 95 95 }, 96 - "entities": { 97 - "color": "#2e7d32", 98 - "description": "Extracts people, companies, projects, and tools from segment content", 99 - "hook": { 100 - "post": "entities" 101 - }, 102 - "load": { 103 - "percepts": true, 104 - "talents": false, 105 - "transcripts": true 106 - }, 107 - "max_output_tokens": 1024, 108 - "mtime": 0, 109 - "output": "json", 110 - "path": "<PROJECT>/solstone/talent/entities.md", 111 - "priority": 10, 112 - "schedule": "segment", 113 - "schema": "entities.schema.json", 114 - "source": "system", 115 - "thinking_budget": 4096, 116 - "title": "Entity Extraction", 117 - "type": "generate" 118 - }, 119 96 "entities:detection": { 120 97 "app": "entities", 121 98 "color": "#00695c",
-8
tests/baselines/api/thinking/generators.json
··· 68 68 }, 69 69 { 70 70 "app": null, 71 - "description": "Extracts people, companies, projects, and tools from segment content", 72 - "disabled": false, 73 - "key": "entities", 74 - "source": "system", 75 - "title": "Entity Extraction" 76 - }, 77 - { 78 - "app": null, 79 71 "description": "Extracts structured intelligence from imported documents", 80 72 "disabled": false, 81 73 "key": "documents",
-8
tests/baselines/api/thinking/providers.json
··· 445 445 "tier": 2, 446 446 "type": "generate" 447 447 }, 448 - "talent.system.entities": { 449 - "disabled": false, 450 - "group": "Think", 451 - "label": "Entity Extraction", 452 - "schedule": "segment", 453 - "tier": 2, 454 - "type": "generate" 455 - }, 456 448 "talent.system.event": { 457 449 "disabled": false, 458 450 "group": "Think",
+7 -7
tests/test_cogitate_read_tools.py
··· 28 28 write("chronicle/20260608/session/090000_300/evidence.md", "x evidence\n") 29 29 write("chronicle/20260608/session/090000_300/nested/deep.txt", "deep x\n") 30 30 write( 31 - "chronicle/20260608/session/090000_300/talents/entities.jsonl", 32 - '{"entity":"rohan"}\n', 31 + "chronicle/20260608/session/090000_300/talents/sense.json", 32 + '{"activity_summary":"rohan"}\n', 33 33 ) 34 34 write("chronicle/20260608/foo", "date redirected\n") 35 35 write("chronicle/20260608/.git/config", "git-secret x\n") ··· 373 373 374 374 root_list = crt.list_directory(journal) 375 375 day_glob = crt.glob(journal, "*090000*", root="chronicle/20260608") 376 - entities_jsonl = crt.glob( 376 + sense_json = crt.glob( 377 377 journal, 378 - "*/talents/entities.jsonl", 378 + "*/talents/sense.json", 379 379 root="chronicle/20260608/session/090000_300", 380 380 ) 381 381 facet_glob = crt.glob(journal, "*", root="facets/work") ··· 391 391 assert _payload_paths(root_list) 392 392 assert day_glob.ok is True 393 393 assert any("090000_300" in path for path in _payload_paths(day_glob)) 394 - assert entities_jsonl.ok is True 395 - assert _payload_paths(entities_jsonl) == [ 396 - "chronicle/20260608/session/090000_300/talents/entities.jsonl" 394 + assert sense_json.ok is True 395 + assert _payload_paths(sense_json) == [ 396 + "chronicle/20260608/session/090000_300/talents/sense.json" 397 397 ] 398 398 assert facet_glob.ok is True 399 399 assert "facets/work/facet.json" in _payload_paths(facet_glob)
+1 -8
tests/test_entities_call_parity.py
··· 736 736 assert busy.stderr == ENTITY_BUSY.message + "\n" 737 737 738 738 739 - def test_update_owner_errors_consolidate_and_observe_busy_byte_exact( 739 + def test_update_owner_errors_and_observe_busy_byte_exact( 740 740 entity_env, 741 741 monkeypatch: pytest.MonkeyPatch, 742 742 ) -> None: ··· 794 794 ) 795 795 assert observe_busy.exit_code == 1 796 796 assert observe_busy.stderr == ENTITY_BUSY.message + "\n" 797 - 798 - consolidate = runner.invoke(app, ["consolidate"]) 799 - consolidate_full = runner.invoke(app, ["consolidate", "--full"]) 800 - assert consolidate.exit_code == 0 801 - assert consolidate.stdout.startswith("Wrote ") 802 - assert consolidate_full.exit_code == 0 803 - assert consolidate_full.stdout.startswith("Wrote ") 804 797 805 798 806 799 def test_observations_and_observe_byte_exact(entity_env) -> None:
-121
tests/test_entities_consolidation.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - import json 5 - import logging 6 - from datetime import datetime 7 - from pathlib import Path 8 - 9 - from solstone.think.entities.consolidation import consolidate_detected_entities 10 - from solstone.think.entities.journal import save_journal_entity 11 - 12 - 13 - def _seed_detection( 14 - journal_path: Path, 15 - detection: dict[str, str], 16 - *, 17 - day: str | None = None, 18 - segment: str = "120000_300", 19 - ) -> Path: 20 - day = day or datetime.now().strftime("%Y%m%d") 21 - talents_dir = journal_path / "chronicle" / day / "default" / segment / "talents" 22 - talents_dir.mkdir(parents=True, exist_ok=True) 23 - path = talents_dir / "entities.jsonl" 24 - path.write_text(json.dumps(detection) + "\n", encoding="utf-8") 25 - return path 26 - 27 - 28 - def _entity_file_count(journal_path: Path) -> int: 29 - return sum(1 for _ in (journal_path / "entities").glob("*/entity.json")) 30 - 31 - 32 - def test_consolidate_writes_new_entity(journal_copy): 33 - journal_path = Path(journal_copy) 34 - _seed_detection( 35 - journal_path, 36 - { 37 - "name": "Zephyr Quartz Index", 38 - "type": "Project", 39 - "description": "Unique regression seed", 40 - }, 41 - ) 42 - 43 - written = consolidate_detected_entities(str(journal_path), full=True) 44 - 45 - assert written == 1 46 - entity_path = journal_path / "entities" / "zephyr_quartz_index" / "entity.json" 47 - assert entity_path.exists() 48 - entity = json.loads(entity_path.read_text(encoding="utf-8")) 49 - assert entity == { 50 - "id": "zephyr_quartz_index", 51 - "name": "Zephyr Quartz Index", 52 - "type": "Project", 53 - "source": "detected", 54 - "created_at": entity["created_at"], 55 - "updated_at": entity["updated_at"], 56 - "description": "Unique regression seed", 57 - } 58 - 59 - 60 - def test_consolidate_skips_fuzzy_match(journal_copy, caplog): 61 - journal_path = Path(journal_copy) 62 - before_count = _entity_file_count(journal_path) 63 - save_journal_entity( 64 - {"id": "jeremie_miller", "name": "Jeremie Miller", "type": "Person"} 65 - ) 66 - _seed_detection( 67 - journal_path, 68 - { 69 - "name": "Jeremee Miler", 70 - "type": "Person", 71 - "description": "Should fuzzy-match and skip", 72 - }, 73 - ) 74 - 75 - with caplog.at_level(logging.INFO): 76 - written = consolidate_detected_entities(str(journal_path), full=True) 77 - 78 - assert written == 0 79 - assert _entity_file_count(journal_path) == before_count + 1 80 - assert ( 81 - "consolidate: 1 detections, 1 matched-skipped (1 fuzzy, 0 exact), 0 new entities" 82 - in caplog.text 83 - ) 84 - 85 - 86 - def test_consolidate_creates_for_unrelated_name(journal_copy): 87 - journal_path = Path(journal_copy) 88 - save_journal_entity( 89 - {"id": "jeremie_miller", "name": "Jeremie Miller", "type": "Person"} 90 - ) 91 - _seed_detection( 92 - journal_path, 93 - { 94 - "name": "Quillon Vastworth", 95 - "type": "Person", 96 - "description": "Unique unrelated entity", 97 - }, 98 - ) 99 - 100 - written = consolidate_detected_entities(str(journal_path), full=True) 101 - 102 - assert written == 1 103 - assert (journal_path / "entities" / "quillon_vastworth" / "entity.json").exists() 104 - 105 - 106 - def test_consolidate_is_idempotent(journal_copy): 107 - journal_path = Path(journal_copy) 108 - _seed_detection( 109 - journal_path, 110 - { 111 - "name": "Zephyr Quartz Index", 112 - "type": "Project", 113 - "description": "Unique regression seed", 114 - }, 115 - ) 116 - 117 - first = consolidate_detected_entities(str(journal_path), full=True) 118 - second = consolidate_detected_entities(str(journal_path), full=True) 119 - 120 - assert first == 1 121 - assert second == 0
-211
tests/test_entities_hook.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tests for entity hook behavior across schedules.""" 5 - 6 - from __future__ import annotations 7 - 8 - import json 9 - import logging 10 - 11 - 12 - def _read_jsonl(path): 13 - return [ 14 - json.loads(line) 15 - for line in path.read_text(encoding="utf-8").splitlines() 16 - if line.strip() 17 - ] 18 - 19 - 20 - def test_entities_post_process_writes_deduped_jsonl(tmp_path, caplog): 21 - from solstone.talent.entities import post_process 22 - 23 - caplog.set_level(logging.INFO) 24 - output_path = ( 25 - tmp_path / "chronicle/20240115/default/120000_300/talents/entities.json" 26 - ) 27 - result = json.dumps( 28 - { 29 - "entities": [ 30 - { 31 - "type": "Person", 32 - "name": "Alice Smith", 33 - "description": "desc1", 34 - }, 35 - { 36 - "type": "Person", 37 - "name": "alice smith", 38 - "description": "DUP", 39 - }, 40 - { 41 - "type": "Tool", 42 - "name": "Grafana", 43 - "description": "desc2", 44 - }, 45 - ] 46 - } 47 - ) 48 - 49 - post_process(result, {"output_path": str(output_path)}) 50 - 51 - entities_path = output_path.parent / "entities.jsonl" 52 - assert entities_path.exists() 53 - assert _read_jsonl(entities_path) == [ 54 - { 55 - "type": "Person", 56 - "name": "Alice Smith", 57 - "description": "desc1", 58 - }, 59 - {"type": "Tool", "name": "Grafana", "description": "desc2"}, 60 - ] 61 - 62 - 63 - def test_entities_post_process_requires_output_path(caplog): 64 - from solstone.talent.entities import post_process 65 - 66 - caplog.set_level(logging.INFO) 67 - post_process( 68 - json.dumps( 69 - { 70 - "entities": [ 71 - { 72 - "type": "Person", 73 - "name": "Alice Smith", 74 - "description": "Mentioned in the meeting", 75 - } 76 - ] 77 - } 78 - ), 79 - {}, 80 - ) 81 - 82 - assert "missing output_path" in caplog.text 83 - 84 - 85 - def test_entities_post_process_drops_malformed_items_with_warning(tmp_path, caplog): 86 - from solstone.talent.entities import post_process 87 - 88 - caplog.set_level(logging.INFO) 89 - output_path = ( 90 - tmp_path / "chronicle/20240115/default/120000_300/talents/entities.json" 91 - ) 92 - result = json.dumps( 93 - { 94 - "entities": [ 95 - { 96 - "type": "Project", 97 - "name": "Zephyr Quartz", 98 - "description": "Valid project", 99 - }, 100 - { 101 - "type": "Person", 102 - "name": "", 103 - "description": "Missing name", 104 - }, 105 - { 106 - "type": "person", 107 - "name": "Alice Smith", 108 - "description": "Invalid type casing", 109 - }, 110 - ] 111 - } 112 - ) 113 - 114 - post_process(result, {"output_path": str(output_path)}) 115 - 116 - assert any( 117 - record.levelno == logging.WARNING 118 - and "dropped 2 malformed entity items" in record.message 119 - for record in caplog.records 120 - ) 121 - assert _read_jsonl(output_path.parent / "entities.jsonl") == [ 122 - { 123 - "type": "Project", 124 - "name": "Zephyr Quartz", 125 - "description": "Valid project", 126 - } 127 - ] 128 - 129 - 130 - def test_entities_post_process_malformed_json_warns_without_write(tmp_path, caplog): 131 - from solstone.talent.entities import post_process 132 - 133 - caplog.set_level(logging.INFO) 134 - output_path = ( 135 - tmp_path / "chronicle/20240115/default/120000_300/talents/entities.json" 136 - ) 137 - 138 - post_process("not json at all", {"output_path": str(output_path)}) 139 - 140 - assert any( 141 - record.levelno == logging.WARNING and "malformed payload" in record.message 142 - for record in caplog.records 143 - ) 144 - assert not (output_path.parent / "entities.jsonl").exists() 145 - 146 - 147 - def test_entities_post_process_empty_is_info_not_warning(tmp_path, caplog): 148 - from solstone.talent.entities import post_process 149 - 150 - caplog.set_level(logging.INFO) 151 - output_path = ( 152 - tmp_path / "chronicle/20240115/default/120000_300/talents/entities.json" 153 - ) 154 - 155 - post_process('{"entities": []}', {"output_path": str(output_path)}) 156 - 157 - assert any( 158 - record.levelno == logging.INFO and "no entities extracted" in record.message 159 - for record in caplog.records 160 - ) 161 - assert not any(record.levelno >= logging.WARNING for record in caplog.records) 162 - assert not (output_path.parent / "entities.jsonl").exists() 163 - 164 - 165 - def test_entities_post_process_indexes_sidecar_under_journal(tmp_path, monkeypatch): 166 - from solstone.talent.entities import post_process 167 - from solstone.think.indexer.journal import search_journal 168 - 169 - journal = tmp_path / "journal" 170 - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 171 - output_path = ( 172 - journal / "chronicle/20240115/default/120000_300/talents/entities.json" 173 - ) 174 - result = json.dumps( 175 - { 176 - "entities": [ 177 - { 178 - "type": "Project", 179 - "name": "Zephyr Quartz Index", 180 - "description": "unique freshness seed", 181 - } 182 - ] 183 - } 184 - ) 185 - 186 - post_process(result, {"output_path": str(output_path)}) 187 - 188 - total, results = search_journal("Zephyr Quartz Index") 189 - assert total >= 1 190 - assert any( 191 - result["metadata"]["path"] 192 - == "20240115/default/120000_300/talents/entities.jsonl" 193 - for result in results 194 - ) 195 - 196 - 197 - def test_entities_talent_loads_schema(): 198 - from solstone.think.talent import get_talent 199 - 200 - config = get_talent("entities") 201 - 202 - assert config.get("output") == "json" 203 - assert "json_schema" in config 204 - 205 - 206 - def test_entities_talent_is_segment_scheduled(): 207 - from solstone.think.talent import get_talent_configs 208 - 209 - segment_prompts = get_talent_configs(schedule="segment") 210 - 211 - assert "entities" in segment_prompts
+47 -23
tests/test_formatters.py
··· 84 84 formatter = get_formatter("random/path/unknown.jsonl") 85 85 assert formatter is None 86 86 87 - def test_get_formatter_segment_entities_jsonl(self): 88 - """Segment entities JSONL uses the dedicated formatter.""" 87 + def test_get_formatter_segment_sense_json(self): 88 + """Segment sense JSON uses the dedicated formatter.""" 89 89 from solstone.think.formatters import get_formatter 90 90 91 - formatter = get_formatter("20240101/default/120000_300/talents/entities.jsonl") 91 + formatter = get_formatter("20240101/default/120000_300/talents/sense.json") 92 92 93 93 assert formatter is not None 94 - assert formatter.__name__ == "format_segment_entities" 94 + assert formatter.__name__ == "format_sense" 95 95 96 96 def test_no_spans_formatter_registered(self): 97 97 """Spans JSONL is no longer registered after the story refactor.""" ··· 581 581 assert "Friend from work" in chunks[0]["markdown"] 582 582 assert "Company: Acme Corp" in chunks[1]["markdown"] 583 583 584 - def test_format_segment_entities_direct(self): 585 - """Test canonical segment entities formatting.""" 586 - from solstone.think.entities.formatting import format_segment_entities 584 + def test_format_sense_direct(self): 585 + """Test canonical segment sense formatting.""" 586 + from solstone.think.entities.formatting import format_sense 587 587 588 - entries = [ 589 - { 590 - "type": "Person", 591 - "name": "Alice Smith", 592 - "description": "In the meeting", 593 - }, 594 - {"type": "Person", "name": "Bob", "description": ""}, 595 - {"type": "Tool", "name": " ", "description": "Skipped"}, 596 - ] 588 + sense_obj = { 589 + "density": "active", 590 + "content_type": "meeting", 591 + "activity_summary": "Reviewed the launch timeline with product leads.", 592 + "entities": [ 593 + { 594 + "type": "Person", 595 + "name": "Alice Smith", 596 + "role": "attendee", 597 + "source": "voice", 598 + "context": "Owned the launch checklist.", 599 + }, 600 + { 601 + "type": "Tool", 602 + "name": "Grafana", 603 + "role": "mentioned", 604 + "source": "screen", 605 + "context": "Displayed dashboard latency.", 606 + }, 607 + ], 608 + "facets": [ 609 + {"facet": "work", "activity": "launch planning", "level": "high"} 610 + ], 611 + "speculative_facet": None, 612 + "meeting_detected": True, 613 + "speakers": ["Alice Smith", "Bob Chen"], 614 + "recommend": {"screen_record": True, "speaker_attribution": True}, 615 + "emotional_register": "collaborative", 616 + } 597 617 598 - chunks, meta = format_segment_entities(entries) 618 + chunks, meta = format_sense([sense_obj]) 599 619 600 - assert [chunk["markdown"] for chunk in chunks] == [ 601 - "Person: Alice Smith — In the meeting", 602 - "Person: Bob", 603 - ] 620 + assert len(chunks) == 1 621 + markdown = chunks[0]["markdown"] 622 + assert "meeting" in markdown 623 + assert "collaborative" in markdown 624 + assert "Reviewed the launch timeline with product leads." in markdown 625 + assert "Person: Alice Smith — Owned the launch checklist." in markdown 626 + assert "work: launch planning (high)" in markdown 627 + assert "**Speakers:** Alice Smith, Bob Chen" in markdown 604 628 assert chunks[0]["timestamp"] == 0 605 - assert chunks[0]["source"] is entries[0] 606 - assert meta["indexer"]["agent"] == "entities" 629 + assert chunks[0]["source"] is sense_obj 630 + assert meta["indexer"]["agent"] == "sense" 607 631 608 632 def test_format_entities_no_description(self): 609 633 """Test that missing description shows placeholder."""
+61 -33
tests/test_journal_index.py
··· 400 400 assert found 401 401 402 402 403 - def test_segment_entities_jsonl_searchable_and_exact(journal_copy): 404 - """Segment entities JSONL is indexed with its dedicated formatter.""" 403 + def test_segment_sense_json_searchable_and_exact(journal_copy): 404 + """Segment sense JSON is indexed with its dedicated formatter.""" 405 + from solstone.think.formatters import find_formattable_files 405 406 from solstone.think.indexer.journal import scan_journal, search_journal 406 407 407 408 day = "20990115" 408 409 segment_dir = journal_copy / "chronicle" / day / "default" / "120000_300" 409 410 talents_dir = segment_dir / "talents" 410 411 talents_dir.mkdir(parents=True, exist_ok=True) 411 - (talents_dir / "entities.jsonl").write_text( 412 + (talents_dir / "sense.json").write_text( 412 413 json.dumps( 413 414 { 414 - "type": "Project", 415 - "name": "Zephyr Quartz Index", 416 - "description": "Unique regression seed phrase", 415 + "density": "active", 416 + "content_type": "coding", 417 + "activity_summary": ( 418 + "Discussed the Monazite Comet Handoff unique regression seed." 419 + ), 420 + "entities": [ 421 + { 422 + "type": "Project", 423 + "name": "Zephyr Quartz Index", 424 + "role": "mentioned", 425 + "source": "screen", 426 + "context": "Anchored the Monazite Comet Handoff.", 427 + } 428 + ], 429 + "facets": [ 430 + {"facet": "work", "activity": "search indexing", "level": "high"} 431 + ], 432 + "speculative_facet": None, 433 + "meeting_detected": True, 434 + "speakers": ["Alice Smith", "Bob Chen"], 435 + "recommend": {"screen_record": True, "speaker_attribution": True}, 436 + "emotional_register": "focused", 417 437 } 418 - ) 419 - + "\n", 438 + ), 420 439 encoding="utf-8", 421 440 ) 422 - (talents_dir / "entities.json").write_text( 423 - json.dumps({"raw": "raw model output should not be indexed"}), 424 - encoding="utf-8", 425 - ) 441 + rel_path = f"{day}/default/120000_300/talents/sense.json" 442 + 443 + formattable = find_formattable_files(str(journal_copy)) 444 + assert rel_path in formattable 426 445 427 446 scan_journal(str(journal_copy), full=True) 428 447 429 448 total, name_results = search_journal("Zephyr Quartz Index") 430 449 assert total >= 1 431 - assert any( 432 - result["metadata"]["path"] == f"{day}/default/120000_300/talents/entities.jsonl" 433 - for result in name_results 434 - ) 450 + assert any(result["metadata"]["path"] == rel_path for result in name_results) 435 451 436 - total, description_results = search_journal("regression seed") 452 + total, description_results = search_journal("Monazite Comet Handoff") 437 453 assert total >= 1 438 - expected = "Project: Zephyr Quartz Index — Unique regression seed phrase" 439 454 matching = [ 440 455 result 441 456 for result in description_results 442 - if result["metadata"]["path"] 443 - == f"{day}/default/120000_300/talents/entities.jsonl" 457 + if result["metadata"]["path"] == rel_path 444 458 ] 445 459 assert matching 446 - assert any(result["text"] == expected for result in matching) 447 - assert all(result["metadata"]["agent"] == "entities" for result in matching) 448 - 449 - conn, _ = get_journal_index(str(journal_copy)) 450 - try: 451 - count = conn.execute( 452 - "SELECT COUNT(*) FROM chunks WHERE path LIKE '%talents/entities.json'" 453 - ).fetchone()[0] 454 - finally: 455 - conn.close() 456 - assert count == 0 460 + assert any("Monazite Comet Handoff" in result["text"] for result in matching) 461 + assert all(result["metadata"]["agent"] == "sense" for result in matching) 457 462 458 463 459 464 def test_search_journal_events(journal_fixture): ··· 1587 1592 journal_path / "chronicle" / today / "default" / "120000_300" / "talents" 1588 1593 ) 1589 1594 segment_dir.mkdir(parents=True) 1590 - (segment_dir / "entities.jsonl").write_text( 1591 - '{"name":"Zephyr Quartz Index","type":"Project","description":"Unique regression seed"}\n', 1595 + (segment_dir / "sense.json").write_text( 1596 + json.dumps( 1597 + { 1598 + "density": "active", 1599 + "content_type": "coding", 1600 + "activity_summary": "Unique regression seed for pure indexing.", 1601 + "entities": [ 1602 + { 1603 + "name": "Zephyr Quartz Index", 1604 + "type": "Project", 1605 + "role": "mentioned", 1606 + "source": "screen", 1607 + "context": "Used only for indexer purity coverage.", 1608 + } 1609 + ], 1610 + "facets": [ 1611 + {"facet": "work", "activity": "search indexing", "level": "high"} 1612 + ], 1613 + "speculative_facet": None, 1614 + "meeting_detected": False, 1615 + "speakers": [], 1616 + "recommend": {"screen_record": False, "speaker_attribution": False}, 1617 + "emotional_register": "focused", 1618 + } 1619 + ), 1592 1620 encoding="utf-8", 1593 1621 ) 1594 1622
+36 -46
tests/test_segment_completion.py
··· 167 167 if density != "idle": 168 168 events.extend( 169 169 [ 170 - _dispatch(segment, "entities", 13, stream=stream), 171 - _complete(segment, "entities", 14, stream=stream), 172 - _dispatch(segment, "documents", 15, stream=stream), 173 - _complete(segment, "documents", 16, stream=stream), 170 + _dispatch(segment, "documents", 13, stream=stream), 171 + _complete(segment, "documents", 14, stream=stream), 174 172 ] 175 173 ) 176 174 return events ··· 274 272 "001_segment.jsonl", 275 273 [ 276 274 _sense_complete(SEGMENT, "active", 1), 277 - _dispatch(SEGMENT, "entities", 2), 278 - _complete(SEGMENT, "entities", 3), 279 - _skip(SEGMENT, "entities", "not_recommended", 4), 280 - _fail(SEGMENT, "entities", 5), 275 + _dispatch(SEGMENT, "documents", 2), 276 + _complete(SEGMENT, "documents", 3), 277 + _skip(SEGMENT, "documents", "not_recommended", 4), 278 + _fail(SEGMENT, "documents", 5), 281 279 _sense_complete(SEGMENT_B, "active", 1), 282 - _dispatch(SEGMENT_B, "entities", 2), 283 - _complete(SEGMENT_B, "entities", 3), 280 + _dispatch(SEGMENT_B, "documents", 2), 281 + _complete(SEGMENT_B, "documents", 3), 284 282 ], 285 283 ) 286 284 ··· 288 286 289 287 assert progress[(None, SEGMENT)].sensed is True 290 288 assert progress[(None, SEGMENT)].density == "active" 291 - assert "entities" not in progress[(None, SEGMENT)].completed 292 - assert progress[(None, SEGMENT)].dispatched == frozenset({"entities"}) 293 - assert progress[(None, SEGMENT_B)].completed == frozenset({"entities"}) 289 + assert "documents" not in progress[(None, SEGMENT)].completed 290 + assert progress[(None, SEGMENT)].dispatched == frozenset({"documents"}) 291 + assert progress[(None, SEGMENT_B)].completed == frozenset({"documents"}) 294 292 295 293 296 294 def test_read_segment_progress_tracks_latest_sense_density(segment_journal): ··· 343 341 _dispatch(SEGMENT, "sense", 20, stream="beta"), 344 342 _complete(SEGMENT, "sense", 21, stream="beta"), 345 343 _sense_complete(SEGMENT, "active", 22, stream="beta"), 346 - _dispatch(SEGMENT, "entities", 23, stream="beta"), 347 - _complete(SEGMENT, "entities", 24, stream="beta"), 348 344 ], 349 345 ) 350 346 ··· 352 348 353 349 assert ("alpha", SEGMENT) in progress 354 350 assert ("beta", SEGMENT) in progress 355 - assert progress[("alpha", SEGMENT)].completed == frozenset( 356 - {"sense", "entities", "documents"} 357 - ) 358 - assert progress[("beta", SEGMENT)].completed == frozenset({"sense", "entities"}) 351 + assert progress[("alpha", SEGMENT)].completed == frozenset({"sense", "documents"}) 352 + assert progress[("beta", SEGMENT)].completed == frozenset({"sense"}) 359 353 assert segment_fully_thought( 360 354 lookup_segment_progress(progress, "alpha", SEGMENT) 361 355 ) == (True, None) ··· 387 381 ) == (True, None) 388 382 assert segment_fully_thought( 389 383 lookup_segment_progress(progress, "beta", SEGMENT) 390 - ) == (False, "floor:entities") 384 + ) == (False, "floor:documents") 391 385 392 386 completion = classify_segment_completion(cluster_segments(day), progress) 393 387 assert completion.not_thought == 1 ··· 403 397 "001_segment.jsonl", 404 398 [ 405 399 _sense_complete(SEGMENT, "active", 1, stream="gamma"), 406 - _complete(SEGMENT, "documents", 2, stream="gamma"), 407 - _skip(SEGMENT, "entities", "no_config", 3, stream="gamma"), 400 + _skip(SEGMENT, "documents", "no_config", 3, stream="gamma"), 408 401 ], 409 402 ) 410 403 411 404 progress = read_segment_progress(day) 412 405 413 - assert progress[("gamma", SEGMENT)].unconfigured == frozenset({"entities"}) 406 + assert progress[("gamma", SEGMENT)].unconfigured == frozenset({"documents"}) 414 407 assert segment_fully_thought( 415 408 lookup_segment_progress(progress, "gamma", SEGMENT) 416 409 ) == (True, None) ··· 539 532 unconfigured=frozenset(), 540 533 ) 541 534 542 - assert segment_fully_thought(progress) == (False, "floor:entities") 535 + assert segment_fully_thought(progress) == (False, "floor:documents") 543 536 544 537 545 538 def test_segment_floor_and_nongating_talents_are_disjoint(): ··· 568 561 sensed=True, 569 562 density="active", 570 563 change_class=None, 571 - dispatched=frozenset({"sense", "entities", "documents"}), 572 - completed=frozenset({"sense", "entities", "documents"}), 564 + dispatched=frozenset({"sense", "documents"}), 565 + completed=frozenset({"sense", "documents"}), 573 566 unconfigured=frozenset(), 574 567 ) 575 568 ··· 581 574 sensed=True, 582 575 density="active", 583 576 change_class=None, 584 - dispatched=frozenset({"sense", "entities", "documents"}), 585 - completed=frozenset({"sense", "entities", "documents"}), 577 + dispatched=frozenset({"sense", "documents"}), 578 + completed=frozenset({"sense", "documents"}), 586 579 unconfigured=frozenset(), 587 580 ) 588 581 ··· 594 587 sensed=True, 595 588 density="active", 596 589 change_class=None, 597 - dispatched=frozenset({"sense", "documents"}), 598 - completed=frozenset({"sense", "documents"}), 599 - unconfigured=frozenset({"entities"}), 590 + dispatched=frozenset({"sense"}), 591 + completed=frozenset({"sense"}), 592 + unconfigured=frozenset({"documents"}), 600 593 ) 601 594 602 595 assert segment_fully_thought(progress) == (True, None) ··· 607 600 sensed=True, 608 601 density="active", 609 602 change_class=None, 610 - dispatched=frozenset({"sense", "entities", "documents", "screen"}), 611 - completed=frozenset({"sense", "entities", "documents"}), 603 + dispatched=frozenset({"sense", "documents", "screen"}), 604 + completed=frozenset({"sense", "documents"}), 612 605 unconfigured=frozenset(), 613 606 ) 614 607 ··· 620 613 sensed=True, 621 614 density="active", 622 615 change_class=None, 623 - dispatched=frozenset({"sense", "entities", "documents", "entities:detection"}), 624 - completed=frozenset({"sense", "entities", "documents"}), 616 + dispatched=frozenset({"sense", "documents", "entities:detection"}), 617 + completed=frozenset({"sense", "documents"}), 625 618 unconfigured=frozenset(), 626 619 ) 627 620 ··· 655 648 { 656 649 "segment": SEGMENT_C, 657 650 "dimension": "not_thought", 658 - "detail": "floor:entities", 651 + "detail": "floor:documents", 659 652 }, 660 653 { 661 654 "segment": SEGMENT_D, ··· 694 687 "001_segment.jsonl", 695 688 [ 696 689 _sense_complete(fail_then_complete, "active", 1), 697 - _complete(fail_then_complete, "documents", 4), 698 - _dispatch(fail_then_complete, "entities", 4), 699 - _fail(fail_then_complete, "entities", 5), 700 - _complete(fail_then_complete, "entities", 6), 690 + _dispatch(fail_then_complete, "documents", 4), 691 + _fail(fail_then_complete, "documents", 5), 692 + _complete(fail_then_complete, "documents", 6), 701 693 _sense_complete(complete_then_fail, "active", 1), 702 - _complete(complete_then_fail, "documents", 4), 703 - _dispatch(complete_then_fail, "entities", 4), 704 - _complete(complete_then_fail, "entities", 5), 705 - _fail(complete_then_fail, "entities", 6), 694 + _dispatch(complete_then_fail, "documents", 4), 695 + _complete(complete_then_fail, "documents", 5), 696 + _fail(complete_then_fail, "documents", 6), 706 697 ], 707 698 ) 708 699 ··· 716 707 { 717 708 "segment": complete_then_fail, 718 709 "dimension": "not_thought", 719 - "detail": "floor:entities", 710 + "detail": "floor:documents", 720 711 } 721 712 ] 722 713 ··· 934 925 "002_segment.jsonl", 935 926 _complete_segment_events(SEGMENT) 936 927 + [ 937 - _skip(SEGMENT, "entities", "already_complete", 30), 938 928 _skip(SEGMENT, "documents", "already_complete", 31), 939 929 ], 940 930 )
+22 -35
tests/test_think_segment.py
··· 31 31 "output": "json", 32 32 "schedule": "segment", 33 33 }, 34 - "entities": { 35 - "priority": 20, 36 - "type": "cogitate", 37 - "schedule": "segment", 38 - }, 39 34 "documents": { 40 35 "priority": 20, 41 36 "type": "cogitate", ··· 167 162 "get_talent_configs", 168 163 lambda schedule=None, **kwargs: _segment_configs( 169 164 "sense", 170 - "entities", 171 165 "documents", 172 166 "timeline:segment_summary", 173 167 "screen", ··· 258 252 monkeypatch.setattr( 259 253 think, 260 254 "get_talent_configs", 261 - lambda schedule=None, **kwargs: _segment_configs("sense", "entities"), 255 + lambda schedule=None, **kwargs: _segment_configs("sense", "documents"), 262 256 ) 263 257 monkeypatch.setattr( 264 258 think, ··· 280 274 stream="default", 281 275 ) 282 276 283 - assert spawned == ["sense", "entities"] 277 + assert spawned == ["sense", "documents"] 284 278 assert success == 2 285 279 assert failed == 0 286 280 assert failed_names == [] ··· 316 310 think, 317 311 "get_talent_configs", 318 312 lambda schedule=None, **kwargs: _segment_configs( 319 - "sense", "entities", "screen" 313 + "sense", "documents", "screen" 320 314 ), 321 315 ) 322 316 monkeypatch.setattr( ··· 403 397 think, 404 398 "get_talent_configs", 405 399 lambda schedule=None, **kwargs: _segment_configs( 406 - "sense", "entities", "screen" 400 + "sense", "documents", "screen" 407 401 ), 408 402 ) 409 403 monkeypatch.setattr( ··· 466 460 think, 467 461 "get_talent_configs", 468 462 lambda schedule=None, **kwargs: _segment_configs( 469 - "sense", "entities", "screen" 463 + "sense", "documents", "screen" 470 464 ), 471 465 ) 472 466 monkeypatch.setattr( ··· 489 483 stream="default", 490 484 ) 491 485 492 - assert spawned == ["sense", "entities", "screen"] 486 + assert spawned == ["sense", "documents", "screen"] 493 487 494 488 @pytest.mark.parametrize("live", [False, True]) 495 489 def test_entities_detection_dispatches_in_stable_order( ··· 508 502 "get_talent_configs", 509 503 lambda schedule=None, **kwargs: _segment_configs( 510 504 "sense", 511 - "entities", 512 505 "documents", 513 506 "timeline:segment_summary", 514 507 "entities:detection", ··· 537 530 538 531 assert spawned == [ 539 532 "sense", 540 - "entities", 541 533 "documents", 542 534 "timeline:segment_summary", 543 535 "entities:detection", ··· 546 538 @pytest.mark.parametrize( 547 539 ("has_embeddings", "expected"), 548 540 [ 549 - (False, ["sense", "entities"]), 550 - (True, ["sense", "entities", "speaker_attribution"]), 541 + (False, ["sense", "documents"]), 542 + (True, ["sense", "documents", "speaker_attribution"]), 551 543 ], 552 544 ) 553 545 def test_conditional_speaker_attribution( ··· 577 569 "get_talent_configs", 578 570 lambda schedule=None, **kwargs: _segment_configs( 579 571 "sense", 580 - "entities", 572 + "documents", 581 573 "speaker_attribution", 582 574 ), 583 575 ) ··· 627 619 "get_talent_configs", 628 620 lambda schedule=None, **kwargs: _segment_configs( 629 621 "sense", 630 - "entities", 631 622 "documents", 632 623 "timeline:segment_summary", 633 624 "screen", ··· 656 647 657 648 assert spawned == [ 658 649 "sense", 659 - "entities", 660 650 "documents", 661 651 "timeline:segment_summary", 662 652 "screen", ··· 717 707 718 708 assert spawned == [ 719 709 "sense", 720 - "entities", 721 710 "documents", 722 711 "timeline:segment_summary", 723 712 "screen", ··· 769 758 770 759 assert spawned == [ 771 760 "sense", 772 - "entities", 773 761 "documents", 774 762 "timeline:segment_summary", 775 763 "screen", ··· 801 789 802 790 assert spawned == [ 803 791 "sense", 804 - "entities", 805 792 "documents", 806 793 "timeline:segment_summary", 807 794 "screen", ··· 923 910 monkeypatch.setattr( 924 911 think, 925 912 "get_talent_configs", 926 - lambda schedule=None, **kwargs: _segment_configs("sense", "entities"), 913 + lambda schedule=None, **kwargs: _segment_configs("sense", "documents"), 927 914 ) 928 915 monkeypatch.setattr( 929 916 think, ··· 945 932 stream="default", 946 933 ) 947 934 948 - assert spawned == ["sense", "entities"] 935 + assert spawned == ["sense", "documents"] 949 936 assert success == 2 950 937 assert failed == 0 951 938 assert failed_names == [] 952 939 953 - def test_entities_always_runs(self, segment_dir, monkeypatch): 940 + def test_documents_always_runs(self, segment_dir, monkeypatch): 954 941 from solstone.think import thinking as think 955 942 956 943 spawned = [] ··· 963 950 think, 964 951 "get_talent_configs", 965 952 lambda schedule=None, **kwargs: _segment_configs( 966 - "sense", "entities", "screen" 953 + "sense", "documents", "screen" 967 954 ), 968 955 ) 969 956 monkeypatch.setattr( ··· 986 973 stream="default", 987 974 ) 988 975 989 - assert "entities" in spawned 976 + assert "documents" in spawned 990 977 assert "screen" not in spawned 991 978 992 979 def test_segment_summary_dispatched_for_non_idle(self, segment_dir, monkeypatch): ··· 1002 989 think, 1003 990 "get_talent_configs", 1004 991 lambda schedule=None, **kwargs: _segment_configs( 1005 - "sense", "entities", "timeline:segment_summary" 992 + "sense", "documents", "timeline:segment_summary" 1006 993 ), 1007 994 ) 1008 995 monkeypatch.setattr( ··· 1040 1027 think, 1041 1028 "get_talent_configs", 1042 1029 lambda schedule=None, **kwargs: _segment_configs( 1043 - "sense", "entities", "timeline:segment_summary" 1030 + "sense", "documents", "timeline:segment_summary" 1044 1031 ), 1045 1032 ) 1046 1033 monkeypatch.setattr( ··· 1077 1064 monkeypatch.setattr( 1078 1065 think, 1079 1066 "get_talent_configs", 1080 - lambda schedule=None, **kwargs: _segment_configs("sense", "entities"), 1067 + lambda schedule=None, **kwargs: _segment_configs("sense"), 1081 1068 ) 1082 1069 monkeypatch.setattr( 1083 1070 think, ··· 1152 1139 monkeypatch.setattr( 1153 1140 think, 1154 1141 "get_talent_configs", 1155 - lambda schedule=None, **kwargs: _segment_configs("sense", "entities"), 1142 + lambda schedule=None, **kwargs: _segment_configs("sense", "documents"), 1156 1143 ) 1157 1144 monkeypatch.setattr( 1158 1145 think, ··· 1281 1268 monkeypatch.setattr( 1282 1269 think, 1283 1270 "get_talent_configs", 1284 - lambda schedule=None, **kwargs: _segment_configs("sense", "entities"), 1271 + lambda schedule=None, **kwargs: _segment_configs("sense", "documents"), 1285 1272 ) 1286 1273 monkeypatch.setattr(think, "cortex_request", mock_cortex_request) 1287 1274 monkeypatch.setattr(think, "_SEND_RETRY_DELAYS", (0.0, 0.0)) ··· 1301 1288 ) 1302 1289 1303 1290 assert calls[0] == "sense" 1304 - assert calls[1:] == ["entities", "entities", "entities"] 1291 + assert calls[1:] == ["documents", "documents", "documents"] 1305 1292 assert success == 1 1306 1293 assert failed == 1 1307 - assert failed_names == ["entities (send)"] 1294 + assert failed_names == ["documents (send)"] 1308 1295 1309 1296 1310 1297 class TestCortexRequestRetry: ··· 1631 1618 monkeypatch.setattr( 1632 1619 think, 1633 1620 "get_talent_configs", 1634 - lambda schedule=None, **kwargs: _segment_configs("sense", "entities"), 1621 + lambda schedule=None, **kwargs: _segment_configs("sense"), 1635 1622 ) 1636 1623 monkeypatch.setattr( 1637 1624 think,
+14 -26
tests/test_think_skip_talents.py
··· 35 35 "output": "json", 36 36 "schedule": "segment", 37 37 }, 38 - "entities": { 39 - "priority": 20, 40 - "type": "cogitate", 41 - "schedule": "segment", 42 - }, 43 38 "documents": { 44 39 "priority": 20, 45 40 "type": "cogitate", ··· 63 58 def _all_segment_configs() -> dict[str, dict]: 64 59 return _segment_configs( 65 60 "sense", 66 - "entities", 67 61 "documents", 68 62 "screen", 69 63 "speaker_attribution", ··· 382 376 from solstone.think.thinking import ThinkingJSONLWriter 383 377 384 378 spawned: list[str] = [] 385 - jsonl_path = segment_dir.parent.parent / "health" / "test_skip_entities.jsonl" 379 + jsonl_path = segment_dir.parent.parent / "health" / "test_skip_documents.jsonl" 386 380 writer = ThinkingJSONLWriter(str(jsonl_path)) 387 381 _write_sense_output(segment_dir, _active_sense_output()) 388 382 (segment_dir / "audio.npz").touch() ··· 395 389 refresh=False, 396 390 verbose=False, 397 391 stream=STREAM, 398 - skip_talents=frozenset({"entities"}), 392 + skip_talents=frozenset({"documents"}), 399 393 ) 400 394 writer.close() 401 395 monkeypatch.setattr(think, "_jsonl", None) 402 396 403 397 assert spawned == [ 404 398 "sense", 405 - "documents", 406 399 "screen", 407 400 "speaker_attribution", 408 401 ] 409 - assert success == 4 402 + assert success == 3 410 403 assert failed == 0 411 404 assert failed_names == [] 412 405 413 406 skip_events = _skip_events(_read_events(jsonl_path)) 414 407 assert len(skip_events) == 1 415 - assert skip_events[0]["name"] == "entities" 408 + assert skip_events[0]["name"] == "documents" 416 409 417 410 418 411 def test_recommended_talent_skip_does_not_dispatch_or_fail( ··· 444 437 assert "screen" not in spawned 445 438 assert spawned == [ 446 439 "sense", 447 - "entities", 448 440 "documents", 449 441 "speaker_attribution", 450 442 ] 451 - assert success == 4 443 + assert success == 3 452 444 assert failed == 0 453 445 assert failed_names == [] 454 446 ··· 489 481 assert "screen" not in spawned 490 482 assert spawned == [ 491 483 "sense", 492 - "entities", 493 484 "documents", 494 485 "speaker_attribution", 495 486 ] 496 - assert success == 4 487 + assert success == 3 497 488 assert failed == 0 498 489 assert failed_names == [] 499 490 ··· 542 533 543 534 assert spawned == [ 544 535 "sense", 545 - "entities", 546 536 "documents", 547 537 "screen", 548 538 "speaker_attribution", 549 539 ] 550 - assert success == 5 540 + assert success == 4 551 541 assert failed == 0 552 542 assert failed_names == [] 553 543 assert _skip_events(_read_events(jsonl_path), reason="new_only_historical") == [] ··· 585 575 monkeypatch.setattr(think, "_jsonl", None) 586 576 587 577 assert "screen" not in spawned 588 - assert success == 4 578 + assert success == 3 589 579 assert failed == 0 590 580 assert failed_names == [] 591 581 ··· 624 614 625 615 assert "sense" not in spawned 626 616 assert spawned == [ 627 - "entities", 628 617 "documents", 629 618 "screen", 630 619 "speaker_attribution", 631 620 ] 632 - assert success == 4 621 + assert success == 3 633 622 assert failed == 0 634 623 assert failed_names == [] 635 624 ··· 663 652 stream=STREAM, 664 653 state_machine=EndedActivityStateMachine(segment_dir.parents[3]), 665 654 skip_activity_prompts=True, 666 - skip_talents=frozenset({"entities"}), 655 + skip_talents=frozenset({"documents"}), 667 656 ) 668 657 writer.close() 669 658 monkeypatch.setattr(think, "_jsonl", None) 670 659 671 - assert "entities" not in spawned 660 + assert "documents" not in spawned 672 661 assert len(append_calls) >= 1 673 662 assert activity_calls == [] 674 - assert success == 4 663 + assert success == 3 675 664 assert failed == 0 676 665 assert failed_names == [] 677 666 678 667 events = _read_events(jsonl_path) 679 - assert [event["name"] for event in _skip_events(events)] == ["entities"] 668 + assert [event["name"] for event in _skip_events(events)] == ["documents"] 680 669 assert any( 681 670 event["event"] == "activity.prompts_skipped" 682 671 and event["activity"] == ACTIVITY_ID ··· 713 702 714 703 assert spawned == [ 715 704 "sense", 716 - "entities", 717 705 "documents", 718 706 "screen", 719 707 "speaker_attribution", 720 708 ] 721 - assert success == 5 709 + assert success == 4 722 710 assert failed == 0 723 711 assert failed_names == [] 724 712 assert _skip_events(_read_events(jsonl_path)) == []