Process shape
One Node process, built by Vite and run by systemd. npm run build is vite build
(package.json:8) and the adapter emits build/index.js, which is what the unit executes.
Deployment mechanics live in operations; this page covers what the process does.
Two things about the build configuration are unusual enough to state plainly:
- There is no
svelte.config.jsin the repository. Both the adapter and the Svelte 5 runes compiler option are passed inline to thesveltekit()Vite plugin (vite.config.ts:9-15), withrunesenabled for every file outsidenode_modules(vite.config.ts:11-12). - Vitest is configured in the same file rather than a separate config, via
test.include(vite.config.ts:17-19).
The dependency set is deliberately small: @lucide/svelte, github-slugger, remark,
remark-frontmatter, remark-parse and unified at runtime
(package.json:29-36); Tailwind 4, daisyUI 5, adapter-node and Vitest as dev dependencies
(package.json:14-28). Notably remark-gfm is absent, which has direct consequences for table
parsing — see md-parser.
Storage layout
There is no database. DATA_DIR is the single storage root, resolved once at module load from the
environment with a hardcoded fallback (src/lib/server/store.ts:10), and split into two
subtrees (src/lib/server/store.ts:11-12):
Citation shorthand on this page: a bare store.ts means src/lib/server/store.ts and a bare
importer.ts means src/lib/server/importer.ts. Every other path is written in full.
| Path | Written by | Contents |
|---|---|---|
decks/<id>/deck.json | writeDeck (store.ts:139-144) | the whole parsed FrameDeck |
decks/<id>/assets/... | copyAssets (importer.ts:207-239) | images copied in at import, subpaths preserved |
sessions/<id>/session.json | createSession, then every commit (store.ts:153-168, store.ts:349) | id, deckId, createdAt, lastSeq, optional collab link |
sessions/<id>/events.jsonl | commit (store.ts:347) | one JSON event per line, append-only |
sessions/<id>/scribbles/ | saveScribble (store.ts:409-414) | PNG overlays, named <frame>--<seq>.png |
The skeleton is created lazily and exactly once: ensureDataDir memoises its promise and clears it
again if the mkdir fails, so a transient failure does not poison every later call
(src/lib/server/store.ts:37-48).
Ids are validated, not trusted. A deck or session id must match /^[a-z0-9][a-z0-9._-]*$/i and stay
under 128 characters, or the call throws a 400 (src/lib/server/store.ts:14-15,
src/lib/server/store.ts:59-64). Every derived path then goes through contain, which resolves the
target and rejects anything that is not the root itself or below it
(src/lib/server/store.ts:51-57). Deck asset paths get the same treatment against the deck’s own
asset directory, because the subpath arrives from the URL (src/lib/server/store.ts:74-81).
JSON records are written atomically — temp file with random suffix, then rename
(src/lib/server/store.ts:83-88) — so a crash mid-write cannot leave a truncated session.json
or deck.json.
The append-only event bus
events.jsonl is the source of truth for a session. The server owns seq and ts; a client may
only supply the semantic fields (src/lib/types.ts:35-36). The full event shape and its validation
rules are documented in api.
Appends are serialised per session. withSessionLock keeps one promise chain per session id and
runs the next task whether the predecessor resolved or rejected, so a single failure cannot stall
the chain (src/lib/server/store.ts:302-320). Everything that assigns a seq runs inside that
chain, which is what makes interleaving or seq reuse impossible.
Sequence numbers come from highWaterSeq, not from session.json alone. It starts at
meta.lastSeq but scans the log and takes the maximum, then caches the result per session
(src/lib/server/store.ts:322-332). That is a deliberate guard: if session.json ever lost an
update, the next append still lands above every seq already on disk rather than colliding with
one. commit then builds the event, appends one line, updates session.json atomically, and
publishes to the broker — in that order, so nothing is announced before it is durable
(src/lib/server/store.ts:334-352).
Reads are tolerant by design. readEvents skips empty lines, and a line that fails JSON.parse is
skipped individually rather than failing the whole read
(src/lib/server/store.ts:246-253). A torn final line — the realistic failure mode for an
append-only log — therefore costs one event, not the session. Events at or below the requested
afterSeq are filtered out in the same pass (src/lib/server/store.ts:254).
Two writes deliberately bypass the event log. Scribble uploads store the PNG and log only the
resulting filename, never the data URL (src/lib/server/store.ts:383-386), and the collab room
link is written straight into session.json and never into an event at all
(src/lib/server/store.ts:360).
The broker and SSE
Live fan-out is a single EventEmitter with its listener cap removed, one listener per open stream
(src/lib/server/broker.ts:9-11). publish emits on a per-session channel
(src/lib/server/broker.ts:13-15) and subscribe returns an unsubscribe function that stream
handlers are required to call (src/lib/server/broker.ts:17-24).
This is in-process only. The module documents it as single-process by design, on the basis that the
service runs as one Node instance under systemd (src/lib/server/broker.ts:4-8). There is no
cross-process pub/sub, so a second replica would not see the first replica’s events. Anything that
needs a durable or cross-process view reads events.jsonl or polls with ?after=.
The replay-to-live handoff is the delicate part, and the stream route solves it by ordering:
subscribe(id, ...) <- listener installed first, events buffered
write(': connected')
readEvents(id, after) <- replay, each event raises lastSeq
replaying = false
flush buffered <- seq gate drops anything replay already sent
setInterval(': ping') <- 25s keepalive
Subscription happens before the replay read and incoming events are buffered until the replay
finishes; the seq <= lastSeq gate then drops whatever the replay already covered, so an event
committed mid-replay is delivered exactly once
(src/routes/api/sessions/[id]/stream/+server.ts:44-65, gate at line 48). The keepalive comment
runs every 25 seconds (src/routes/api/sessions/[id]/stream/+server.ts:7,
src/routes/api/sessions/[id]/stream/+server.ts:67). A failed write or a cancel tears down both
the listener and the timer through one idempotent shutdown
(src/routes/api/sessions/[id]/stream/+server.ts:27-31,
src/routes/api/sessions/[id]/stream/+server.ts:71-73). The response sets
x-accel-buffering: no so a reverse proxy does not buffer the stream
(src/routes/api/sessions/[id]/stream/+server.ts:81).
How the client maps onto the files
The workspace route loads three things and tolerates two of them failing: the session with its deck
(hard failure, 404 or 502), then the event history, then the deck list
(src/routes/s/[sessionId]/+page.ts:6-33).
| Surface | File | Reads | Writes |
|---|---|---|---|
| Left rail | src/lib/components/Rail.svelte | deck sections, route_done set | nothing; emits a jump callback |
| Frame list | src/lib/components/FrameCard.svelte | one Frame | verdict, note, task, scribble callbacks |
| Dialog rail | src/lib/components/DialogRail.svelte | the event array | nothing; emits a jump callback |
| Voice | src/lib/voice.ts | agent say events | speech events |
| Scribble | src/lib/Scribble.svelte | the frame image | POST /api/sessions/:id/scribble |
| Collab rail | src/lib/components/CollabRail.svelte | GET /api/sessions/:id/collab | POST and DELETE on the same path |
The rail groups sections by heading depth: level 2 or shallower starts a new group, deeper sections
become its items (src/lib/components/Rail.svelte:19-35). A group counts as done only when every
one of its frame-bearing sections is in the done set
(src/lib/components/Rail.svelte:49-52), and its icon is chosen from the section’s findings and
first frame type (src/lib/components/Rail.svelte:54-62).
Frame cards are keyed by DOM id frame-<frame.id> (src/lib/components/FrameCard.svelte:44), which
is why the page scrolls with getElementById and never querySelector — frame ids contain a slash
(src/routes/s/[sessionId]/+page.svelte:112-114). Image frames render frame.src lazily
(src/lib/components/FrameCard.svelte:84) while table and code frames render frame.caption
directly (src/lib/components/FrameCard.svelte:88-90), which is the client-side consequence of the
parser quirk described in md-parser. A card carries a finding or clean badge from
frame.badges plus a separate verdict badge when one has been recorded
(src/lib/components/FrameCard.svelte:55-66).
Event flow in the browser is idempotent on seq. ingest drops any event whose seq it has
already seen, then keeps the array sorted (src/routes/s/[sessionId]/+page.svelte:87-90). Posting
an event optimistically ingests it using the server-assigned seq from the response, so the SSE
echo of the same event deduplicates against it
(src/routes/s/[sessionId]/+page.svelte:96-110). The stream is reconnected with exponential backoff
from one second, doubling to a 30-second ceiling, always resuming from the highest seq held
locally (src/routes/s/[sessionId]/+page.svelte:278-303).
Navigation is derived, not clicked. An IntersectionObserver over the scroll container tracks the
active section (src/routes/s/[sessionId]/+page.svelte:330-341) and a debounced effect emits at
most one nav event per section change, one second after it settles
(src/routes/s/[sessionId]/+page.svelte:317-327). On reopen, the last nav event in the history
restores scroll position and focus (src/routes/s/[sessionId]/+page.svelte:272-275,
src/routes/s/[sessionId]/+page.svelte:120-129).
Speech is browser-side in both directions. Agent say events are spoken only when the speaker
toggle is on (src/routes/s/[sessionId]/+page.svelte:93) through a queued
speechSynthesis wrapper that strips markdown before speaking
(src/lib/voice.ts:76-94, src/lib/voice.ts:123-130). Push-to-talk uses SpeechRecognition
(src/lib/voice.ts:162) and publishes the final transcript as a user speech event
(src/lib/voice.ts:232-245).
The dialog rail hides status events entirely (src/lib/components/DialogRail.svelte:22), maps each
kind to a Lucide icon (src/lib/components/DialogRail.svelte:24-41), and auto-scrolls to the newest
entry on the next animation frame (src/lib/components/DialogRail.svelte:47-54).
Read next: api for the endpoint contracts, md-parser for the deck format, operations for the unit and vhosts, or back to index.