assistant

package
v1.54.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 22, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package assistant is the cockpit's own conversation: one conversation with an installed coder that is bound to the cockpit instead of to a project.

A conversation is a real provider session driven in non-interactive mode. The cockpit keeps the readable transcript, the conversation context lives in the provider's own session and is resumed by id on every turn.

One substrate, one binding. The substrate is the conversation, its store, the streaming and the process handling; the binding is what makes it the assistant: its own store paths, a working directory of its own, the memory the user can see and edit, and the jobs it steers. The working directory deliberately sits next to the cockpit state, never on top of it: the state directory holds webhook URLs and push keys, and no coder gets those as its default file scope.

This package must not import internal/coder. The coder package refers to assistant.Runner for its optional conversation capability, so an import in the other direction would close a cycle. Everything this package needs from the coder side arrives through the small interfaces in runner.go, wired in main.

<state-dir>/assistant/assistant.json          the conversation index
<state-dir>/assistant/conversations/<id>.json one transcript
<state-dir>/assistant/workspace               the working directory
<state-dir>/assistant/workspace/memory        one markdown file per fact
<state-dir>/assistant/workspace/user-upload/<id> what a message carried

Index

Constants

View Source
const (
	// FrameStart opens a generation. Clients reset their buffer on it.
	FrameStart = "start"
	// FrameDelta appends assistant text.
	FrameDelta = "delta"
	// FrameHTML carries the answer so far, rendered on the server. Markdown is
	// only additive as long as nothing is half open (a fence, a table, an
	// emphasis), so the browser never parses the stream itself: it shows the
	// raw text as it arrives and replaces it with the rendered prefix whenever
	// one of these lands, which also keeps model output out of any client side
	// parser.
	FrameHTML = "html"
	// FrameTool reports that the provider is working with a tool.
	FrameTool = "tool"
	// FrameEnd closes a generation, carrying the final message state.
	FrameEnd = "end"
	// FrameMessage announces a message that appeared without a generation the
	// page was following: a wake writing its report, or a message that queued
	// while a turn ran. The client pulls that one message and appends or
	// replaces it, and touches nothing else, because a chat answer may be
	// streaming at the same time.
	FrameMessage = "message"
	// FrameGone announces a message that was removed while it waited, so every
	// open page drops its bubble.
	FrameGone = "gone"
	// FramePing proves the stream is alive to a browser that cannot see the SSE
	// keepalive, which is a comment and fires no event. Silence in the middle
	// of an answer is the normal case, thinking sends nothing at all, so only a
	// missing life sign says the socket died. It carries nothing, the client
	// stamps it and drops it, and it never travels the hub: the stream handler
	// writes it on a beat of its own.
	FramePing = "ping"
)

Stream frame kinds. Every frame carries the run and message id it belongs to, so a stream that outlived a cancelled turn can never write into the message of a newer retry.

View Source
const (
	// MaxPromptBytes is the largest accepted user message.
	MaxPromptBytes = 32 << 10
	// MaxResponseBytes is the largest accepted assistant answer.
	MaxResponseBytes = 1 << 20
	// MaxTitleRunes bounds a conversation title.
	MaxTitleRunes = 120
	// MaxQueuedMessages bounds what may wait while a turn runs. The flush joins
	// every waiting message into one prompt, and that prompt has to stay within
	// what a process argument accepts.
	MaxQueuedMessages = 20
	// DefaultTitle names a conversation that has not seen a prompt yet.
	DefaultTitle = "New conversation"
)

Limits the feature enforces before a prompt reaches a process and while a response is parsed. The prompt bound keeps the argv within what execve accepts, the output bound keeps one runaway answer from growing the state file without limit.

View Source
const BlockSeparator = "\n\n"

BlockSeparator stands between two text blocks of one turn. A turn that works with tools answers in several blocks, what it says before a call and the answer after it, and every runner hands those over as one stream of deltas that is appended as it arrives, so without it the end of one block and the start of the next are welded into one word. It is the blank line markdown needs between two blocks, and a runner writes it only where the provider's own output says a block ended: never in front of a turn's first block, never behind its last, and never guessed from the text that is already there.

View Source
const ContextTierLong = "long_context"

ContextTierLong is the wide context tier a coder may put a session on. It is copilot's name for it, and the only tier name that changes a window.

View Source
const MaxMemoryBytes = 16 << 10

MaxMemoryBytes bounds one memory entry. The memory is read on every turn, so a runaway file would be paid for again and again.

View Source
const MaxSessionNameBytes = 80

MaxSessionNameBytes bounds the name a provider session is created with. A conversation title is written for the page and may be long; copilot refuses a name over 100 characters, so a turn hands over a name both CLIs take.

View Source
const Name = "Assistant"

Name is what the surface is called in the UI.

View Source
const TranscriptEntriesShown = 6

TranscriptEntriesShown is how many messages a transcript reading keeps by default: enough to see where a conversation stands, small enough to stay a fraction of the answer that carries it.

View Source
const TranscriptMessageRunes = 600

TranscriptMessageRunes is how many runes one message may cost in a capped transcript reading. Callers pass it as the budget; zero lifts the cap, for the one answer that needs a message whole.

Variables

View Source
var ErrBusy = errors.New("The coders are busy with other conversations right now. Try again in a moment.")

ErrBusy is returned when the global generation limit is reached.

View Source
var ErrNotLoggedIn = errors.New("The coder is not logged in on this machine. Start it once in a terminal, log in there, and send this again.")

ErrNotLoggedIn is what a parser returns when its CLI never got going because nobody logged it in on this machine. The sentence lives here and not next to a coder on purpose: a parser answers whether it happened, this package owns what the user reads about it, so no CLI's own wording leaks into a conversation.

Functions

func ContextWindow

func ContextWindow(coderID, model, tier string) int

ContextWindow resolves how much this coder's model holds under this tier, 0 when the table says nothing about it. An empty tier means the standard one, which is what a session runs under unless the coder put it on a wider tier. The lookup is case insensitive and ignores a date suffix, so a dated release like `claude-haiku-4-5-20251001` finds the entry its family has.

func DoneWhenLine

func DoneWhenLine(doneWhen string) string

DoneWhenLine is what a report or a list shows as a job's done-when. A job from the page may carry none: then the session's own task decides, and every surface says exactly that instead of an empty line.

func IsCheckSession

func IsCheckSession(name string) bool

IsCheckSession reports whether a stored provider session was a check's own. Used at startup to sweep what a hard restart left behind: nothing running answers to these names, because a live check keeps its session reserved and invisible for as long as it runs.

func JobsPath

func JobsPath(stateDir string) string

JobsPath is where the jobs live for a state directory.

func LooksLikeLogin

func LooksLikeLogin(text string) bool

LooksLikeLogin reports whether text reads like a CLI that was never logged in. It is exported because every coder answers that question about its own output, and the pattern behind the answer stays one.

func MediaKind

func MediaKind(name string) string

MediaKind maps a file name onto how the browser should show it.

func New

func New(stateDir string, coders Coders, cockpit Cockpit) (*Service, *Workspace, error)

New wires the assistant over the installed coders and returns both halves: the conversation service that drives the conversations and the assistant service that owns workspace and memory. cockpit describes how a turn can look at the cockpit itself, it may be empty.

func Paths

func Paths(stateDir string) (index, conversations, uploads string)

Paths returns the assistant's store locations for a state directory. The inspection commands build the same store to know which provider sessions belong to a conversation, so the layout lives in one place.

func RunPaths

func RunPaths(stateDir string) (index, dir string)

RunPaths returns the register and the directory of raw output files for a state directory.

func SessionName

func SessionName(title string) string

SessionName cuts a conversation title down to a provider session name, on a rune boundary so a multi byte title never breaks in the middle of a character.

func SortJobs

func SortJobs(list []Job)

SortJobs puts the jobs that still wake the assistant first, then the newest. Exported because every list of jobs is read in this order, the page, the conversation and `dev-cockpit assistant job-list`, and one order means one function.

func TruncateTask

func TruncateTask(raw string) (string, string)

TruncateTask cuts a task to its bound and says so. The task only describes, it may be a whole briefing, so a long one is stored cut instead of refused, but the cut must not stay silent. The second return is the notice for the caller, empty when nothing was cut. Like ValidateDoneWhen it is the one rule set: Steer applies it, and the routes that answer a caller take the notice from the same function instead of copying the bound and the sentence.

func UnreadableLine

func UnreadableLine(line []byte) string

UnreadableLine shortens a raw output line for the server log. A parser that meets a line it cannot decode logs it through this and reads on, so the log says what arrived without carrying a whole answer, and the turn's outcome stays with the records the parser does evaluate.

func ValidID

func ValidID(id string) bool

ValidID reports whether an id is usable as a conversation identifier.

func ValidateDoneWhen

func ValidateDoneWhen(raw string) (string, error)

ValidateDoneWhen normalizes a done-when and refuses what a job cannot store: an empty one, and one over the bound. Lines survive, a done-when may be a list with one check per line, so only the line endings and the edges are normalized; folding to one line is a display concern of the places that need one. It is the one rule set: Steer applies it, and whoever wants to check a done-when before anything else exists (the coder create route, so a refused done-when cannot leave a running coder without its job) calls the same function instead of copying the bound and the message.

Types

type Activity

type Activity struct {
	// Text is what the session last did, newest last.
	Text string
	// Finished says its turn is over: it is waiting, not working.
	Finished bool
	// Screen says Text is the terminal picture rather than a recorded
	// conversation. Then the reading carries the coder's input line, and the
	// prompt has to say so.
	Screen bool
}

Activity is what the coder answered about a steered session: what it last did and whether it is still doing it. Why it stopped is deliberately absent. A dialog waiting for an answer, a context window with no room left and a turn that is simply over are the same picture from here, and the difference is not a flag anybody can set reliably: it is a screen that has to be read. The check reads it.

type AdoptedCheck

type AdoptedCheck struct {
	Terminal string
	// Context is what the check found before it started, carried through the
	// register so the job is judged the same way whether or not a restart
	// happened in between.
	Context checkContext
	// contains filtered or unexported fields
}

AdoptedCheck is a check that outlived the server. The watcher takes it from here: it is the one that knows the job the check belongs to.

type Attachment

type Attachment struct {
	Name string `json:"name"`
	// Path is the absolute host path. It stays in the transcript so a later
	// turn can point the coder at the same file again.
	Path string `json:"path"`
	// Media classifies the file for the browser: image, video, audio or file.
	Media string `json:"media"`
	Size  int64  `json:"size"`
}

Attachment is one file a prompt carries. The cockpit stores it inside the conversation's own files directory and hands the coder the absolute path, so a coder that can look at images gets a real file instead of a copy of the bytes.

type Cockpit

type Cockpit struct {
	Executable  string
	StateDir    string
	ProjectsDir string
	// Version is what this build calls itself, a release tag or a dev build
	// with its commit. Named in the instructions, so an answer about the
	// software is about the software that is actually running.
	Version string
	// RepoURL is the web page of the repository this software lives in, a
	// full URL. The instructions name it, so a question about the
	// implementation has somewhere to go when the source is not on the
	// machine it answers from.
	RepoURL string
}

Cockpit is how a turn looks at the cockpit itself: the absolute path of the running binary plus the arguments that point its read only inspection commands at this instance's data. Passing the resolved path means the assistant never depends on where the binary sits or on PATH.

type CoderInfo

type CoderInfo struct {
	ID     string
	Label  string
	Runner Runner
}

CoderInfo describes one able to answer a turn coder.

type Coders

type Coders interface {
	Available() []CoderInfo
}

Coders resolves the coders that can answer a turn of this installation. Implemented outside this package, see the package comment on the import direction.

type Command

type Command struct {
	Name string
	Args []string
}

Command is the process one turn runs: a program and its arguments, never a shell line. A prompt travels in the argv, so it can never become a command.

type ContextUsage

type ContextUsage struct {
	// Model is what answered the turn, as the provider named it. It is kept so
	// a reading can be understood later, and so the window can be resolved
	// again when the table grows.
	Model string `json:"model,omitempty"`
	// Tier is the context tier the coder had the session on, empty for a coder
	// without tiers. It is kept for the same reason as the model: it is half of
	// what the window is looked up by.
	Tier string `json:"tier,omitempty"`
	// Tokens is what the context held at the end of the turn.
	Tokens int `json:"tokens"`
	// Window is how much that model's context holds, zero when unknown.
	Window int `json:"window"`
}

ContextUsage is how full a coder's context window stood at the end of one turn. It is a per turn reading, not a running total: after a compact the tokens drop, and the value simply follows.

func (ContextUsage) Known

func (u ContextUsage) Known() bool

Known reports whether this reading can be shown as a percentage.

func (ContextUsage) Percent

func (u ContextUsage) Percent() int

Percent is how full the window stands, 0 when the reading says nothing. A window that is over full reads as 100: the provider is the one that knows where its own limit sits, and a number above it would only look broken.

func (ContextUsage) PercentIn

func (u ContextUsage) PercentIn(coderID string) int

PercentIn is Percent for a reading whose window was never resolved. What the turn reported is the model and the tokens, and those never change; how large that model's window is, is a lookup, so a reading taken while the table still said nothing about the model fills in by itself once it does, without waiting for another turn.

type Conversation

type Conversation struct {
	Summary
	// NativeSessionID is the provider session this conversation drives. It equals the
	// conversation id, which is UUID shaped so it also works as a tmux session name
	// once the conversation is transferred.
	NativeSessionID string `json:"nativeSessionId"`
	// TransferredSessionID is the coder terminal that took over, set once.
	TransferredSessionID string    `json:"transferredSessionId,omitempty"`
	UpdatedAt            time.Time `json:"updatedAt"`
	Messages             []Message `json:"messages"`
	// Draft is what was typed into the composer and not sent yet. It belongs to
	// the conversation, not to the browser that typed it, so the same words are
	// there after a page change and on the next device.
	Draft Draft `json:"draft,omitempty"`
	// Context is how full the coder's context window stood at the end of the
	// last turn that reported it. It lives on the conversation, not on a
	// message: it describes the whole conversation as it stands, and a reader
	// coming back has to see the number without a turn running.
	Context *ContextUsage `json:"context,omitempty"`
}

Conversation is one complete conversation with its transcript.

func (Conversation) Idle

func (c Conversation) Idle() bool

Idle reports whether the conversation currently has no unfinished assistant turn.

func (Conversation) Last

func (c Conversation) Last() (Message, bool)

Last returns the final message of the transcript.

type Draft

type Draft struct {
	Text        string       `json:"text,omitempty"`
	Attachments []Attachment `json:"attachments,omitempty"`
	UpdatedAt   time.Time    `json:"updatedAt,omitempty"`
}

Draft is an unsent prompt with the files that were already uploaded for it. The files travel with the text, otherwise coming back shows a message whose attachments are gone.

func (Draft) Empty

func (d Draft) Empty() bool

Empty reports whether there is nothing to restore.

func (Draft) Same

func (d Draft) Same(text string, attachments []Attachment) bool

Same reports whether a draft would store exactly what is already stored, which is how a repeated flush avoids rewriting the transcript.

type Entry

type Entry struct {
	Slug    string
	Title   string
	Body    string
	Updated time.Time
}

Entry is one thing the assistant knows about the user.

type Event

type Event struct {
	Kind EventKind
	Text string
	Err  error
	// Usage carries the context reading of an EventUsage and is nil otherwise.
	Usage *ContextUsage
}

Event is one structured message from a provider run. The channel closing without an EventError means the turn completed.

type EventKind

type EventKind string

EventKind classifies a runner event.

const (
	// EventDelta carries assistant text to append.
	EventDelta EventKind = "delta"
	// EventTool marks that the provider started working with a tool. It
	// carries no arguments, the UI only shows that something is happening.
	EventTool EventKind = "tool"
	// EventUsage reports how full the coder's context window stands. It arrives
	// at most once per turn, at its end, because that is when the provider says
	// it; a later one replaces an earlier one.
	EventUsage EventKind = "usage"
	// EventError ends the turn with a curated, user facing message.
	EventError EventKind = "error"
)

type Job

type Job struct {
	// Terminal is the coder session, which is also the id its news arrives
	// under, so a signal resolves to a job with one lookup.
	Terminal string `json:"terminal"`
	Name     string `json:"name"`
	Project  string `json:"project"`
	CoderID  string `json:"coderId"`
	// Task and DoneWhen are the job itself: one thing to do and the one
	// criterion that decides it is done. A job that needs a second step gets it
	// the same way the first one arrived, from the assistant a DONE woke.
	Task      string    `json:"task"`
	DoneWhen  string    `json:"doneWhen"`
	State     JobState  `json:"state"`
	Wakes     int       `json:"wakes"`
	MaxWakes  int       `json:"maxWakes"`
	CreatedAt time.Time `json:"createdAt"`
	ExpiresAt time.Time `json:"expiresAt"`
	UpdatedAt time.Time `json:"updatedAt"`
	// Note is the one line the last check left, and NoteAt when that was. A
	// check without news writes nothing else, so this is where the user sees
	// that the job is alive.
	Note   string    `json:"note,omitempty"`
	NoteAt time.Time `json:"noteAt,omitempty"`
	// LastAssistantInputAt is when this terminal last got input from an
	// assistant turn. The standstill rule reads it: a check that reports
	// WORKING on a coder that stood still must really have sent it something.
	LastAssistantInputAt time.Time `json:"lastAssistantInputAt,omitempty"`
	// LastWakeAt is when the last check ran. The heartbeat reads it: its own
	// quiet window keeps a paid check from being followed by another look
	// right away.
	LastWakeAt time.Time `json:"lastWakeAt,omitempty"`
	// CheckingSince is set while a check is running, so the page can say so and
	// nobody has to read a counter to know. A restart leaves it standing, and
	// the watcher picks those jobs up when it comes back.
	CheckingSince time.Time `json:"checkingSince,omitempty"`
	// Silent counts the checks in a row that came back without a verdict: a
	// crash, a time limit, an answer with no words. Those cost no wake, so
	// something has to stop a job from retrying them forever.
	Silent int `json:"silent,omitempty"`
	// ActivityDigest and ActivityAt are what the coder last looked like and when
	// that changed. A picture that stops changing is how the heartbeat tells a
	// coder that is working from one that is stuck without being able to say so.
	ActivityDigest string    `json:"activityDigest,omitempty"`
	ActivityAt     time.Time `json:"activityAt,omitempty"`
}

Job is one steered job.

func (Job) Checking

func (j Job) Checking() bool

Checking reports whether a check is running on this job right now.

func (Job) Spent

func (j Job) Spent() bool

Spent reports whether the job used up its budget.

type JobState

type JobState string

JobState is where a job stands.

const (
	// JobSteering is a job that still wakes the assistant.
	JobSteering JobState = "steering"
	// JobDone is a job whose criterion was met.
	JobDone JobState = "done"
	// JobBlocked is a job that cannot go on without the user.
	JobBlocked JobState = "blocked"
	// JobExpired is a job that ran out of wakes or out of time.
	JobExpired JobState = "expired"
	// JobStopped is a job the user or the assistant called off.
	JobStopped JobState = "stopped"
)

func (JobState) Open

func (s JobState) Open() bool

Open reports whether this state still wakes the assistant.

type JobStore

type JobStore struct {
	// contains filtered or unexported fields
}

JobStore persists the jobs. One file, read through on every call like every other state file, so a job survives a restart and two processes cannot hold different ideas about it.

func NewJobStore

func NewJobStore(stateDir string) *JobStore

NewJobStore returns the store for a state directory.

func (*JobStore) Delete

func (s *JobStore) Delete(terminal string)

Delete removes the job of one terminal.

func (*JobStore) Get

func (s *JobStore) Get(terminal string) (Job, bool)

Get returns the job of one terminal.

func (*JobStore) List

func (s *JobStore) List() []Job

List returns every job, newest first.

func (*JobStore) PruneTerminals

func (s *JobStore) PruneTerminals(keep map[string]bool) int

PruneTerminals drops the jobs of terminals that are not in keep. The startup restore calls it with what its pass found, the same place and the same reason the notifications are pruned there: a session that was deleted leaves an entry that resolves to nothing forever, and every read of the store pays for parsing it. Open jobs stay whatever their terminal does. A job that ends has to say so to the user, and ending a job whose terminal is gone is the heartbeat's, with a report; dropping it here would be the one ending nobody hears. Returns how many entries were removed.

func (*JobStore) Save

func (s *JobStore) Save(w Job)

Save writes one job, replacing the entry of the same terminal.

func (*JobStore) Update

func (s *JobStore) Update(terminal string, change func(*Job) bool) (Job, bool)

Update changes one job in place, under the same lock that reads it. Both the watcher and the input route write to the same record while a check runs, and each of them owns different fields: the assistant's last input belongs to the input route, the counters and the state to the check. Read, change and write in two separate lock sections would let the later writer put back what it read before the other one wrote, and what disappears that way is either a paid check or the send that was meant to get the coder going again.

change decides whether its change stands: false leaves the file alone, which is what a caller wants once it sees, under this lock, that the job is not the one it meant to write to any more. The reported bool is whether the job was written, so a caller that has to decide something after the write asks that one question instead of two.

type LineHandler

type LineHandler func(line []byte) error

LineHandler consumes one structured output line. Returning an error ends the turn with that error as the user facing message.

type Message

type Message struct {
	ID          string       `json:"id"`
	Role        Role         `json:"role"`
	Content     string       `json:"content"`
	Attachments []Attachment `json:"attachments,omitempty"`
	CreatedAt   time.Time    `json:"createdAt"`
	// RunID ties an assistant message to the generation that produced it, so a
	// stale stream cannot write into a newer retry.
	RunID string `json:"runId,omitempty"`
	State State  `json:"state"`
	// Error is a curated, user facing sentence. Provider stderr, argv and
	// paths never reach it.
	Error string `json:"error,omitempty"`
	// Wake marks a message a check wrote, so the page can show where it came
	// from. A check's prompt is never stored, only what it concluded, which is
	// why nothing in a transcript can look like something the user said.
	Wake *WakeNote `json:"wake,omitempty"`
}

Message is one turn half, a user prompt or an assistant answer.

type Parser

type Parser interface {
	// Line consumes one structured output line. Returning an error ends the
	// turn with that error as the user facing message.
	Line(line []byte) error
	// Finish is called when the process ended, and reports what is wrong when
	// the record that closes a turn never arrived.
	Finish() error
	// Diagnose names the error of a failed turn out of everything this parser
	// saw, plus the end of standard error. Returning nil keeps err as it is.
	// It is called once, only for a turn that failed, and only after the
	// output was read to its end and the event channel is closed, so nothing
	// else touches the parser any more and there is no race on its state.
	// Every parser has to answer it, that is the point of it being part of the
	// interface: a new coder has to say what its CLI does when it never gets
	// going at all, which is where the exit code would have been the obvious
	// signal and is not available (it is not kept, and a turn adopted after a
	// restart has none left to get).
	Diagnose(err error, stderr string) error
}

Parser turns one provider's output into events. Implemented next to each coder, because only they know the shape of their CLI's output.

type Projects

type Projects interface {
	ValidatePath(raw string) (string, error)
	ProjectNameFor(path string) string
}

Projects validates the project directory a conversation is bound to.

type Role

type Role string

Role distinguishes the two message authors.

const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
)

type Run

type Run struct {
	RunID      string
	MessageID  string
	ReplacedID string
	// UserMessageID names the prompt that was just written into the transcript.
	// The page that sent it already shows it, and the same message is announced
	// on the stream for the other pages, so this is how the sender recognizes
	// the announcement of its own message instead of showing it twice.
	UserMessageID string
	// Title is the conversation title after this turn, so a page that derived its
	// title from the first prompt can show it without a reload.
	Title string
	// Queued says no generation started: the message waits in the transcript
	// until the running turn ends and the server flushes the queue.
	Queued bool
}

Run identifies a started generation for the browser. ReplacedID names the message a retry dropped, so the page can remove that bubble instead of showing the failed turn next to its replacement.

type RunKind

type RunKind string

RunKind separates the two turns that exist. They differ in what finishing means: a chat turn writes into the message it already has, a check hands its verdict to the watcher, which decides whether the user hears about it at all.

const (
	// RunChat is a turn the user asked for.
	RunChat RunKind = "chat"
	// RunCheck is a turn a steered job asked for.
	RunCheck RunKind = "check"
)

type RunRecord

type RunRecord struct {
	ID   string  `json:"id"`
	Kind RunKind `json:"kind"`
	// Conversation and MessageID name what the turn writes into. A chat turn
	// has its placeholder message already and names the conversation it belongs
	// to; a check carries no conversation, only the id its report will get, so
	// writing that report twice is impossible and it lands wherever the user is
	// when it comes back.
	Conversation string `json:"conversation,omitempty"`
	MessageID    string `json:"messageId"`
	// CoderID picks the runner that can read this output, SessionID the
	// provider session the turn drives. Both are needed to attach: the parser
	// belongs to the coder, and a check keeps its session reserved until it is
	// over.
	CoderID   string `json:"coderId"`
	SessionID string `json:"sessionId"`
	// Terminal and Context are what a check needs to be concluded by whoever
	// finds it: the terminal its job steers, and what the watcher saw before it
	// started.
	Terminal string       `json:"terminal,omitempty"`
	Context  checkContext `json:"context,omitempty"`
	// Output is the provider's raw output, Errors its standard error.
	Output string `json:"output"`
	Errors string `json:"errors"`
	// PID is the turn process, the one that holds the turn's lock while the
	// provider runs. It is what a kill signals and what a self update reaps,
	// but whether the turn still runs is decided by Lock alone.
	PID int `json:"pid"`
	// Lock is the file whose exclusive lock the turn process inherited before it
	// started. As long as something holds it, the turn is running.
	Lock string `json:"lock,omitempty"`
	// Processed is how far the output file was turned into events. A file that
	// is shorter than this was replaced under a running turn, and then it is not
	// the answer to this prompt any more.
	Processed int64     `json:"processed"`
	StartedAt time.Time `json:"startedAt"`
	// Deadline is when this turn is killed. A check has one, a chat turn runs
	// as long as the user lets it.
	Deadline time.Time `json:"deadline,omitempty"`
	// Cancelled marks a turn the user stopped. It is written before the process
	// is killed, so a stop that races a restart is still read as a stop and not
	// as a coder that died.
	Cancelled bool `json:"cancelled,omitempty"`
}

RunRecord is one turn as it exists outside this process.

type RunStore

type RunStore struct {
	// contains filtered or unexported fields
}

RunStore persists the register. One file, read through on every call like every other state file, so the entry a dying process wrote is the entry the next one finds.

func NewRunStore

func NewRunStore(stateDir string) *RunStore

NewRunStore returns the register for a state directory.

func NewRunStoreAt

func NewRunStoreAt(index, dir string) *RunStore

NewRunStoreAt returns a register over explicit paths, so the layout stays in one place.

func (*RunStore) Delete

func (s *RunStore) Delete(id string)

Delete removes one entry and the raw output it describes. The transcript holds the answer by then, and a file nobody reads any more is only disk.

func (*RunStore) Files

func (s *RunStore) Files(id string) (output, errors, lock string, err error)

Files returns the paths a new turn lives in: its output, its standard error, and its lock. The directory is created here, because a turn that cannot write its output must not be started at all.

func (*RunStore) List

func (s *RunStore) List() []RunRecord

List returns every registered turn.

func (*RunStore) Save

func (s *RunStore) Save(r RunRecord)

Save writes one entry, replacing the one with the same id.

func (*RunStore) Sweep

func (s *RunStore) Sweep()

Sweep removes raw output files no entry points at any more. A process killed between writing the file and registering it would otherwise leave it behind forever.

func (*RunStore) Update

func (s *RunStore) Update(id string, change func(*RunRecord)) (RunRecord, bool)

Update changes one entry in place, on the copy that is on disk right now. Two writers touch a running turn, the reader that records its progress and a stop that marks it cancelled, and neither may put the other's field back.

type Runner

type Runner interface {
	// Command builds the process for one turn.
	Command(req TurnRequest) (Command, error)
	// Parse returns a fresh parser for this coder's raw output. It is called
	// again when a turn is picked up after a restart, so it may not depend on
	// anything but its arguments.
	Parse(sessionID string, events chan<- Event) Parser
	// SessionExists reports whether the provider still holds this session.
	SessionExists(sessionID string) bool
	// DeleteSession removes the provider side conversation.
	DeleteSession(sessionID string) error
}

Runner describes one provider CLI in non-interactive mode. Implementations live next to their coder (internal/coder/<coder>/assistant.go) and are the only place that knows provider flags and output shapes.

A runner does not own the process. It says what to run and how to read what comes back, and this package starts it detached, keeps its raw output on disk and reads it, which is what lets a turn outlive the server: a process this server never started is attached with the same two calls.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service owns conversation state, provider processes and the per conversation streams.

func (*Service) Cancel

func (s *Service) Cancel(id string) error

Cancel stops the running generation, keeping the partial answer. The stop is written down before the process is killed, so a restart that happens in between still reads it as a stop.

func (*Service) Coders

func (s *Service) Coders() []CoderInfo

Coders returns the coders that can answer a turn.

func (*Service) Current

func (s *Service) Current() (Conversation, bool)

Current is the conversation the assistant is in right now. One is live at a time, so nothing that acts has to carry an id: a check reports here, a job is listed here, and the page opens here.

func (*Service) Delete

func (s *Service) Delete(id string) error

Delete removes a conversation. An active conversation takes its provider session with it, a transferred one does not: the coder terminal owns that session now.

func (*Service) Discard

func (s *Service) Discard(conversationID, messageID string) error

Discard removes one waiting message before the queue flushed it. The decision falls under the service lock, the same one the flush holds: a message is either still waiting and comes out, or it already went and the caller hears so.

func (*Service) Draft

func (s *Service) Draft(id string) (Draft, error)

Draft is what the composer of a conversation holds right now. It is its own read so a device catching up pulls the draft, not the transcript.

func (*Service) Get

func (s *Service) Get(id string) (Conversation, error)

Get loads one conversation.

func (*Service) LastAnswer

func (s *Service) LastAnswer(id string) (Message, bool)

LastAnswer returns the newest assistant message of a conversation. The notification for a finished turn is raised right after that turn ended, so this is the message it is about: it names the answer in the entry and lets the entry link straight at it.

func (*Service) List

func (s *Service) List() []Summary

List returns the conversation index, newest activity first.

func (*Service) Open

func (s *Service) Open(coderID string) (Conversation, error)

Open is the live conversation, started when there is none. coderID picks the coder for a conversation that has to be created and is empty for "whichever one is live".

An untouched conversation is reused instead of leaving a trail of empty ones behind: pressing new twice is one conversation, and a check that arrives before the user ever opened the page writes into the one they will find.

func (*Service) ReapUploads

func (s *Service) ReapUploads(grace time.Duration)

ReapUploads removes uploads nothing points at, neither a message nor a draft. A file is stored the moment it is picked, before the message that carries it exists, so a composer that is closed instead of sent leaves it behind and nothing else ever collects it: deleting a conversation takes its whole directory, and a sent file is referenced forever.

grace keeps a file that is waiting for its message: only uploads older than that are candidates, so a reap can never race a composer the user is still filling.

func (*Service) Recover

func (s *Service) Recover() []AdoptedCheck

Recover picks up the turns a previous server left behind and is what makes a restart cost nothing. Every one of them is decided the same way: is the process still writing, and does its output still hold what was already read. A turn that is alive is followed on, a turn that ended in the meantime is read to its end and settled, and only a turn whose output is gone becomes an interrupted answer.

It must run after everything the service publishes through is wired, and after the local API socket of this process is bound: an adopted check acts while it runs.

func (*Service) Rename

func (s *Service) Rename(id, rawTitle string) error

Rename sets a new title.

func (*Service) Reserved

func (s *Service) Reserved(coderID, sessionID string) bool

Reserved reports whether a provider session belongs to an active conversation. The coder managers ask before listing a session, so a conversation never shows up as a ghost resumable coder. A transferred conversation releases its reservation, and its session then appears exactly once, as the coder terminal that owns it.

func (*Service) Retry

func (s *Service) Retry(id string) (Run, error)

Retry runs the last prompt again after a failed, cancelled or interrupted turn. It is always explicit: a turn that may already have been charged is never resent on its own.

func (*Service) RunUploadReaper

func (s *Service) RunUploadReaper(interval, grace time.Duration)

RunUploadReaper reaps on an interval until the process ends. Never returns, run it on a goroutine.

func (*Service) Running

func (s *Service) Running(id string) bool

Running reports whether a generation is in flight for this conversation.

func (*Service) SaveDraft

func (s *Service) SaveDraft(id, text string, attachments []Attachment) (Draft, bool, error)

SaveDraft stores what the composer holds without sending it and says whether that changed anything. A repeated save writes nothing and announces nothing, so a long transcript is not rewritten for a keystroke and the other devices are not woken for a draft that already is what they hold.

func (*Service) Search

func (s *Service) Search(word string) []Summary

Search returns the conversation index, newest activity first, narrowed to the conversations where a word appears in the title or in a message, compared case insensitively. An empty word returns the whole index. The title is answered from the index alone, the message match loads the transcript; nothing here writes.

func (*Service) Send

func (s *Service) Send(id, prompt string, attachments []Attachment) (Run, error)

Send appends a prompt and starts a generation. Attachments are already on disk when they arrive here, the prompt only points the coder at them.

While a turn runs the message queues instead: it goes into the transcript as a waiting entry, and the end of the turn flushes everything waiting as one new turn. The decision falls under the service lock, the same one the turn's end takes to stop counting as running, so a send racing that end either sees the run and queues, or sees the freed conversation and starts. A send that finds older messages still waiting queues behind them even when nothing runs, so the order of what was typed is the order of what goes out.

func (*Service) SetHooks

func (s *Service) SetHooks(onChange func(), onDone func(conversationID string))

SetHooks registers the coarse change publisher and the completion notification. Both run outside the service lock.

func (*Service) SetRenderer

func (s *Service) SetRenderer(render func(string) (string, error))

SetRenderer installs the Markdown renderer used while an answer streams. The browser never parses model output itself, it only shows what this produced.

func (*Service) Subscribe

func (s *Service) Subscribe(id string) (StreamEvent, bool, <-chan StreamEvent, func())

Subscribe attaches to the conversation's stream and returns the in-flight state.

func (*Service) Transcript

func (s *Service) Transcript(id string, entries, budget int) (Conversation, int, error)

Transcript returns one conversation windowed to its last entries, each message cut to budget runes with a note saying how much of it is shown. entries zero or less means the default window, budget zero or less keeps every message whole. The second return is how many older messages the window dropped. It only reads, the stored transcript stays as it is.

func (*Service) Transfer

func (s *Service) Transfer(id string, terminals Terminals) (string, error)

Transfer promotes the conversation's provider session into a coder terminal and returns the terminal id. It is one way: from here the terminal owns the conversation and the conversation keeps a readable transcript.

func (*Service) UploadDir

func (s *Service) UploadDir(id string) (string, error)

UploadDir is the directory holding the uploads of one conversation.

type Sessions

type Sessions interface {
	Activity(coderID, terminal string) (Activity, error)
	// Running reports whether the job's terminal still exists at all. A job
	// whose terminal is gone can never be met, and asking a coder that is not
	// there costs a paid turn to learn what the cockpit already knows.
	Running(coderID, terminal string) bool
}

Sessions is how a check reaches the coder of the job it checks. It asks the coder, not the terminal: a screen carries the coder's input line with whatever draft stands in it, and a draft is not a message from anybody. What a session really did is the coder's own knowledge, so the coder answers, and how it answers depends on the provider. Implemented on the coder side, wired in main.

type State

type State string

State is the delivery state of one message.

const (
	StateComplete    State = "complete"
	StateStreaming   State = "streaming"
	StateCancelled   State = "cancelled"
	StateFailed      State = "failed"
	StateInterrupted State = "interrupted"
	// StateQueued marks a user message waiting for the running turn to end. It
	// sits in the transcript, deletable until the server flushes every waiting
	// message as one new turn.
	StateQueued State = "queued"
)

func (State) Retryable

func (s State) Retryable() bool

Retryable reports whether a turn in this state may be sent again. A completed turn is never resent, it would charge the user twice for an answer that is already on screen.

func (State) Settled

func (s State) Settled() bool

Settled reports whether no further content can arrive for this state.

type Status

type Status string

Status is the lifecycle state of a conversation.

const (
	// StatusActive marks a conversation that still owns its provider session.
	StatusActive Status = "active"
	// StatusTransferred marks a conversation whose provider session was handed to a
	// coder terminal. The transcript stays readable, the composer does not.
	StatusTransferred Status = "transferred"
	// StatusArchived marks a conversation that a newer one replaced. The
	// transcript stays readable, its provider session is gone: nothing can
	// continue it, and the cockpit holds the whole conversation on disk.
	StatusArchived Status = "archived"
)

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists the conversation index and one file per transcript:

<index-path>                                     index, no messages
<dir>/<conversation-id>.json                     one transcript
<upload-root>/<conversation-id>/<name>           what a prompt carried

Index and transcripts go through internal/statefile, so reads pick up outside changes, writes are atomic and a corrupt file is quarantined instead of overwritten.

func NewStore

func NewStore(stateDir string) *Store

NewStore returns the assistant's store for a state directory, the layout Paths describes.

func NewStoreAt

func NewStoreAt(indexPath, dir, uploadRoot string) *Store

NewStoreAt returns a store over explicit paths, so the layout stays in one place: the caller decides where index, transcripts and uploads live.

func (*Store) Delete

func (s *Store) Delete(id string) error

Delete removes the transcript and its index entry.

func (*Store) List

func (s *Store) List() []Summary

List returns the index, newest activity first.

func (*Store) Load

func (s *Store) Load(id string) (Conversation, bool)

Load reads one transcript. A missing or unreadable transcript reports false without touching the index: the entry stays visible and recoverable instead of disappearing behind a silent self-heal.

func (*Store) Save

func (s *Store) Save(c Conversation)

Save writes the transcript and refreshes its index entry.

func (*Store) UploadDir

func (s *Store) UploadDir(id string) (string, error)

UploadDir is where the uploads of one conversation live. It is created on demand by the upload path, never here.

func (*Store) UploadRoot

func (s *Store) UploadRoot() string

UploadRoot is the directory holding one upload subdirectory per conversation.

type StreamEvent

type StreamEvent struct {
	Kind      string `json:"kind"`
	RunID     string `json:"runId,omitempty"`
	MessageID string `json:"messageId,omitempty"`
	Text      string `json:"text,omitempty"`
	HTML      string `json:"html,omitempty"`
	State     string `json:"state,omitempty"`
	Error     string `json:"error,omitempty"`
	// Context rides the end frame and is how full the coder's context window
	// stands, in percent. It is left out when the turn reported nothing or the
	// model's window is unknown, and the page then leaves its ring as it is.
	Context int `json:"context,omitempty"`
}

StreamEvent is one frame on a conversation's own SSE stream. Conversation text never goes to the app wide event bus, only the coarse conversations event does.

type Summary

type Summary struct {
	ID            string    `json:"id"`
	Title         string    `json:"title"`
	CoderID       string    `json:"coderId"`
	ProjectPath   string    `json:"projectPath"`
	Status        Status    `json:"status"`
	CreatedAt     time.Time `json:"createdAt"`
	LastMessageAt time.Time `json:"lastMessageAt"`
	// Preview is the opening of the last assistant answer, bounded for the list.
	Preview string `json:"preview"`
	// Unfinished marks a conversation whose last turn did not complete, so the list can
	// show it without loading the transcript.
	Unfinished bool `json:"unfinished"`
	// MessageCount is the number of stored messages, used for the list subtitle.
	MessageCount int `json:"messageCount"`
}

Summary is one index entry. It deliberately carries no messages: the list page renders from the index alone, so opening it never loads a transcript.

type Terminals

type Terminals interface {
	// ResumeReserved starts a terminal on an existing provider session and
	// returns its identifier.
	ResumeReserved(coderID, sessionID, projectPath, title string) (string, error)
	// Stop kills a terminal again, used to roll a failed transfer back.
	Stop(coderID, terminalID string) error
}

Terminals promotes a conversation's provider session into a coder terminal. The web layer implements it over the coder managers.

type TurnRequest

type TurnRequest struct {
	// SessionID is the provider session this turn belongs to. The first turn
	// creates it, every later turn resumes it.
	SessionID string
	// Resume selects resume over create. The service decides it by asking the
	// runner whether the session already exists, so a failed first turn that
	// still wrote provider state cannot make the retry collide.
	Resume bool
	// Title names the provider session on creation, so a transferred conversation
	// shows up as a coder terminal with the conversation's title.
	Title string
	// Workdir is the validated project directory the process runs in.
	Workdir string
	Prompt  string
}

TurnRequest is one prompt handed to a provider CLI.

type Verdict

type Verdict string

Verdict is what a wake concluded.

const (
	VerdictDone    Verdict = "done"
	VerdictBlocked Verdict = "blocked"
	VerdictWorking Verdict = "working"
	VerdictNothing Verdict = "nothing"
	// VerdictExpired is not something a check answers. It is what the watcher
	// records when a job runs out of checks or out of time, so the message that
	// says nobody is steering any more looks like the other reports.
	VerdictExpired Verdict = "expired"
)

func (Verdict) News

func (v Verdict) News() bool

News reports whether this verdict is worth the user's attention.

type WakeNote

type WakeNote struct {
	Terminal string `json:"terminal"`
	// Name is the coder's name as the job carried it when this report was
	// written. It is written down instead of looked up later because the job
	// is gone by then, or worse, the terminal is steered again and the lookup
	// answers with its successor. An older report simply carries none.
	Name string `json:"name,omitempty"`
	// Project travels with the name and for the same reason, so a report says
	// where that coder worked without asking anybody.
	Project string `json:"project,omitempty"`
	Verdict string `json:"verdict"`
}

WakeNote says which terminal a check was about and what it concluded.

type Watcher

type Watcher struct {
	// contains filtered or unexported fields
}

Watcher turns a coder's news into a check by the assistant. It owns the gates that decide whether a wake may happen at all, so the cost of the feature is visible in one place.

func NewWatcher

func NewWatcher(service *Service, store *JobStore, sessions Sessions) *Watcher

NewWatcher wires the watcher. There is no global switch: steering is explicit per job, so a job that exists is a job that wakes.

func (*Watcher) Forget

func (w *Watcher) Forget(terminal string)

Forget removes the job of a terminal that is gone for good. Release is the end of a job whose terminal stays: it leaves the entry standing, so the user can see what happened to it. A deleted session has nothing left to show it next to, its entry would be read on every look at the store forever, and nothing can ever move it again, so it goes. A check that is still running is killed like on a release, for the same reason: nobody pays for its answer.

func (*Watcher) Get

func (w *Watcher) Get(terminal string) (Job, bool)

Get returns one job by the terminal it steers.

func (*Watcher) Handle

func (w *Watcher) Handle(terminal string)

Handle is one signal from a terminal. Every reason not to wake is checked in one place, so the cost of this feature never depends on what a prompt decides, and the repeat run of a check asks the same questions again.

func (*Watcher) Heartbeat

func (w *Watcher) Heartbeat()

Heartbeat is one pass over the open jobs. The first thing it does is end the jobs whose time or budget is up: that report used to need a signal, and a job that ran out is exactly the one that has none left to give.

func (*Watcher) List

func (w *Watcher) List() []Job

List returns every job, open ones first.

func (*Watcher) Marks

func (w *Watcher) Marks() (steered map[string]bool, doneWhens map[string]string)

Marks is what the pages render about the jobs: which terminals an open job holds right now, and the stored criterion of the closed ones, which is what the steer dialog offers as its prefill when a terminal is steered again.

func (*Watcher) NoteAssistantInput

func (w *Watcher) NoteAssistantInput(terminal string)

NoteAssistantInput records that an assistant turn wrote into this terminal. The user's inputs are none of the job's business: steering is ownership, and only steer and release change it. What is recorded here serves the checks themselves. A terminal nobody steers is ignored, so this costs one lookup on the input path.

func (*Watcher) OnStateChange

func (w *Watcher) OnStateChange(fn func(project string))

OnStateChange registers who hears about a change of ownership: steer, release, reopen, and every verdict that closes a job. Called with the job's project so a page can refresh just that project's fragments.

func (*Watcher) Recover

func (w *Watcher) Recover(adopted []AdoptedCheck)

Recover picks up what a restart left behind. A check whose process outlived the restart is handed over by the service and simply carried on, and only the jobs whose check really died are checked again: the signal that started that check is spent, its inbox file is gone, and nothing will ever repeat it, so the job is the only one who knows. That is why a running check is written on the job at all.

The provider session such a dead check left behind is swept by the caller, which owns the coders, see IsCheckSession.

func (*Watcher) Release

func (w *Watcher) Release(terminal string) error

Release calls a job off. It stays visible in that state, so the user can see what happened to it instead of finding it gone. A check that is running on the job right now is killed: release takes the actor away, and a dead check writes nothing and costs nothing more.

func (*Watcher) RunHeartbeat

func (w *Watcher) RunHeartbeat(interval time.Duration)

RunHeartbeat looks at the open jobs for as long as the process lives. Blocks; run it in a goroutine.

func (*Watcher) Steer

func (w *Watcher) Steer(spec Job) (Job, error)

Steer starts a job on a terminal. The criterion may be empty: the check then judges against the task the session itself was given, and every place that shows the criterion says so (DoneWhenLine). Whether empty is allowed is the caller's rule, not this one's: the page may leave it out, the assistant's own coder-steer command may not, and the jobs handler enforces that at the door. A criterion over its bound is refused, never cut: it is what decides that a job is done, and a check against a criterion that ends mid-sentence judges against half a sentence without anybody knowing.

func (*Watcher) SweepExpired

func (w *Watcher) SweepExpired()

SweepExpired ends every job whose time or budget is used up. It is the same report a signal would have produced, written without one: a job ends most often by going quiet, and every gate used to sit behind a signal, so the one ending nobody can hear was the one that mattered. The heartbeat runs this first on every pass, which is what makes that report happen without a signal.

type Workspace

type Workspace struct {
	// contains filtered or unexported fields
}

Workspace owns the assistant's directories and its memory. The conversations themselves live in the Service built by New.

func (*Workspace) CockpitCommand

func (s *Workspace) CockpitCommand(name string) string

CockpitCommand spells out one of the cockpit's own commands the way a turn has to call it: the absolute path of the binary that is running, the assistant command group, and the directories of this instance. A bare `dev-cockpit` depends on a PATH a turn does not control, and on a machine with several instances it would reach whichever one owns the default state directory, which is the wrong cockpit and possibly somebody else's terminal.

func (*Workspace) DeleteMemory

func (s *Workspace) DeleteMemory(slug string) error

DeleteMemory drops one entry and rebuilds the instruction files.

func (*Workspace) Memory

func (s *Workspace) Memory() []Entry

Memory returns every entry, newest first.

func (*Workspace) MemoryEntry

func (s *Workspace) MemoryEntry(slug string) (Entry, error)

MemoryEntry loads one entry.

func (*Workspace) ProjectNameFor

func (s *Workspace) ProjectNameFor(string) string

ProjectNameFor implements conversation.Projects. The assistant has no project, and an empty name keeps the project badge off its pages.

func (*Workspace) ResolveWorkspaceFile

func (s *Workspace) ResolveWorkspaceFile(rel string) (string, error)

ResolveWorkspaceFile turns a path an answer mentions into an absolute path inside the workspace. Anything pointing outside is refused, so a rendered answer can never link to a file the assistant does not own.

func (*Workspace) SaveMemory

func (s *Workspace) SaveMemory(slug, title, body string) (Entry, error)

SaveMemory writes one entry and rebuilds the instruction files. A new entry takes its file name from the title, an existing one keeps its name so the links the assistant wrote to it stay valid.

func (*Workspace) SaveUpload

func (s *Workspace) SaveUpload(dir, name string, src io.Reader) (Attachment, error)

SaveUpload stores one file of a message and classifies it for the browser.

func (*Workspace) Sync

func (s *Workspace) Sync() error

Sync rebuilds the generated instruction files from the memory directory. It writes only on a real change, so an unchanged memory does not touch the files a coder watches.

func (*Workspace) ValidatePath

func (s *Workspace) ValidatePath(raw string) (string, error)

ValidatePath implements conversation.Projects. The assistant is bound to its workspace and to nothing else, so an empty binding resolves to it and any other path is refused.

func (*Workspace) Workspace

func (s *Workspace) Workspace() string

Workspace is the working directory every turn runs in.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL