The shared vocabulary of prosa. Read this once and the rest of docs/ will
make more sense. For the why behind these choices, see
../INTENT.md.
A session is one unbroken interaction with an AI coding agent — a single JSONL file on disk, from the moment the agent started until the moment it stopped or moved on.
Every session has:
- a stable ID assigned by the source agent (Claude Code, Codex, Cursor, Gemini, Antigravity, Hermes, Grok Build) — prosa never invents one;
- an agent name (
claude-code,codex,cursor,gemini,antigravity,hermes,grok-build); - a device it ran on (see Device identity);
- a project context (see Project identity);
- a timestamp for start and last activity;
- a first prompt (the user's first message, truncated for display);
- a model the agent used (e.g.
claude-sonnet-4-6); - the raw JSONL preserved on disk, hash-addressed by sha256;
- a stream of turns (user/assistant messages) extracted for FTS;
- an aggregation of tool usage (
Read14,Bash7, …).
The canonical mapping every importer must satisfy is documented in
architecture/canonical-session.md. The
domain types live in pkg/session/types.go.
┌──────────┐ file appears or changes
│ detect │ ───────────────────────────┐
└──────────┘ ▼
┌───────────────────┐
│ hash sha256 │
└────────┬──────────┘
│
hash unchanged │ hash differs
┌────────────────────┼────────────────────┐
▼ ▼
┌──────────┐ ┌──────────────────┐
│ no-op │ │ parse + extract │
└──────────┘ └────────┬─────────┘
│
▼
┌────────────────────┐
│ upsert metadata │
│ rewrite turns │
│ rewrite tool counts│
│ preserve raw │
└────────┬───────────┘
│
▼
┌────────────────────┐
│ push to server │
│ update sync_state │
└────────────────────┘
A session is active when its last_activity_at is within 10 minutes of
now. The CLI marks active sessions with *; the panel shows a live dot.
Active sessions are synced normally — the hash flips as the raw file grows,
which triggers a new push. There is no debounce.
A session is closed when last_activity_at is older than 10 minutes.
Closed sessions are only synced if their raw file is modified after the
fact (rare — agents usually rotate to a new session).
A session is orphaned if the source agent deletes or rotates the
original .jsonl. prosa keeps the raw copy it already preserved.
A session is pruned when prosa prune deletes its local raw copy after
the server confirms it holds the same raw_hash. Pruning is always an
explicit command — nothing is deleted automatically. A pruned session keeps
its metadata, turns, FTS entries, and analytics locally; only raw reads
(prosa show --raw, the non-TTY pipe default) stream from the server. A
re-import of a changed source file rewrites the raw copy and un-prunes the
session.
prosa prune lists only sessions a prior sync confirmed: pushed_hash
equals the current raw_hash, the row is not pruned, and last activity is
older than the window. Older sessions that still lack that confirmation stay
on disk. Prune reports their count and names prosa sync, which records the
confirmation from the server manifest.
prosa picks a project for each session in this order:
git remote get-url originin the session's working directory. The URL becomes the project identity. Stable across devices (the same repo on two laptops resolves to the same project)..prosa.yamlmarker file in the cwd or an ancestor, with aproject: <name>field.- The cwd path itself as a fallback. Marked as
unscoped— visible in timelines but kept separate from real projects.
Sessions in the same project (by remote URL or marker) on different devices
are the same project. This is what makes --remote analytics
meaningful.
A device is a single machine prosa has been set up on. Each device has a
fingerprint and a friendly_name.
The fingerprint is hex(sha256(hostname + NUL + machine-id))[:16].
On macOS the hostname is the LocalHostName (scutil --get LocalHostName,
trimmed). When that command fails or returns empty, prosa uses the system
hostname with a trailing .local removed. On Linux, and on every other
platform, the hostname is the system hostname (os.Hostname()) with a
trailing .local removed.
Machine-id sources:
- Linux:
/etc/machine-id, then/var/lib/dbus/machine-id. - macOS:
IOPlatformUUID. - Windows:
MachineGuidfrom the registry. (Not in MVP target platforms.)
The friendly_name defaults to the hostname. You can rename via
prosa devices rename <id|self> <name> (cross-device only, talks to the
server).
prosa sync and prosa prune upsert this machine's device row. They move
sessions still stored on the seed local id onto the current fingerprint,
and they collapse other local device rows that share its machine-id:
sessions are reassigned to the current fingerprint and the extra rows are
deleted. Rows with an empty machine-id stay in place.
The CLI sends its device fingerprint as part of every authenticated request. The server uses this to attribute pushed sessions to a device.
Two flavors, both single-user.
CLI auth (PKCE + localhost callback): prosa login (or the prosa setup
wizard) calls AuthService.BeginLogin with a PKCE challenge and a
http://127.0.0.1:<port>/callback redirect. The CLI opens the panel
authorize URL in the browser; you click Authorize this device. The panel
redirects the auth code to the CLI's local listener; the CLI calls
ExchangeCode and saves the bearer to ~/.config/prosa/auth.json.
On headless machines, forward the callback port with ssh -L or copy
auth.json from a machine that completed login.
Tokens are revocable via prosa devices revoke <id|self>. The server
stores only the hash of each token, never the plaintext.
Panel auth (OAuth): GitHub OAuth.
The verified email is matched against PROSA_OWNER_EMAILS. Any non-listed
email gets a 403. The session is an HMAC-signed cookie (HttpOnly,
Secure, SameSite=Lax, 30-day TTL).
For dev: PROSA_PANEL_DEV_LOGIN=<email> exposes a passwordless
/dev-login route. The server logs a loud warning at boot. Don't enable in
production.
The CLI reads the local store by default. No network calls. No "the server is down" failure mode for everyday queries.
prosa(the timeline),prosa search,prosa show,prosa analytics— all local by default.--remoteis opt-in. When you pass it, the same command runs against the server (Postgres FTS, server-side analytics).prosa syncis the only command that always touches the server;prosa prunerequires it (deletion only happens after the server re-confirms it holds the raw).- Raw reads of a pruned session stream from the server — the one local read that needs the network.
Practical consequence: you can work offline indefinitely. When you come
back online, the next prosa sync (manual or scheduled) reconciles
everything via the manifest.
Sync is one-way: client → server. The server stores; it does not push back.
- Per session, the client computes
sha256(raw). - If
hash == sync_state.last_hash, no-op. - Otherwise:
Push(session, turns, tools, raw)over Connect. The server stores the raw in S3, upserts Postgres, and returns the S3 URI. - The client updates
sync_state.last_hashand records the confirmed push (pushed_at,pushed_hash,remote_uri) — the local evidenceprosa pruneand the sync advisory rely on.
Re-syncs are cheap: every session re-hashes in milliseconds; unchanged sessions become no-ops.
There is no diff sync, no byte-range upload, no per-turn streaming. Hashing
the whole file is fast enough — see
../INTENT.md.
The store has two layers, each with one job:
- Metadata + FTS in a database (SQLite locally, Postgres remotely). Indexed; queryable by SQL. Holds session metadata, the extracted turns for FTS, and tool aggregates.
- Raw transcript files on disk (locally) or in S3 (remotely). Hash-addressed. Never mutated.
The raw layer is the source of truth for content until a session is pruned;
from then on the server's copy is, reachable by session id via GetRaw.
The metadata layer is an index of sessions whose agent source is still on
disk. The next sync rebuilds those rows by re-importing that source. A
pruned session whose source is gone stays only on the server until a raw
read streams it with GetRaw.
There is no DuckDB, no Parquet, no content-addressable store. SQLite +
Postgres is enough. See
../INTENT.md for why.
In a sentence: the smallest thing that lets one person answer what did I work on in the last N days? across the agents and machines they use, including the analytics around the work.
Specifically in scope:
- Three Go binaries:
prosa,prosa-server,prosa-panel. - Importers for Claude Code, Codex, Cursor, Gemini, Antigravity, Hermes, and Grok Build.
- Single-user auth (PKCE + localhost callback for CLI, OAuth + whitelist for panel).
- Push-only sync, idempotent by sha256.
- Chronological timeline with project/agent/device/time filters.
- FTS over turns (local and remote).
- Fixed analytics reports, including session/tool/model/project/error counts, daily activity heatmap, and token usage/cost estimates where agents expose reliable counters.
- Scheduled background sync.
Specifically out of scope (today):
- Multi-tenant. The schema has no
user_id. Organizations and users are a known post-MVP direction. - MCP server. High-value, possibly post-MVP.
- Residential TUI. The panel handles long-form browsing.
- Export. The raw is on disk; roll your own.
- Redaction at upload. TLS in transit; trust at rest.
- Pull-down of remote sessions to the local store. Push-only stays push-only for sync; the one read-back is streaming a pruned session's raw on demand, and it never re-enters the local store.
- Automatic retention. Pruning exists (
prosa prune) but only runs when you invoke it;prosa syncmerely advertises what it would reclaim. - Cold-tier object storage. One bucket.
- Incremental upload by byte range or turn. Whole-file hash is fast enough.
- DuckDB / Parquet / columnar sidecars.
When in doubt about scope, see
../INTENT.md and ask whether
the change serves the central question.