diff --git a/apps/calendar/_day.html b/apps/calendar/_day.html
index 1741244ea..459a8e4b0 100644
--- a/apps/calendar/_day.html
+++ b/apps/calendar/_day.html
@@ -500,7 +500,7 @@
const isPlanned = o.occurred === false;
const cardClass = isPlanned ? 'all-day-card planned' : 'all-day-card';
const badge = isPlanned ? 'Planned' : '';
- const title = escapeHtml(o.title || o.summary || o.topic || 'Event');
+ const title = escapeHtml(o.title || o.summary || o.agent || 'Event');
html += `
- ${topics.map(topic => {
- const count = eventsByTopic[topic];
+ ${agents.map(agent => {
+ const count = eventsByAgent[agent];
const percent = maxCount > 0 ? (count / maxCount) * 100 : 0;
return `
`;
}).join('')}
@@ -543,7 +543,7 @@
const facetData = data.facets[name] || {};
const todos = facetData.todos || [];
const pendingTodos = todos.filter(t => !t.completed).length;
- const eventCount = Object.values(facetData.events_by_topic || {}).reduce((a, b) => a + b, 0);
+ const eventCount = Object.values(facetData.events_by_agent || {}).reduce((a, b) => a + b, 0);
// For recent days, use recent entities count if no detected entities
const detectedCount = (facetData.entities || []).length;
const recentCount = recentDay ? (data.recent_entities?.[name] || []).length : 0;
diff --git a/apps/search/maint/003_migrate_index_stream.py b/apps/search/maint/003_migrate_index_stream.py
index 6c94b3439..63a95bf8b 100644
--- a/apps/search/maint/003_migrate_index_stream.py
+++ b/apps/search/maint/003_migrate_index_stream.py
@@ -20,7 +20,7 @@ from think.utils import get_journal, setup_cli
logger = logging.getLogger(__name__)
-EXPECTED_COLUMNS = {"content", "path", "day", "facet", "topic", "stream", "idx"}
+EXPECTED_COLUMNS = {"content", "path", "day", "facet", "agent", "stream", "idx"}
def _get_db_path(journal: str) -> str:
diff --git a/apps/search/maint/004_migrate_topic_to_agent.py b/apps/search/maint/004_migrate_topic_to_agent.py
new file mode 100644
index 000000000..4a108beed
--- /dev/null
+++ b/apps/search/maint/004_migrate_topic_to_agent.py
@@ -0,0 +1,163 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright (c) 2026 sol pbc
+
+"""Migrate journal event/stats keys from topic naming to agent naming."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from dataclasses import dataclass
+from pathlib import Path
+
+from think.utils import get_journal, setup_cli
+
+STATS_KEY_RENAMES = {
+ "topic_data": "agent_data",
+ "topic_counts": "agent_counts",
+ "topic_minutes": "agent_minutes",
+ "topic_counts_by_day": "agent_counts_by_day",
+}
+
+
+@dataclass
+class MigrationCounters:
+ """Mutable counters for migration operations."""
+
+ events_modified: int = 0
+ events_skipped: int = 0
+ events_errors: int = 0
+ stats_modified: int = 0
+ stats_skipped: int = 0
+ stats_errors: int = 0
+ event_records_renamed: int = 0
+ stats_keys_renamed: int = 0
+
+
+def _migrate_event_file(
+ file_path: Path, *, dry_run: bool, counters: MigrationCounters
+) -> None:
+ """Migrate one events JSONL file from topic key to agent key."""
+ try:
+ lines = file_path.read_text(encoding="utf-8").splitlines()
+ out_lines: list[str] = []
+ modified = False
+
+ for line in lines:
+ stripped = line.strip()
+ if not stripped:
+ continue
+
+ row = json.loads(stripped)
+ if isinstance(row, dict) and "topic" in row:
+ if "agent" not in row:
+ row["agent"] = row["topic"]
+ del row["topic"]
+ modified = True
+ counters.event_records_renamed += 1
+
+ out_lines.append(json.dumps(row, ensure_ascii=False))
+
+ if not modified:
+ counters.events_skipped += 1
+ return
+
+ counters.events_modified += 1
+ if dry_run:
+ print(f"[DRY-RUN] update {file_path}")
+ return
+
+ payload = "\n".join(out_lines)
+ if payload:
+ payload += "\n"
+ file_path.write_text(payload, encoding="utf-8")
+ except Exception as exc:
+ counters.events_errors += 1
+ print(f"[ERROR] events migration failed for {file_path}: {exc}")
+
+
+def _migrate_stats_file(
+ file_path: Path, *, dry_run: bool, counters: MigrationCounters
+) -> None:
+ """Migrate one stats.json file from topic keys to agent keys."""
+ try:
+ data = json.loads(file_path.read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ counters.stats_skipped += 1
+ return
+
+ modified = False
+ for old_key, new_key in STATS_KEY_RENAMES.items():
+ if old_key not in data:
+ continue
+
+ if new_key not in data:
+ data[new_key] = data[old_key]
+ del data[old_key]
+ modified = True
+ counters.stats_keys_renamed += 1
+
+ if not modified:
+ counters.stats_skipped += 1
+ return
+
+ counters.stats_modified += 1
+ if dry_run:
+ print(f"[DRY-RUN] update {file_path}")
+ return
+
+ file_path.write_text(
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ except Exception as exc:
+ counters.stats_errors += 1
+ print(f"[ERROR] stats migration failed for {file_path}: {exc}")
+
+
+def migrate_topic_to_agent(*, journal: str, dry_run: bool) -> MigrationCounters:
+ """Run topic->agent key migration for events JSONL and stats JSON files."""
+ counters = MigrationCounters()
+ journal_path = Path(journal)
+
+ event_files = sorted(journal_path.glob("facets/*/events/*.jsonl"))
+ stats_files = sorted(journal_path.rglob("stats.json"))
+
+ for file_path in event_files:
+ _migrate_event_file(file_path, dry_run=dry_run, counters=counters)
+
+ for file_path in stats_files:
+ _migrate_stats_file(file_path, dry_run=dry_run, counters=counters)
+
+ print("Migration complete")
+ print(f" events modified: {counters.events_modified}")
+ print(f" events skipped: {counters.events_skipped}")
+ print(f" events errors: {counters.events_errors}")
+ print(f" stats modified: {counters.stats_modified}")
+ print(f" stats skipped: {counters.stats_skipped}")
+ print(f" stats errors: {counters.stats_errors}")
+ print(f" event rows renamed: {counters.event_records_renamed}")
+ print(f" stats keys renamed: {counters.stats_keys_renamed}")
+ print("After migration, run: sol indexer --rebuild")
+
+ return counters
+
+
+def main() -> None:
+ """CLI entry point for topic->agent migration."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Preview file changes without writing them.",
+ )
+ args = setup_cli(parser)
+
+ if args.dry_run:
+ print("[DRY-RUN] No files will be modified.")
+
+ migrate_topic_to_agent(journal=get_journal(), dry_run=args.dry_run)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/search/routes.py b/apps/search/routes.py
index 539a1fdf6..9e592221e 100644
--- a/apps/search/routes.py
+++ b/apps/search/routes.py
@@ -18,8 +18,8 @@ search_bp = Blueprint(
url_prefix="/app/search",
)
-# Topic icons for display
-TOPIC_ICONS = {
+# Agent icons for display
+AGENT_ICONS = {
"flow": "π",
"knowledge_graph": "πΊοΈ",
"meetings": "π
",
@@ -34,8 +34,8 @@ TOPIC_ICONS = {
"import": "π₯",
}
-# Topic display names
-TOPIC_LABELS = {
+# Agent display names
+AGENT_LABELS = {
"flow": "Flow",
"knowledge_graph": "Knowledge Graph",
"meetings": "Meetings",
@@ -56,9 +56,9 @@ def _parse_facet_filter() -> str | None:
return request.args.get("facet", "").strip() or None
-def _parse_topic_filter() -> str | None:
- """Parse single topic filter from request args."""
- return request.args.get("topic", "").strip() or None
+def _parse_agent_filter() -> str | None:
+ """Parse single agent filter from request args."""
+ return request.args.get("agent", "").strip() or None
def _parse_stream_filter() -> str | None:
@@ -85,7 +85,7 @@ def _highlight_query_terms(text: str, query: str) -> str:
def _format_result(result: dict, query: str, facets_map: dict) -> dict:
"""Format a search result for API response."""
meta = result.get("metadata", {})
- topic = meta.get("topic", "")
+ agent = meta.get("agent", "")
text = result.get("text", "")
facet_name = meta.get("facet", "")
@@ -105,9 +105,9 @@ def _format_result(result: dict, query: str, facets_map: dict) -> dict:
return {
"id": result.get("id", ""),
"day": meta.get("day", ""),
- "topic": topic,
- "topic_icon": TOPIC_ICONS.get(topic, "π"),
- "topic_label": TOPIC_LABELS.get(topic, topic.title()),
+ "agent": agent,
+ "agent_icon": AGENT_ICONS.get(agent, "π"),
+ "agent_label": AGENT_LABELS.get(agent, agent.title()),
"facet": facet_name,
"facet_title": facet_info.get("title", facet_name),
"facet_color": facet_info.get("color", ""),
@@ -129,14 +129,14 @@ def search_journal_api() -> Any:
limit: Max results per day (default 5)
offset: Day offset for pagination (default 0)
facet: Filter by facet name (optional, empty string for no-facet items)
- topic: Filter by single topic (optional)
+ agent: Filter by single agent (optional)
Returns:
JSON with:
- total: Total match count
- days: List of day groups, each with date info and results
- facets: List of facets with counts for filter sidebar
- - topics: List of topics with counts for filter sidebar
+ - agents: List of agents with counts for filter sidebar
"""
query = request.args.get("q", "").strip()
@@ -144,7 +144,7 @@ def search_journal_api() -> Any:
results_per_day = int(request.args.get("limit", 5))
day_offset = int(request.args.get("offset", 0))
facet_filter = _parse_facet_filter()
- topic_filter = _parse_topic_filter()
+ agent_filter = _parse_agent_filter()
stream_filter = _parse_stream_filter()
# Load facet metadata for enriching results
@@ -154,11 +154,11 @@ def search_journal_api() -> Any:
# First get unfiltered counts for sidebar display
base_counts = search_counts(query, stream=stream_filter)
facet_counts = dict(base_counts["facets"])
- topic_counts = dict(base_counts["topics"])
+ agent_counts = dict(base_counts["agents"])
# Get filtered counts for results
filtered_counts = search_counts(
- query, facet=facet_filter, topic=topic_filter, stream=stream_filter
+ query, facet=facet_filter, agent=agent_filter, stream=stream_filter
)
day_counts = dict(filtered_counts["days"])
@@ -177,7 +177,7 @@ def search_journal_api() -> Any:
offset=0,
day=day,
facet=facet_filter,
- topic=topic_filter,
+ agent=agent_filter,
stream=stream_filter,
)
total_in_day = day_counts.get(day, 0)
@@ -212,14 +212,14 @@ def search_journal_api() -> Any:
# Sort by count descending
facets_list.sort(key=lambda x: x["count"], reverse=True)
- # Build topic list for sidebar (unfiltered counts for discovery)
- topics_list = []
- for topic, count in sorted(topic_counts.items(), key=lambda x: -x[1]):
- topics_list.append(
+ # Build agent list for sidebar (unfiltered counts for discovery)
+ agents_list = []
+ for agent, count in sorted(agent_counts.items(), key=lambda x: -x[1]):
+ agents_list.append(
{
- "name": topic,
- "label": TOPIC_LABELS.get(topic, topic.title()),
- "icon": TOPIC_ICONS.get(topic, "π"),
+ "name": agent,
+ "label": AGENT_LABELS.get(agent, agent.title()),
+ "icon": AGENT_ICONS.get(agent, "π"),
"count": count,
}
)
@@ -231,7 +231,7 @@ def search_journal_api() -> Any:
"showing_days": len(days_response),
"days": days_response,
"facets": facets_list,
- "topics": topics_list,
+ "agents": agents_list,
}
)
@@ -246,7 +246,7 @@ def day_results_api() -> Any:
offset: Result offset within the day (default 0)
limit: Max results (default 20)
facet: Facet filter (optional)
- topic: Single topic filter (optional)
+ agent: Single agent filter (optional)
"""
query = request.args.get("q", "").strip()
day = request.args.get("day", "").strip()
@@ -256,14 +256,14 @@ def day_results_api() -> Any:
offset = int(request.args.get("offset", 0))
limit = int(request.args.get("limit", 20))
facet_filter = _parse_facet_filter()
- topic_filter = _parse_topic_filter()
+ agent_filter = _parse_agent_filter()
stream_filter = _parse_stream_filter()
facets_map = get_facets()
# Get total count for this day with filters
counts = search_counts(
- query, day=day, facet=facet_filter, topic=topic_filter, stream=stream_filter
+ query, day=day, facet=facet_filter, agent=agent_filter, stream=stream_filter
)
total_in_day = counts["total"]
@@ -274,7 +274,7 @@ def day_results_api() -> Any:
offset=offset,
day=day,
facet=facet_filter,
- topic=topic_filter,
+ agent=agent_filter,
stream=stream_filter,
)
diff --git a/apps/search/tests/test_maint_migrate_index.py b/apps/search/tests/test_maint_migrate_index.py
index 456b4b2ed..d879f0604 100644
--- a/apps/search/tests/test_maint_migrate_index.py
+++ b/apps/search/tests/test_maint_migrate_index.py
@@ -30,12 +30,12 @@ def _create_old_schema(tmp_path):
path UNINDEXED,
day UNINDEXED,
facet UNINDEXED,
- topic UNINDEXED,
+ agent UNINDEXED,
idx UNINDEXED
)
""")
conn.execute(
- "INSERT INTO chunks(content, path, day, facet, topic, idx) "
+ "INSERT INTO chunks(content, path, day, facet, agent, idx) "
"VALUES ('test', 'test.md', '20240101', 'work', 'flow', 0)"
)
conn.commit()
@@ -56,7 +56,7 @@ def _create_current_schema(tmp_path):
path UNINDEXED,
day UNINDEXED,
facet UNINDEXED,
- topic UNINDEXED,
+ agent UNINDEXED,
stream UNINDEXED,
idx UNINDEXED
)
@@ -80,7 +80,7 @@ def test_migrate_old_schema_rebuilds(tmp_path):
# Verify new schema accepts stream column
conn = sqlite3.connect(db_path)
conn.execute(
- "INSERT INTO chunks(content, path, day, facet, topic, stream, idx) "
+ "INSERT INTO chunks(content, path, day, facet, agent, stream, idx) "
"VALUES ('test', 'test.md', '20240101', 'work', 'flow', 'archon', 0)"
)
row = conn.execute("SELECT stream FROM chunks").fetchone()
diff --git a/apps/search/workspace.html b/apps/search/workspace.html
index d4b3cf1d7..7a4cdbda1 100644
--- a/apps/search/workspace.html
+++ b/apps/search/workspace.html
@@ -142,7 +142,7 @@
flex-wrap: wrap;
}
-.result-topic {
+.result-agent {
display: flex;
align-items: center;
gap: 0.25rem;
@@ -151,7 +151,7 @@
color: #374151;
}
-.result-topic-icon {
+.result-agent-icon {
font-size: 0.9rem;
}
@@ -289,9 +289,9 @@
-