Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ audit-rust: ## Check RustSec advisories (requires cargo-audit)

test-go: ## Run Go engine tests
cd engine && go test ./...
bash scripts/validate-story-core-deps.sh

test-desktop: ## Run desktop frontend tests and production build
cd apps/desktop && pnpm lint && pnpm test && pnpm build
Expand Down
343 changes: 343 additions & 0 deletions docs/superpowers/plans/2026-08-22-mcp-first-pivot.md

Large diffs are not rendered by default.

229 changes: 229 additions & 0 deletions docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions engine/internal/ai/messages.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ai

import (
"github.com/devlikebear/linetta/engine/internal/storycontext"
"github.com/devlikebear/tars/pkg/llm"
)

// BuildMessages wraps storycontext.Render into the two-message system+user
// pair the engine sends to tars. This adapter is the only place the rendered
// brief meets an LLM message type; it goes away with this package in the
// MCP-first pivot's removal phase.
//
// Why msg.Content (string) and not msg.ContentBlocks: both claude-code-cli and
// openai-codex providers in tars/pkg/llm read the plain `Content` field; the
// openai-codex provider only puts system messages into the Responses API's
// `instructions` field when `msg.Content` is non-empty, and claude-code-cli's
// system-prompt assembler ignores ContentBlocks entirely. ContentBlocks is for
// multimodal inputs (images, PDFs) which we don't send.
func BuildMessages(c storycontext.Context) []llm.ChatMessage {
system, user := storycontext.Render(c)
return []llm.ChatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user},
}
}
59 changes: 59 additions & 0 deletions engine/internal/ai/messages_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package ai

import (
"strings"
"testing"

"github.com/devlikebear/linetta/engine/internal/storycontext"
)

// The adapter is the only ai-side prompt logic left after the storycontext
// extraction: it must wrap storycontext.Render verbatim into the system+user
// chat-message pair, applying the context selection exactly once.
func TestBuildMessagesWrapsRender(t *testing.T) {
c := storycontext.Context{
SceneLabel: "씬 1",
SceneText: "본문 텍스트",
UserPrompt: "이어서 써줘",
Options: storycontext.Options{Language: "ko"},
}
wantSystem, wantUser := storycontext.Render(c)

msgs := BuildMessages(c)
if len(msgs) != 2 {
t.Fatalf("len(msgs) = %d, want 2", len(msgs))
}
if msgs[0].Role != "system" || msgs[1].Role != "user" {
t.Fatalf("roles = %q, %q", msgs[0].Role, msgs[1].Role)
}
if msgs[0].Content != wantSystem {
t.Errorf("system mismatch:\n got %q\nwant %q", msgs[0].Content, wantSystem)
}
if msgs[1].Content != wantUser {
t.Errorf("user mismatch:\n got %q\nwant %q", msgs[1].Content, wantUser)
}
if !strings.Contains(msgs[1].Content, "본문 텍스트") {
t.Errorf("scene text missing from user message: %q", msgs[1].Content)
}
}

// Selection must be applied inside the adapter path: a disabled section that
// Render would drop must not reappear in the messages.
func TestBuildMessagesAppliesSelection(t *testing.T) {
off := false
c := storycontext.Context{
SceneLabel: "씬 1",
SceneText: "지워질 본문",
UserPrompt: "요청",
Options: storycontext.Options{
Language: "ko",
Context: storycontext.ContextSelection{CurrentScene: &off},
},
}
msgs := BuildMessages(c)
for _, m := range msgs {
if strings.Contains(m.Content, "지워질 본문") {
t.Errorf("disabled current scene leaked into %q message", m.Role)
}
}
}
33 changes: 33 additions & 0 deletions engine/internal/ai/payloads.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package ai

// DeltaPayload is the body of an "ai.delta" notification.
type DeltaPayload struct {
RunID string `json:"run_id"`
Text string `json:"text"`
}

// DonePayload is the body of an "ai.done" notification.
type DonePayload struct {
RunID string `json:"run_id"`
FullText string `json:"full_text"`
}

// ErrorPayload is the body of an "ai.error" notification.
type ErrorPayload struct {
RunID string `json:"run_id"`
Message string `json:"message"`
}

// CancelledPayload is the body of an "ai.cancelled" notification.
type CancelledPayload struct {
RunID string `json:"run_id"`
}

// ResetPayload is the body of an "ai.reset" notification. Sent when the
// streaming text needs to be REPLACED (not appended) — used when the upstream
// provider's transparent retry produces deltas that diverge from earlier ones
// and we need to reconcile the frontend's view to the deduplicated buffer.
type ResetPayload struct {
RunID string `json:"run_id"`
Text string `json:"text"`
}
5 changes: 3 additions & 2 deletions engine/internal/ai/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/devlikebear/linetta/engine/internal/rpc"
"github.com/devlikebear/linetta/engine/internal/store"
"github.com/devlikebear/linetta/engine/internal/storycontext"
"github.com/devlikebear/linetta/engine/internal/streamdedup"
"github.com/devlikebear/tars/pkg/llm"
"github.com/google/uuid"
Expand Down Expand Up @@ -50,7 +51,7 @@ func NewRunner(notify rpc.Notifier, runs *store.AIRunsRepo, factory ClientFactor

// Start enqueues a run and returns its id immediately. The work happens on a
// goroutine that emits notifications via the Notifier.
func (r *Runner) Start(ctx context.Context, c Context, now Clock) (string, error) {
func (r *Runner) Start(ctx context.Context, c storycontext.Context, now Clock) (string, error) {
runID := uuid.NewString()
startedAt := now()
ctxJSON, _ := json.Marshal(c)
Expand Down Expand Up @@ -92,7 +93,7 @@ func (r *Runner) Start(ctx context.Context, c Context, now Clock) (string, error
return runID, nil
}

func (r *Runner) run(ctx context.Context, runID string, c Context, client llm.Client, now Clock) {
func (r *Runner) run(ctx context.Context, runID string, c storycontext.Context, client llm.Client, now Clock) {
defer func() {
r.mu.Lock()
delete(r.active, runID)
Expand Down
10 changes: 6 additions & 4 deletions engine/internal/ai/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"testing"
"time"

"github.com/devlikebear/linetta/engine/internal/storycontext"

"github.com/devlikebear/linetta/engine/internal/project"
"github.com/devlikebear/linetta/engine/internal/store"
"github.com/devlikebear/tars/pkg/llm"
Expand Down Expand Up @@ -92,7 +94,7 @@ func TestRunner_streams_thenEmitsDone(t *testing.T) {
r := NewRunner(notif, runs, func(ResolvedProvider) (llm.Client, error) { return fake, nil }, fixedProvider("claude-code-cli"))
now := func() int64 { return 1234 }

c := Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
c := storycontext.Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
runID, err := r.Start(context.Background(), c, now)
if err != nil {
t.Fatalf("Start: %v", err)
Expand Down Expand Up @@ -141,7 +143,7 @@ func TestRunner_cancel_emitsCancelled_andPersistsCancelled(t *testing.T) {
r := NewRunner(notif, runs, func(ResolvedProvider) (llm.Client, error) { return fake, nil }, fixedProvider("claude-code-cli"))
now := func() int64 { return 1234 }

c := Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
c := storycontext.Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
runID, err := r.Start(context.Background(), c, now)
if err != nil {
t.Fatalf("Start: %v", err)
Expand Down Expand Up @@ -224,7 +226,7 @@ func TestRunner_readsProviderOnEachStart(t *testing.T) {
r := NewRunner(notif, runs, rf.build, src)
now := func() int64 { return 1 }

c := Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
c := storycontext.Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
if _, err := r.Start(context.Background(), c, now); err != nil {
t.Fatalf("first start: %v", err)
}
Expand Down Expand Up @@ -265,7 +267,7 @@ func TestRunner_providerError_emitsError(t *testing.T) {
r := NewRunner(notif, runs, func(ResolvedProvider) (llm.Client, error) { return fake, nil }, fixedProvider("claude-code-cli"))
now := func() int64 { return 1234 }

c := Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
c := storycontext.Context{ProjectID: p.ID, NodeID: *p.LastOpenedNodeID, SceneLabel: "씬 1", UserPrompt: "안녕"}
if _, err := r.Start(context.Background(), c, now); err != nil {
t.Fatalf("Start: %v", err)
}
Expand Down
39 changes: 21 additions & 18 deletions engine/internal/companion/companion.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"

"github.com/devlikebear/linetta/engine/internal/ai"
Expand All @@ -23,6 +22,8 @@ import (
"github.com/devlikebear/linetta/engine/internal/relationship"
"github.com/devlikebear/linetta/engine/internal/rpc"
"github.com/devlikebear/linetta/engine/internal/snapshot"
"github.com/devlikebear/linetta/engine/internal/storycontext"
"github.com/devlikebear/linetta/engine/internal/storyops"
"github.com/devlikebear/linetta/engine/internal/thread"
"github.com/devlikebear/tars/pkg/session"
)
Expand Down Expand Up @@ -52,11 +53,11 @@ type ImageAttachment struct {
}

type SendOptions struct {
Context ai.ContextSelection `json:"context,omitempty"`
OutlineStructure string `json:"outline_structure,omitempty"`
Intent RequestIntent `json:"intent,omitempty"`
Scope string `json:"scope,omitempty"`
Language string `json:"language,omitempty"`
Context storycontext.ContextSelection `json:"context,omitempty"`
OutlineStructure string `json:"outline_structure,omitempty"`
Intent RequestIntent `json:"intent,omitempty"`
Scope string `json:"scope,omitempty"`
Language string `json:"language,omitempty"`
}

// ClientFactory and ProviderSource are shared with the ai package so the same
Expand Down Expand Up @@ -87,11 +88,9 @@ type Service struct {
manuscript *manuscript.Searcher
snaps *snapshot.Repo

// Outline snapshots taken before a structural apply, kept so the writer can
// undo the change that just landed.
undoMu sync.Mutex
undoBatches map[string]undoBatch
undoOrder []string
// story applies validated op batches and owns rollback/undo state; the
// companion delegates every mutation to it (see internal/storyops).
story *storyops.Service
}

// NewService constructs the companion service. sessionsDir is passed to
Expand All @@ -111,6 +110,8 @@ func NewService(
notify: notify, factory: factory, src: src, workDir: workDir,
memBase: filepath.Join(sessionsDir, "mem"),
}
s.story = storyops.New(projects, nodes, threads, beats, entities, relationships).
WithMemory(s)
s.runner = newRunner(s)
return s
}
Expand Down Expand Up @@ -139,11 +140,13 @@ func (s *Service) WithManuscript(searcher *manuscript.Searcher) *Service {
// companion-before checkpoint before mutating scene text.
func (s *Service) WithSnapshots(snaps *snapshot.Repo) *Service {
s.snaps = snaps
s.story.WithSnapshots(snaps)
return s
}

func (s *Service) WithFacts(repo *fact.Repo) *Service {
s.facts = repo
s.story.WithFacts(repo)
return s
}

Expand Down Expand Up @@ -526,10 +529,10 @@ func (s *Service) DeleteProjectData(ctx context.Context, projectID string) error

// PreviewContext returns the same context sections a companion turn can inject,
// with selected flags derived from the writer's current checklist choices.
func (s *Service) PreviewContext(ctx context.Context, projectID, nodeID string, selection ai.ContextSelection) (ai.ContextPreview, error) {
func (s *Service) PreviewContext(ctx context.Context, projectID, nodeID string, selection storycontext.ContextSelection) (storycontext.ContextPreview, error) {
data, err := s.gatherContext(ctx, projectID, nodeID, "")
if err != nil {
return ai.ContextPreview{}, err
return storycontext.ContextPreview{}, err
}
return previewFromPromptData(data, selection), nil
}
Expand Down Expand Up @@ -710,24 +713,24 @@ func normalizeImageAttachments(images []ImageAttachment) ([]ImageAttachment, err
// Send starts a companion turn; returns the run id. Streaming + proposal arrive
// via notifications.
func (s *Service) Send(ctx context.Context, projectID, nodeID, text string, now func() int64) (string, error) {
return s.SendWithContext(ctx, projectID, nodeID, text, ai.DefaultContextSelection(), now)
return s.SendWithContext(ctx, projectID, nodeID, text, storycontext.DefaultContextSelection(), now)
}

// SendWithContext starts a companion turn using the writer-selected context
// checklist state.
func (s *Service) SendWithContext(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, now func() int64) (string, error) {
func (s *Service) SendWithContext(ctx context.Context, projectID, nodeID, text string, selection storycontext.ContextSelection, now func() int64) (string, error) {
return s.SendWithContextAndImages(ctx, projectID, nodeID, text, selection, nil, now)
}

// SendWithContextAndImages starts a companion turn with transient multimodal
// images attached to the latest user message.
func (s *Service) SendWithContextAndImages(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, images []ImageAttachment, now func() int64) (string, error) {
return s.SendWithOptionsAndImages(ctx, projectID, nodeID, text, ai.Options{Context: selection}, images, now)
func (s *Service) SendWithContextAndImages(ctx context.Context, projectID, nodeID, text string, selection storycontext.ContextSelection, images []ImageAttachment, now func() int64) (string, error) {
return s.SendWithOptionsAndImages(ctx, projectID, nodeID, text, storycontext.Options{Context: selection}, images, now)
}

// SendWithOptionsAndImages starts a companion turn with the full per-call
// option payload used by the desktop client.
func (s *Service) SendWithOptionsAndImages(ctx context.Context, projectID, nodeID, text string, opts ai.Options, images []ImageAttachment, now func() int64) (string, error) {
func (s *Service) SendWithOptionsAndImages(ctx context.Context, projectID, nodeID, text string, opts storycontext.Options, images []ImageAttachment, now func() int64) (string, error) {
return s.SendWithCompanionOptionsAndImages(ctx, projectID, nodeID, text, SendOptions{
Context: opts.Context,
OutlineStructure: opts.OutlineStructure,
Expand Down
13 changes: 7 additions & 6 deletions engine/internal/companion/companion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/devlikebear/linetta/engine/internal/project"
"github.com/devlikebear/linetta/engine/internal/relationship"
"github.com/devlikebear/linetta/engine/internal/store"
"github.com/devlikebear/linetta/engine/internal/storycontext"
"github.com/devlikebear/linetta/engine/internal/thread"
"github.com/devlikebear/tars/pkg/llm"
"github.com/devlikebear/tars/pkg/session"
Expand Down Expand Up @@ -312,7 +313,7 @@ func TestSendWithContextAndImages_AttachesLatestUserMessageBlocks(t *testing.T)
svc, notif, projectID := newSvcWithClient(t, client)
imageData := base64.StdEncoding.EncodeToString([]byte{1, 2, 3})

runID, err := svc.SendWithContextAndImages(context.Background(), projectID, "", "이 장면 이미지를 참고해줘", ai.DefaultContextSelection(), []ImageAttachment{{
runID, err := svc.SendWithContextAndImages(context.Background(), projectID, "", "이 장면 이미지를 참고해줘", storycontext.DefaultContextSelection(), []ImageAttachment{{
Name: "scene.png",
MediaType: "image/png",
Data: imageData,
Expand Down Expand Up @@ -1106,7 +1107,7 @@ func TestApplyContextSelection_RemovesUncheckedCompanionSections(t *testing.T) {
Memories: []string{"작가는 철학적인 질문을 좋아한다"},
}

selection := ai.ContextSelection{
selection := storycontext.ContextSelection{
CurrentScene: &off,
Overview: &off,
Plot: &off,
Expand Down Expand Up @@ -1153,17 +1154,17 @@ func TestPreviewFromPromptData_RendersSelectableCompanionSections(t *testing.T)
Memories: []string{"작가는 모호한 결말을 선호한다"},
}

preview := previewFromPromptData(data, ai.ContextSelection{Facts: &off})
preview := previewFromPromptData(data, storycontext.ContextSelection{Facts: &off})

var sawScene, sawFact bool
for _, section := range preview.Sections {
if section.ID == ai.ContextKeyCurrentScene {
if section.ID == storycontext.ContextKeyCurrentScene {
sawScene = true
if !section.Selected || !strings.Contains(section.Preview, "인간의 개별성") {
t.Fatalf("scene preview not selected/rendered: %+v", section)
}
}
if section.ID == ai.ContextKeyFacts {
if section.ID == storycontext.ContextKeyFacts {
sawFact = true
if section.Selected || !strings.Contains(section.Preview, "일반 경찰") {
t.Fatalf("facts preview should be visible but unselected: %+v", section)
Expand Down Expand Up @@ -1223,7 +1224,7 @@ func TestApplyContextSelection_RemovesReferences(t *testing.T) {
Status: ReferenceStatusActive,
}},
}
text := buildContext(applyContextSelection(data, ai.ContextSelection{References: &off}), "")
text := buildContext(applyContextSelection(data, storycontext.ContextSelection{References: &off}), "")
if strings.Contains(text, "프롬프트에 들어가면 안 되는") {
t.Fatalf("unchecked reference still rendered:\n%s", text)
}
Expand Down
Loading
Loading