Session
April 22, 2026 · View on GitHub
A "session" is a bounded chunk of focused work. OpenChronicle's writer pipeline is driven by session boundaries — the reducer writes incremental flush entries every 5 min while the session is active, the classifier fires every 30 min over whatever entries landed since its last pass, and when the session closes a final reducer pass + terminal classifier catch-up cover any trailing window. Each stage advances its own bookmark on the sessions row (flush_end, classified_end) so entries are never double-processed.
Three cut rules
Implemented in session/manager.py, enforced in check_cuts() and on every on_event(). All times are local.
1. Hard cut (idle gap)
If no capture-worthy event has arrived for session.gap_minutes (default 5), the session ends at the last event's timestamp.
Rationale: lunch / phone call / real break. The gap itself isn't work, so the session ends where work paused, not when you came back.
2. Soft cut (single unrelated app)
If one unrelated app has held focus for session.soft_cut_minutes (default 3) and frequent-switching is not active, the session ends.
"Frequent-switching" = ≥2 distinct apps were focused in the last 2 minutes. Prevents the soft cut from firing during fast multi-app work (e.g. IDE + terminal + browser reference).
3. Timeout
A session older than session.max_session_hours (default 2) is force-cut regardless of activity. Safety net against runaway sessions.
Session state machine
stateDiagram-v2
[*] --> active: on_session_start
active --> active: flush tick (every flush_minutes)<br/>flush_end advanced, [flush] entry appended
active --> active: classifier tick (every interval_minutes)<br/>classified_end advanced, durable facts maybe written
active --> ended: on_session_end<br/>(idle-gap / soft-cut / timeout /<br/>daemon-shutdown / 23:55 safety-net)
ended --> reduced: reducer OK<br/>+ terminal classifier (trailing window)
ended --> failed: reducer fail
failed --> reduced: retry schedule (5/15/30/60/120 min)<br/>or daily safety-net
failed --> failed: retry still fails<br/>(retry_count++ up to MAX_RETRIES=5)
reduced --> [*]
Rows live in the sessions table (see writer.md). flush_end tracks the last reduced window boundary so the next flush (or the terminal reduce) only covers new timeline blocks; classified_end plays the same role for the classifier.
Flush tick (incremental reduce)
While a session is still active, a daemon task wakes every session.flush_minutes (default 5, clamped to a 5-min floor to keep LLM cost bounded) and:
- Snapshots the active
(session_id, session_start)atomically. - Queries closed timeline blocks in
[flush_end or session_start, now). - If any new blocks exist, runs the reducer with
is_final=Falseand appends a[flush]-tagged entry to today'sevent-YYYY-MM-DD.md. - Advances
flush_endto the newest block boundary.
The classifier does not fire per flush — it runs on its own separate cadence (every classifier.interval_minutes, default 30; see writer.md) and again at the terminal reduce for the trailing window. Flush failures are logged but not retried — the next tick covers a bigger window, and the terminal reduce is the authoritative one.
Why 5-min minimum: the timeline stage is a verbatim-preserving normalizer, not a summarizer, so its blocks are narrow (default 1 min). A sub-5-min flush would mean many LLM calls over tiny block batches; at 5 min the flush consumes ~5 timeline blocks per call.
Wiring
session/tick.py::build_manager returns a SessionManager with two callbacks wired:
on_session_start— persists anactiverow immediately. A crash mid-session leaves a recoverable trace.on_session_end— marks the rowended, then spawnsreduce_session_async. On terminal-reduce success, the reducer'son_donecallback fires the classifier over[classified_end or session_start, now)— the trailing window the 30-min tick didn't reach.
Four daemon tasks back this up:
run_check_cuts— everysession.tick_seconds(default 30s), callscheck_cuts()so idle-gap and timeout cuts fire even when no events are arriving.run_flush_tick— everysession.flush_minutes(default 5), runs the reducer over the active session's new blocks and advancesflush_end.run_classifier_tick— everyclassifier.interval_minutes(default 30), classifies event-daily entries that landed sinceclassified_endand advances it.run_daily_safety_net— at localreducer.daily_tick_hour:minute(default 23:55), force-ends the currently-open session and runsreduce_all_pendingto catch anything stranded atended/failed.
CLI
openchronicle writer run # catch up any pending sessions + classify
This is the same code path the safety-net cron uses. Safe to run any time — idempotent via the session status check inside the reducer.
Tuning
Almost every session-boundary complaint is one of these:
| Symptom | Knob |
|---|---|
| Sessions cut too eagerly during real focused work across multiple apps | session.soft_cut_minutes up (3 → 5), or leave it — the frequent-switching exception already handles most of these. |
| Sessions cut too late after idle (event-daily entries span more than the actual work) | session.gap_minutes down (5 → 3). |
| A single deep-work session grew past 2h and got chopped in half | session.max_session_hours up (2 → 4). You rarely want to disable this. |
check_cuts feels laggy | session.tick_seconds down (30 → 10). Cost is negligible — the check is just arithmetic. |
Why not write per-capture?
V1 did. Two production failure modes pushed v2 to session-level:
- Long sessions under-reported. Once the writer had appended an entry about app X, every subsequent capture of app X triaged to "already recorded" — a 28-minute session would land as a 3-minute "user played a few minutes" entry. The dedup layer saved on tokens but lost the tail.
- Event files conflated days. The old weekly rollup accumulated a whole week of user-stated facts + activity. A "what did I do today?" query had to scan 7 days.
Session-level writes make long work correct by construction (the reducer sees every timeline block in the range and prints an explicit time range), and event-YYYY-MM-DD.md is a one-file-per-day boundary that trivially answers day-scope queries.