Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f121e58
feat: guide ScreenPipe capture-node setup
AnkushMalaker Jul 22, 2026
977bdfb
fix: refresh capture timeline and tolerate recorder startup
AnkushMalaker Jul 22, 2026
9e3bc74
fix: group Timeline audio chunks into sessions
AnkushMalaker Jul 22, 2026
09cff63
fix: unblock CI — test env defaults + workers-test startup deadlock
AnkushMalaker Jul 22, 2026
107626c
fix: land SessionStore conversation-assignment API tested by #336
AnkushMalaker Jul 22, 2026
8c77139
feat: add Linux Chronicle desktop tray
AnkushMalaker Jul 22, 2026
328d3dd
feat: add ScreenPipe context thumbnails to Timeline
AnkushMalaker Jul 22, 2026
e3514f9
ci: install libopus0 for advanced backend unit tests
AnkushMalaker Jul 22, 2026
f7d102c
fix: preserve meaningful ScreenPipe transitions
AnkushMalaker Jul 22, 2026
0c177a3
fix: normalize ScreenPipe audio timestamps
AnkushMalaker Jul 22, 2026
20a69b6
fix: ignore opuslib SyntaxWarning under pytest error filter
AnkushMalaker Jul 22, 2026
abb59fc
fix: make memory.processed fire under the no-api mock config
AnkushMalaker Jul 22, 2026
260453e
ci: real redis/mongo services for backend unit tests; upload robot se…
AnkushMalaker Jul 22, 2026
85d561f
fix: resolve host.docker.internal in test containers on Docker/CI
AnkushMalaker Jul 22, 2026
f510211
fix: runtime config overrides survive reloads
AnkushMalaker Jul 22, 2026
662e321
fix: first runtime settings save no longer fails without config.yml
AnkushMalaker Jul 22, 2026
0896992
feat(webui): apply Chronicle Espresso design system + standard UI pri…
AnkushMalaker Jul 22, 2026
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
17 changes: 17 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ jobs:
defaults:
run:
working-directory: backends/advanced
services:
# Some backend tests hit real local services (vault locks are Redis-backed
# and fail closed; test_leading_silence_trim_db uses Mongo). Match the
# defaults the code assumes: redis://localhost:6379, mongodb://localhost:27018.
redis:
image: redis:7-alpine
ports:
- 6379:6379
mongo:
image: mongo:8
ports:
- 27018:27017

steps:
- name: Checkout code
Expand All @@ -90,6 +102,11 @@ jobs:
with:
version: "latest"

- name: Install system dependencies
# libopus0: native library behind opuslib (services/device_audio.py),
# imported transitively during test collection.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libopus0

- name: Install test dependencies
run: uv sync --locked --group test

Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/robot-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ jobs:
echo "✓ Logs saved to backends/advanced/logs/"
ls -lh logs/

- name: Upload service logs
if: always()
uses: actions/upload-artifact@v4
with:
name: robot-service-logs
path: backends/advanced/logs/
if-no-files-found: warn
retention-days: 14

- name: Check if test results exist
if: always()
id: check_results
Expand Down
16 changes: 14 additions & 2 deletions backends/advanced/docker-compose-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ services:
- LANGFUSE_BASE_URL=http://langfuse-web-test:3000
- LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key
- LANGFUSE_SECRET_KEY=sk-lf-test-secret-key
extra_hosts:
# Mock services (LLM/STT) publish ports on the host and are addressed as
# host.docker.internal. Docker on Linux (CI) does not resolve that name
# without an explicit host-gateway mapping; podman resolves it natively.
- "host.docker.internal:host-gateway"
depends_on:
mongo-test:
condition: service_healthy
Expand Down Expand Up @@ -383,9 +388,16 @@ services:
- LANGFUSE_BASE_URL=http://langfuse-web-test:3000
- LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key
- LANGFUSE_SECRET_KEY=sk-lf-test-secret-key
extra_hosts:
# See chronicle-backend-test: required for Docker-on-Linux (CI) to reach
# the mock LLM/STT services published on the host.
- "host.docker.internal:host-gateway"
depends_on:
chronicle-backend-test:
condition: service_healthy
# NOTE: no dependency on chronicle-backend-test. The backend's /readiness
# healthcheck requires the worker fleet heartbeat in Redis, so gating the
# workers on backend health deadlocks startup (backend waits for workers,
# workers wait for backend). Matches prod compose: workers only need
# mongo + redis.
mongo-test:
condition: service_healthy
redis-test:
Expand Down
3 changes: 3 additions & 0 deletions backends/advanced/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ filterwarnings = [
"ignore::UserWarning",
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
# opuslib 3.x uses `is not 0`, a SyntaxWarning on py3.12 that "error" would
# promote to SyntaxError on first (uncached) import. Third-party; not ours.
"ignore::SyntaxWarning:.*opuslib.*",
]

[tool.coverage.run]
Expand Down
35 changes: 27 additions & 8 deletions backends/advanced/src/advanced_omi_backend/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
# Global config cache
_config_cache: Optional[DictConfig] = None

# Runtime overrides registered by save_config_section(), keyed by section path.
# Re-applied on every load_config() so they survive cache reloads even when
# CONFIG_FILE points away from config.yml (test environments).
_runtime_overrides: dict = {}


def get_config_dir() -> Path:
"""Get config directory path (single source of truth)."""
Expand Down Expand Up @@ -117,6 +122,14 @@ def load_config(force_reload: bool = False) -> DictConfig:
f"{[m.get('name') for m in extra_defaults]}"
)

# Re-apply runtime overrides saved via save_config_section(). When
# CONFIG_FILE points away from config.yml (test environments), the saved
# values exist only in memory — without this they silently revert on the
# next reload, so a runtime toggle only "took" until some unrelated code
# path refreshed the config.
for section_path, values in _runtime_overrides.items():
OmegaConf.update(merged, section_path, values, merge=True)

# Cache result
_config_cache = merged

Expand Down Expand Up @@ -182,8 +195,11 @@ def save_config_section(section_path: str, values: dict) -> bool:
try:
config_path = get_config_dir() / "config.yml"

# Load existing config
existing_config = {}
# Load existing config. Must be a DictConfig even when the file doesn't
# exist yet — OmegaConf.update() raises "Unexpected type" on a plain
# dict, which made the very first runtime settings save fail silently
# on installs without a config.yml.
existing_config = OmegaConf.create({})
if config_path.exists():
existing_config = OmegaConf.load(config_path)

Expand All @@ -193,13 +209,16 @@ def save_config_section(section_path: str, values: dict) -> bool:
# Save back to file
OmegaConf.save(existing_config, config_path)

# Reload config from the primary config file (CONFIG_FILE env var)
merged = reload_config()
# Register a runtime override BEFORE reloading: when CONFIG_FILE points
# to a different file than config.yml (test configs), the value we just
# saved is not in the file load_config() reads, so it must be re-applied
# on every load — a one-shot in-memory patch would silently revert on
# the next reload_config() from any code path.
_runtime_overrides[section_path] = values

# Also apply the values to the in-memory cache directly.
# This is needed when CONFIG_FILE points to a different file than config.yml
# (e.g., test configs), so the saved values still take effect at runtime.
OmegaConf.update(merged, section_path, values, merge=True)
# Reload config from the primary config file (CONFIG_FILE env var);
# load_config() re-applies _runtime_overrides on top.
reload_config()

logger.info(f"Saved config section '{section_path}' to {config_path}")
return True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
utcnow,
)
from advanced_omi_backend.models.user import User
from advanced_omi_backend.services.device_context import request_conversation_context_jobs
from advanced_omi_backend.services.device_context import (
request_conversation_context_jobs,
)
from advanced_omi_backend.services.memory.vault_manager import ConvDocVaultManager

router = APIRouter(prefix="/device-input", tags=["device-input"])
Expand Down Expand Up @@ -180,6 +182,24 @@ async def ingest_activity(
try:
await item.insert()
accepted += 1
frame_id = incoming.metadata.get("representative_frame_id")
if (
frame_id is not None
and "screen_context" in source.capabilities
and any(
incoming.metadata.get(key)
for key in ("app_name", "window_name", "text")
)
):
await DeviceInputJob(
user_id=source.user_id,
source_id=source.source_id,
kind="thumbnail",
start_at=incoming.captured_at,
end_at=incoming.ended_at,
purpose="timeline_thumbnail",
payload={"item_id": str(item.id), "frame_id": frame_id},
).insert()
except DuplicateKeyError:
duplicates += 1
existing = await DeviceInputItem.find_one(
Expand Down Expand Up @@ -307,6 +327,37 @@ async def complete_job(
return {"ok": True}


@router.post("/jobs/{job_id}/thumbnail")
async def complete_thumbnail_job(
job_id: str,
file: UploadFile = File(...),
source: CaptureSource = Depends(_device_source),
):
job = await DeviceInputJob.get(job_id)
if job is None or job.source_id != source.source_id or job.kind != "thumbnail":
raise HTTPException(status_code=404, detail="Thumbnail job not found")
item_id = job.payload.get("item_id")
item = await DeviceInputItem.get(item_id) if item_id else None
if item is None or item.source_id != source.source_id:
raise HTTPException(status_code=404, detail="Timeline item not found")
content_type = (file.content_type or "").split(";", 1)[0]
if not content_type.startswith("image/"):
raise HTTPException(status_code=415, detail="Thumbnail must be an image")
data = await file.read(_MAX_IMAGE_BYTES + 1)
if len(data) > _MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="Thumbnail exceeds the media limit")
item.media_data = data
item.media_filename = file.filename or "screenpipe-thumbnail.jpg"
item.media_content_type = content_type
item.content_hash = hashlib.sha256(data).hexdigest()
item.metadata = {**item.metadata, "thumbnail_available": True}
await item.save()
job.status = "complete"
job.completed_at = utcnow()
await job.save()
return {"ok": True, "item_id": str(item.id)}


@router.get("/sources")
async def list_sources(user: User = Depends(current_active_user)):
rows = (
Expand Down Expand Up @@ -476,6 +527,16 @@ async def _immich_bytes(asset_id: str, endpoint: str) -> tuple[bytes, str]:
@router.get("/items/{item_id}/thumbnail")
async def context_thumbnail(item_id: str, user: User = Depends(current_active_user)):
item = await _owned_item(item_id, user)
if (
item.media_data
and item.media_content_type
and item.media_content_type.startswith("image/")
):
return Response(
content=item.media_data,
media_type=item.media_content_type,
headers={"Cache-Control": "private, max-age=3600"},
)
asset_id = item.metadata.get("asset_id")
if item.kind != "immich_memory" or not asset_id:
raise HTTPException(
Expand All @@ -489,6 +550,43 @@ async def context_thumbnail(item_id: str, user: User = Depends(current_active_us
)


@router.post("/items/{item_id}/request-thumbnail")
async def request_item_thumbnail(
item_id: str, user: User = Depends(current_active_user)
):
item = await _owned_item(item_id, user)
if item.kind != "activity":
raise HTTPException(status_code=409, detail="Only activity items have frames")
if item.media_data:
return {"status": "complete"}
frame_id = (
item.metadata.get("representative_frame_id")
or item.metadata.get("last_frame_id")
or item.metadata.get("first_frame_id")
)
if frame_id is None:
raise HTTPException(status_code=409, detail="Activity has no source frame")
existing = await DeviceInputJob.find_one(
DeviceInputJob.source_id == item.source_id,
DeviceInputJob.kind == "thumbnail",
{"payload.item_id": item_id},
{"status": {"$in": ["pending", "claimed"]}},
)
if existing:
return {"status": existing.status, "job_id": str(existing.id)}
job = DeviceInputJob(
user_id=item.user_id,
source_id=item.source_id,
kind="thumbnail",
start_at=item.captured_at,
end_at=item.ended_at,
purpose="timeline_thumbnail",
payload={"item_id": item_id, "frame_id": frame_id},
)
await job.insert()
return {"status": "pending", "job_id": str(job.id)}


@router.post("/items/{item_id}/promote")
async def promote_context_item(item_id: str, user: User = Depends(current_active_user)):
item = await _owned_item(item_id, user)
Expand Down
Loading