chat

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 21, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package chat is the web chat: the third conductor.Source, and the only one whose conversation Podium itself holds.

Slack has threads and Linear has issues; a browser has nothing, so the chats and chat_messages tables ARE the conversation. What a turn is handed as history, what a reload renders and what the chat list shows all come out of them — including what a task said on its way to an answer, which is stored under its own role. The one thing that is not a row is the placeholder: that is the conductor announcing a turn, and the running indicator is where it belongs.

Everything in a chat is content. A human wrote the user messages and a task wrote the assistant and progress ones; this package stores and relays them all and interprets none.

Index

Constants

View Source
const (
	StatusStarted  = "started"
	StatusFinished = "finished"
	StatusFailed   = "failed"
	StatusAwaiting = "awaiting"
)

Turn states a status frame carries. They are the source's three reactions in the words the wire uses.

View Source
const ChatTitleArtifact = "chat-title.txt"

ChatTitleArtifact is the file a first chat turn writes so the conductor can name the conversation from the model rather than from a truncation of the query. It is runtime- owned: it is never attached as a download.

View Source
const MaxTitleRunes = 48

MaxTitleRunes caps a generated chat title. Long enough for a short phrase, short enough that the rail does not wrap.

Variables

View Source
var ErrNotPullRequestURL = errors.New("not a GitHub pull request URL")

ErrNotPullRequestURL is what the manual attach returns for something that is not a GitHub pull request. The handler maps it to InvalidArgument.

View Source
var ErrTurnRunning = errors.New("a turn is already running for this chat")

ErrTurnRunning is what Send returns while a turn of that chat is still in flight. The UI disables the composer; this is the guarantee behind it.

Functions

func SanitizeTitle

func SanitizeTitle(raw string) string

SanitizeTitle is what a model-written title has to survive before it is stored. One line, no quotes wrapping the whole thing, no control characters, the same rune cap as a query title. Empty means the model wrote nothing we can use.

func TitleFromQuery

func TitleFromQuery(query string) string

TitleFromQuery is the name a chat gets the moment its first message lands, before the turn's model has had a chance to write ChatTitleArtifact. It is the first line of the query, playbook prefix stripped, bounded — not a model, so a conductor with no credential still leaves "New chat" behind.

Types

type Broadcaster

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

Broadcaster fans a chat's frames out to whoever is watching it, in this process.

It is deliberately in-process rather than LISTEN/NOTIFY: the conductor is one process by design, and the store is already the durable half of the story — every frame worth keeping is a row before it is broadcast. A second conductor would see the rows and miss the live frames, which is a reason not to run two, not a reason to build a bus.

func NewBroadcaster

func NewBroadcaster() *Broadcaster

NewBroadcaster returns an empty broadcaster.

func (*Broadcaster) Publish

func (b *Broadcaster) Publish(chatID string, f Frame)

Publish offers one frame to every subscriber of a chat.

Every send is non-blocking: one browser that has stopped reading must not stall a turn. A dropped progress line is forgotten, because the next one supersedes it. A dropped durable frame raises the subscriber's resync signal instead, and the subscriber re-reads from the store — so a slow subscriber loses its place in the stream but never loses a message.

func (*Broadcaster) Subscribe

func (b *Broadcaster) Subscribe(ctx context.Context, chatID string) *Subscriber

Subscribe starts watching one chat. The subscriber is removed when ctx is cancelled or when Close is called, whichever happens first.

func (*Broadcaster) Subscribers

func (b *Broadcaster) Subscribers(chatID string) int

Subscribers is how many watchers a chat has. Tests read it; nothing else does.

type Frame

type Frame struct {
	Kind FrameKind
	// Message is set for FrameMessage.
	Message store.ChatMessage
	// Progress is set for FrameProgress.
	Progress string
	// State and TaskID are set for FrameStatus.
	State  string
	TaskID string
	// Chat is set for FrameChat.
	Chat store.Chat
	// PullRequests is set for FramePullRequests.
	PullRequests []store.ChatPullRequest
}

Frame is one thing that happened in a chat.

type FrameKind

type FrameKind string

FrameKind says which member of a Frame is set. It exists so the API layer can map a frame onto the proto oneof without type switching on empty strings.

const (
	// FrameMessage is a stored message. It may repeat with the same seq when a turn's
	// attachments are resolved after the message was posted; a consumer keyed on seq
	// replaces rather than appends.
	FrameMessage FrameKind = "message"
	// FrameProgress is a line a turn said on its way to an answer. It is never stored.
	FrameProgress FrameKind = "progress"
	// FrameStatus is a turn starting, finishing or failing.
	FrameStatus FrameKind = "status"
	// FrameChat is the conversation's own row after a title or playbook change. A reload
	// re-reads it from ListChats, so dropping it is recovered; it is still durable so a
	// subscriber that fell behind is told to resync rather than keep a stale name.
	FrameChat FrameKind = "chat"
	// FramePullRequests is the whole set of pull requests linked to the chat, sent again
	// every time it changes. It is the set rather than the change because there is no
	// order to reconcile: a subscriber that takes the newest frame as the truth is right,
	// and a browser that missed one is corrected by the next.
	FramePullRequests FrameKind = "pull_requests"
)

The frame kinds.

type Options

type Options struct {
	Store Store
	// DisplayName is the bot's name, used as the author of its own transcript entries.
	DisplayName string
	// UIURL is the web UI as a HUMAN reaches it. It becomes the deep link in the brief and
	// in a retained memory's provenance; a chat memory with no URL is a chip that goes
	// nowhere.
	UIURL  string
	Logger *slog.Logger
}

Options is what a Source needs.

type SendRequest

type SendRequest struct {
	ChatID string
	// Login is the caller, as podium-server asserted it. It owns the chat and authors the
	// message.
	Login string
	Text  string
	// Override is the composer's model picker: what ANSWERS this message. Empty everywhere
	// means the assistant's own model, which is the profile's.
	//
	// It does not reach a task the turn delegates: that runs on its playbook's model. "Answer
	// me on Grok" is about the conversation, not about how a container does its job.
	Override profiles.Override
}

SendRequest is one human message arriving from the browser.

type Source

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

Source is the chat as the conductor sees it: a conductor.Source like any other.

func New

func New(opts Options) (*Source, error)

New returns a chat source. It has no loop of its own to run: a browser calling SendChatMessage is what delivers an event.

func (*Source) Attach

func (s *Source) Attach(ctx context.Context, ref string, file conductor.Attachment) error

Attach implements conductor.Source.

The bytes are deliberately not read. The browser is on the same origin as the control plane and fetches GET /artifacts/{id} with its own credential, so relaying megabytes through the conductor would buy nothing; what the chat needs is the artifact's identity. It lands on the newest assistant message — the turn loop posts a final before it resolves that final's attachments (step 17) — and the message frame is published again with the same seq, which a client keyed on seq replaces rather than appends.

func (*Source) AttachPullRequest

func (s *Source) AttachPullRequest(
	ctx context.Context, chatID, login, rawURL string,
) ([]store.ChatPullRequest, error)

AttachPullRequest is a person linking one by hand: the turn missed it, or it is a related pull request they want on the conversation. It returns the chat's links as they now stand.

func (*Source) Awaiting

func (s *Source) Awaiting(chatID string) bool

Awaiting reports whether this chat is parked on a human reply, so a browser that reconnects can enable the composer instead of treating the turn as busy.

func (*Source) DetachPullRequest

func (s *Source) DetachPullRequest(
	ctx context.Context, chatID, login, rawURL string,
) ([]store.ChatPullRequest, error)

DetachPullRequest takes one link off a chat. The URL is canonicalised first, so detaching the link by the address the browser is showing — which may carry /files or a comment fragment — removes the row that was actually stored.

func (*Source) Edit

func (s *Source) Edit(ctx context.Context, ref, _ string, out conductor.Outbound) error

Edit implements conductor.Source.

The turn loop edits its placeholder to show the newest progress. A chat has no placeholder to edit — the running indicator is not a message — so each edit is another line in the transcript, which is what makes the trail readable after the fact instead of only while somebody was watching.

func (*Source) Events

func (s *Source) Events() <-chan conductor.InboundEvent

Events implements conductor.Source.

func (*Source) FetchTranscript

func (s *Source) FetchTranscript(ctx context.Context, ref string) ([]conductor.BriefEntry, error)

FetchTranscript implements conductor.Source: the questions and answers, oldest first. The brief's 96 KiB cap is what truncates a long one, oldest entry first (step 17).

func (*Source) Kind

func (s *Source) Kind() string

Kind implements conductor.Source. It is "chat", not "dev": the two share the brief's source.kind and nothing else, and a turn only retains a memory when the kind is a real one (step 19).

func (*Source) LinkPullRequests

func (s *Source) LinkPullRequests(ctx context.Context, ref string, prs []conductor.PullRequest) error

LinkPullRequests implements the conductor's optional pull-request half of Source: the turn that just finished named these, so the conversation carries them.

It is quiet when nothing changed. A follow-up turn that mentions the pull request the previous one opened links nothing new, and neither does one whose link a human has already detached — the store's insert is what decides, and the frame goes out only when it actually took a row.

func (*Source) Mirrored

func (s *Source) Mirrored(chatID string, msg store.ChatMessage)

Mirrored implements conductor.MirrorWatcher: a row the conductor wrote into a mirrored thread reaches whoever has that chat open, exactly as this source's own rows do.

func (*Source) Post

func (s *Source) Post(ctx context.Context, ref string, out conductor.Outbound) (string, error)

Post implements conductor.Source.

Everything said is a row, progress included: a conversation Podium holds itself has nowhere else to keep it. Every row carries out.TaskID, which is empty for the assistant's own words and set for a delegated task's — the only thing that tells them apart once they are both lines in the same transcript.

func (*Source) React

func (s *Source) React(ctx context.Context, ref string, kind conductor.Reaction) error

React implements conductor.Source: the turn's state, as an ephemeral frame. A reload re-derives it from the turn row, so nothing here needs to be stored — except a failure that never said anything, which would otherwise leave a chat looking like the question was ignored.

func (*Source) Running

func (s *Source) Running(ctx context.Context, chatID string) (bool, error)

Running reports whether a turn of this chat is in flight, from both halves of the story: what this process is about to do and what the database says was left running by a process that died.

func (*Source) Send

func (s *Source) Send(ctx context.Context, req SendRequest) (store.ChatMessage, error)

Send stores one human message and starts a turn on it.

The order matters: the running check and the busy flag are taken together, then the row is written, then the frame goes out, then the event. A caller that gets ErrTurnRunning has changed nothing.

func (*Source) SetAutoTitle

func (s *Source) SetAutoTitle(ctx context.Context, ref, title string) error

SetAutoTitle is the model-written name of a chat, applied only while AutoTitle is still true. The first turn writes ChatTitleArtifact; the conductor reads it and calls this.

func (*Source) Subscribe

func (s *Source) Subscribe(ctx context.Context, chatID string) *Subscriber

Subscribe starts watching one chat's live frames.

func (*Source) Subscribers

func (s *Source) Subscribers(chatID string) int

Subscribers is how many browsers are watching one chat. Tests read it; nothing else does.

func (*Source) URL

func (s *Source) URL(chatID string) string

URL is the deep link to one chat, as a human reaches it. Empty when no UI URL is known.

type Store

type Store interface {
	GetChat(ctx context.Context, id string) (store.Chat, error)
	AppendChatMessage(ctx context.Context, msg store.ChatMessage) (store.ChatMessage, error)
	ListChatMessages(ctx context.Context, chatID string, fromSeq uint64) ([]store.ChatMessage, error)
	AttachToLastAssistantMessage(
		ctx context.Context, chatID string, file store.ChatAttachment,
	) (store.ChatMessage, error)
	ChatTurnRunning(ctx context.Context, chatID string) (bool, error)
	SetChatChoice(ctx context.Context, id string, c store.ChatChoice) (store.Chat, error)
	SetChatTitle(ctx context.Context, id, title string) (store.Chat, error)
	LinkChatPullRequest(ctx context.Context, pr store.ChatPullRequest) (bool, error)
	AttachChatPullRequest(ctx context.Context, pr store.ChatPullRequest) (store.ChatPullRequest, error)
	DetachChatPullRequest(ctx context.Context, chatID, url string) error
	ListChatPullRequests(ctx context.Context, chatID string) ([]store.ChatPullRequest, error)
}

Store is the part of the conductor's database this source reads and writes. It is an interface so the source is testable without Postgres, and so it is visible exactly which operations the chat needs — a source touching the store at all is a departure from the Slack and Linear sources, and it is the point: there is nowhere else for a web conversation to live.

type Subscriber

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

Subscriber is one live watcher of one chat.

Frames is the stream. Resync fires when this subscriber fell behind and a durable frame was dropped: the consumer must re-read the chat from the last seq it saw. It is a separate one-slot channel on purpose — the reason a frame was dropped is that Frames was full, so the signal cannot travel down Frames.

func (*Subscriber) Close

func (s *Subscriber) Close()

Close unsubscribes. It is idempotent and safe to call from a defer.

func (*Subscriber) Frames

func (s *Subscriber) Frames() <-chan Frame

Frames is the frame stream. It is never closed; the consumer stops on its own context.

func (*Subscriber) Resync

func (s *Subscriber) Resync() <-chan struct{}

Resync fires at most once per fall-behind. Re-read the chat from the last seq seen.

Jump to

Keyboard shortcuts

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