Documentation
¶
Overview ¶
This file implements session export/import (US-008, #124): a session can be exported to a self-contained JSONL or HTML file, and a JSONL export can be imported back as a fresh, resumable session. The JSONL form is the same role-discriminated schema the store persists, so an export → import round-trip is lossless (message contents and the id/parentId tree survive verbatim); the HTML form is a read-only, self-contained transcript with inline styles and no external network resources, suitable for sharing.
This file holds the HTML rendering helpers for session export (US-008, #124). The output is a single self-contained document: all CSS is inlined in a <style> block, there are no <script> tags, no external fonts, and no network requests, so the transcript renders identically offline and cannot phone home. Every piece of session-derived text is escaped via html.EscapeString before it reaches the document.
Package session implements local JSONL session persistence and resume (US-024, #43). A session is stored as a single append-only JSONL file: the first line is a SessionHeader (schema version + metadata), and every subsequent line is one persisted message (user / assistant / toolResult), using the same "role"-discriminated encoding as agentcore.MessageList.
The format is internally self-consistent and deliberately NOT wire-compatible with pi's session files (spec #16, session-format decision #5): golder owns the schema and versions it via SessionHeader.Version so future migrations have a hook.
A persisted session round-trips into an agentcore.AgentContext via Load, so a run can be resumed by feeding the reconstructed context to a fresh run and the transcript replays correctly in the REPL.
This file implements the shareable session export (issue #570, ported from pi's session-sharing practice): a Markdown rendering of a session suitable for archiving or publishing, with credential redaction ON by default so a transcript that touched real keys can be shared without leaking them.
Redaction covers the credential shapes pi-share-hf warns about and more: sk-… API tokens, GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_), AWS AKIA ids, Slack xox… tokens, and env-shaped assignments (…_API_KEY=…, …_TOKEN=…, …_SECRET=…). Redaction applies to every text block in every message; the session header's system prompt is dropped unless explicitly included (it frequently carries environment details the user does not want published).
The JSONL format stays the lossless resumable export (export.go); sharing a JSONL with redaction is allowed but breaks the round-trip by design — the flag exists for archival of transcripts that touched real secrets.
Index ¶
- Constants
- func ExportFormatFromPath(outPath string) string
- func FileName(id string) string
- func NewID(now time.Time) string
- func ProjectRoot(dir string) string
- func ReadJSONL(r io.Reader) (SessionHeader, []Entry, error)
- func RedactText(s string) string
- func SameProject(a, b string) bool
- func WriteHTML(w io.Writer, header SessionHeader, entries []Entry) error
- func WriteJSONL(w io.Writer, header SessionHeader, entries []Entry) error
- func WriteMarkdown(w io.Writer, header SessionHeader, entries []Entry, opts ShareOptions) error
- type Entry
- type SessionHeader
- type ShareOptions
- type Store
- func (s *Store) Append(id string, updatedAt time.Time, messages agentcore.MessageList) error
- func (s *Store) AppendBranch(header SessionHeader, parentLeafID string, messages agentcore.MessageList) (string, error)
- func (s *Store) Dir() string
- func (s *Store) Export(id, outPath string) (int, error)
- func (s *Store) ExportShare(id, outPath, format string, opts ShareOptions) (int, error)
- func (s *Store) Fork(sourceID, leafID string, now time.Time) (SessionHeader, []Entry, error)
- func (s *Store) Import(inPath string, now time.Time) (SessionHeader, []Entry, error)
- func (s *Store) List() ([]SessionHeader, error)
- func (s *Store) Load(id string) (SessionHeader, agentcore.MessageList, error)
- func (s *Store) LoadEntries(id string) (SessionHeader, []Entry, error)
- func (s *Store) Save(header SessionHeader, messages agentcore.MessageList) error
- func (s *Store) SaveEntries(header SessionHeader, entries []Entry) error
- type TreeLine
Constants ¶
const SchemaVersion = 3
SchemaVersion is the current session file schema version. It is written into every SessionHeader and checked on read; an unknown (higher) version is a hard error so a newer file is never silently misread by an older binary.
v2 adds the inline "compaction" message role (US-003): a compaction checkpoint persisted as one message line.
v3 gives every persisted entry an id/parentId, forming a tree (US-005, #121) — the prerequisite for fork/clone/tree navigation. Each message line is wrapped as {"id","parentId","timestamp","message":{…}}. v1/v2 files (bare message lines) remain fully readable: readSession migrates them on load by synthesizing ids and chaining parentId to the previous entry, so old sessions still load and resume.
Variables ¶
This section is empty.
Functions ¶
func ExportFormatFromPath ¶
ExportFormatFromPath infers a share format from an output path's extension.
func NewID ¶
NewID returns a time-ordered session id: a UTC timestamp stem that sorts lexicographically by creation time (e.g. "20260710-142530-uniq"). The suffix disambiguates sessions created within the same second.
func ProjectRoot ¶ added in v1.2.16
ProjectRoot returns the directory that identifies dir's project: the nearest ancestor of dir (dir itself included) holding a .git entry, else dir itself.
The probe is a filesystem stat, not a git subprocess: it costs nothing worth caching, it works on a machine without git installed, and it needs no accounting for a missing or broken git. Nested checkouts therefore collapse to the innermost repository (the submodule boundary), which is also where git itself draws the line.
dir is made absolute before the walk, so a relative launch directory matches the absolute path a session recorded. An empty or unresolvable dir yields "".
func ReadJSONL ¶
func ReadJSONL(r io.Reader) (SessionHeader, []Entry, error)
ReadJSONL decodes a JSONL export (as produced by WriteJSONL or a raw session file) into a header and entries, migrating v1/v2 bare-message files the same way LoadEntries does. It is the import counterpart to WriteJSONL.
func RedactText ¶
RedactText strips credential-shaped substrings, returning shareable text.
func SameProject ¶ added in v1.2.16
SameProject reports whether directories a and b belong to the same project. Two unresolvable paths are never "the same project": attribution requires a real directory on both sides.
func WriteHTML ¶
func WriteHTML(w io.Writer, header SessionHeader, entries []Entry) error
WriteHTML writes a self-contained HTML transcript of the session: inline CSS only (no external stylesheets, fonts, scripts, or network resources), role color-coding, and tool-call/result blocks. All message text is HTML-escaped so a transcript containing markup or a crafted "</script>" cannot break out of its container or inject active content (defensive against a hostile session).
func WriteJSONL ¶
func WriteJSONL(w io.Writer, header SessionHeader, entries []Entry) error
WriteJSONL writes header + entries as JSONL in the store's on-disk schema (header line first, then one entry line each, ids/parentIds preserved). It is the export counterpart to writeSessionEntries and the exact input ReadJSONL expects, so a WriteJSONL → ReadJSONL round-trip is lossless.
func WriteMarkdown ¶
func WriteMarkdown(w io.Writer, header SessionHeader, entries []Entry, opts ShareOptions) error
WriteMarkdown renders header + entries as a shareable Markdown transcript.
Types ¶
type Entry ¶
type Entry struct {
// ID is this entry's stable identifier (see newEntryID). Unique within a file.
ID string
// ParentID is the ID this entry descends from; empty for a root entry.
ParentID string
// Timestamp is when the entry was persisted (RFC 3339, UTC).
Timestamp time.Time
// Message is the wrapped agent message (user / assistant / toolResult / …).
Message agentcore.Message
}
Entry wraps one persisted message with the tree metadata introduced in schema v3 (US-005, #121): a stable ID plus the ParentID it descends from. A linear session is the degenerate tree where every entry's ParentID is the previous entry's ID; the first entry has an empty ParentID (a root). The wrapper is what lets a session fork/clone later — PathToLeaf walks the ParentID chain to reconstruct the linear conversation feeding any leaf.
func PathToLeaf ¶
PathToLeaf walks the ParentID chain from the entry identified by leafID back to its root and returns the entries in root→leaf order — the linear conversation that feeds the given leaf (US-005 acceptance criterion c). An empty leafID (or an unknown one) yields nil. If the chain references a missing parent the walk stops at the last resolvable ancestor rather than failing, so a partially corrupt file still yields the recoverable prefix.
func (Entry) MarshalJSON ¶
MarshalJSON emits the entry as {"id","parentId","timestamp","message":{…}}.
func (*Entry) UnmarshalJSON ¶
UnmarshalJSON decodes an entry line, decoding the inner message with the same discriminated logic as agentcore.MessageList (by wrapping it in a one-element array).
type SessionHeader ¶
type SessionHeader struct {
// Version is the schema version (SchemaVersion at write time).
Version int `json:"version"`
// ID is the session identifier, also the file stem (see FileName).
ID string `json:"id"`
// CreatedAt is when the session file was created (RFC 3339, UTC).
CreatedAt time.Time `json:"createdAt"`
// UpdatedAt is when the session was last appended to.
UpdatedAt time.Time `json:"updatedAt"`
// Model / Provider record what the session ran against, for display (the
// /resume list, exports, /status's session section). They are history, not a
// configuration: resuming a session serves it with the live provider/model of
// the process doing the resuming, exactly as a launch does. Optional.
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
// SystemPrompt is the system prompt the session ran under. Persisted so the
// resumed context is faithful. Optional.
SystemPrompt string `json:"systemPrompt,omitempty"`
// BaseInstruction and AppendInstructions are the USER-AUTHORED halves of
// SystemPrompt: --system-prompt's replacement text (empty when the default
// guide was used) and the --append-system-prompt blocks. They are recorded
// separately because a resumed session rebuilds its prompt: our own text
// (guide, environment block, AGENTS.md, skills) describes the CURRENT binary
// and working tree and is recomputed, while these two are the user's words
// and are carried over verbatim. Optional and additive: sessions written
// before these fields existed omit them and are rebuilt from the current
// defaults.
BaseInstruction string `json:"baseInstruction,omitempty"`
AppendInstructions []string `json:"appendInstructions,omitempty"`
// ParentSession is the id of the session this one was forked/cloned from
// (US-006, #122). Empty for a session created from scratch. It records
// lineage only; a fork is otherwise a fully independent session file.
ParentSession string `json:"parentSession,omitempty"`
// Cwd is the absolute working directory the session ran in, recorded so a
// session can be attributed to a project (its stable project id derives from
// this path). Used by /dream distillation to select the current project's
// recent sessions (SPEC §5.3 / §11.3). Optional and additive: older schemas
// omit it and still load; a session with an empty Cwd is treated as
// unattributed and never matches a project-scoped distill.
Cwd string `json:"cwd,omitempty"`
// ContextFrom is the id of the session this one inherited its collapsed
// context from (#480, "infinite context"). Empty when the session started
// with no inherited checkpoint. Optional and additive: older schemas (v1/v2/v3)
// omit it and still load.
ContextFrom string `json:"contextFrom,omitempty"`
// ContextWatermark is the message index up to which context was collapsed
// into an inherited checkpoint (#480). Messages before this index live in the
// checkpoint summary rather than the replayed transcript. Optional/additive.
ContextWatermark int `json:"contextWatermark,omitempty"`
}
SessionHeader is the first line of a session file: schema version plus the metadata needed to list and resume a session without reading its messages.
type ShareOptions ¶
type ShareOptions struct {
Redact bool
// is dropped by default: it often embeds environment details.
IncludeSystemPrompt bool
}
ShareOptions controls one shareable export.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store persists sessions as JSONL files under a directory (typically ~/.golder/sessions). The zero value is unusable; construct with NewStore.
func (*Store) Append ¶
Append adds messages to an existing session file and bumps its UpdatedAt to updatedAt. It is the incremental-persistence primitive: a driver that wants to grow a session turn-by-turn appends only the newly produced messages rather than rewriting the whole file itself. If the session does not exist it is an error — use Save to create one first.
Because the header lives on the first line and JSONL is otherwise append-only, updating UpdatedAt requires rewriting the file; Append does a load-modify-save under the hood, which is simple and correct for the session sizes golder produces.
func (*Store) AppendBranch ¶
func (s *Store) AppendBranch(header SessionHeader, parentLeafID string, messages agentcore.MessageList) (string, error)
AppendBranch appends messages as a chain descending from parentLeafID, preserving every existing entry — and therefore any other branches — in the session file (US-007, #123). It is the tree-aware counterpart to Append (which rewrites the file as a single linear chain): where Append flattens, AppendBranch grows the on-disk tree, so switching the active leaf to a historical entry and continuing produces a real sibling branch rather than truncating history.
Each message becomes a fresh entry (new id, ParentID chained to the previous one, first chained to parentLeafID). An empty parentLeafID roots the new chain. header (Version forced to SchemaVersion, UpdatedAt as the caller set it) is rewritten as line 1. It returns the id of the new leaf — the last appended entry — so the caller can track the active branch. If the session file does not yet exist it is created (the fresh-session first-turn case).
func (*Store) Export ¶
Export writes the session identified by id to outPath. The format is chosen by outPath's extension: ".html"/".htm" produces a self-contained HTML transcript; anything else (including ".jsonl") produces JSONL. The parent directory of outPath must already exist. It returns the number of entries written so a caller can report progress.
func (*Store) ExportShare ¶
func (s *Store) ExportShare(id, outPath, format string, opts ShareOptions) (int, error)
ExportShare writes the session identified by id to outPath in the share format named by format ("md" or "markdown" for Markdown, "json"/"jsonl" for the redactable JSONL, "html" for the self-contained HTML transcript). The parent directory of outPath must already exist. It returns the number of entries written.
func (*Store) Fork ¶
Fork creates a new session whose contents are the linear path from the root down to leafID in the source session, copied verbatim (each entry keeps its id/parentId). The new session gets a fresh id (derived from now) and its header carries ParentSession = sourceID so the lineage is recorded. It returns the new session's header and its entries.
This is the primitive behind /fork and /clone (US-006, #122):
- /clone passes the current leaf id → the entire current conversation is duplicated into an independent session (position "at").
- /fork passes a historical user message's PARENT id → the new session holds everything up to but excluding that message, so the user re-prompts from that point on a fresh branch (position "before").
Because the copy lands in a brand-new file, appending to either the source or the fork never touches the other — the two branches are fully isolated. An empty leafID copies nothing but the header (an empty new session rooted at the source), which is the correct behavior for forking before the very first message.
func (*Store) Import ¶
Import reads a JSONL export at inPath and materializes it as a fresh session in the store: a new id (derived from now) is assigned, the original id is recorded as ParentSession for lineage, and the entries are written verbatim (ids/parentIds preserved) so the tree — and thus PathToLeaf/resume — behaves exactly as in the source. It returns the new header and the imported entries. An HTML file (or any non-JSONL input) fails to parse and returns an error rather than importing garbage.
func (*Store) List ¶
func (s *Store) List() ([]SessionHeader, error)
List returns the headers of all sessions in the store, sorted by UpdatedAt descending (most recently used first). Files that fail to parse are skipped rather than failing the whole listing, so one corrupt session does not hide the rest.
func (*Store) Load ¶
func (s *Store) Load(id string) (SessionHeader, agentcore.MessageList, error)
Load reads a session file and returns its header and messages. It validates the schema version (an unknown version is rejected) and decodes each entry line, returning the messages in file order (root→leaf for a linear session). Old v1/v2 files (bare message lines) are migrated transparently. Load is the linear view; LoadEntries exposes the id/parentId tree metadata.
func (*Store) LoadEntries ¶
func (s *Store) LoadEntries(id string) (SessionHeader, []Entry, error)
LoadEntries reads a session file and returns its header plus the tree entries (id/parentId + message) in file order. v1/v2 files are migrated on load: every bare message line is wrapped in an Entry with a synthesized id and its ParentID chained to the previous entry, so old sessions load and resume exactly as they did before (US-005 acceptance criterion b/d).
func (*Store) Save ¶
func (s *Store) Save(header SessionHeader, messages agentcore.MessageList) error
Save writes header and messages to a fresh session file, overwriting any existing file for the same id. The header's Version is forced to SchemaVersion; CreatedAt/UpdatedAt are left as the caller set them. Save is the whole-session write; Append adds messages to an existing file.
func (*Store) SaveEntries ¶
func (s *Store) SaveEntries(header SessionHeader, entries []Entry) error
SaveEntries writes header plus the given entries verbatim — preserving each entry's id/parentId — to a fresh session file, overwriting any existing file for header.ID. Unlike Save (which generates fresh ids and a linear chain from a MessageList), SaveEntries persists an already-known tree, which is what Fork needs: it copies a path of existing entries into a new session file without disturbing their identifiers. The header's Version is forced to SchemaVersion.
type TreeLine ¶
type TreeLine struct {
Entry Entry
Text string
// Current is true for the entry whose id == leafID; callers render their own
// tag for it (the REPL appends "← current", the TUI picker marks the row).
Current bool
}
TreeLine pairs one entry with its rendered display line, so a caller can print the tree AND map a 1-based selection index back to the entry it refers to (the slice order is the render order — a stable pre-order DFS). See RenderTreeLines.
func RenderTreeLines ¶
RenderTreeLines renders the entry forest as human-readable lines for a pure line REPL — no TUI, no cursor control (US-007, #123). Each entry becomes one line in render order (element i ↔ the i-th printed line, so 1-based selector n → [n-1]); TreeLine.Current marks the entry behind leafID.
Indentation is chain-compressed: a node with exactly one child continues that child in the same column (no connector, no extra indent), so a linear conversation — the common case — renders as a flat list and its width never grows with the message count. ├─/└─ connectors appear only where the tree actually forks, which keeps the indent proportional to the number of fork points on the path (capped at treeMaxIndentCells), never to session length. Roots (entries with an empty or dangling ParentID) anchor the forest; children are ordered by timestamp then id for stable output.