Skip to content

core.config

User-level configuration persistence.

Stores editable settings in a TOML file and the immutable install identity (user_id) in a separate .user_id file so that users cannot accidentally break label continuity by editing their config.

On first run a unique UUID user_id is generated and written to .user_id. This stable ID is written into every LabelSpan and never changes. UserConfig is a dataclass (UserConfig(data_dir=...)).

The editable username field is a display name that can be changed freely without affecting label identity or continuity.

Location

<data_dir>/config.toml   # user-editable settings
<data_dir>/.user_id      # stable UUID (auto-generated, do not edit)
<data_dir>/.title_secret # local-only secret for title hashing/sketching

Default: ~/Library/Application Support/taskclf/data/processed/

config.toml schema

On first run, if config.toml is missing, taskclf writes a full commented starter file once (see the User config template guide and configs/user_config.template.toml). The file is not regenerated on later startups.

# Canonical template:
#   GitHub: https://github.com/fruitiecutiepie/taskclf/blob/master/configs/user_config.template.toml
#   Download: https://raw.githubusercontent.com/fruitiecutiepie/taskclf/master/configs/user_config.template.toml
#   Guide: https://fruitiecutiepie.github.io/taskclf/guide/config_template/

# --- Identity ---
# Display name in exported labels; cosmetic only (stable identity is in a separate file).
username = "default-user"


# --- Notifications ---
# If false, suppresses tray desktop notifications.
notifications_enabled = true

# If true, notification text hides raw app names (recommended for screen sharing).
privacy_notifications = true


# --- ActivityWatch ---
# Base URL of your ActivityWatch server (typically http://127.0.0.1:5600).
aw_host = "http://localhost:5600"

# How often the tray asks ActivityWatch for the active window (seconds).
poll_seconds = 60

# HTTP timeout for ActivityWatch API calls (seconds).
aw_timeout_seconds = 10


# --- Web UI ---
# TCP port for the embedded labeling dashboard (http://127.0.0.1:this port).
ui_port = 8741

# Auto-dismiss the model suggestion banner after N seconds; 0 keeps it until you act.
suggestion_banner_ttl_seconds = 0


# --- Transitions and gaps ---
# How long a foreground app must stay dominant before a transition is detected.
transition_minutes = 2

# Shorter threshold for lockscreen/idle apps (BreakIdle); often below transition_minutes.
idle_transition_minutes = 1

# Unlabeled minutes before the tray shows gap-fill escalation (orange icon).
gap_fill_escalation_minutes = 480
Key Type Default Description
username str "default-user" Display name in labels (stable UUID is in .user_id)
notifications_enabled bool true Tray desktop notifications on/off
privacy_notifications bool true Hide raw app names in notification text
aw_host str "http://localhost:5600" ActivityWatch server base URL
poll_seconds int 60 Polling interval for the active window (seconds)
aw_timeout_seconds int 10 ActivityWatch HTTP timeout (seconds)
ui_port int 8741 Embedded labeling dashboard TCP port
suggestion_banner_ttl_seconds int 0 Auto-dismiss suggestion banner after N seconds; 0 until user acts
transition_minutes int 2 Dominance time before a transition is detected (minutes)
idle_transition_minutes int 1 Threshold for lockscreen/idle / BreakIdle (minutes)
gap_fill_escalation_minutes int 480 Unlabeled time before gap-fill escalation (minutes)

The user_id UUID is stored separately in .user_id and never appears in config.toml. The title-featurization secret is stored separately in .title_secret and is intentionally omitted from config.toml, REST payloads, and UserConfig.as_dict().

Backward compatibility

On startup, if config.json exists but config.toml does not, the JSON file is automatically migrated: settings go to config.toml, user_id goes to .user_id, and the original is renamed to config.json.bak.

If old code wrote user_id into config.toml, it is automatically moved to .user_id and removed from the TOML file.

Legacy title_salt values are also migrated out of config.toml into .title_secret. UserConfig.title_salt remains as a read-only compatibility alias for code paths that still expect that name.

CLI usage

Set display name at tray launch (persisted for future runs):

taskclf tray --username alice

REST API

Method Endpoint Description
GET /api/config/user Read user_id, username, and suggestion_banner_ttl_seconds
PUT /api/config/user Update username and/or suggestion_banner_ttl_seconds

GET /api/config/user

Returns:

{
  "user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "username": "alice",
  "suggestion_banner_ttl_seconds": 0
}

PUT /api/config/user

Body (user_id is ignored -- it is immutable):

{"username": "bob", "suggestion_banner_ttl_seconds": 600}

Returns the full updated config.

Python usage

from taskclf.core.config import UserConfig

cfg = UserConfig("data/processed")
cfg.user_id            # stable UUID (read-only, from .user_id)
cfg.username           # "default-user"
cfg.username = "alice"  # persists immediately to config.toml
cfg.as_dict()          # {"user_id": "...", "username": "alice", ...}

taskclf.core.config

User-level configuration persistence.

Stores editable settings in config.toml and the immutable install identity (user_id) in a separate .user_id file so that users cannot accidentally break label continuity by editing their config.

Typical locations::

data/processed/config.toml   # user-editable settings
data/processed/.user_id      # stable UUID, never shown in config

Usage::

from taskclf.core.config import UserConfig

cfg = UserConfig(data_dir)
cfg.user_id    # stable UUID, auto-generated on first run
cfg.username   # editable display name (defaults to "default-user")
cfg.username = "alice"   # persists immediately
cfg.as_dict()

UserConfigField dataclass

One user-editable key in config.toml with default and comment.

Source code in src/taskclf/core/config.py
@dataclass(frozen=True, slots=True)
class UserConfigField:
    """One user-editable key in ``config.toml`` with default and comment."""

    key: str
    default: Any
    comment: str
    section_title: str | None = None

UserConfig dataclass

Read/write access to config.toml and .user_id in a data directory.

The immutable user_id lives in a separate .user_id file so it never appears in the user-editable config. On first run a random UUID is generated and written there.

username is a free-form display name that can be changed at any time without affecting label continuity.

On first run, if config.toml is missing, a full commented starter template is written once. Existing files are not regenerated on later loads.

Mutations from update() or property setters persist immediately. config.toml uses # comments above each known setting.

Source code in src/taskclf/core/config.py
@dataclass(eq=False)
class UserConfig:
    """Read/write access to ``config.toml`` and ``.user_id`` in a data directory.

    The immutable ``user_id`` lives in a separate ``.user_id`` file so
    it never appears in the user-editable config.  On first run a random
    UUID is generated and written there.

    ``username`` is a free-form display name that can be changed at any
    time without affecting label continuity.

    On first run, if ``config.toml`` is missing, a full commented starter
    template is written once. Existing files are not regenerated on later
    loads.

    Mutations from ``update()`` or property setters persist immediately.
    ``config.toml`` uses ``#`` comments above each known setting.
    """

    data_dir: Path | str = DEFAULT_DATA_DIR
    _dir: Path = field(init=False)
    _path: Path = field(init=False)
    _user_id_path: Path = field(init=False)
    _title_secret_path: Path = field(init=False)
    _data: dict[str, Any] = field(init=False)
    _uid: str = field(init=False)
    _title_secret: str = field(init=False)

    def __post_init__(self) -> None:
        self._dir = Path(self.data_dir)
        self._path = self._dir / _CONFIG_FILENAME
        self._user_id_path = self._dir / _USER_ID_FILENAME
        self._title_secret_path = self._dir / _TITLE_SECRET_FILENAME
        self._migrate_json()
        self._data = self._load()
        self._uid = self._load_user_id()
        self._title_secret = self._load_title_secret()
        self._ensure_starter_config_if_missing()

    # -- migration & loading ---------------------------------------------------

    def _migrate_json(self) -> None:
        """One-time migration from config.json to config.toml + .user_id."""
        json_path = self._dir / _LEGACY_JSON_FILENAME
        if self._path.exists() or not json_path.exists():
            return
        try:
            data = json.loads(json_path.read_text("utf-8"))
            data.pop("_help", None)

            uid = data.pop("user_id", None)
            if uid:
                self._dir.mkdir(parents=True, exist_ok=True)
                self._user_id_path.write_text(uid, "utf-8")

            self._dir.mkdir(parents=True, exist_ok=True)
            self._path.write_text(_to_commented_toml(data), "utf-8")
            json_path.rename(json_path.with_suffix(".json.bak"))
            logger.info(
                "Migrated %s%s + %s", json_path, self._path, self._user_id_path
            )
        except Exception:
            logger.warning("Failed to migrate %s", json_path, exc_info=True)

    def _load(self) -> dict[str, Any]:
        if self._path.exists():
            try:
                return dict(tomllib.loads(self._path.read_text("utf-8")))
            except tomllib.TOMLDecodeError, OSError:
                logger.warning("Corrupt config at %s — using defaults", self._path)
        return {}

    def _load_user_id(self) -> str:
        """Read or generate the stable user_id from .user_id file."""
        if self._user_id_path.exists():
            uid = self._user_id_path.read_text("utf-8").strip()
            if uid:
                return uid

        # Migrate from config.toml if it was written there by old code
        uid = self._data.pop("user_id", None)
        if uid:
            self._persist_user_id(uid)
            self._persist()
            return uid

        uid = str(uuid.uuid4())
        self._persist_user_id(uid)
        return uid

    def _persist_user_id(self, uid: str) -> None:
        self._dir.mkdir(parents=True, exist_ok=True)
        self._user_id_path.write_text(uid, "utf-8")

    def _load_title_secret(self) -> str:
        """Read, migrate, or generate the stable per-install title secret."""
        if self._title_secret_path.exists():
            secret = self._title_secret_path.read_text("utf-8").strip()
            if secret:
                return secret

        legacy = self._data.pop("title_salt", None)
        if legacy:
            secret = str(legacy).strip()
            if secret:
                self._persist_title_secret(secret)
                self._persist()
                return secret

        secret = secrets.token_hex(32)
        self._persist_title_secret(secret)
        return secret

    def _persist_title_secret(self, secret: str) -> None:
        self._dir.mkdir(parents=True, exist_ok=True)
        self._title_secret_path.write_text(secret, "utf-8")

    def _ensure_starter_config_if_missing(self) -> None:
        """Write the full default template only when ``config.toml`` does not exist."""
        if self._path.exists():
            return
        self._dir.mkdir(parents=True, exist_ok=True)
        self._path.write_text(render_default_user_config_toml(), "utf-8")
        self._data = self._load()

    def _persist(self) -> None:
        self._path.parent.mkdir(parents=True, exist_ok=True)
        self._path.write_text(_to_commented_toml(self._data), "utf-8")

    # -- user_id (stable, read-only after creation) ----------------------------

    @property
    def user_id(self) -> str:
        """Stable UUID assigned to this install.  Never changes."""
        return self._uid

    @property
    def title_secret(self) -> str:
        """Stable per-install secret used for privacy-preserving title featurization."""
        return self._title_secret

    @property
    def title_salt(self) -> str:
        """Backward-compatible alias for the local title secret."""
        return self._title_secret

    # -- username (editable display name) --------------------------------------

    @property
    def username(self) -> str:
        return self._data.get("username", _DEFAULT_USERNAME)

    @username.setter
    def username(self, value: str) -> None:
        value = value.strip()
        if not value:
            raise ValueError("username must not be empty")
        self._data["username"] = value
        self._persist()

    # -- generic helpers -------------------------------------------------------

    def as_dict(self) -> dict[str, Any]:
        return {
            "user_id": self.user_id,
            "username": self.username,
            **{
                k: v
                for k, v in self._data.items()
                if k not in ("user_id", "username", "title_salt", "title_secret")
            },
        }

    def update(self, patch: dict[str, Any]) -> dict[str, Any]:
        """Merge *patch* into the config and persist.  Returns the full config.

        ``user_id`` is ignored in *patch* — it is immutable after creation.
        """
        if "username" in patch:
            name = str(patch["username"]).strip()
            if not name:
                raise ValueError("username must not be empty")
            self._data["username"] = name
        for key, val in patch.items():
            if key in ("user_id", "username", "title_salt", "title_secret"):
                continue
            self._data[key] = val
        self._persist()
        return self.as_dict()

user_id property

Stable UUID assigned to this install. Never changes.

title_secret property

Stable per-install secret used for privacy-preserving title featurization.

title_salt property

Backward-compatible alias for the local title secret.

update(patch)

Merge patch into the config and persist. Returns the full config.

user_id is ignored in patch — it is immutable after creation.

Source code in src/taskclf/core/config.py
def update(self, patch: dict[str, Any]) -> dict[str, Any]:
    """Merge *patch* into the config and persist.  Returns the full config.

    ``user_id`` is ignored in *patch* — it is immutable after creation.
    """
    if "username" in patch:
        name = str(patch["username"]).strip()
        if not name:
            raise ValueError("username must not be empty")
        self._data["username"] = name
    for key, val in patch.items():
        if key in ("user_id", "username", "title_salt", "title_secret"):
            continue
        self._data[key] = val
    self._persist()
    return self.as_dict()

default_starter_config_dict()

Return a copy of the full default config.toml key set and values.

Source code in src/taskclf/core/config.py
def default_starter_config_dict() -> dict[str, Any]:
    """Return a copy of the full default ``config.toml`` key set and values."""
    return dict(_DEFAULT_STARTER_DICT)

render_default_user_config_toml()

Return the full commented default config.toml text (for docs and templates).

Source code in src/taskclf/core/config.py
def render_default_user_config_toml() -> str:
    """Return the full commented default ``config.toml`` text (for docs and templates)."""
    return _USER_CONFIG_TEMPLATE_REMOTE_HEADER + _to_commented_toml(
        _DEFAULT_STARTER_DICT
    )