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
5 changes: 5 additions & 0 deletions .github/workflows/desktop-launcher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ on:
- 'api/main.py'
- 'api/config.py'
- 'frontend/vite.config.ts'
# The launcher builds the frontend into a staging dir via KARMA_BUILD_OUT.
- 'frontend/svelte.config.js'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/tsconfig.json'
- 'frontend/src/app.html'
- 'frontend/static/**'
- 'frontend/src/lib/components/settings/DesktopAppSetting.svelte'
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ python3 scripts/install_karma_app.py --dock # macOS: also pin it to the Dock
python3 scripts/install_karma_app.py --uninstall
```

Click the icon and Karma starts whichever servers aren't already running, then opens the dashboard. The first launch after a reboot has to compile the frontend (a minute or two), so it posts a "Starting Karma…" desktop notification straight away — the click never looks like it did nothing — and opens the dashboard once the servers answer.
Click the icon and Karma starts whichever servers aren't already running, then opens the dashboard. It serves a production build of the frontend: the first launch, and the first launch after you pull frontend changes, builds it once (about half a minute) and posts a desktop notification while it works, so the click never looks like it did nothing. Every other launch is instant. Run `python3 scripts/karma_desktop/launcher.py --dev` to use the Vite dev server instead.

Nothing is hardcoded: the installer works out your repo location from its own path and finds a Python interpreter on the machine, so a clone anywhere works.

Expand All @@ -282,7 +282,7 @@ python3 scripts/install_karma_app.py --autostart # on
python3 scripts/install_karma_app.py --no-autostart # off
```

This adds a launchd agent (macOS) or a Startup-folder entry (Windows) so the servers are already up when you log in — the dashboard is simply always there, with no icon to click first. It costs roughly **150–350 MB** of memory and almost no CPU while idle.
This adds a launchd agent (macOS) or a Startup-folder entry (Windows) so the servers are already up when you log in — the dashboard is simply always there, with no icon to click first. It costs roughly **200–250 MB** of memory and almost no CPU while idle. The servers run at normal priority; older installs that ran them as a throttled background task are repaired automatically the next time Karma launches, and take effect at the next login.

It's also what makes a browser-installed Karma work on its own: a PWA is only a window and cannot start servers, so with autostart you never need the launcher icon at all.

Expand Down
13 changes: 7 additions & 6 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ The link looks different depending on your terminal:
| iTerm2, WezTerm, Kitty, Ghostty | A real clickable link — "see this in karma?", url hidden underneath it (Cmd+click) |
| Apple Terminal.app | The label plus the plain url, both visible — a plain click does nothing here; try Cmd+double-click or right-click the url and choose "Open URL" (Terminal.app has never implemented OSC 8, the standard that lets a label hide a different url; no setting fixes this) |

**Prerequisite:** the link only opens something if Karma is reachable when you click it — either the dev servers are running, or the desktop app's autostart-at-login is on. Neither running means a blank page or browser error, not a bug.
**Prerequisite:** the link only opens something if Karma is reachable when you click it — either the servers are running (`npm run dev` or the desktop launcher), or the desktop app's autostart-at-login is on. Neither running means a blank page or browser error, not a bug.

---

Expand Down Expand Up @@ -525,14 +525,15 @@ python3 scripts/karma_desktop/launcher.py --no-open
# Expect: "OK: Karma is ready at http://localhost:5180"
```

Then click the icon. On the first launch of the day the frontend has to
compile (a minute or two), so a "Starting Karma…" desktop notification appears
right away and the dashboard opens once the servers answer — the click never
looks dead.
Then click the icon. The launcher serves a production build of the frontend.
The first launch, and the first launch after frontend changes are pulled,
builds it (about half a minute) with a desktop notification while it works;
later launches open the dashboard right away. Add `--dev` to the launcher
command to use the Vite dev server instead.

> **Agent notes:** Installing the icon is safe to do on your own. Autostart is
> **a decision for the user — ask before enabling it, do not enable it by
> default.** It trades ~150–350 MB of idle memory for the dashboard always
> default.** It trades ~200–250 MB of idle memory for the dashboard always
> being up at `http://localhost:5180` after any reboot, with no icon to click.
> If the user says yes, add `--autostart`; then tell them macOS notifies them a
> background item was added (System Settings → General → Login Items) — it is
Expand Down
191 changes: 107 additions & 84 deletions api/collectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
iteration instead of multiple passes.
"""

import os
import threading
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Set
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

from cachetools import TTLCache

from command_helpers import classify_invocation, is_command_category, is_skill_category
from config import FILE_TOOL_MAPPINGS
Expand All @@ -17,18 +22,6 @@
from utils import FileOperation, extract_file_operation, normalize_key


@dataclass
class ToolCall:
"""Represents a single tool invocation."""

tool_use_id: str
name: str
input: Dict[str, Any]
timestamp: datetime
actor: str # "session" or agent_id
actor_type: str # "session" or "subagent"


@dataclass
class ConversationData:
"""
Expand Down Expand Up @@ -71,7 +64,6 @@ class SessionData:
initial_prompt: Optional[str] = None

# Tool usage
tool_calls: List[ToolCall] = field(default_factory=list)
session_tool_counts: Counter = field(default_factory=Counter)
subagent_tool_counts: Counter = field(default_factory=Counter)

Expand Down Expand Up @@ -314,17 +306,6 @@ def collect_session_data(session: Session, include_subagents: bool = False) -> S
else:
data.subagent_tool_counts[tool_name] += 1

# Create tool call record
tool_call = ToolCall(
tool_use_id=block.id,
name=tool_name,
input=block.input or {},
timestamp=msg.timestamp,
actor=actor,
actor_type=actor_type,
)
data.tool_calls.append(tool_call)

# Extract file operations
file_op = _extract_file_operation(block, msg.timestamp, actor, actor_type)
if file_op:
Expand Down Expand Up @@ -356,42 +337,105 @@ def collect_session_data(session: Session, include_subagents: bool = False) -> S

def _collect_subagent_data(subagent, data: SessionData) -> None:
"""Add subagent data to the session data collector."""
agent_id = subagent.agent_id
digest = _subagent_digest(subagent)
data.subagent_tool_counts.update(digest.tool_counts)
data.subagent_skill_counts.update(digest.skill_counts)
data.subagent_command_counts.update(digest.command_counts)
data.file_operations.extend(digest.file_operations)

for msg in subagent.iter_messages():
if isinstance(msg, AssistantMessage):
for block in msg.content_blocks:
if isinstance(block, ToolUseBlock):
tool_name = block.name

# Count tools for subagents
data.subagent_tool_counts[tool_name] += 1
@dataclass(frozen=True)
class _SubagentDigest:
"""Everything the collectors need from one subagent file, read in one pass."""

# Create tool call record
tool_call = ToolCall(
tool_use_id=block.id,
name=tool_name,
input=block.input or {},
timestamp=msg.timestamp,
actor=agent_id,
actor_type="subagent",
)
data.tool_calls.append(tool_call)
tool_counts: Counter
skill_counts: Counter
command_counts: Counter
file_operations: Tuple[FileOperation, ...]
initial_prompt: Optional[str]
message_count: int

# Extract file operations
file_op = _extract_file_operation(block, msg.timestamp, agent_id, "subagent")
if file_op:
data.file_operations.append(file_op)

# Extract skill/command names from Skill tool inputs
if tool_name == "Skill" and block.input:
skill_name = block.input.get("skill")
if skill_name:
kind = classify_invocation(skill_name, source="skill_tool")
if is_skill_category(kind):
data.subagent_skill_counts[skill_name] += 1
elif is_command_category(kind):
data.subagent_command_counts[skill_name] += 1
# Keyed by (path, mtime_ns, size): a finished subagent's file never changes, so its digest
# is reused by every endpoint and request; a growing one is re-read only when it changes.
_digest_cache: TTLCache = TTLCache(maxsize=2048, ttl=3600)
_digest_lock = threading.Lock()
_digest_key_locks: Dict[tuple, threading.Lock] = {}


def clear_subagent_digest_cache() -> None:
with _digest_lock:
_digest_cache.clear()


def _file_version(path: Path) -> Optional[tuple]:
try:
stat = os.stat(path)
except OSError:
return None
return (str(path), stat.st_mtime_ns, stat.st_size)


def _subagent_digest(subagent) -> _SubagentDigest:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session page fires its requests in parallel, so a plain cache would miss in all of them at once and parse in parallel. The per-key lock makes concurrent callers wait for one parse; test_concurrent_requests_parse_each_file_once covers it.

"""Cached digest of a subagent file; concurrent callers wait for one parse."""
key = _file_version(subagent.jsonl_path)
if key is None:
return _read_subagent_digest(subagent)
with _digest_lock:
digest = _digest_cache.get(key)
if digest is not None:
return digest
key_lock = _digest_key_locks.setdefault(key, threading.Lock())
with key_lock:
with _digest_lock:
digest = _digest_cache.get(key)
if digest is None:
digest = _read_subagent_digest(subagent)
with _digest_lock:
_digest_cache[key] = digest
_digest_key_locks.pop(key, None)
return digest


def _read_subagent_digest(subagent) -> _SubagentDigest:
agent_id = subagent.agent_id
tool_counts: Counter = Counter()
skill_counts: Counter = Counter()
command_counts: Counter = Counter()
file_operations: List[FileOperation] = []
initial_prompt: Optional[str] = None
message_count = 0

for msg in subagent.iter_messages():
message_count += 1
if isinstance(msg, UserMessage):
if initial_prompt is None:
initial_prompt = msg.content[:5000] if msg.content else None
elif isinstance(msg, AssistantMessage):
for block in msg.content_blocks:
if not isinstance(block, ToolUseBlock):
continue
tool_counts[block.name] += 1
file_op = _extract_file_operation(block, msg.timestamp, agent_id, "subagent")
if file_op:
file_operations.append(file_op)
if block.name == "Skill" and block.input:
skill_name = block.input.get("skill")
if skill_name:
kind = classify_invocation(skill_name, source="skill_tool")
if is_skill_category(kind):
skill_counts[skill_name] += 1
elif is_command_category(kind):
command_counts[skill_name] += 1

return _SubagentDigest(
tool_counts=tool_counts,
skill_counts=skill_counts,
command_counts=command_counts,
file_operations=tuple(file_operations),
initial_prompt=initial_prompt,
message_count=message_count,
)


@dataclass
Expand Down Expand Up @@ -437,30 +481,8 @@ def collect_subagent_info(
subagents_info: List[SubagentInfo] = []

for subagent in session.list_subagents():
# Count tools, skills, and commands for this subagent - single pass
tool_counts: Counter = Counter()
skill_counts: Counter = Counter()
command_counts: Counter = Counter()
initial_prompt = None

for msg in subagent.iter_messages():
if isinstance(msg, UserMessage):
if initial_prompt is None:
initial_prompt = msg.content[:5000] if msg.content else None
elif isinstance(msg, AssistantMessage):
for block in msg.content_blocks:
if isinstance(block, ToolUseBlock):
tool_counts[block.name] += 1

# Extract skill/command names from Skill tool inputs
if block.name == "Skill" and block.input:
skill_name = block.input.get("skill")
if skill_name:
kind = classify_invocation(skill_name, source="skill_tool")
if is_skill_category(kind):
skill_counts[skill_name] += 1
elif is_command_category(kind):
command_counts[skill_name] += 1
digest = _subagent_digest(subagent)
initial_prompt = digest.initial_prompt

# Match subagent to Task invocation
subagent_type = agent_id_to_type.get(subagent.agent_id)
Expand All @@ -485,12 +507,13 @@ def collect_subagent_info(
SubagentInfo(
agent_id=subagent.agent_id,
slug=subagent.slug,
tool_counts=tool_counts,
skills_used=skill_counts,
commands_used=command_counts,
# Copies: callers own these, the cached digest must not change
tool_counts=Counter(digest.tool_counts),
skills_used=Counter(digest.skill_counts),
commands_used=Counter(digest.command_counts),
initial_prompt=initial_prompt,
subagent_type=subagent_type,
message_count=subagent.message_count,
message_count=digest.message_count,
)
)

Expand Down
5 changes: 4 additions & 1 deletion api/db/indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@ def _update_project_summaries(conn: sqlite3.Connection) -> None:
from pathlib import Path

from services.git_identity import read_git_identity
from utils import compute_project_slug
from utils import compute_project_slug, resolve_git_root

rows = conn.execute(
"""
Expand Down Expand Up @@ -889,6 +889,9 @@ def _update_project_summaries(conn: sqlite3.Connection) -> None:
slug = compute_project_slug(encoded_name, project_path)
display_name = Path(project_path).name if project_path else encoded_name
git_identity = read_git_identity(project_path)
# Warm the cache /projects reads, so the page never waits on git
if project_path and Path(project_path).is_dir():
resolve_git_root(project_path.replace("\\", "/"))

conn.execute(
"""
Expand Down
31 changes: 24 additions & 7 deletions api/services/git_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@
import logging
import re
import subprocess
import threading
from typing import Optional

from cachetools import TTLCache, cached

from utils import GIT_LOOKUP_TTL_SECONDS

logger = logging.getLogger(__name__)

# Match either:
Expand Down Expand Up @@ -95,17 +100,29 @@ def read_git_identity(project_path: Optional[str]) -> Optional[str]:
if not project_path:
return None
try:
result = subprocess.run(
["git", "-C", project_path, "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
return _cached_git_identity(project_path)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
logger.debug("git_identity read failed for %s: %r", project_path, e)
return None


# "No remote" is cached like a remote; errors raise above and are not cached.
_identity_cache: TTLCache = TTLCache(maxsize=4096, ttl=GIT_LOOKUP_TTL_SECONDS)


@cached(_identity_cache, lock=threading.Lock())
def _cached_git_identity(project_path: str) -> Optional[str]:
result = subprocess.run(
["git", "-C", project_path, "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
if result.returncode != 0:
return None
return normalize_git_url(result.stdout)


def clear_git_identity_cache() -> None:
_identity_cache.clear()
13 changes: 13 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@
# =============================================================================


@pytest.fixture(autouse=True)
def _fresh_process_caches():
"""Git lookups and subagent digests are cached process-wide; start each test cold."""
# Only clear caches whose module is loaded, so minimal CI envs never import them.
for module, clear in (
("utils", "clear_git_caches"),
("collectors", "clear_subagent_digest_cache"),
):
if module in sys.modules:
getattr(sys.modules[module], clear)()
yield


@pytest.fixture
def sample_user_message_data() -> Dict[str, Any]:
"""Sample user message data from session JSONL."""
Expand Down
Loading
Loading