diff --git a/apps/home/routes.py b/apps/home/routes.py index f2c83bfee..78e8853cf 100644 --- a/apps/home/routes.py +++ b/apps/home/routes.py @@ -19,6 +19,7 @@ from flask import Blueprint, jsonify, render_template from convey.apps import _resolve_attention from convey.bridge import get_cached_state +from think import skills as think_skills from think.awareness import get_current from think.capture_health import get_capture_health from think.facets import get_enabled_facets, get_facets @@ -1112,91 +1113,105 @@ def _collect_routines() -> list[dict[str, Any]]: def _collect_skills() -> list[dict[str, Any]]: - """Collect mature skills from all enabled facets.""" + """Collect owner-wide skill profiles for the Pulse view.""" try: - journal = Path(get_journal()) state = _load_skills_state() last_seen = state.get("skills_last_seen") last_seen_dt = datetime.fromisoformat(last_seen) if last_seen else None - skills = [] - for facet_name in get_enabled_facets(): - skills_dir = journal / "facets" / facet_name / "skills" - if not skills_dir.exists(): - continue - - # Read patterns.jsonl for mature skills (skill_generated: true) - patterns_path = skills_dir / "patterns.jsonl" - if not patterns_path.exists(): - continue - - mature_ids = set() + skills: list[dict[str, Any]] = [] + for pattern in think_skills.load_patterns(): try: - with open(patterns_path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - pattern = json.loads(line) - if pattern.get("skill_generated"): - mature_ids.add(pattern.get("id", "")) - except json.JSONDecodeError: - continue - except OSError: - continue + slug = str(pattern.get("slug") or "").strip() + status = str(pattern.get("status") or "").strip() or "emerging" + if not slug or status == "retired": + continue - for skill_id in sorted(mature_ids): - skill_path = skills_dir / f"{skill_id}.md" - if not skill_path.exists(): + profile_markdown = think_skills.load_profile(slug) + if profile_markdown is None: continue + + post = frontmatter.loads(profile_markdown) + meta = post.metadata + observations = [ + observation + for observation in pattern.get("observations", []) + if isinstance(observation, dict) + ] + observation_times = sorted( + str(observation.get("recorded_at") or observation.get("day") or "") + for observation in observations + if observation.get("recorded_at") or observation.get("day") + ) + facets = sorted( + { + str(item).strip() + for item in pattern.get("facets_touched", []) + if str(item).strip() + } + or { + str(observation.get("facet") or "").strip() + for observation in observations + if str(observation.get("facet") or "").strip() + } + ) + confidence = meta.get("confidence", 0.0) + if isinstance(confidence, (int, float)) and not isinstance( + confidence, bool + ): + confidence_value = float(confidence) + else: + confidence_value = 0.0 + try: - post = frontmatter.load(str(skill_path)) - meta = post.metadata - - summary = meta.get("activity_type", "") - typical_time = meta.get("typical_time", "") - if typical_time: - summary = ( - f"{summary} · {typical_time}" if summary else typical_time - ) - - observations = meta.get("observations", 0) - last_seen_str = meta.get("last_seen", "") - - try: - mtime_dt = datetime.fromtimestamp(skill_path.stat().st_mtime) - except OSError: - mtime_dt = None - - seen = ( - last_seen_dt is not None - and mtime_dt is not None - and mtime_dt <= last_seen_dt + profile_mtime = datetime.fromtimestamp( + think_skills.profile_path(slug).stat().st_mtime ) + except OSError: + profile_mtime = None - skills.append( - { - "id": skill_id, - "name": meta.get("name", skill_id), - "facet": facet_name, - "summary": summary, - "observations": observations, - "last_seen": last_seen_str, - "content": post.content, - "seen": seen, - } - ) - except Exception: - logger.warning( - "home: failed to load skill %s/%s", - facet_name, - skill_id, - exc_info=True, - ) - continue + seen = ( + last_seen_dt is not None + and profile_mtime is not None + and profile_mtime <= last_seen_dt + ) - skills.sort(key=lambda s: s.get("last_seen", ""), reverse=True) + skills.append( + { + "id": slug, + "slug": slug, + "name": ( + str(meta.get("display_name") or "").strip() + or str(pattern.get("name") or "").strip() + or slug + ), + "description": str(meta.get("description") or "").strip(), + "category": str(meta.get("category") or "").strip(), + "confidence": confidence_value, + "status": status, + "facets": facets, + "observations": len(observations), + "first_seen": observation_times[0] if observation_times else "", + "last_seen": observation_times[-1] if observation_times else "", + "content": post.content, + "seen": seen, + } + ) + except Exception: + logger.warning( + "home: failed to load skill %s", + pattern.get("slug"), + exc_info=True, + ) + continue + + skills.sort( + key=lambda skill: ( + float(skill.get("confidence") or 0.0), + str(skill.get("last_seen") or ""), + ), + reverse=True, + ) return skills except Exception: logger.warning("home: failed to collect skills", exc_info=True) diff --git a/apps/home/workspace.html b/apps/home/workspace.html index 3c1f944a1..39b5d51ad 100644 --- a/apps/home/workspace.html +++ b/apps/home/workspace.html @@ -1172,11 +1172,11 @@ {% for skill in skills %}
- {{ skill.name }} + {{ skill.name }}{{ ' (dormant)' if skill.status == 'dormant' else '' }} {{ skill.observations }} obs - {{ skill.facet }} - {% if skill.summary %} - — {{ skill.summary }} + {{ skill.facets[0] if skill.facets else '' }} + {% if skill.description %} + — {{ skill.description }} {% endif %}
@@ -1692,13 +1692,14 @@ html += '
'; html += '
'; skills.forEach(function(s) { - var name = esc(s.name); + var name = esc(s.name + (s.status === 'dormant' ? ' (dormant)' : '')); + var facet = esc((s.facets && s.facets.length > 0) ? s.facets[0] : ''); var newClass = s.seen ? '' : ' is-new'; html += '
'; html += '' + name + ' '; html += '' + (s.observations || 0) + ' obs '; - html += '' + esc(s.facet) + ''; - if (s.summary) html += ' — ' + esc(s.summary) + ''; + html += '' + facet + ''; + if (s.description) html += ' — ' + esc(s.description) + ''; html += '
'; html += ''; }); diff --git a/tests/test_home_skills.py b/tests/test_home_skills.py index 855d63363..205d583c8 100644 --- a/tests/test_home_skills.py +++ b/tests/test_home_skills.py @@ -3,6 +3,8 @@ """Tests for home pulse skill surfacing.""" +from __future__ import annotations + import json from datetime import datetime, timedelta @@ -26,158 +28,184 @@ def home_client(): return app.test_client() -def _write_skill_fixtures(tmp_path, facet_name, patterns, skill_files): - """Write patterns.jsonl and skill .md files for a facet. - - Parameters - ---------- - patterns : list[dict] - Each dict is one line in patterns.jsonl. - skill_files : dict[str, str] - Mapping of {slug: markdown_content} for skill files. - """ - skills_dir = tmp_path / "facets" / facet_name / "skills" +def _write_skill_fixtures(tmp_path, patterns, skill_files): + """Write owner-wide skill patterns and markdown profiles.""" + skills_dir = tmp_path / "skills" skills_dir.mkdir(parents=True, exist_ok=True) + lines = [json.dumps(pattern) for pattern in patterns] + (skills_dir / "patterns.jsonl").write_text( + "\n".join(lines) + ("\n" if lines else ""), + encoding="utf-8", + ) + for slug, content in skill_files.items(): + (skills_dir / f"{slug}.md").write_text(content, encoding="utf-8") - # Write facet.json so get_enabled_facets() finds this facet - facet_dir = tmp_path / "facets" / facet_name - (facet_dir / "facet.json").write_text( - json.dumps( + +def _pattern( + *, + slug: str, + name: str, + status: str = "mature", + observations: list[dict] | None = None, +) -> dict: + rows = observations or [ + { + "day": "2026-04-10", + "facet": "work", + "activity_ids": ["act_1"], + "notes": "", + "recorded_at": "2026-04-10T09:15:00Z", + } + ] + return { + "slug": slug, + "name": name, + "status": status, + "observations": rows, + "facets_touched": sorted( { - "title": facet_name.title(), - "description": "", - "color": "#000", - "emoji": "📁", + str(observation.get("facet") or "") + for observation in rows + if observation.get("facet") } ), - encoding="utf-8", - ) - - # Write patterns.jsonl - lines = [json.dumps(p) for p in patterns] - (skills_dir / "patterns.jsonl").write_text( - "\n".join(lines) + "\n", encoding="utf-8" - ) + "first_seen": rows[0]["day"], + "last_seen": rows[-1]["day"], + "needs_profile": False, + "needs_refresh": False, + "profile_generated_at": "2026-04-11T10:00:00Z", + "created_at": "2026-04-10T09:20:00Z", + "updated_at": "2026-04-11T10:00:00Z", + } + + +def _profile_markdown( + *, + name: str, + display_name: str, + description: str, + category: str = "coordination", + confidence: float = 0.7, + body: str = "## Overview\n\nProfile body.", +) -> str: + return f"""--- +name: "{name}" +display_name: "{display_name}" +description: "{description}" +category: "{category}" +confidence: {confidence} +--- - # Write skill markdown files - for slug, content in skill_files.items(): - (skills_dir / f"{slug}.md").write_text(content, encoding="utf-8") +{body} +""" def test_collect_skills_no_facets(monkeypatch, tmp_path): - """No facets directory yields empty skills list.""" + """No owner-wide skills directory yields an empty list.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) assert _collect_skills() == [] -def test_collect_skills_no_skills_dir(monkeypatch, tmp_path): - """Facet exists but has no skills directory.""" +def test_collect_skills_no_patterns(monkeypatch, tmp_path): + """An empty owner-wide skills directory yields an empty list.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) - facet_dir = tmp_path / "facets" / "work" - facet_dir.mkdir(parents=True) - (facet_dir / "facet.json").write_text( - json.dumps( - {"title": "Work", "description": "", "color": "#000", "emoji": "💼"} - ), - encoding="utf-8", - ) + (tmp_path / "skills").mkdir(parents=True) assert _collect_skills() == [] -def test_collect_skills_with_mature_skill(monkeypatch, tmp_path): - """Mature skill (skill_generated: true) is collected with correct fields.""" +def test_collect_skills_with_owner_wide_profile(monkeypatch, tmp_path): + """Pulse collects owner-wide profiles with the new payload shape.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) - skill_md = """--- -name: Morning Standup -activity_type: meeting -facet: work -observations: 5 -first_seen: "2026-03-01T09:00:00" -last_seen: "2026-04-10T09:15:00" -typical_duration: 15m -typical_time: "9:00 AM" -key_entities: - - Engineering Team ---- - -## when this happens - -Daily morning standup with the engineering team. - -## what the owner does - -Gives status updates and listens to blockers. -""" - _write_skill_fixtures( tmp_path, - "work", - [{"id": "morning-standup", "skill_generated": True, "observation_count": 5}], - {"morning-standup": skill_md}, + [ + _pattern( + slug="morning-standup", + name="Standup Pattern", + observations=[ + { + "day": "2026-03-01", + "facet": "work", + "activity_ids": ["act_1"], + "notes": "", + "recorded_at": "2026-03-01T09:00:00Z", + }, + { + "day": "2026-04-10", + "facet": "work", + "activity_ids": ["act_2"], + "notes": "", + "recorded_at": "2026-04-10T09:15:00Z", + }, + ], + ) + ], + { + "morning-standup": _profile_markdown( + name="morning-standup", + display_name="Morning Standup", + description="Daily engineering sync for blockers and updates.", + category="coordination", + confidence=0.9, + body="## when this happens\n\nDaily morning standup with the engineering team.", + ) + }, ) skills = _collect_skills() assert len(skills) == 1 assert skills[0]["id"] == "morning-standup" + assert skills[0]["slug"] == "morning-standup" assert skills[0]["name"] == "Morning Standup" - assert skills[0]["facet"] == "work" - assert skills[0]["observations"] == 5 - assert "meeting" in skills[0]["summary"] - assert "9:00 AM" in skills[0]["summary"] + assert ( + skills[0]["description"] == "Daily engineering sync for blockers and updates." + ) + assert skills[0]["category"] == "coordination" + assert skills[0]["confidence"] == 0.9 + assert skills[0]["status"] == "mature" + assert skills[0]["facets"] == ["work"] + assert skills[0]["observations"] == 2 + assert skills[0]["first_seen"] == "2026-03-01T09:00:00Z" + assert skills[0]["last_seen"] == "2026-04-10T09:15:00Z" assert "when this happens" in skills[0]["content"] assert skills[0]["seen"] is False -def test_collect_skills_immature_excluded(monkeypatch, tmp_path): - """Patterns with skill_generated: false are excluded.""" +def test_collect_skills_hides_pattern_without_profile(monkeypatch, tmp_path): + """Observer-only patterns stay hidden until a profile exists.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) - skill_md = """--- -name: Random Chat -activity_type: conversation -observations: 1 ---- - -Some content. -""" - _write_skill_fixtures( tmp_path, - "work", - [{"id": "random-chat", "skill_generated": False, "observation_count": 1}], - {"random-chat": skill_md}, + [_pattern(slug="random-chat", name="Random Chat")], + {}, ) assert _collect_skills() == [] def test_collect_skills_seen_flag(monkeypatch, tmp_path): - """Skills modified before skills_last_seen are marked seen.""" + """Profiles older than the last seen marker are marked seen.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) - skill_md = """--- -name: Daily Review -activity_type: review -observations: 3 -last_seen: "2026-04-09T17:00:00" ---- - -Review content. -""" - _write_skill_fixtures( tmp_path, - "work", - [{"id": "daily-review", "skill_generated": True, "observation_count": 3}], - {"daily-review": skill_md}, + [_pattern(slug="daily-review", name="Daily Review")], + { + "daily-review": _profile_markdown( + name="daily-review", + display_name="Daily Review", + description="End-of-day review habit.", + body="Review content.", + ) + }, ) - # Mark as seen AFTER the file was written (file mtime < skills_last_seen) _save_skills_state( {"skills_last_seen": (datetime.now() + timedelta(minutes=5)).isoformat()} ) @@ -188,6 +216,113 @@ Review content. assert skills[0]["seen"] is True +def test_collect_skills_shows_dormant(monkeypatch, tmp_path): + """Dormant skills stay visible in Pulse.""" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + _write_skill_fixtures( + tmp_path, + [_pattern(slug="deep-work", name="Deep Work", status="dormant")], + { + "deep-work": _profile_markdown( + name="deep-work", + display_name="Deep Work", + description="Focused solo execution work.", + ) + }, + ) + + skills = _collect_skills() + + assert len(skills) == 1 + assert skills[0]["status"] == "dormant" + + +def test_collect_skills_hides_retired(monkeypatch, tmp_path): + """Retired skills are excluded from Pulse.""" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + _write_skill_fixtures( + tmp_path, + [_pattern(slug="legacy", name="Legacy Skill", status="retired")], + { + "legacy": _profile_markdown( + name="legacy", + display_name="Legacy Skill", + description="Old profile.", + ) + }, + ) + + assert _collect_skills() == [] + + +def test_collect_skills_sorts_by_confidence_then_last_seen(monkeypatch, tmp_path): + """Skills sort by confidence first, then recency.""" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + _write_skill_fixtures( + tmp_path, + [ + _pattern(slug="high-confidence", name="High Confidence"), + _pattern( + slug="recent-mid", + name="Recent Mid", + observations=[ + { + "day": "2026-04-12", + "facet": "work", + "activity_ids": ["act_2"], + "notes": "", + "recorded_at": "2026-04-12T08:00:00Z", + } + ], + ), + _pattern( + slug="older-mid", + name="Older Mid", + observations=[ + { + "day": "2026-04-05", + "facet": "work", + "activity_ids": ["act_3"], + "notes": "", + "recorded_at": "2026-04-05T08:00:00Z", + } + ], + ), + ], + { + "high-confidence": _profile_markdown( + name="high-confidence", + display_name="High Confidence", + description="High confidence skill.", + confidence=0.95, + ), + "recent-mid": _profile_markdown( + name="recent-mid", + display_name="Recent Mid", + description="Recent mid confidence skill.", + confidence=0.6, + ), + "older-mid": _profile_markdown( + name="older-mid", + display_name="Older Mid", + description="Older mid confidence skill.", + confidence=0.6, + ), + }, + ) + + skills = _collect_skills() + + assert [skill["id"] for skill in skills] == [ + "high-confidence", + "recent-mid", + "older-mid", + ] + + def test_api_skills_seen(monkeypatch, tmp_path, home_client): """Seen endpoint persists the skills seen timestamp.""" monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) @@ -223,11 +358,16 @@ def test_api_pulse_includes_skills(monkeypatch, home_client): lambda: [ { "id": "morning-standup", + "slug": "morning-standup", "name": "Morning Standup", - "facet": "work", - "summary": "meeting · 9:00 AM", + "description": "Daily engineering sync for blockers and updates.", + "category": "coordination", + "confidence": 0.9, + "status": "mature", + "facets": ["work"], "observations": 5, - "last_seen": "2026-04-10T09:15:00", + "first_seen": "2026-03-01T09:00:00Z", + "last_seen": "2026-04-10T09:15:00Z", "content": "# Standup\n\nDaily standup.", "seen": False, } @@ -240,6 +380,7 @@ def test_api_pulse_includes_skills(monkeypatch, home_client): data = resp.get_json() assert "skills" in data assert data["skills"][0]["name"] == "Morning Standup" + assert data["skills"][0]["facets"] == ["work"] assert "skills_summary" in data assert "skills_content" in data assert "morning-standup" in data["skills_content"]