Local-first Personal Model Runtime for macOS
Persome is one local daemon with one production ingestion path. It observes a person, forms bounded state, updates an auditable personal model, and exposes that model to local consumers. There is no product workflow or predictor hidden beside this path.
flowchart LR
AX["macOS AX watcher"] --> S0["S0 debounce and dedup"]
ING["trusted local ingest"] --> S1
S0 --> S1["S1 focused element, visible text, URL"]
OCR["optional local OCR worker"] --> CAP
S1 --> CAP[("capture buffer and captures FTS")]
CAP --> TL["one-minute timeline normalizer"]
TL --> BL[("timeline_blocks")]
BL --> SES["three-rule session cutter"]
SES --> RED["five-minute active reducer"]
RED --> EVT[("event daily memory")]
RED --> ACTIVE["active-window modeling"]
ACTIVE --> DELTA["evidence-gated memory_delta"]
SES -->|session end| FIN["trailing-window finalizer"]
EVT --> FIN
BL --> FIN
FIN --> DELTA
DELTA --> APPLY["deterministic Point and Line apply"]
FIN --> PAT["repeated behavior memory"]
APPLY --> EVO[("evomem, Markdown, FTS")]
PAT --> EVO
EVO --> BUILD["case, schema, cross-domain, Root, layout"]
BUILD --> SNAP["versioned personal model"]
SNAP --> MCP["MCP"]
SNAP --> VIEW["localhost /model"]
capture.source="daemon", the source-versioned Swift watcher emits AX
events and capture/event_dispatcher.py performs event deduplication,
debounce, and minimum-gap control. With source="ingest", a trusted local
producer owns OS capture and sends authenticated frames; the daemon starts no
watcher. Both modes converge in the same scheduler/commit path.capture/scheduler.py builds an S1 record. AX is primary in daemon mode. When explicitly
enabled and AX text is poor, a focused screenshot is sent to an isolated
local OCR subprocess; its text is backfilled into captures FTS.timeline/aggregator.py consults both the capture JSON and OCR backfill,
removes UI repetition, preserves authored evidence, and writes wall-clock
aligned one-minute blocks.session/manager.py cuts work using idle-gap, single-app soft-cut, and
maximum-duration rules.writer/session_reducer.py flushes active sessions every five minutes;
writer.agent.model_active_session turns each new window into Points/Lines.
Session end writes and models only the trailing range.The control path is generation-bound. .daemon.lock is acquired before start
preflight and inherited for the complete foreground or double-forked daemon
lifetime. The daemon publishes .runtime-state.json with starting/ready
phase, random generation, current permission probes, OCR policy/worker state,
and its last fresh-capture, ingest-readiness, paused, or locked receipt. HTTP
onboarding reads the same data through authenticated endpoints; HTTP-disabled
mode reads the owner-only file directly.
Lifecycle operations treat .pid as compatibility input, then verify user,
command/executable, process start time, and generation again before signaling.
LaunchAgent ownership additionally requires its loaded job program/PID and
configured plist to match the recorded Runtime; .launchagent-owner preserves
intent across updates. Ambiguous live state fails closed rather than starting a
second writer.
AX permissions are similarly bound to the actual principals. The immutable
mac-ax-helper and optional mac-ax-watcher each self-check/request
Accessibility. Their machine-local path is derived from architecture and Swift
source bytes, so same-version installs reuse the exact executable. Changed
helper source resolves a new path and requires a new explicit grant; rollback
resolves the old helper again. [capture].ocr_policy independently preserves
auto, explicit enabled, or explicit disabled intent across onboarding/update.
The updater holds a separate owner-only lock, builds a marked inactive
venv.replacement.update, and atomically exchanges it with venv. The old code
stays at the replacement path until the replacement’s final background or
LaunchAgent owner passes the mode-aware readiness proof. Transaction phase and
candidate marker are fsynced, so recovery can tell whether exchange occurred
even if the process died before recording the next phase; rollback performs the
same atomic exchange in reverse.
Every successful active flush enters writer.agent.model_active_session and
the windowed memory-delta path. Every reduced session then enters
writer.agent.finalize_session, regardless of
whether the terminal reducer wrote a new entry. This matters when prior flushes
already covered the whole session or when the reducer exhausted its LLM retries
and wrote a heuristic fallback.
The finalizer runs:
skills/skill-*.md;memory_delta extraction over the unmodeled tail;Each memory-delta window is persisted before apply. apply_status allows a
crashed apply to resume without another LLM call or duplicate relation
reinforcement. delta_end advances after successful active apply; the session
receives modeled_at only after all terminal stages finish. A kernel
session-model.lock coordinates daemon, retry, CLI, and model-build callers.
New Point/Line evidence triggers a debounced build every 30 minutes by default;
persome model build and the unconditional 00:15 build call the same locked
coordinator:
/model.Each stage records complete, skipped, or failed. Missing geometry or a failed
enabled substage makes the build degraded. The build never fabricates an
empty replacement for a previously valid Root.
The registry in src/persome/daemon.py is the authoritative task list.
| Task | Cadence and responsibility |
|---|---|
capture |
Continuous AX watcher or trusted ingest runner; writes deduplicated S1 captures and updates session activity. |
session |
Every session.tick_seconds; evaluates idle, soft-cut, and timeout boundaries. |
reducer-retry |
Every 60 seconds; consumes next_retry_at, then sends reduced or heuristic terminal results through the shared finalizer. |
daily-safety-net |
At 23:55 by default; force-ends the open session, catches all stranded reduction/modeling work, reprojects, checkpoints, snapshots, prunes telemetry, and runs enabled maintenance. |
timeline |
Every 60 seconds; materializes closed timeline windows and applies capture retention. |
flush |
Every session.flush_minutes; reduces and models the new active-session window as Points/Lines. |
classifier-tick |
Legacy-only: every classifier.interval_minutes when delta apply is disabled. |
vector-embed-tick |
Every 60 seconds when hybrid retrieval is enabled; drains the embedding queue. It is a no-op without credentials. |
model-refresh |
Every schema.refresh_minutes when new Point/Line evidence exists; refreshes Face/Volume/Root. |
schema-tick |
At 00:15 by default; invokes the shared personal-model build. |
mcp |
Hosts streamable HTTP MCP, REST routes, and /model; restarts with backoff after a crash. |
--capture-only keeps capture, session, reducer-retry, the daily safety
net, and configured MCP. It disables timeline, flush, classifier, vectors, and
schema/model processing. It is a diagnostic/embedding mode, not a second
ingestion architecture.
src/persome/paths.py owns every location. The default root is ~/.persome.
| Artifact | Role |
|---|---|
capture-buffer/*.json |
Bounded raw S1 records and optional encrypted screenshots. |
memory/*.md |
Human-readable event, fact, schema, and correction history. |
memory/skills/skill-*.md |
Evidence-backed repeated behavior. |
index.db |
WAL-mode sessions, FTS5, evomem, relations, geometry, receipts, vectors, and audit tables. |
model-build.json |
Owner-only build conditions and stage outcomes. |
exports/*.json |
Owner-only, redacted-by-default snapshots. |
backup/*.db |
Verified daily SQLite snapshots when enabled. |
Markdown is the default write authority and evomem is its maintained shadow.
An operator may explicitly invert authority to evomem; this does not change the
public snapshot contract. SQLite access must use with fts.cursor() as conn: so
readers and writers coexist under WAL mode.
persome model open while the daemon HTTP server is active. It
exchanges the owner bearer for a one-time browser capability, then reads
/model/graph and packaged local Three.js assets.The Runtime contains no click/type actuation, notification lifecycle, meeting audio, or evaluation runner.
delta_end does not advance, so the next flush retries a larger window.modeled_at remains null and retry/recovery can resume.model-build.lock waits or reports busy.See capture.md, timeline.md, session.md, and writer.md for stage details.