Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions packages/studyloop/src/studyloop/doctor/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -411,14 +424,15 @@ 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(
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,
)
Expand All @@ -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,
)
Expand All @@ -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,
Expand Down
69 changes: 46 additions & 23 deletions packages/studyloop/src/studyloop/doctor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
)
Expand Down
98 changes: 94 additions & 4 deletions packages/studyloop/src/studyloop/doctor/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,20 +192,71 @@ 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:
return None
return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC)


#: Where kiro-cli 2.x keeps its sessions: one ``<uuid>.jsonl`` plus ``<uuid>.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:
Expand All @@ -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
Comment on lines +280 to +283
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,
Expand All @@ -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",
]
47 changes: 27 additions & 20 deletions packages/studyloop/src/studyloop/doctor/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading