diff --git a/CHANGELOG.md b/CHANGELOG.md index f373bd93..2566a714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,38 @@ experience may change before `1.0.0`. ### Fixed +- The web header is one line of controls on the brand's centre line. + **Voice**, **Theme**, **Font** and **Size** each have a visible label, set + above the control and out of the layout, so every control is the same 32px + height and sits on one centre line; the voice picker matches the other + pickers instead of a smaller 12px style. The voice-engine badge ("System + voices", "Kokoro (server)") is a coloured dot and small text beside the Voice + label, yellow when something other than the host's Kokoro is speaking; it + never had a style, so it rendered as large bold text in the middle of the + header. At a 1024px tablet width with the semantic chip showing, the controls + wrap instead of running 75px past the window. +- CSS and JavaScript are revalidated before a browser reuses them + (`Cache-Control: no-cache`, answered with a 304 when unchanged), like the page + itself. They were sent with no Cache-Control, so after an update a browser + could pair the new page with a days-old stylesheet or script. +- `studyloop doctor` no longer gives advice that is wrong or leads nowhere: + - `project_aliases` is no longer called inert. Session search reads it + (`PROJECT_ALIASES.md`), and deleting it as the row advised would have cut + `--project` searches off from every aliased path. + - Grok Build's definition is checked against the repo-root `AGENTS.md` it + shares with Codex, instead of "No manifest entry for grok". + - The xTiles row says whether the wind-down skill is installed and which + harnesses register an `xtiles` MCP server; the offer can only appear in a + session connected to one. + - A stale `export_freshness` row lists every harness's own age, and says so + when kiro-cli's session store (`~/.kiro/sessions/cli/`) has moved on while + Kiro exports have not: kiro-cli 2.x keeps sessions there and the exporter + does not read it yet (#49). + - The Kokoro model-files row appears only for `tts.backend: kokoro`, the one + backend that reads them, and says they download on first use. + - "Obsidian export disabled" says it means the session-memory export + (`obsidian.export_enabled`), which is independent of `second_brain`, and + how to turn it on. - Web (ACP) Kiro sessions run StudyLoop's own `studyloop` agent. They started `kiro-cli acp` with no `--agent`, so each ran under whatever the learner's default Kiro agent was: Kiro's built-in (its own system prompt, the learner's diff --git a/packages/studyloop/src/studyloop/doctor/agents.py b/packages/studyloop/src/studyloop/doctor/agents.py index 92886d00..d0d243fd 100644 --- a/packages/studyloop/src/studyloop/doctor/agents.py +++ b/packages/studyloop/src/studyloop/doctor/agents.py @@ -392,8 +392,21 @@ def check_agent_definitions() -> list[CheckResult]: results: list[CheckResult] = [] manifest_agents = manifest.get("agents", {}) + from studyloop.installers import _TOOL_LINKS + for tool in tools: tool_keys = [k for k in manifest_agents if k.startswith(f"{tool}/")] + if not tool_keys: + # A harness with no definition of its own is checked against the + # manifest entries of the files its installer links. Grok Build reads + # the repo-root AGENTS.md that ``codex/AGENTS.md`` tracks; looking only + # for a ``grok/`` key reported "No manifest entry" and left the file it + # actually reads unchecked. + tool_keys = [ + key + for spec in _TOOL_LINKS.get(tool, ()) + if (key := spec.source.removeprefix("agents/")) in manifest_agents + ] if not tool_keys: results.append( CheckResult( @@ -411,6 +424,7 @@ def check_agent_definitions() -> list[CheckResult]: is_primary = Path(key).name == primary_name check_name = f"agent_{tool}" if is_primary else f"agent_{tool}_{Path(key).stem}" label = tool if is_primary else f"{tool} {Path(key).stem}" + shared = "" if key.startswith(f"{tool}/") else f" (shares {key})" if not install_path.exists(): results.append( @@ -418,7 +432,7 @@ def check_agent_definitions() -> list[CheckResult]: "agents", check_name, "warn", - f"{tool} detected but agent definition not installed", + f"{tool} detected but agent definition not installed{shared}", "studyloop upgrade --component agents", fix_auto=True, ) @@ -433,7 +447,7 @@ def check_agent_definitions() -> list[CheckResult]: "agents", check_name, "pass", - f"{label} agent definition current", + f"{label} agent definition current{shared}", "", False, ) @@ -447,6 +461,7 @@ def check_agent_definitions() -> list[CheckResult]: ( f"{label} agent definition outdated" f" (local={local_hash[:8]}... expected={expected_hash[:8]}...)" + f"{shared}" ), "studyloop upgrade --component agents", fix_auto=True, diff --git a/packages/studyloop/src/studyloop/doctor/config.py b/packages/studyloop/src/studyloop/doctor/config.py index bfb986fd..54842009 100644 --- a/packages/studyloop/src/studyloop/doctor/config.py +++ b/packages/studyloop/src/studyloop/doctor/config.py @@ -176,38 +176,43 @@ def check_pandoc() -> list[CheckResult]: ] +def _obsidian_export_disabled(vault: object, memory_dir: str) -> list[CheckResult]: + """The one "off" row for both ways export can be off. + + "Obsidian export disabled" alone named no reason and no next step, and a + learner on ``second_brain.provider: xtiles`` read it as "off because Obsidian + is not my second brain". The two settings are independent: this export writes + session-memory notes; ``second_brain`` only chooses where study notes go. + """ + target = f"{Path(str(vault)).expanduser()}/{memory_dir}" if str(vault) else memory_dir + return [ + CheckResult( + "config", + "obsidian_export", + "info", + "Obsidian session-memory export disabled: obsidian.export_enabled is not true. " + "It is independent of second_brain, which only chooses where study notes go.", + f"To write session notes into {target}, set export_enabled: true under the " + "obsidian: section of config.yaml", + False, + ) + ] + + def check_obsidian_export() -> list[CheckResult]: """Check Obsidian export configuration and vault writability. If export is enabled, verify that the resolved vault_path/memory_dir is present (or at least that the vault exists). If export is disabled, - return an informational result. + return an informational result that says how to turn it on. """ settings = _load_settings() obsidian = getattr(settings, "obsidian", None) if obsidian is None: - return [ - CheckResult( - "config", - "obsidian_export", - "info", - "Obsidian export disabled", - "", - False, - ) - ] + return _obsidian_export_disabled(getattr(settings, "obsidian_base", ""), "AgentMemory") if not obsidian.export_enabled: - return [ - CheckResult( - "config", - "obsidian_export", - "info", - "Obsidian export disabled", - "", - False, - ) - ] + return _obsidian_export_disabled(obsidian.vault_path, obsidian.memory_dir) # Export is enabled — verify vault_path / memory_dir is accessible. vault_path = Path(obsidian.vault_path).expanduser() @@ -307,13 +312,31 @@ def check_second_brain() -> list[CheckResult]: return [] if config.provider == "xtiles": + # "prompts and an opt-in assistant skill" read as "the skill still needs + # installing". It is installed for every harness by `studyloop install + # agents`; the opt-in is the learner's yes at wind-down. What decides + # whether the offer can ever appear is an MCP server named `xtiles` in + # the session, so the row says where one is registered. + from studyloop import installers + + skill = ( + "The wind-down skill is installed" + if installers.XTILES_SKILL_HUB.exists() + else "The wind-down skill is not installed (studyloop install agents installs it)" + ) + servers = installers.xtiles_mcp_harnesses() + where = ( + f"an xtiles MCP server is registered in: {', '.join(servers)}" + if servers + else "no harness here registers an xtiles MCP server, so the offer cannot appear yet" + ) return [ CheckResult( "config", "second_brain_provider", "info", - "Second brain: xTiles (no programmatic backend; prompts and an " - "opt-in assistant skill)", + f"Second brain: xTiles (no programmatic backend). {skill}; it offers to " + f"write only in a session with an xtiles MCP server connected, and {where}.", "See docs/second-brain.md for the xTiles setup and the three prompts.", False, ) diff --git a/packages/studyloop/src/studyloop/doctor/exporter.py b/packages/studyloop/src/studyloop/doctor/exporter.py index 7adfc0a0..66e8dfab 100644 --- a/packages/studyloop/src/studyloop/doctor/exporter.py +++ b/packages/studyloop/src/studyloop/doctor/exporter.py @@ -192,7 +192,11 @@ def newest_message_at(db_path: Path) -> datetime | None: conn.close() if not row or not row[0]: return None - text = str(row[0]).replace("Z", "+00:00") + return _parse_stamp(row[0]) + + +def _parse_stamp(raw: object) -> datetime | None: + text = str(raw).replace("Z", "+00:00") try: stamp = datetime.fromisoformat(text) except ValueError: @@ -200,12 +204,59 @@ def newest_message_at(db_path: Path) -> datetime | None: return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC) +#: Where kiro-cli 2.x keeps its sessions: one ``.jsonl`` plus ``.json`` +#: per session. The Kiro exporter still reads only data.sqlite3's conversation +#: tables, which kiro-cli stopped updating on 2026-09-05 on the machine that +#: reported the gap (StudyLoop #49). Until the exporter reads this store, the +#: directory changing after the newest ``kiro_cli`` export means Kiro sessions are +#: missing from the database. Retire this probe when #49 lands. +KIRO_SESSIONS_DIR = Path.home() / ".kiro" / "sessions" / "cli" + + +def newest_message_by_source(db_path: Path) -> dict[str, datetime]: + """Newest exported message per ``sessions.source``; ``{}`` when unreadable.""" + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + except sqlite3.Error: + return {} + try: + rows = conn.execute( + "SELECT s.source, MAX(m.timestamp) FROM messages m " + "JOIN sessions s ON s.id = m.session_id GROUP BY s.source" + ).fetchall() + except sqlite3.Error: + return {} + finally: + conn.close() + newest: dict[str, datetime] = {} + for source, raw in rows: + stamp = _parse_stamp(raw) if raw else None + if source and stamp: + newest[str(source)] = stamp + return newest + + +def _age(hours: float) -> str: + if hours < 1: + return "under 1 h" + return f"{hours:.0f} h" if hours < 48 else f"{hours / 24:.0f} d" + + def check_export_freshness( db_path: Path | None = None, *, now: datetime | None = None, max_age_hours: float = EXPORT_FRESHNESS_HOURS, + kiro_sessions_dir: Path | None = None, ) -> CheckResult: + """How long ago each harness last had a message exported. + + The newest message across every harness hid a 22-day Kiro gap behind a 48 h + Codex age (reported 2026-09-27), so a warning lists every harness's own age. + Kiro gets one more probe: kiro-cli's session store changing well after the + newest ``kiro_cli`` export means sessions the exporter cannot read (#49). That + warns even when another harness is fresh, because the fresh one hides it. + """ db_path = db_path or _db_path() newest = newest_message_at(db_path) if db_path.exists() else None if newest is None: @@ -217,15 +268,52 @@ def check_export_freshness( fix_hint="studyloop doctor --fix (installs the export hooks), then close one session", fix_auto=True, ) - age_hours = ((now or datetime.now(UTC)) - newest).total_seconds() / 3600 + now = now or datetime.now(UTC) + by_source = newest_message_by_source(db_path) + listing = ", ".join( + f"{source} {_age((now - stamp).total_seconds() / 3600)}" + for source, stamp in sorted(by_source.items(), key=lambda item: item[1], reverse=True) + ) + + store = kiro_sessions_dir or KIRO_SESSIONS_DIR + kiro_newest = by_source.get("kiro_cli") + try: + store_changed = datetime.fromtimestamp(store.stat().st_mtime, tz=UTC) + except OSError: + store_changed = None + if ( + kiro_newest is not None + and store_changed is not None + and (store_changed - kiro_newest).total_seconds() / 3600 > max_age_hours + ): + kiro_age = _age((now - kiro_newest).total_seconds() / 3600) + changed_age = _age((now - store_changed).total_seconds() / 3600) + return CheckResult( + category="harness", + name="export_freshness", + status="warn", + message=( + f"kiro_cli's newest export is {kiro_age} old, but kiro-cli wrote to {store} " + f"{changed_age} ago: kiro-cli keeps its sessions there now and the exporter " + f"does not read that store yet, so newer Kiro sessions are not in the " + f"database. By harness: {listing}" + ), + fix_hint="Nothing to repair locally; the exporter has to learn this store " + "(StudyLoop #49). The session files stay on disk for a later backfill.", + fix_auto=False, + ) + + age_hours = (now - newest).total_seconds() / 3600 if age_hours > max_age_hours: + by_harness = f"; by harness: {listing}" if listing else "" return CheckResult( category="harness", name="export_freshness", status="warn", message=( - f"newest exported message is {age_hours:.0f} h old (limit {max_age_hours:.0f} h); " - f"if you have used a harness since, read {installers.EXPORT_HOOK_LOG}" + f"newest exported message is {age_hours:.0f} h old (limit {max_age_hours:.0f} h)" + f"{by_harness}; if you have used a harness since, read " + f"{installers.EXPORT_HOOK_LOG}" ), fix_hint="", fix_auto=False, @@ -242,10 +330,12 @@ def check_export_freshness( __all__ = [ "EXPORT_FRESHNESS_HOURS", + "KIRO_SESSIONS_DIR", "check_export_freshness", "check_exporter_schema", "database_user_version", "exporter_schema_version", "newest_message_at", + "newest_message_by_source", "pinned_exporter_path", ] diff --git a/packages/studyloop/src/studyloop/doctor/voice.py b/packages/studyloop/src/studyloop/doctor/voice.py index 08e6ef1c..3c38ba2b 100644 --- a/packages/studyloop/src/studyloop/doctor/voice.py +++ b/packages/studyloop/src/studyloop/doctor/voice.py @@ -67,28 +67,35 @@ def check_voice_readiness() -> list[CheckResult]: ) ) - if _KOKORO_MODEL.exists() and _KOKORO_VOICES.exists(): - results.append( - CheckResult( - "voice", - "kokoro_models", - "pass", - "Kokoro model and voices are available", - "", - False, + # Only study-speak's local ``kokoro`` backend reads these files, and it + # downloads them itself on first use (agent_session_tools.speak). For any + # other backend the row asked a learner to pre-download ~354 MB that nothing + # on their machine would ever read, so it is not reported at all. + if backend == "kokoro": + if _KOKORO_MODEL.exists() and _KOKORO_VOICES.exists(): + results.append( + CheckResult( + "voice", + "kokoro_models", + "pass", + "Kokoro model and voices are available", + "", + False, + ) ) - ) - else: - results.append( - CheckResult( - "voice", - "kokoro_models", - "info", - "Kokoro model files are not pre-warmed", - "See docs/voice-output.md for the model download command", - False, + else: + results.append( + CheckResult( + "voice", + "kokoro_models", + "info", + "Kokoro model files (about 354 MB) are not downloaded yet; " + "study-speak fetches them on first use", + "Nothing to do. To fetch them now instead of on first use, see " + "docs/voice-output.md", + False, + ) ) - ) afplay = shutil.which("afplay") results.append( diff --git a/packages/studyloop/src/studyloop/installers.py b/packages/studyloop/src/studyloop/installers.py index 09566bad..33996c2c 100644 --- a/packages/studyloop/src/studyloop/installers.py +++ b/packages/studyloop/src/studyloop/installers.py @@ -991,6 +991,34 @@ def unregister_mcp_servers(tools: list[str]) -> dict[str, int]: return changed +def xtiles_mcp_harnesses() -> list[str]: + """Harnesses whose MCP config registers a server named ``xtiles``, sorted. + + Read-only, for the doctor's xTiles row. The wind-down skill offers only in a + session where an ``xtiles`` MCP server is connected, so where one is + registered decides where the offer can ever appear. Reads the same files + :func:`_mcp_config_path` names; Grok Build is not read (its registration goes + through its own CLI). A missing or unreadable config counts as "not + registered there", never as an error. + """ + import json + import tomllib + + sections = {"claude": "mcpServers", "codex": "mcp_servers", "kiro": "mcpServers"} + sections["opencode"] = "mcp" + found: list[str] = [] + for tool, section in sections.items(): + try: + text = _mcp_config_path(tool).read_text(encoding="utf-8") + data = tomllib.loads(text) if tool == "codex" else json.loads(text) + except (OSError, ValueError): + continue + servers = data.get(section) if isinstance(data, dict) else None + if isinstance(servers, dict) and "xtiles" in servers: + found.append(tool) + return sorted(found) + + def mcp_registration_status(tools: list[str] | None = None) -> dict[str, bool]: """Report registration state without modifying any harness configuration.""" import json diff --git a/packages/studyloop/src/studyloop/settings.py b/packages/studyloop/src/studyloop/settings.py index 19a38bcb..13127ff5 100644 --- a/packages/studyloop/src/studyloop/settings.py +++ b/packages/studyloop/src/studyloop/settings.py @@ -611,6 +611,12 @@ def replace_config(_current: dict[str, Any]) -> dict[str, Any]: "semantic_search", "excluded_dirs", "endpoints", # legacy sync format, agent_session_tools.get_endpoints() + # agent_session_tools.query_utils.build_project_filter: the explicit + # alias groups that let session search find one project's history + # under old paths, usernames and worktrees (PROJECT_ALIASES.md). Read + # with load_config().get(), not through DEFAULT_CONFIG, so the + # unknown-key check called it inert and told learners to delete it. + "project_aliases", } ) diff --git a/packages/studyloop/src/studyloop/web/app.py b/packages/studyloop/src/studyloop/web/app.py index 81b5949b..20835512 100644 --- a/packages/studyloop/src/studyloop/web/app.py +++ b/packages/studyloop/src/studyloop/web/app.py @@ -8,7 +8,7 @@ from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse @@ -22,6 +22,23 @@ STATIC_DIR = Path(__file__).parent / "static" +class RevalidatedStaticFiles(StaticFiles): + """Static files the browser must revalidate before reusing. + + ``/`` is served ``no-store``, but without a Cache-Control of their own the + CSS and JS it loads were subject to heuristic freshness -- commonly a tenth + of the file's age -- so a stylesheet unchanged for weeks was reused for days + without asking. After an update the learner got the new page beside the old + CSS and JS. ``no-cache`` keeps the copy and asks first; an unchanged file + costs a 304 against the ETag Starlette already sends. + """ + + def file_response(self, *args: Any, **kwargs: Any) -> Response: + response = super().file_response(*args, **kwargs) + response.headers["Cache-Control"] = "no-cache" + return response + + @asynccontextmanager async def _lifespan(app: FastAPI) -> AsyncIterator[None]: """Prepare the database, then run the session-slot reaper for the app's life. @@ -353,7 +370,7 @@ async def session_page() -> RedirectResponse: return RedirectResponse(url="/#study-session") # Mount static files LAST (catch-all) - app.mount("/", StaticFiles(directory=str(STATIC_DIR)), name="static") + app.mount("/", RevalidatedStaticFiles(directory=str(STATIC_DIR)), name="static") # Security headers: wraps the FINISHED app object rather than being # registered via app.add_middleware(). Starlette always puts its own diff --git a/packages/studyloop/src/studyloop/web/static/index.html b/packages/studyloop/src/studyloop/web/static/index.html index 102a7381..38fdf36d 100644 --- a/packages/studyloop/src/studyloop/web/static/index.html +++ b/packages/studyloop/src/studyloop/web/static/index.html @@ -134,17 +134,25 @@ - - - + +
+ + + + + +