From 42e0a6ac5dbf14d2a50aed8a6586df832900fc9a Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:16:44 +0900 Subject: [PATCH 01/25] docs: add MCP-first pivot design spec and implementation plan Linetta becomes a pure writing tool; AI collaboration moves to external MCP clients (Claude Code / Claude Desktop). Tracking issue: #47. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-22-mcp-first-pivot.md | 338 ++++++++++++++++++ .../2026-08-22-mcp-first-pivot-design.md | 229 ++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-22-mcp-first-pivot.md create mode 100644 docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md new file mode 100644 index 00000000..80f73046 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -0,0 +1,338 @@ +# MCP 우선 전환 — 구현 계획 + +> **에이전트 작업자에게:** 커밋은 기능 단위로, `feat/fix/chore` 메시지로 작성합니다. 동작이 바뀌는 작업은 실패하는 테스트를 먼저 쓰고, 각 단계 종료 시 `make test`를 통과시킵니다. 각 단계는 체크박스(`- [ ]`)로 추적합니다. +> **설계 문서:** `docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md` + +**목표:** Linetta를 순수 창작 도구로 되돌리고, AI 협업은 Claude Desktop/Claude Code 같은 외부 MCP 클라이언트로 옮긴다. 앱은 LLM을 호출하지 않고 API 키를 요구하지 않으며, 외부 에이전트가 큐레이션된 스토리 컨텍스트를 읽고 원고를 쓸 수 있는 작업장이 된다. + +**아키텍처:** 스토리 코어(컨텍스트 조립 + 스토리 옵 적용)를 LLM 코드에서 분리해 `internal/storycontext`, `internal/storyops`로 추출한다. 컨텍스트는 오늘 두 경로(`ai.ContextBuilder`의 씬 중심 브리프, 컴패니언 `gatherContext`의 팩트·메모리·레퍼런스)로 나뉘어 있으므로 추출은 이동이 아니라 **병합**이다. 그 위에 `internal/mcphost`가 공식 Go SDK의 Streamable HTTP 핸들러를 `127.0.0.1:7391`에 올린다. LLM 루프 제거는 검증이 끝난 뒤에만 한다. + +**기술 스택:** Go 1.26 엔진, `github.com/modelcontextprotocol/go-sdk` v1.7.0, Tauri 2 / Rust 셸, React 18 + TypeScript + Vitest. + +## 전역 제약 + +- 엔진 모듈은 `github.com/devlikebear/linetta/engine`. 빌드 태그 `mas`와 `mobile`은 서로 독립이며 둘 다 계속 빌드되어야 한다: `make test`, `make test-mobile-engine`, `cd engine && go build -tags mas ./...`. +- **MCP 게이트는 `//go:build !mobile`이다.** `mas`를 제외하지 않는다 — 컴패니언이 사라지면 MAS 빌드의 유일한 AI 경로가 MCP이기 때문이다. +- MCP는 **기본 꺼짐**. 사용자가 설정에서 켜기 전에는 어떤 리스너도 바인딩하지 않는다. +- 어떤 쓰기 경로도 `storyops.ApplyOps` / `manuscriptedit`를 우회하지 않는다. 스냅샷, 멘션 재동기화, 원고 재색인이 거기 있다. +- `storycontext`와 `storyops`는 `tars/pkg/llm`·`pkg/agentloop`·`pkg/session`을 import하지 않는다(`remember` 기록과 메모리 recall이 쓰는 `pkg/memory`는 두 패키지 모두 허용 — 또는 인터페이스 주입으로 tars 무관하게 유지). `go list -deps` 검증을 CI에 넣는다. +- UI가 호출하는 새 엔진 메서드는 `apps/desktop/src-tauri/src/lib.rs:16`의 `RENDERER_ENGINE_METHODS`에 추가해야 한다. 빠뜨리면 UI가 조용히 실패한다. +- 새 알림은 `apps/desktop/src-tauri/src/ffi.rs:215`의 `notification_event`에 추가하고 **동시에** `useEngineEvent` 리스너를 붙여야 한다. +- **1~4단계 동안 컴패니언은 그대로 동작한다.** 제거는 6단계에서만 일어난다. + +--- + +## Phase 0 — 결정 확정 + +구현 전에 설계 문서 10절의 항목을 확정한다. 코드 작업 없음. + +- [ ] 컴패니언 제거 시점: MCP 실사용 검증 후 단계적 (권장) +- [ ] 모바일에서 AI 기능이 완전히 사라지는 것을 수용할지 확인 +- [ ] `web_search` 설정 제거 여부 (`web_fetch`는 유지) +- [ ] 목표 버전 1.0.0 확정 +- [ ] 결정 결과를 설계 문서 10절에 반영 + +--- + +## Phase 1 — 스토리 코어 추출 (LLM에서 분리) + +**이 단계에 사용자에게 보이는 새 기능은 없다.** 원칙은 "동작 변경 없는 이동"이고, 예외는 딱 두 가지 — 렌더러의 평문화(Task 1.1)와 팩트·메모리 병합(Task 1.3) — 이며 각각 명시적 작업으로 분리한다. + +### Task 1.1 — `internal/storycontext` 추출과 렌더러 평문화 + +**파일:** `engine/internal/storycontext/*`(신규), `engine/internal/ai/*`(축소), 호출부 + +- [ ] `Context`, `ContextSelection`, `ContextBuilder`, 프롬프트 렌더링(`buildSystem`/`buildUser` 계열)을 `internal/storycontext`로 이동한다. +- [ ] **렌더러는 평문 문자열을 반환하게 바꾼다** (`RenderSystem`/`RenderUser`). 현재 `ai.BuildMessages`는 반환 타입으로 `tars/pkg/llm.ChatMessage`를 쓰는데, 내부는 문자열 두 개를 만드는 것뿐이다. `internal/ai`에는 `llm.ChatMessage`로 감싸는 얇은 `BuildMessages` 어댑터만 남겨 컴패니언·AI 실행기가 전환 기간 동안 그대로 돌게 한다. +- [ ] `storycontext`가 `tars/pkg/llm`을 import하지 않는지 `go list -deps`로 검증한다(테스트 또는 CI 스크립트). +- [ ] 기존 `ai` 테스트를 함께 옮기고 전부 통과시킨다. + +### Task 1.2 — `internal/storyops` 추출 + +**파일:** `engine/internal/storyops/*`(신규), `engine/internal/companion/*`(축소), 호출부 + +- [ ] `Proposal`, `validateProposal`, `ApplyOps`, undo 배치, 메모리 기록(`remember`) 경로를 `internal/storyops`로 이동한다. `remember`가 쓰는 `tars/pkg/memory` 의존은 유지된다. +- [ ] `companion.Service`는 새 `storyops`를 호출하도록 바꾼다. 컴패니언 동작은 변하지 않는다. +- [ ] `companion.apply_ops` / `companion.undo_apply` 핸들러는 그대로 두되 내부적으로 `storyops`를 쓴다. +- [ ] 기존 적용/되돌리기 테스트를 옮기고 전부 통과시킨다. + +### Task 1.3 — 컨텍스트 병합: 팩트·메모리·레퍼런스 + +설계 문서 3.1절. `ai.ContextSelection`에는 `Facts`/`Memories`/`References` 토글이 이미 있지만 실제 수집은 컴패니언 `gatherContext`에만 있다. 이 작업이 빠지면 MCP 브리프에 팩트북과 메모리가 빠진다. + +**파일:** `engine/internal/storycontext/*`, `+ 테스트` + +- [ ] 컴패니언 `gatherContext`의 팩트(씬 필터 포함)·메모리(recall)·레퍼런스 수집을 `storycontext` 빌더의 선택적 섹션으로 이식한다. +- [ ] `Context` 구조체에 `Facts`/`Memories`/`References` 필드를 추가하고 렌더러가 해당 섹션을 출력하게 한다(빈 섹션은 생략 — 기존 관례). +- [ ] 기존 토글(`ContextSelection`)이 실제로 이 섹션들을 켜고 끄는지 테스트한다. +- [ ] 컴패니언의 기존 프롬프트 조립은 건드리지 않는다 — 이 병합은 MCP 툴을 위한 것이고, 컴패니언은 6단계까지 자기 경로를 유지한다. + +### Task 1.4 — 요약기 경계 정리 + +**파일:** `engine/internal/summarizer/*` + +- [ ] 비-LLM 경로(`minRunesForLLM` 미만 평문 요약)와 LLM 경로를 파일 단위로 분리한다. +- [ ] `nodes.SetSummary(id, summary, contentVersion)`를 외부에서 호출할 수 있는 형태로 정리한다(3단계의 `linetta_write_summary`가 쓴다). +- [ ] `nodes.update_content`의 `postUpdate` 훅 구조는 유지한다 — 6단계에서 훅이 부르는 대상만 비-LLM 요약기로 바뀐다. + +**1단계 종료 조건:** `make test` 통과, 사용자에게 보이는 동작 변화 0, `storycontext`/`storyops`가 LLM 코드에 의존하지 않음이 `go list -deps`로 확인됨. + +--- + +## Phase 2 — MCP 호스트, 인증, 읽기 툴 + +읽기 전용 MCP 서버가 동작한다. 쓰기가 없으니 배관이 자리 잡는 동안 위험 반경은 0이다. + +### Task 2.1 — SDK 의존성 추가 + +**파일:** `engine/go.mod`, `engine/go.sum` + +- [ ] `cd engine && go get github.com/modelcontextprotocol/go-sdk@v1.7.0` +- [ ] `go build -tags mas ./...`가 SDK를 **링크하는지** 확인한다(MAS도 MCP를 쓴다). +- [ ] `go test -tags mobile ./...`는 SDK를 링크하지 않아야 한다. `go list -deps -tags mobile ./... | grep modelcontextprotocol`이 비어야 한다. + +### Task 2.2 — 설정 키와 시크릿 토큰 + +**파일:** `engine/internal/settings/settings.go`, `secrets.go`, `+ 테스트` + +- [ ] `MCPMode`(`off`|`read_only`|`full`, 기본 `off`), `MCPPort`(기본 `7391`), `MCPProjectID`, `MCPConsentVersion`, `MCPConsentedAt`를 `Settings`와 `SettingsPatch`에 추가한다. +- [ ] `MCPTokenSet bool`(읽기용 존재 플래그)과 시크릿 저장소를 통해 쓰는 `RegenerateMCPToken()`을 추가한다. 토큰 값 자체는 `settings.get`이 절대 반환하지 않는다 — `api_key` 처리 방식과 동일하다. +- [ ] 테스트: `settings.get`이 토큰을 가리고 존재 플래그만 노출한다. 모드가 왕복한다. 알 수 없는 모드는 `off`로 떨어진다. + +### Task 2.3 — `mcphost` 골격, 인증, 수명 주기 + +**파일:** `engine/internal/mcphost/host.go`, `auth.go`, `discovery.go`, `+ 테스트` + +- [ ] `mcphost.New(deps)`가 `*mcp.Server`와 `http.Server`를 만들고 설정된 포트로 `net.Listen("tcp", "127.0.0.1:"+port)` 한다. 저장된 클라이언트 설정이 재시작을 견디도록 포트는 고정이다. +- [ ] 포트가 이미 사용 중이면 설정 화면이 "7391 포트가 사용 중입니다 — 다른 포트를 선택하세요"로 렌더링할 수 있는 타입 에러를 반환한다. **다른 포트로 조용히 넘어가지 않는다.** +- [ ] 인증 미들웨어: 상수 시간 베어러 비교, `Origin`이 있는데 루프백이 아니면 거부, `Host`가 루프백이 아니면 거부. +- [ ] `Start()`가 `$LINETTA_HOME/mcp.json`(권한 0600, `{port, token, pid, started_at}`)을 쓰고, `Stop()`이 삭제하며 리스너를 내린다. 설정 파일 `settings.json`과는 별개 파일이다. +- [ ] 테스트: 토큰 없음 → 401, 토큰 틀림 → 401, `Origin: https://evil.test` → 403, 포트 점유 → 타입 에러, POSIX에서 디스커버리 파일 권한 0600, `Stop` 후 파일 삭제. + +### Task 2.4 — `engineapp` 연결 + +**파일:** `engine/internal/engineapp/mcp_enabled.go`(`//go:build !mobile`), `mcp_disabled.go`(`//go:build mobile`), `engineapp.go`, `+ 테스트` + +- [ ] `gitsync_enabled.go` / `gitsync_disabled.go` 패턴을 그대로 따른다: `const mcpAvailable`, `setupMCP(deps) mcpController`. +- [ ] RPC `mcp.status`, `mcp.enable`, `mcp.disable`, `mcp.regenerate_token`, `mcp.activity`를 등록한다. 비활성 쌍둥이는 `CodeMethodNotFound`를 반환한다. +- [ ] 호스트의 `Stop`을 `a.closers`에 넣어 앱과 함께 리스너가 죽게 한다. +- [ ] `handlers.Capabilities`에 `MCPAvailable`을 추가하고 `diagnostics.version` / `diagnostics.get`으로 노출한다. +- [ ] 테스트: 모드 `off`면 아무것도 바인딩하지 않음, `mcp.enable` 후 `mcp.status`가 포트를 보고함, `Close()`가 포트를 반납함. + +### Task 2.5 — 읽기 툴 9개 + +**파일:** `engine/internal/mcphost/tools_read.go`, `+ 테스트` + +- [ ] 설계 문서 5절의 읽기 툴 9개를 `mcp.AddTool`로 등록한다. 입출력을 타입 구조체로 선언해 스키마가 생성되게 한다. +- [ ] `linetta_get_story_context`는 병합된 `storycontext` 빌더(Task 1.3 완료가 전제)로 브리프를 조립하고, 평문 렌더러로 마크다운을 만들어 "무엇이 포함됐는지" 요약과 함께 반환한다. +- [ ] `linetta_read_scene`은 `content_version`을 반환하고, 설명에 쓰기에는 이 값이 필요하다고 명시한다. +- [ ] `MCPProjectID` 범위 제한은 툴마다가 아니라 공용 헬퍼 한 곳에서 강제한다. +- [ ] 테스트: 씨드된 임시 스토어로 각 툴 검증, 범위 밖 `project_id` 차단, `read_only` 모드에서 정확히 이 9개만 등록됨, **LLM 프로바이더가 설정되지 않은 상태에서 `linetta_get_story_context`가 요약만 빈 채 팩트·메모리를 포함한 완전한 브리프를 에러 없이 반환함**(전환의 전제가 이 테스트에 달려 있다). + +### Task 2.6 — 활동 로그 + +**파일:** `engine/internal/store/migrations/*`, `engine/internal/mcphost/activity.go`, `+ 테스트` + +- [ ] `mcp_activity` 테이블 마이그레이션(`id, at, tool, project_id, target_id, ok, detail`). +- [ ] 성공·실패 관계없이 모든 툴 호출을 기록하고, 기존 스냅샷 정리 잡에 보존 한도를 얹는다. +- [ ] `mcp.activity` RPC가 최근 기록을 반환한다. + +**2단계 종료 조건:** `claude mcp add --transport http linetta http://127.0.0.1:7391/mcp --header "Authorization: Bearer "`로 연결되고, 실제 Claude Code 세션이 작품 구조를 설명할 수 있다. + +--- + +## Phase 3 — 쓰기 툴과 안전장치 + +### Task 3.1 — `linetta_write_scene` + +**파일:** `engine/internal/mcphost/tools_write.go`, `+ 테스트` + +- [ ] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다. +- [ ] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다. +- [ ] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다. +- [ ] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부. + +### Task 3.2 — `linetta_revise_scene` + +- [ ] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다. +- [ ] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환. + +### Task 3.3 — `linetta_apply_story_ops` + +- [ ] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다. +- [ ] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다. +- [ ] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함. + +### Task 3.4 — `linetta_write_summary` + +**전환의 급소다.** 설계 문서 6절 참조. + +- [ ] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유). +- [ ] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다. +- [ ] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨. + +### Task 3.5 — 체크포인트와 되돌리기 + +- [ ] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다. +- [ ] `linetta_undo_last_change`는 `storyops.UndoApply`를 감싸고, 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. + +### Task 3.6 — 호출 한도와 모드 강제 + +- [ ] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다. +- [ ] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다. + +### Task 3.7 — 변경 알림 + +**파일:** `engine/internal/mcphost/*`, `apps/desktop/src-tauri/src/ffi.rs`, `apps/desktop/src/hooks/*` + +- [ ] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다. +- [ ] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다. +- [ ] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.** +- [ ] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest. + +**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`를 구동하고 원고가 원래 바이트로 돌아온다. + +--- + +## Phase 4 — 브리지, 설정 UX, MAS + +### Task 4.1 — `cmd/linetta-mcp` 브리지 + +**파일:** `engine/cmd/linetta-mcp/main.go`, `+ 테스트` + +- [ ] `$LINETTA_HOME/mcp.json`을 읽고, 고급 설정을 위한 `--url` / `--token` 재정의를 지원한다. +- [ ] SDK의 `StreamableClientTransport`(로컬 엔드포인트, 인증 헤더 부착)와 SDK의 `StdioTransport` 서버 쪽을 합성한다. **단순 바이트 펌프가 아니다** — SSE 응답 스트림, 서버 발신 메시지용 GET 스트림, `Mcp-Session-Id` 상태를 SDK가 처리하게 맡긴다. +- [ ] `--print-header`를 추가한다. `Authorization` 헤더 값만 출력하고 종료하며, 생성된 `.mcp.json`이 `headersHelper`로 이걸 호출한다. 그래야 설정 파일에 리터럴 토큰이 남지 않는다. +- [ ] Linetta가 실행 중이 아닐 때 사람이 읽을 수 있는 메시지로 종료한다("Linetta를 열고 설정에서 MCP를 켜세요"). 이 문자열이 사용자가 클라이언트에서 보게 될 문구다. +- [ ] 테스트: 스텁 HTTP 서버 대상으로 스트리밍 응답 포함 왕복 1회, 디스커버리 파일 없음 → 안내 메시지와 0이 아닌 종료 코드, `--print-header`는 헤더 값만 출력. + +### Task 4.2 — 빌드와 번들 + +**파일:** `scripts/build-mcp-bridge.sh`, `Makefile`, `apps/desktop/src-tauri/tauri.conf.json`, `.github/workflows/*` + +- [ ] `make build-mcp-bridge`가 호스트 OS용으로 빌드하고, CI가 플랫폼별 산출물을 만든다. 브리지는 cgo 없는 순수 Go라 크로스 컴파일이 쉽다. +- [ ] 직접 배포 빌드에서는 Tauri 리소스로 번들하고 경로를 프론트엔드에 노출한다. +- [ ] **MAS 빌드에서는 번들하지 않는다.** Homebrew/GitHub 릴리스로 별도 배포한다. +- [ ] `scripts/validate-distribution.sh`를 확장해 브리지 없이 나가는 릴리스가 게이트에서 실패하게 한다. + +### Task 4.3 — MAS 엔타이틀먼트 + +**파일:** `apps/desktop/src-tauri/*.entitlements`, `packaging/*`, `docs/` + +- [ ] MAS 엔타이틀먼트에 `com.apple.security.network.server`를 추가한다. +- [ ] 샌드박스 빌드에서 루프백 리스너가 실제로 뜨는지 로컬 검증한다(`make build-mas-local`). +- [ ] MAS에서는 브리지 없이 Claude Code HTTP 직접 연결 경로만 안내하고, 토큰 수동 붙여넣기 흐름을 문서화한다. +- [ ] 심사 설명 문구를 준비한다: 로컬 루프백 전용, 사용자가 명시적으로 켜야 함, 원격 접속 없음. + +### Task 4.4 — 설정 화면 + +**파일:** `apps/desktop/src/routes/Settings.tsx`, 신규 컴포넌트, `apps/desktop/src-tauri/src/lib.rs`, i18n 리소스 + +- [ ] 토글, 모드 선택, 포트 입력, 작품 제한, 동의 문구, 토큰 재발급, 킬 스위치, 활동 목록. +- [ ] 복사 가능한 스니펫 3종을 설정된 포트와 실제 브리지 경로로 생성한다. **리터럴 토큰은 `claude mcp add` 한 줄에만 담는다**(사용자 자기 기기에 쓰이므로). `.mcp.json` 스니펫은 `headersHelper` + `linetta-mcp --print-header`를 쓴다 — `.mcp.json`은 사람들이 커밋하는 파일이다. +- [ ] 포트 점유 상태를 Task 2.3의 타입 에러로 렌더링하고 다른 포트를 고르게 한다. +- [ ] `mcp.status`, `mcp.enable`, `mcp.disable`, `mcp.regenerate_token`, `mcp.activity`를 `RENDERER_ENGINE_METHODS`에 추가한다. +- [ ] `capabilities.mcp_available`이 false면 화면 전체를 숨긴다. +- [ ] 3개 언어 번역. +- [ ] Vitest: 스니펫 렌더링, 동의 전에는 활성화가 막힘, 킬 스위치가 `mcp.disable`을 호출함. + +### Task 4.5 — 연결 표시등 + +- [ ] 세션이 활성인 동안 워크스페이스에 눈에 띄지 않는 표시등을 띄우고, 클릭하면 활동 로그로 간다. 작가가 "뭔가 다른 것이 내 원고를 고칠 수 있다"는 사실에 놀라는 일이 없어야 한다. + +### Task 4.6 — `.mcp.json`을 동기화에서 제외 + +**파일:** `engine/internal/gitsync/*`, `engine/internal/foldersync/*`, `+ 테스트` + +- [ ] 두 내보내기 모두에서 `.mcp.json`을 제외한다. +- [ ] 테스트: 동기화 디렉터리의 `.mcp.json`이 스테이징되지 않고 살아남는다. + +**4단계 종료 조건:** 처음 쓰는 사용자가 MCP를 켜고 명령 한 줄을 붙여넣어 Claude Code 또는 Claude Desktop으로 자기 작품에 초고를 쓸 수 있다. + +--- + +## Phase 5 — 컴패니언 강등과 검증 + +**아직 지우지 않는다.** MCP 경로가 실제로 컴패니언을 대체할 수 있는지 확인하는 단계다. + +### Task 5.1 — 신규 사용자 기본값 전환 + +- [ ] 신규 설치의 온보딩에서 AI 설정 마법사 대신 **MCP 연결 안내**를 보여준다. +- [ ] 컴패니언과 프로바이더 설정은 기존 사용자에게만 보이게 하고, 설정 화면에서 "레거시" 섹션으로 내린다. +- [ ] 레거시 섹션에 전환 안내와 MCP 설정으로 가는 링크를 넣는다. + +### Task 5.2 — 실사용 검증 + +- [ ] 실제 작품으로 씬 10개 이상을 MCP 경로만으로 집필하고, 컴패니언으로만 가능했던 작업이 있는지 기록한다. +- [ ] 요약 쓰기 흐름이 실제로 돌아가는지 확인한다 — 에이전트가 `linetta_write_summary`를 자발적으로 부르는가, 아니면 툴 설명을 고쳐야 하는가. +- [ ] 빠진 툴을 목록화하고, 15개 예산 안에서 추가할지 판단한다. + +### Task 5.3 — 데이터 보존 경로 + +- [ ] 컴패니언 대화 히스토리와 메모리를 내보낼 수 있는 경로를 만든다(마크다운 내보내기 또는 읽기 전용 뷰). +- [ ] **테이블을 조용히 드롭하지 않는다.** 제거 후에도 데이터는 남고, 필요하면 읽을 수 있어야 한다. + +**5단계 종료 조건:** MCP만으로 한 작품 분량의 실제 집필이 가능하다는 것이 확인됐고, 빠진 기능 목록이 비었거나 수용 가능하다. + +--- + +## Phase 6 — 제거와 1.0.0 + +5단계 검증이 끝난 뒤에만 진행한다. + +### Task 6.1 — 엔진 제거 + +- [ ] `internal/ai`의 LLM 클라이언트/실행기와 `BuildMessages` 어댑터, `internal/companion` 잔여(에이전트 루프·세션·스트리밍), `internal/modelcatalog`, `internal/openrouter`, `internal/clidetect` 삭제. +- [ ] `internal/summarizer`의 LLM 경로 삭제. 비-LLM 짧은 씬 경로와 `nodes.update_content`의 `postUpdate` 훅은 유지 — 훅이 부르는 대상만 바뀐다. +- [ ] RPC `ai.*`, `companion.*`, `providers.*`, `openrouter.*` 제거. `handlers.Capabilities`의 `UnavailableProviders` 정리. +- [ ] **RPC `projects.rewrite_synopsis` 제거.** 프로바이더 없는 상태에서 이 메서드는 컨테이너 요약을 지우고 빈 문자열을 돌려주는 파괴적 동작이 된다(설계 문서 3.3절). `projects.clear_synopsis`는 무해하므로 유지 여부만 판단. +- [ ] 설정에서 `provider`, `providers`, `ai_data_sharing_consent_*` 제거. 마이그레이션은 기존 값을 무시하되 파괴하지 않는다. +- [ ] `tars` 의존성은 **유지한다** — `pkg/tools`의 `web_fetch`가 팩트북 URL 캡처에 쓰이고(`handlers/facts.go:108`), `storyops`의 `remember`가 `pkg/memory`를 쓴다. `pkg/llm`, `pkg/agentloop`, `pkg/session` 사용만 사라진다. +- [ ] Phase 0에서 `web_search` 제거를 택했다면 `handlers/websearch.go`, `web_search.test` RPC, `web_search_*` 설정도 함께 제거한다. + +### Task 6.2 — 프론트엔드 제거 + +- [ ] `components/ai/*`, `components/companion/*`, `hooks/useCompanion*` 삭제. +- [ ] Settings의 LLM 섹션, AI 온보딩 마법사, 관련 단축키·명령 팔레트 항목 삭제. +- [ ] `ffi.rs`의 `notification_event`에서 `ai.*` / `companion.*` 매핑 제거, `lib.rs`의 `RENDERER_ENGINE_METHODS`에서 해당 메서드 제거. +- [ ] `Cmd/Ctrl+J`를 비우거나 다른 집필 기능에 재할당한다. + +### Task 6.3 — 문서와 스토어 문구 + +- [ ] README를 다시 쓴다: "AI is optional" 섹션을 "당신의 에이전트와 함께 쓰기(MCP)"로 교체. +- [ ] `docs/privacy-policy.md`에서 AI 프로바이더로 원고를 전송한다는 문구를 제거하고, MCP를 켠 경우 외부 클라이언트가 가져간다는 설명으로 바꾼다. +- [ ] `docs/DEVELOPMENT.md`의 "AI companion tools" 절을 MCP 툴 카탈로그로 교체. +- [ ] Mac App Store 설명, 스크린샷, CHANGELOG 갱신. + +### Task 6.4 — 1.0.0 + +- [ ] `make bump-version VERSION=1.0.0` +- [ ] 마이그레이션 안내 문서: 기존 컴패니언 사용자가 MCP로 넘어오는 방법, 히스토리 데이터가 어떻게 되는지. + +**6단계 종료 조건:** 앱이 어떤 LLM도 직접 호출하지 않고, 프로바이더 설정 화면이 존재하지 않으며, `make test` / `make test-mobile-engine` / `go build -tags mas ./...`가 전부 통과하고, `go list -deps`에 `tars/pkg/llm`·`pkg/agentloop`·`pkg/session`이 나타나지 않는다. + +--- + +## Phase 7 — 순수 창작 도구 강화 (후속) + +제거로 확보한 여력을 집필 기능에 투자한다. 이 계획의 범위 밖이지만 방향을 적어 둔다. + +- [ ] `contextualedit`(설정 변경 → 관련 씬 일괄 수정) 같은 결정론적 파워 기능 확장 — LLM 없이 동작하며 이 제품 방향의 대표 기능이다. +- [ ] 손으로 쓰는 요약 UI (Phase 0 결정에 따라) +- [ ] 집필 통계, 원고 진행 관리, 퇴고 워크플로 +- [ ] MCP 프롬프트("다음 씬 초고", "연속성 점검")와 리소스(`linetta://work/{id}/scene/{id}`) +- [ ] `--headless` 엔진 모드 — 앱을 열지 않고도 에이전트가 작업 + +--- + +## 검증 계약 + +각 단계 종료 시: + +```bash +make test +make test-mobile-engine +cd engine && go build -tags mas ./... +``` + +2·3단계는 인메모리 MCP 종단 테스트를 추가로 통과해야 한다. 4단계는 개발자 기기에서 Claude Code와 Claude Desktop 양쪽 수동 왕복을 추가로 요구한다. 6단계는 위 세 명령에 더해, LLM 호출 경로가 하나도 남지 않았음을 `go list -deps`로 확인한다. diff --git a/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md b/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md new file mode 100644 index 00000000..fea6b00a --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md @@ -0,0 +1,229 @@ +# MCP 우선 전환 — 설계 문서 + +**작성일:** 2026-08-22 +**상태:** 제안 +**구현 계획:** `docs/superpowers/plans/2026-08-22-mcp-first-pivot.md` + +## 1. 무엇을 바꾸려는가 + +지금 Linetta는 앱 안에 BYOK 방식으로 LLM 프로바이더를 설정하고, `Cmd/Ctrl+J` 컴패니언으로 AI 협업을 제공합니다. 이 문서는 그 구조를 뒤집는 전환을 설계합니다. + +**전환 후:** + +- **Linetta = 순수 창작 도구.** 사람이 직접 소설을 쓰는 것을 돕는 데 집중합니다. 앱 자체는 LLM을 호출하지 않고, API 키를 요구하지 않고, 토큰을 쓰지 않습니다. +- **AI 협업 = MCP 클라이언트.** Claude Desktop, Claude Code 등 사용자가 이미 쓰는 에이전트가 MCP로 Linetta에 접속해 원고를 읽고 씁니다. + +즉 AI를 앱 안에 넣는 대신, **앱을 AI가 일할 수 있는 작업장으로 만드는** 방향입니다. 작가는 책상과 서류함과 거부권을 계속 쥐고 있고, 에이전트는 외부에서 고용된 집필자로 들어옵니다. + +## 2. 왜 이 전환인가 + +**BYOK가 앱에 지우는 부담이 실제로 큽니다.** 프로바이더 설정 화면, 모델 카탈로그, OpenRouter OAuth, CLI 탐지, API 키 보관, 프로바이더별 실패 메시지 번역, AI 데이터 공유 동의, AI 온보딩 마법사 — 이 전부가 "소설 쓰기"가 아니라 "LLM 배관"입니다. + +**사용자는 이미 에이전트를 갖고 있습니다.** Claude 구독자는 Claude Code/Desktop을 씁니다. Linetta 안에서 API 키를 또 넣고 토큰을 또 결제하는 건 중복입니다. + +**외부 에이전트가 앱 내장 컴패니언보다 강합니다.** 최신 모델, 긴 컨텍스트, 서브에이전트, 파일 시스템 접근, 웹 검색을 이미 갖추고 있습니다. Linetta가 따라잡을 필요가 없습니다. + +**Linetta만 줄 수 있는 것은 따로 있습니다.** 일반 파일시스템 MCP 서버도 마크다운은 읽고 씁니다. 못 하는 건 특정 씬 하나를 위해 조립된 큐레이션 컨텍스트입니다 — 아웃라인, 계층 요약, 직전 씬 요약, 등장인물·관계 브리프, 플롯 스파인, 팩트북 카드, 메모리, 문체·시점·분량 목표. 이 브리프가 이 제품의 본체이고, 외주 원고가 14화와 모순되지 않게 돌아오는 이유입니다. + +## 3. 제거 경계 — 패키지가 아니라 기능으로 나눈다 + +**가장 중요한 설계 판단입니다.** MCP 서버의 핵심 툴들은 지금 제거 후보로 보이는 패키지 위에 서 있습니다. + +- 스토리 컨텍스트 → `ai.ContextBuilder` (`internal/ai`) +- 스토리 옵 적용/되돌리기 → `companion.Service.ApplyOps` / `UndoApply` (`internal/companion`) + +따라서 "`internal/ai`와 `internal/companion`을 지운다"는 계획은 **자기 발을 쏘는 계획**입니다. 경계는 패키지가 아니라 **LLM 루프 vs 스토리 오퍼레이션/컨텍스트**입니다. + +### 3.1 컨텍스트 조립 경로가 지금 두 개라는 사실 + +코드를 확인한 결과, 오늘의 Linetta에는 컨텍스트 조립 경로가 **둘** 있고 서로 다른 것을 담습니다. + +| 경로 | 담는 것 | 안 담는 것 | +| --- | --- | --- | +| `ai.ContextBuilder` → `ai.Context` (씬 중심) | 현재 씬 본문, 직전 씬 요약, 계층(부/장) 요약, 인접·관련 씬, 엔티티·관계 브리프, 플롯 스파인, 여백 노트, 문체 목표 | **팩트, 메모리, 레퍼런스** | +| `companion.gatherContext` → `PromptData` (작품 중심) | 아웃라인 노드 id, 씬 발췌, 스레드, 엔티티, 관계, **팩트, 메모리, 레퍼런스** | 계층 요약, 직전 씬 요약, 관련 씬 RAG | + +`ai.ContextSelection`에는 `Facts`/`Memories`/`References` 토글이 이미 있지만, 실제 수집은 컴패니언 쪽에만 있습니다. **추출은 이동이 아니라 병합입니다** — `internal/storycontext`는 `ai.ContextBuilder`를 기반으로 하되, 컴패니언의 팩트·메모리·레퍼런스 수집을 이식해 토글을 실제로 완성해야 합니다. 이걸 명시하지 않으면 구현자가 계획서 문면대로 따라가서 팩트북과 메모리가 빠진 브리프를 만들게 됩니다. + +### 3.2 살려서 옮기는 것 (추출) + +| 지금 위치 | 옮길 곳 | 내용 | +| --- | --- | --- | +| `internal/ai` — `Context`, `ContextSelection`, `ContextBuilder`, 프롬프트 렌더링 | `internal/storycontext` | 큐레이션된 스토리 브리프 조립. LLM 호출 없음 | +| `internal/companion` — 팩트·메모리·레퍼런스 수집 (`gatherContext`의 해당 부분) | `internal/storycontext` | 위 병합의 재료 | +| `internal/companion` — `Proposal`, `validateProposal`, `ApplyOps`, undo 배치, 메모리 기록(`remember`) 경로 | `internal/storyops` | 구조화된 스토리 변경 적용 + 되돌리기 | + +**렌더러의 타입 의존성 주의:** `ai.BuildMessages`는 반환 타입으로 `tars/pkg/llm.ChatMessage`를 씁니다. 내부는 시스템/유저 문자열 두 개를 만드는 것뿐입니다(`buildSystem`/`buildUser`). 추출 시 `storycontext`는 **평문 문자열을 반환하는 렌더러**(`RenderSystem`/`RenderUser`)로 바꾸고, 전환 기간 동안 `internal/ai`에 `llm.ChatMessage`로 감싸는 얇은 어댑터만 남깁니다. 그래야 "storycontext는 LLM 코드를 import하지 않는다"는 검증이 성립하고, MCP 툴은 마크다운을 직접 렌더링할 수 있습니다. + +이 추출이 끝나면 두 패키지의 나머지(프로바이더 글루, 에이전트 루프, 채팅 세션, 스트리밍)는 안전하게 제거할 수 있습니다. + +### 3.3 제거하는 것 + +- `internal/ai`의 LLM 클라이언트 팩토리·실행기 (`client.go`, `runner.go`)와 프로바이더 글루 +- `internal/companion` 잔여 — TARS 에이전트 루프, 채팅 히스토리, 스트리밍, 제안 대화 흐름 +- `internal/modelcatalog`, `internal/openrouter`, `internal/clidetect` +- `internal/summarizer`의 LLM 경로 (짧은 씬용 비-LLM 경로는 유지 — 6절) +- RPC: `ai.*`, `companion.*`, `providers.*`, `openrouter.*` +- RPC: **`projects.rewrite_synopsis`** — 프로바이더가 사라진 뒤에는 이 메서드가 **파괴적으로 변합니다.** `DeriveProjectSynopsis(refresh=true)`는 컨테이너 요약을 먼저 지우고 요약기를 불러 다시 채우는데, 요약기가 없으면 지우기만 하고 빈 문자열을 돌려줍니다. 남겨두면 클릭 한 번으로 요약을 날리는 버튼이 됩니다. 시놉시스는 에이전트가 쓰기 툴로 채웁니다(5절). +- 설정: `provider`, `providers`, `ai_data_sharing_consent_*` (`web_search_*`는 Phase 0 결정에 따름) +- 프론트엔드: `components/ai/*`, `components/companion/*`, `hooks/useCompanion*`, Settings의 LLM 섹션, AI 온보딩 마법사 + +### 3.4 그대로 두는 것 + +- `internal/contextualedit` — **LLM을 전혀 쓰지 않습니다.** 생성자가 받는 건 엔티티·팩트·관계·원고 편집기·노드뿐입니다(`contextualedit.go:150`). 인물 설정을 바꾸면 관련 씬을 찾아 일괄 수정하는 이 기능은 오히려 "순수 창작 도구" 방향의 대표 기능입니다. +- `internal/manuscriptedit`, `snapshot`, `search`, `manuscript`(FTS), `plot`, `mention`, `stats`, `backup`, `gitsync`, `foldersync` +- **`tars` 의존성 자체는 남습니다.** `pkg/tools`의 `NewWebFetchTool`이 팩트북 URL 캡처에 쓰이고(`handlers/facts.go:108`), 메모리 기능이 `remember` 옵으로 유지되므로 `pkg/memory`도 남습니다. 빠지는 건 `pkg/llm`, `pkg/agentloop`, `pkg/session` 사용입니다. + +> **규모에 대한 정직한 표기:** 제거 규모를 "패키지 LOC 합계"로 세면 과장됩니다. `internal/companion` 9,800줄 중 상당 부분이 `storyops`와 `storycontext`로 살아남습니다. 계획서는 규모를 기능 단위로 적고, 실제 삭제 줄 수는 추출이 끝난 뒤 측정합니다. + +## 4. MCP 서버 아키텍처 + +### 4.1 실행 중인 앱 안에서 호스팅한다 + +새 패키지 `engine/internal/mcphost`를 `engineapp.register`에 연결하고, 공식 Go SDK(`github.com/modelcontextprotocol/go-sdk` v1.7.0)의 Streamable HTTP 핸들러를 `127.0.0.1`에 붙입니다. + +**별도 프로세스가 `library.db`를 직접 여는 방식은 기각했습니다.** + +1. `engineapp.Open`이 백업 루프, 스냅샷 정리, 요약기, 폴더/Git 동기화를 무조건 시작합니다. 프로세스가 둘이면 일일 동기화가 이중 실행되고 같은 행을 두 번 처리합니다. +2. `store.Open`이 열 때마다 `ApplyMigrations`를 돌립니다. 버전 업그레이드 직후 두 프로세스가 마이그레이션 전 백업과 마이그레이션 자체를 경합합니다. +3. **UI에 프로세스 간 갱신 경로가 없습니다.** 알림은 Go → C 콜백 → `notify_trampoline` → `app.emit` → `useEngineEvent`(`apps/desktop/src-tauri/src/ffi.rs:182`)로 흐르는 프로세스 내부 전용 경로입니다. 외부 프로세스가 고친 씬을 작가는 모른 채 계속 타이핑하게 됩니다. +4. SQLite WAL은 다중 프로세스를 견디지만, `store.Open`의 `db.SetMaxOpenConns(1)`은 **프로세스 안에서만** 직렬화합니다. + +나중에 `engineapp.Options{DisableBackgroundJobs: true}`를 받는 명시적 `--headless` 모드로 되살릴 수 있습니다(후속 단계). + +### 4.2 포트는 고정, 임의 포트 아님 + +설정 `mcp_port`, 기본값 **7391**. 클라이언트 설정은 한 번 쓰고 몇 달을 씁니다. 임의 포트(`:0`)를 쓰면 앱을 재시작할 때마다 저장된 설정이 전부 죽고, Claude Code에는 URL이 바뀌는 것을 흡수할 클라이언트 측 수단이 없습니다(`headersHelper`는 헤더 전용입니다). 포트가 이미 사용 중이면 조용히 다른 포트로 넘어가지 말고 **"7391 포트가 사용 중입니다 — 다른 포트를 선택하세요"** 라고 화면에 띄웁니다. + +고정 포트가 보안을 약화시키지는 않습니다. 엔드포인트를 지키는 건 포트의 비밀성이 아니라 베어러 토큰과 `Origin` 검사입니다. + +### 4.3 전송 방식이 두 개 필요한 이유 + +| 클라이언트 | 연결 경로 | +| --- | --- | +| Claude Code | HTTP 직접: `claude mcp add --transport http linetta http://127.0.0.1:7391/mcp --header "Authorization: Bearer "` | +| Claude Desktop | 번들된 `linetta-mcp` 브리지를 통한 stdio | +| 기타 MCP 클라이언트 | 지원하는 전송 방식에 따라 둘 중 하나 | + +Claude Desktop은 현재 로컬 HTTP MCP 서버에 직접 붙지 못합니다. `claude_desktop_config.json`은 stdio 항목만 검증하고(`url` 필드는 조용히 버려집니다), 설정 → 커넥터 경로는 Anthropic 클라우드가 URL을 여는 구조라 공인 인증서가 달린 공개 HTTPS가 필요합니다. **따라서 Claude Desktop을 지원 대상으로 삼는 한 브리지는 선택이 아닙니다.** + +브리지는 스토리 로직을 담지 않습니다. SDK의 `StreamableClientTransport`(로컬 엔드포인트로, 인증 헤더 부착)와 SDK의 `StdioTransport` 서버 쪽을 합성한 것뿐입니다. 단순 바이트 펌프가 아니라는 점이 중요합니다 — Streamable HTTP는 SSE 응답 스트림, 서버 발신 메시지를 위한 GET 스트림, `Mcp-Session-Id` 상태를 다룹니다. 그 처리는 전부 SDK에 맡깁니다. 툴이 바뀌어도 브리지는 함께 배포할 필요가 없습니다. + +## 5. 툴 카탈로그 (15개) + +엔진 RPC 100여 개를 1:1로 노출하지 않습니다. 클라이언트의 툴 예산은 유한하고, 툴이 늘수록 선택 정확도가 떨어집니다. 전부 `linetta_` 접두사를 씁니다. + +### 읽기 (9) + +| 툴 | 기반 | 비고 | +| --- | --- | --- | +| `linetta_list_works` | `projects.list` | 작품 id, 제목, 상태, 씬 수 | +| `linetta_get_outline` | `nodes.list_tree` | 트리 id, 라벨, 종류, 상태, 분량 | +| `linetta_get_story_context` | `storycontext` (병합된 빌더) | **핵심 툴.** 씬 하나를 위한 큐레이션 브리프 — 3.1절의 병합 완료가 전제 | +| `linetta_read_scene` | `nodes.get` | 본문과 **`content_version`** 반환 — 안전한 쓰기에 필수 | +| `linetta_search_manuscript` | `manuscript.search` | 원고 전문 검색 | +| `linetta_list_characters` | `entities.list` | `kind` 필터로 장소·사물·개념까지 | +| `linetta_where_does_appear` | `entities.scenes` | 특정 인물이 등장하는 씬 목록 | +| `linetta_get_plot` | `plot.spine_panel` | 스토리라인과 비트 | +| `linetta_get_fact_cards` | `facts.list` | 출처가 붙은 조사 노트 | + +### 쓰기 (6) + +| 툴 | 기반 | 비고 | +| --- | --- | --- | +| `linetta_write_scene` | `nodes.update_content` | `expected_content_version` 필수, 쓰기 전 자동 스냅샷 | +| `linetta_revise_scene` | `manuscript.replace_preview` + `replace_apply` | 씬 전체를 다시 보내지 않는 부분 수정 | +| `linetta_apply_story_ops` | `storyops.ApplyOps` | 기존 `Proposal` 옵 어휘를 배치로 | +| `linetta_write_summary` | `nodes.SetSummary` / `project.Update(Synopsis)` | **전환의 핵심.** 씬·컨테이너 요약과 작품 시놉시스를 모두 담당. 6절 참조 | +| `linetta_create_checkpoint` | `snapshots.create_manual` | 큰 개작 전 라벨 붙은 복원 지점 | +| `linetta_undo_last_change` | `storyops.UndoApply` | `batch_id`로 되돌리기 | + +메모리는 툴 하나를 더 쓰지 않고 `linetta_apply_story_ops`의 기존 `remember` 옵으로 처리합니다. + +### 설계 규칙 + +- **적용기를 재사용하고 병렬 쓰기 경로를 만들지 않습니다.** `ApplyOps`(`engine/internal/companion/tools.go:324`)는 제안 검증, 되돌리기용 아웃라인 캡처, 멘션 재동기화, 원고 재색인을 이미 수행합니다. 두 번째 쓰기 경로는 이 전부를 조용히 건너뜁니다. +- **동시 편집은 예외가 아니라 정상입니다.** 에이전트가 고쳐 쓰는 씬을 사람이 동시에 타이핑하는 상황이 기본 시나리오입니다. `nodes.update_content`에는 이미 `expected_content_version` → `ErrContentConflict` → JSON-RPC `-32009` 낙관적 동시성이 있습니다(`handlers/nodes.go:59`). MCP 툴은 이걸 "씬을 다시 읽고 최신 버전으로 재시도하라"는 툴 에러로 노출하며, 절대 조용히 덮어쓰지 않습니다. +- **툴 설명이 작업 흐름을 담습니다.** 쓰기 툴 설명에는 변경이 스냅샷되고 되돌릴 수 있다는 점, 초고를 쓰기 전에 `linetta_get_story_context`를 먼저 부르라는 점, 씬을 읽거나 쓴 뒤에는 `linetta_write_summary`로 요약을 갱신하라는 점을 적습니다. +- MCP **프롬프트**("다음 씬 초고", "연속성 점검")와 **리소스**(`linetta://work/{id}/scene/{id}`)는 후속입니다. 툴이 먼저입니다. + +## 6. 요약 문제 — 이 전환의 급소 + +프로바이더를 제거하면 요약기가 멈춥니다. 그런데 요약은 `linetta_get_story_context`의 계층 요약과 직전 씬 요약을 채우는 재료입니다. 즉 **핵심 툴의 품질이 제거 대상에 의존합니다.** + +**해법: 요약을 외부 에이전트가 써서 돌려보냅니다.** 에이전트는 어차피 다음 씬을 쓰기 위해 이전 씬을 읽습니다. `linetta_write_summary`로 그 결과를 저장하게 하면, 의존성이 협업 지점으로 바뀌고 요약 비용은 사용자의 기존 구독에 흡수됩니다. + +구현 시 주의: + +- `SetSummary(nodeID, summary, contentVersion)`에 **에이전트가 읽은 시점의 `content_version`을 넘겨야** 합니다. 그래야 사람이 이후에 본문을 고쳤을 때 `SummaryForVersion != ContentVersion`이 되어 요약이 다시 낡은 것으로 표시됩니다. 낡은 버전으로 온 요약은 거부합니다. +- 씬(leaf)뿐 아니라 **컨테이너(부/장) 요약도 같은 툴로** 씁니다 — 계층 컨텍스트가 컨테이너 요약에서 나옵니다. 작품 시놉시스도 이 툴이 담당합니다(`projects.rewrite_synopsis`는 제거되므로 — 3.3절). +- 짧은 씬은 지금도 LLM 없이 평문 요약이 저장됩니다(`summarizer.go`의 `minRunesForLLM` 분기). 이 경로와 `nodes.update_content`의 `postUpdate` 훅은 유지합니다 — 훅이 부르는 대상이 LLM 요약기에서 비-LLM 짧은 씬 요약기로 바뀔 뿐입니다. +- **정직한 폴백 고지:** MCP 클라이언트를 전혀 쓰지 않는 순수 수동 작가는 긴 씬에 대해 요약이 비어 있게 됩니다. 브리프의 나머지(아웃라인, 엔티티, 관계, 플롯 스파인, 팩트, 메모리, 문체 목표)는 전부 데이터베이스 상태라 항상 채워집니다. 손으로 요약을 적는 UI를 추가할지는 **결정 필요 사항**(10절)으로 남깁니다. + +## 7. 보안과 동의 + +기본값 꺼짐. 설정 화면은 외부 에이전트가 무엇을 읽고 무엇을 바꿀 수 있는지 평이한 말로 설명합니다. + +- **루프백 전용 바인딩**(`127.0.0.1`). LAN 바인딩, 터널, 원격 접속은 어느 단계에서도 없습니다. +- **베어러 토큰** 32바이트 난수. 기존 설정 시크릿 저장소(`engine/internal/settings/secrets*.go`)를 통해 보관합니다. 설정에서 재발급·폐기할 수 있고, 재발급하면 기존 클라이언트 설정은 전부 무효가 됩니다. +- **`Origin`/`Host` 검증.** `Origin`이 있는데 루프백이 아니면 거부합니다(MCP 명세의 DNS 리바인딩 방어). 그렇지 않으면 임의 웹페이지가 `127.0.0.1`로 POST할 수 있습니다. +- **토큰이 Git 원격으로 새면 안 됩니다.** 폴더 동기화 디렉터리에 놓인 `.mcp.json`은 Git sync가 그대로 커밋·푸시합니다. 방어 두 겹을 모두 적용합니다: 생성되는 `.mcp.json` 스니펫은 리터럴 토큰 대신 Claude Code의 `headersHelper`(`linetta-mcp --print-header`, 0600 디스커버리 파일을 읽음)를 쓰고, Git/폴더 동기화 내보내기에서 `.mcp.json`을 제외합니다. +- **디스커버리 파일** `$LINETTA_HOME/mcp.json`(설정 파일 `settings.json`과 별개), 권한 0600, `{port, token, pid, started_at}`. 종료 시 삭제합니다. 신뢰 경계: 같은 사용자로 실행되는 프로세스는 이미 `library.db`와 시크릿 저장소를 직접 읽을 수 있으므로 이 파일이 기준을 낮추지 않습니다. +- **모드 설정** — `off`(기본) / `read_only` / `full`. `read_only`에서는 쓰기 툴을 아예 등록하지 않으므로 `tools/list`에 나타나지도, 호출되지도 않습니다. +- **작품 제한** — `mcp_project_id`가 설정되면 모든 툴이 한 작품으로 제한됩니다. +- **모든 변경은 되돌릴 수 있습니다** — 씬 쓰기 전 자동 스냅샷, 구조 변경 전 아웃라인 캡처, `batch_id` 반환, `linetta_undo_last_change`. +- **활동 로그.** 모든 툴 호출(시각, 툴, 작품, 대상, 결과)을 기록하고 설정에서 보여줍니다. "내가 자는 동안 뭘 했나"에 대한 답입니다. +- **한도.** 호출당 본문 크기 상한, 분당 호출 상한. 폭주하는 에이전트 루프는 벽에 부딪혀야지 씬 40개를 고쳐 쓰면 안 됩니다. + +**동의 모델은 오히려 단순해집니다.** 전환이 끝나면 `ai_data_sharing_consent_*`는 프로바이더와 함께 사라지고, MCP 동의 하나만 남습니다. Linetta가 원고를 어디로도 보내지 않고, 외부 클라이언트가 가져가는 구조이기 때문입니다. + +## 8. 플랫폼별 결말 + +**데스크톱(직접 배포 macOS/Windows/Linux):** 전체 기능. 브리지 번들. + +**Mac App Store:** 컴패니언이 사라지면 MAS 빌드에는 AI 경로가 **하나도** 남지 않습니다. 따라서 MAS의 MCP 지원은 선택이 아니라 필수 범위입니다. 빌드 게이트는 `!mas && !mobile`이 아니라 **`!mobile`**입니다. + +- 샌드박스에 `com.apple.security.network.server` 엔타이틀먼트를 추가하면 루프백 리스너는 허용됩니다. +- 브리지 번들은 다릅니다. 외부 앱이 실행하는 두 번째 실행 파일을 샌드박스 앱 번들에 넣는 것은 심사 위험이 있고, 컨테이너 안 디스커버리 파일을 외부 프로세스가 읽을 수 있는지도 확실하지 않습니다. **확인되지 않은 것을 확인된 것처럼 쓰지 않습니다.** +- MAS 대응: Claude Code는 HTTP로 직접 붙습니다(포트 + 토큰 수동 붙여넣기). Claude Desktop용 브리지는 앱 번들이 아니라 Homebrew/GitHub 릴리스로 별도 배포합니다. + +**모바일(iOS/iPad/Android):** MCP 서버를 호스팅하지 않습니다. 즉 **컴패니언 제거는 모바일에서 AI 기능이 완전히 사라지고 대체 경로가 없다는 뜻입니다.** 최근 iPad UX에 투자한 것을 고려하면 이건 각주가 아니라 명시적으로 확인받아야 할 결과입니다(10절). + +## 9. 권장 작업 흐름 (문서화할 내용) + +1. Linetta 설정에서 MCP를 켜고 `claude mcp add` 한 줄을 복사합니다. +2. Linetta 폴더 동기화를 어떤 디렉터리로 지정하고, **그 디렉터리에서** Claude Code를 실행합니다. 에이전트는 내보내진 마크다운(grep 가능한 산문)과 Linetta의 구조화된 툴(정본 스토리 상태)을 동시에 갖게 됩니다. 그 디렉터리에 `.mcp.json`을 두려면 `headersHelper`를 쓰고 동기화에서 제외해야 합니다 — Git sync가 푸시하는 바로 그 디렉터리이기 때문입니다. +3. 씬 단위로 지시합니다. "4-2 씬 컨텍스트를 읽고, 확립된 문체로 초고를 쓰고, 해소된 비트와 요약을 갱신해줘." +4. Linetta에서 검토합니다. 되돌리기는 툴 한 번 또는 클릭 한 번입니다. + +## 10. 결정이 필요한 사항 + +계획서는 아래 항목에 권장안을 담되, 최종 판단은 사용자 몫입니다. + +| 항목 | 선택지 | 권장 | +| --- | --- | --- | +| 컴패니언 제거 시점 | 즉시 / MCP 검증 후 단계적 | **검증 후.** MCP 경로가 실사용에서 컴패니언을 대체함이 확인되기 전에 지우면, 사용자 손에는 AI 없는 앱만 남습니다 | +| 모바일에서 AI 완전 소멸 | 수용 / 모바일만 컴패니언 유지 | **수용.** 두 갈래 유지는 전환의 목적을 무너뜨립니다. 다만 사용자 확인 필요 | +| `web_search` 설정 | 유지 / 제거 | **제거.** Brave/Perplexity 키도 결국 BYOK입니다. 검색은 에이전트가 더 잘합니다. `web_fetch`(키 불필요, 팩트북 URL 캡처)는 유지 | +| 손으로 쓰는 요약 UI | 추가 / 미추가 | 미추가로 시작. MCP 없이 쓰는 사용자 비중을 보고 판단 | +| 버전 | 0.10.x / 1.0.0 | **1.0.0.** 파괴적 변경이자 제품 정체성 전환입니다 | + +## 11. 리스크 + +| 리스크 | 완화 | +| --- | --- | +| MCP 검증 전에 컴패니언을 지워 사용자가 빈손이 됨 | 단계 순서를 강제: 추출 → 구축 → 강등 → 검증 → 제거 | +| 추출 중 `ApplyOps`/컨텍스트 빌더 동작이 미묘하게 깨짐 | 추출은 **동작 변경 없는 이동**으로만 진행하고 기존 테스트를 그대로 통과시킴. 유일한 예외(팩트·메모리 병합, 렌더러 평문화)는 명시적 작업으로 분리 | +| 폭주 에이전트가 원고를 뒤엎음 | 모드 설정, 호출·크기 한도, 호출별 스냅샷, 되돌리기, 활동 로그, 킬 스위치 | +| 사람과 에이전트가 같은 씬을 편집 | `expected_content_version`, `-32009` 노출, 에디터 배너 | +| 에이전트가 요약 갱신을 빼먹어 브리프 품질 저하 | 쓰기 툴 설명에 요약 갱신 지시 포함, Phase 5 실사용 검증에서 실제 호출 여부 측정 | +| MAS 심사에서 로컬 서버가 문제됨 | 엔타이틀먼트만 사용하고 브리지는 번들 밖으로. 문제 시 MAS는 HTTP 직접 연결만 지원 | +| 기존 컴패니언 사용자의 데이터 | 히스토리·메모리 데이터는 **삭제하지 않고** 읽기 또는 내보내기로 보존 | +| Claude Desktop의 로컬 서버 정책 변화 | 브리지는 얇고 유지 비용이 낮음. HTTP 경로는 명세 표준 | + +## 12. 비목표 + +- 원격/LAN 접속, 터널, OAuth, 다중 사용자. +- Linetta가 MCP **클라이언트**가 되는 방향(외부 MCP 서버를 앱이 소비). 이 전환의 정반대입니다. +- 모바일 MCP 호스팅. +- 집필 기능 자체의 재설계. 이 전환은 AI 경계를 옮기는 것이지 에디터를 다시 만드는 것이 아닙니다. From 3dcd45c73c8137bb918acd2b2af95ceb18ea4155 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:24:31 +0900 Subject: [PATCH 02/25] refactor(engine): extract storycontext from internal/ai Move the story-brief assembly - Context types, ContextSelection, ContextBuilder, prompt rendering, token estimates - into a new internal/storycontext package that performs no LLM calls and cannot import LLM client code (verified: zero tars deps). The renderer now returns plain strings (storycontext.Render); the only prompt logic left in internal/ai is the BuildMessages adapter wrapping Render into llm.ChatMessage for the runner, covered by new adapter tests. RPC method names and notification literals (ai.run, ai.delta, ...) are unchanged. Part of the MCP-first pivot (#47), Phase 1 Task 1.1. Co-Authored-By: Claude Opus 5 --- engine/internal/ai/messages.go | 25 ++++++++ engine/internal/ai/messages_test.go | 59 +++++++++++++++++++ engine/internal/ai/payloads.go | 33 +++++++++++ engine/internal/ai/runner.go | 5 +- engine/internal/ai/runner_test.go | 10 ++-- engine/internal/companion/companion.go | 25 ++++---- engine/internal/companion/companion_test.go | 13 ++-- engine/internal/companion/prompt.go | 54 ++++++++--------- engine/internal/companion/references.go | 6 +- engine/internal/companion/runner.go | 4 +- engine/internal/engineapp/engineapp.go | 3 +- engine/internal/fact/fact_test.go | 36 +++++------ engine/internal/plot/builder.go | 1 - engine/internal/rpc/handlers/ai.go | 19 +++--- engine/internal/rpc/handlers/ai_test.go | 5 +- engine/internal/rpc/handlers/companion.go | 8 +-- engine/internal/rpc/handlers/projects.go | 4 +- engine/internal/rpc/handlers/projects_test.go | 6 +- .../context.go => storycontext/builder.go} | 2 +- .../builder_test.go} | 2 +- .../{ai/prompts.go => storycontext/render.go} | 25 ++------ .../render_test.go} | 11 +++- .../internal/{ai => storycontext}/tokens.go | 2 +- .../{ai => storycontext}/tokens_test.go | 2 +- .../{ai/ai.go => storycontext/types.go} | 39 ++---------- engine/internal/summarizer/summarizer.go | 2 +- 26 files changed, 246 insertions(+), 155 deletions(-) create mode 100644 engine/internal/ai/messages.go create mode 100644 engine/internal/ai/messages_test.go create mode 100644 engine/internal/ai/payloads.go rename engine/internal/{ai/context.go => storycontext/builder.go} (99%) rename engine/internal/{ai/context_test.go => storycontext/builder_test.go} (99%) rename engine/internal/{ai/prompts.go => storycontext/render.go} (94%) rename engine/internal/{ai/prompts_test.go => storycontext/render_test.go} (97%) rename engine/internal/{ai => storycontext}/tokens.go (96%) rename engine/internal/{ai => storycontext}/tokens_test.go (95%) rename engine/internal/{ai/ai.go => storycontext/types.go} (89%) diff --git a/engine/internal/ai/messages.go b/engine/internal/ai/messages.go new file mode 100644 index 00000000..dc998bc6 --- /dev/null +++ b/engine/internal/ai/messages.go @@ -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}, + } +} diff --git a/engine/internal/ai/messages_test.go b/engine/internal/ai/messages_test.go new file mode 100644 index 00000000..970585f4 --- /dev/null +++ b/engine/internal/ai/messages_test.go @@ -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) + } + } +} diff --git a/engine/internal/ai/payloads.go b/engine/internal/ai/payloads.go new file mode 100644 index 00000000..27b02562 --- /dev/null +++ b/engine/internal/ai/payloads.go @@ -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"` +} diff --git a/engine/internal/ai/runner.go b/engine/internal/ai/runner.go index 30c6929a..8580a5e1 100644 --- a/engine/internal/ai/runner.go +++ b/engine/internal/ai/runner.go @@ -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" @@ -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) @@ -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) diff --git a/engine/internal/ai/runner_test.go b/engine/internal/ai/runner_test.go index f163adcb..5f13f6d8 100644 --- a/engine/internal/ai/runner_test.go +++ b/engine/internal/ai/runner_test.go @@ -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" @@ -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) @@ -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) @@ -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) } @@ -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) } diff --git a/engine/internal/companion/companion.go b/engine/internal/companion/companion.go index a2f33291..6ae246d4 100644 --- a/engine/internal/companion/companion.go +++ b/engine/internal/companion/companion.go @@ -23,6 +23,7 @@ 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/thread" "github.com/devlikebear/tars/pkg/session" ) @@ -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 @@ -526,10 +527,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 } @@ -710,24 +711,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, diff --git a/engine/internal/companion/companion_test.go b/engine/internal/companion/companion_test.go index 6195c852..538468ff 100644 --- a/engine/internal/companion/companion_test.go +++ b/engine/internal/companion/companion_test.go @@ -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" @@ -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, @@ -1106,7 +1107,7 @@ func TestApplyContextSelection_RemovesUncheckedCompanionSections(t *testing.T) { Memories: []string{"작가는 철학적인 질문을 좋아한다"}, } - selection := ai.ContextSelection{ + selection := storycontext.ContextSelection{ CurrentScene: &off, Overview: &off, Plot: &off, @@ -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) @@ -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) } diff --git a/engine/internal/companion/prompt.go b/engine/internal/companion/prompt.go index 8537d636..7b6fd80a 100644 --- a/engine/internal/companion/prompt.go +++ b/engine/internal/companion/prompt.go @@ -5,12 +5,12 @@ import ( "sort" "strings" - "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/relationship" + "github.com/devlikebear/linetta/engine/internal/storycontext" "github.com/devlikebear/linetta/engine/internal/thread" ) @@ -386,64 +386,64 @@ func buildContext(d PromptData, language string) string { return strings.TrimSpace(b.String()) } -func applyContextSelection(d PromptData, selection ai.ContextSelection) PromptData { - if !selection.Enabled(ai.ContextKeyCurrentScene) { +func applyContextSelection(d PromptData, selection storycontext.ContextSelection) PromptData { + if !selection.Enabled(storycontext.ContextKeyCurrentScene) { d.SceneExcerpts = nil } - if !selection.Enabled(ai.ContextKeyOverview) { + if !selection.Enabled(storycontext.ContextKeyOverview) { d.Outline = "" } - if !selection.Enabled(ai.ContextKeyPlot) { + if !selection.Enabled(storycontext.ContextKeyPlot) { d.HasSpine = false d.Spine = plot.Spine{} d.Threads = nil } - if !selection.Enabled(ai.ContextKeyEntities) { + if !selection.Enabled(storycontext.ContextKeyEntities) { d.Entities = nil } - if !selection.Enabled(ai.ContextKeyRelationships) { + if !selection.Enabled(storycontext.ContextKeyRelationships) { d.Relationships = nil } - if !selection.Enabled(ai.ContextKeyFacts) { + if !selection.Enabled(storycontext.ContextKeyFacts) { d.Facts = nil } - if !selection.Enabled(ai.ContextKeyMemories) { + if !selection.Enabled(storycontext.ContextKeyMemories) { d.Memories = nil } - if !selection.Enabled(ai.ContextKeyReferences) { + if !selection.Enabled(storycontext.ContextKeyReferences) { d.References = nil } return d } -func previewFromPromptData(d PromptData, selection ai.ContextSelection) ai.ContextPreview { - sections := []ai.PreviewSection{} - add := func(id ai.ContextKey, label string, count int, preview string) { +func previewFromPromptData(d PromptData, selection storycontext.ContextSelection) storycontext.ContextPreview { + sections := []storycontext.PreviewSection{} + add := func(id storycontext.ContextKey, label string, count int, preview string) { present := count > 0 || strings.TrimSpace(preview) != "" trimmed := trimPreview(strings.TrimSpace(preview)) - sections = append(sections, ai.PreviewSection{ + sections = append(sections, storycontext.PreviewSection{ ID: id, Label: label, Present: present, Selected: present && selection.Enabled(id), Count: count, Preview: trimmed, - CharCount: ai.EstimateChars(preview), - TokenEstimate: ai.EstimateTokens(preview), + CharCount: storycontext.EstimateChars(preview), + TokenEstimate: storycontext.EstimateTokens(preview), }) } - add(ai.ContextKeyCurrentScene, "작성된 본문 발췌", len(d.SceneExcerpts), renderSceneExcerptsPreview(d.SceneExcerpts)) + add(storycontext.ContextKeyCurrentScene, "작성된 본문 발췌", len(d.SceneExcerpts), renderSceneExcerptsPreview(d.SceneExcerpts)) overview := strings.TrimSpace(d.Outline) - add(ai.ContextKeyOverview, "작품 개요", boolCount(overview != ""), overview) + add(storycontext.ContextKeyOverview, "작품 개요", boolCount(overview != ""), overview) - add(ai.ContextKeyFacts, "팩트 자료집", len(d.Facts), renderFactsPreview(d.Facts)) - add(ai.ContextKeyPlot, "플롯 (스토리라인&비트)", companionPlotCount(d), renderCompanionPlotPreview(d)) - add(ai.ContextKeyEntities, "세계관 요소", len(d.Entities), renderCompanionEntitiesPreview(d.Entities)) - add(ai.ContextKeyRelationships, "관계", len(d.Relationships), renderCompanionRelationshipsPreview(d.Entities, d.Relationships)) - add(ai.ContextKeyMemories, "컴패니언 기억", len(d.Memories), renderMemoriesPreview(d.Memories)) - add(ai.ContextKeyReferences, "추가 레퍼런스", len(activeReferences(d.References)), renderReferencesPreview(d.References)) + add(storycontext.ContextKeyFacts, "팩트 자료집", len(d.Facts), renderFactsPreview(d.Facts)) + add(storycontext.ContextKeyPlot, "플롯 (스토리라인&비트)", companionPlotCount(d), renderCompanionPlotPreview(d)) + add(storycontext.ContextKeyEntities, "세계관 요소", len(d.Entities), renderCompanionEntitiesPreview(d.Entities)) + add(storycontext.ContextKeyRelationships, "관계", len(d.Relationships), renderCompanionRelationshipsPreview(d.Entities, d.Relationships)) + add(storycontext.ContextKeyMemories, "컴패니언 기억", len(d.Memories), renderMemoriesPreview(d.Memories)) + add(storycontext.ContextKeyReferences, "추가 레퍼런스", len(activeReferences(d.References)), renderReferencesPreview(d.References)) selectedCount := 0 selectedChars := 0 @@ -465,8 +465,8 @@ func previewFromPromptData(d PromptData, selection ai.ContextSelection) ai.Conte selectedTokens += section.TokenEstimate } - return ai.ContextPreview{ - PreviewCounts: ai.PreviewCounts{ + return storycontext.ContextPreview{ + PreviewCounts: storycontext.PreviewCounts{ NearbyScenes: len(d.SceneExcerpts), HasOutline: overview != "", Entities: len(d.Entities), @@ -680,7 +680,7 @@ func renderReferencesPreview(refs []Reference) string { if r.Status == ReferenceStatusSummarized { b.WriteString(" · 요약") } - b.WriteString(fmt.Sprintf(" · 약 %d tokens\n", ai.EstimateTokens(referencePromptText(r)))) + b.WriteString(fmt.Sprintf(" · 약 %d tokens\n", storycontext.EstimateTokens(referencePromptText(r)))) if text := strings.TrimSpace(referencePromptText(r)); text != "" { b.WriteString(" " + trimPreview(text) + "\n") } diff --git a/engine/internal/companion/references.go b/engine/internal/companion/references.go index cc6b65e0..9a8849c4 100644 --- a/engine/internal/companion/references.go +++ b/engine/internal/companion/references.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/devlikebear/linetta/engine/internal/ai" + "github.com/devlikebear/linetta/engine/internal/storycontext" "github.com/google/uuid" ) @@ -256,8 +256,8 @@ func normalizeReference(ref Reference) Reference { if ref.Title == "" { ref.Title = defaultReferenceTitle(ref.SourceType) } - ref.CharCount = ai.EstimateChars(ref.Content) - ref.TokenEstimate = ai.EstimateTokens(ref.Content) + ref.CharCount = storycontext.EstimateChars(ref.Content) + ref.TokenEstimate = storycontext.EstimateTokens(ref.Content) if ref.Summary == "" && ref.CharCount > referenceAutoSummaryRunes { ref.Summary = deterministicReferenceSummary(ref) if ref.Status == ReferenceStatusActive { diff --git a/engine/internal/companion/runner.go b/engine/internal/companion/runner.go index f0018525..a66764f6 100644 --- a/engine/internal/companion/runner.go +++ b/engine/internal/companion/runner.go @@ -10,7 +10,7 @@ import ( "sync/atomic" "time" - "github.com/devlikebear/linetta/engine/internal/ai" + "github.com/devlikebear/linetta/engine/internal/storycontext" "github.com/devlikebear/linetta/engine/internal/streamdedup" "github.com/devlikebear/tars/pkg/agentloop" "github.com/devlikebear/tars/pkg/llm" @@ -278,7 +278,7 @@ func newRunner(svc *Service) *Runner { return &Runner{svc: svc, active: map[string]context.CancelFunc{}} } -func (r *Runner) start(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, outlineStructure string, requestIntent RequestIntent, requestedScope string, images []ImageAttachment, language string, now func() int64) (string, error) { +func (r *Runner) start(ctx context.Context, projectID, nodeID, text string, selection storycontext.ContextSelection, outlineStructure string, requestIntent RequestIntent, requestedScope string, images []ImageAttachment, language string, now func() int64) (string, error) { sess, err := r.svc.sessions.EnsureWorker(projectID) if err != nil { return "", err diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 195c8472..81553452 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -37,6 +37,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/stats" "github.com/devlikebear/linetta/engine/internal/store" + "github.com/devlikebear/linetta/engine/internal/storycontext" "github.com/devlikebear/linetta/engine/internal/summarizer" "github.com/devlikebear/linetta/engine/internal/thread" ) @@ -164,7 +165,7 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error return nil }) - contextBuilder := ai.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships). + contextBuilder := storycontext.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships). WithSummaryRefresher(summ) syncDeps := syncDeps{ diff --git a/engine/internal/fact/fact_test.go b/engine/internal/fact/fact_test.go index 03176ae6..bb7b9198 100644 --- a/engine/internal/fact/fact_test.go +++ b/engine/internal/fact/fact_test.go @@ -36,9 +36,9 @@ func TestRepo_CreateRequiresSourceURL(t *testing.T) { f := newFixture(t) _, err := f.repo.Create(context.Background(), 10, NewInput{ ProjectID: f.projectID, - Claim: "서울 지하철 막차는 보통 새벽까지 운행한다", - Result: "노선별로 다르므로 최신 시간표 확인이 필요하다.", - Status: StatusUncertain, + Claim: "서울 지하철 막차는 보통 새벽까지 운행한다", + Result: "노선별로 다르므로 최신 시간표 확인이 필요하다.", + Status: StatusUncertain, }) if err == nil { t.Fatal("expected source-required error") @@ -51,14 +51,14 @@ func TestRepo_CreateListUpdateDelete(t *testing.T) { card, err := f.repo.Create(ctx, 10, NewInput{ ProjectID: f.projectID, NodeID: &f.nodeID, - Claim: "런던 경찰은 제복 근무 중 총을 항상 휴대한다", - Result: "일반 경찰은 통상 총기를 휴대하지 않는다.", - Status: StatusVerified, - Category: "police", + Claim: "런던 경찰은 제복 근무 중 총을 항상 휴대한다", + Result: "일반 경찰은 통상 총기를 휴대하지 않는다.", + Status: StatusVerified, + Category: "police", Sources: []SourceInput{{ - URL: "https://www.met.police.uk/", - Title: "Met Police", - Snippet: "Official policing reference", + URL: "https://www.met.police.uk/", + Title: "Met Police", + Snippet: "Official policing reference", AccessedAt: 10, }}, }) @@ -100,10 +100,10 @@ func TestRepo_ListWithNodeIncludesProjectWideCards(t *testing.T) { ctx := context.Background() projectWide, err := f.repo.Create(ctx, 10, NewInput{ ProjectID: f.projectID, - Claim: "프로젝트 전체 자료", - Result: "전체 배경에 쓰는 자료", - Status: StatusVerified, - Sources: []SourceInput{{URL: "https://example.com/project", AccessedAt: 10}}, + Claim: "프로젝트 전체 자료", + Result: "전체 배경에 쓰는 자료", + Status: StatusVerified, + Sources: []SourceInput{{URL: "https://example.com/project", AccessedAt: 10}}, }) if err != nil { t.Fatalf("Create project-wide: %v", err) @@ -111,10 +111,10 @@ func TestRepo_ListWithNodeIncludesProjectWideCards(t *testing.T) { sceneCard, err := f.repo.Create(ctx, 20, NewInput{ ProjectID: f.projectID, NodeID: &f.nodeID, - Claim: "현재 씬 자료", - Result: "현재 씬에만 연결된 자료", - Status: StatusUncertain, - Sources: []SourceInput{{URL: "https://example.com/scene", AccessedAt: 20}}, + Claim: "현재 씬 자료", + Result: "현재 씬에만 연결된 자료", + Status: StatusUncertain, + Sources: []SourceInput{{URL: "https://example.com/scene", AccessedAt: 20}}, }) if err != nil { t.Fatalf("Create scene: %v", err) diff --git a/engine/internal/plot/builder.go b/engine/internal/plot/builder.go index 02316150..df92e8dd 100644 --- a/engine/internal/plot/builder.go +++ b/engine/internal/plot/builder.go @@ -112,4 +112,3 @@ func (b *Builder) Build(ctx context.Context, nodeID string) (Spine, error) { } return out, nil } - diff --git a/engine/internal/rpc/handlers/ai.go b/engine/internal/rpc/handlers/ai.go index 3f44341e..08258813 100644 --- a/engine/internal/rpc/handlers/ai.go +++ b/engine/internal/rpc/handlers/ai.go @@ -6,18 +6,19 @@ import ( "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/rpc" + "github.com/devlikebear/linetta/engine/internal/storycontext" ) type runAIParams struct { - NodeID string `json:"node_id"` - Prompt string `json:"prompt"` - SelectionText string `json:"selection_text"` - Options ai.Options `json:"options"` + NodeID string `json:"node_id"` + Prompt string `json:"prompt"` + SelectionText string `json:"selection_text"` + Options storycontext.Options `json:"options"` } // RunAI returns a handler for ai.run. It builds the Context via the supplied // ContextBuilder and asks the Runner to start; returns the run id immediately. -func RunAI(builder *ai.ContextBuilder, runner *ai.Runner, now Clock) rpc.Handler { +func RunAI(builder *storycontext.ContextBuilder, runner *ai.Runner, now Clock) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) { var p runAIParams if err := json.Unmarshal(params, &p); err != nil || p.NodeID == "" { @@ -54,14 +55,14 @@ func CancelAI(runner *ai.Runner) rpc.Handler { } type previewContextParams struct { - NodeID string `json:"node_id"` - Options ai.Options `json:"options"` + NodeID string `json:"node_id"` + Options storycontext.Options `json:"options"` } // PreviewContext returns a handler for ai.preview_context. It builds the full // Context for the given node and returns counts plus inspectable sections so // the frontend can show what will be injected before the user runs generation. -func PreviewContext(builder *ai.ContextBuilder) rpc.Handler { +func PreviewContext(builder *storycontext.ContextBuilder) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) { var p previewContextParams if err := json.Unmarshal(params, &p); err != nil || p.NodeID == "" { @@ -71,6 +72,6 @@ func PreviewContext(builder *ai.ContextBuilder) rpc.Handler { if err != nil { return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} } - return json.Marshal(ai.PreviewFromContext(c, p.Options.Context)) + return json.Marshal(storycontext.PreviewFromContext(c, p.Options.Context)) } } diff --git a/engine/internal/rpc/handlers/ai_test.go b/engine/internal/rpc/handlers/ai_test.go index e0bb527b..5f089d49 100644 --- a/engine/internal/rpc/handlers/ai_test.go +++ b/engine/internal/rpc/handlers/ai_test.go @@ -18,6 +18,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/relationship" "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/thread" "github.com/devlikebear/tars/pkg/llm" ) @@ -59,7 +60,7 @@ func (streamingFake) Chat(ctx context.Context, _ []llm.ChatMessage, opts llm.Cha }, nil } -func newAIFixture(t *testing.T) (*ai.Runner, *ai.ContextBuilder, string, string) { +func newAIFixture(t *testing.T) (*ai.Runner, *storycontext.ContextBuilder, string, string) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") s, err := store.Open(context.Background(), dbPath) @@ -76,7 +77,7 @@ func newAIFixture(t *testing.T) (*ai.Runner, *ai.ContextBuilder, string, string) runs := store.NewAIRunsRepo(s) notif := &capNotif{} runner := ai.NewRunner(notif, runs, func(ai.ResolvedProvider) (llm.Client, error) { return streamingFake{}, nil }, fixedProvider("claude-code-cli")) - builder := ai.NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := storycontext.NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) return runner, builder, p.ID, *p.LastOpenedNodeID } diff --git a/engine/internal/rpc/handlers/companion.go b/engine/internal/rpc/handlers/companion.go index c7e7137a..b9b81045 100644 --- a/engine/internal/rpc/handlers/companion.go +++ b/engine/internal/rpc/handlers/companion.go @@ -6,9 +6,9 @@ import ( "errors" "strings" - "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/companion" "github.com/devlikebear/linetta/engine/internal/rpc" + "github.com/devlikebear/linetta/engine/internal/storycontext" ) type companionSendParams struct { @@ -35,9 +35,9 @@ func CompanionSend(svc *companion.Service, now Clock) rpc.Handler { } type companionPreviewContextParams struct { - ProjectID string `json:"project_id"` - NodeID string `json:"node_id"` - Options ai.Options `json:"options"` + ProjectID string `json:"project_id"` + NodeID string `json:"node_id"` + Options storycontext.Options `json:"options"` } // CompanionPreviewContext returns inspectable companion context sections before diff --git a/engine/internal/rpc/handlers/projects.go b/engine/internal/rpc/handlers/projects.go index 197c6fe4..e66f0229 100644 --- a/engine/internal/rpc/handlers/projects.go +++ b/engine/internal/rpc/handlers/projects.go @@ -5,9 +5,9 @@ import ( "encoding/json" "errors" - "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/rpc" + "github.com/devlikebear/linetta/engine/internal/storycontext" ) // Clock is an injected millisecond-precision source. Tests pass deterministic @@ -116,7 +116,7 @@ func UpdateProject(repo *project.Repo, now Clock) rpc.Handler { // RewriteProjectSynopsis derives a fresh synopsis from the current root // container summaries and stores it on the project as the editable synopsis. -func RewriteProjectSynopsis(repo *project.Repo, builder *ai.ContextBuilder, now Clock) rpc.Handler { +func RewriteProjectSynopsis(repo *project.Repo, builder *storycontext.ContextBuilder, now Clock) rpc.Handler { return func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) { var p idParam if err := json.Unmarshal(params, &p); err != nil || p.ID == "" { diff --git a/engine/internal/rpc/handlers/projects_test.go b/engine/internal/rpc/handlers/projects_test.go index c303c40e..292a74b6 100644 --- a/engine/internal/rpc/handlers/projects_test.go +++ b/engine/internal/rpc/handlers/projects_test.go @@ -6,7 +6,6 @@ import ( "path/filepath" "testing" - "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/beat" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" @@ -15,6 +14,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/relationship" "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/thread" ) @@ -32,7 +32,7 @@ func newRepo(t *testing.T) *project.Repo { type projectSynopsisFixture struct { projects *project.Repo nodes *node.Repo - builder *ai.ContextBuilder + builder *storycontext.ContextBuilder project project.Project root node.Node } @@ -70,7 +70,7 @@ func newProjectSynopsisFixture(t *testing.T) projectSynopsisFixture { if err != nil { t.Fatalf("CreateSibling: %v", err) } - builder := ai.NewContextBuilder( + builder := storycontext.NewContextBuilder( projects, nodes, mention.NewRepo(s), diff --git a/engine/internal/ai/context.go b/engine/internal/storycontext/builder.go similarity index 99% rename from engine/internal/ai/context.go rename to engine/internal/storycontext/builder.go index 5425e1ca..1e4a7bca 100644 --- a/engine/internal/ai/context.go +++ b/engine/internal/storycontext/builder.go @@ -1,4 +1,4 @@ -package ai +package storycontext import ( "context" diff --git a/engine/internal/ai/context_test.go b/engine/internal/storycontext/builder_test.go similarity index 99% rename from engine/internal/ai/context_test.go rename to engine/internal/storycontext/builder_test.go index b9bd14ac..77b07186 100644 --- a/engine/internal/ai/context_test.go +++ b/engine/internal/storycontext/builder_test.go @@ -1,4 +1,4 @@ -package ai +package storycontext import ( "context" diff --git a/engine/internal/ai/prompts.go b/engine/internal/storycontext/render.go similarity index 94% rename from engine/internal/ai/prompts.go rename to engine/internal/storycontext/render.go index b8f09402..606e665b 100644 --- a/engine/internal/ai/prompts.go +++ b/engine/internal/storycontext/render.go @@ -1,4 +1,4 @@ -package ai +package storycontext import ( "fmt" @@ -6,7 +6,6 @@ import ( "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" - "github.com/devlikebear/tars/pkg/llm" ) // langIsEnglish reports whether the app UI language selects English prompts. @@ -54,24 +53,12 @@ func PresetSeed(p PresetID) string { return "" } -// BuildMessages converts a Context into the two-message system+user pair the -// engine sends to tars. The system message governs tone and length; the user -// message contains the structured context. -// -// 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 Context) []llm.ChatMessage { +// Render applies the context selection and returns the final system and +// user prompt texts. Callers that need chat-message envelopes wrap these +// strings themselves (see internal/ai.BuildMessages). +func Render(c Context) (system, user string) { c = ApplyContextSelection(c) - system := buildSystem(c) - user := buildUser(c) - return []llm.ChatMessage{ - {Role: "system", Content: system}, - {Role: "user", Content: user}, - } + return buildSystem(c), buildUser(c) } func buildSystem(c Context) string { diff --git a/engine/internal/ai/prompts_test.go b/engine/internal/storycontext/render_test.go similarity index 97% rename from engine/internal/ai/prompts_test.go rename to engine/internal/storycontext/render_test.go index dfdbf5cc..df2f54d0 100644 --- a/engine/internal/ai/prompts_test.go +++ b/engine/internal/storycontext/render_test.go @@ -1,4 +1,4 @@ -package ai +package storycontext import ( "strings" @@ -7,6 +7,15 @@ import ( "github.com/devlikebear/linetta/engine/internal/plot" ) +// buildMessagesShim mirrors the old two-message shape so the assertions below +// keep reading naturally; production message assembly lives in internal/ai. +type renderedMsg struct{ Role, Content string } + +func BuildMessages(c Context) []renderedMsg { + system, user := Render(c) + return []renderedMsg{{Role: "system", Content: system}, {Role: "user", Content: user}} +} + func TestPresetSeed(t *testing.T) { if PresetSeed(PresetRewrite) == "" { t.Error("rewrite seed should be non-empty") diff --git a/engine/internal/ai/tokens.go b/engine/internal/storycontext/tokens.go similarity index 96% rename from engine/internal/ai/tokens.go rename to engine/internal/storycontext/tokens.go index 7d19c526..85a06022 100644 --- a/engine/internal/ai/tokens.go +++ b/engine/internal/storycontext/tokens.go @@ -1,4 +1,4 @@ -package ai +package storycontext // EstimateChars returns the visible rune count used for approximate context // budgeting. It intentionally avoids byte length so Korean text is not diff --git a/engine/internal/ai/tokens_test.go b/engine/internal/storycontext/tokens_test.go similarity index 95% rename from engine/internal/ai/tokens_test.go rename to engine/internal/storycontext/tokens_test.go index 5018e49e..f21b8f75 100644 --- a/engine/internal/ai/tokens_test.go +++ b/engine/internal/storycontext/tokens_test.go @@ -1,4 +1,4 @@ -package ai +package storycontext import "testing" diff --git a/engine/internal/ai/ai.go b/engine/internal/storycontext/types.go similarity index 89% rename from engine/internal/ai/ai.go rename to engine/internal/storycontext/types.go index ef8e2b86..363d4cda 100644 --- a/engine/internal/ai/ai.go +++ b/engine/internal/storycontext/types.go @@ -1,5 +1,8 @@ -// Package ai owns prompt assembly and run management for AI mode. -package ai +// Package storycontext assembles the curated story brief for one scene: +// outline, hierarchical summaries, entity/relationship briefs, plot spine, +// notes, and style targets. It performs no LLM calls and must not import +// LLM client code; renderers return plain strings. +package storycontext import "github.com/devlikebear/linetta/engine/internal/plot" @@ -221,35 +224,3 @@ type EntityBrief struct { Attributes map[string]string `json:"attributes"` Recent []string `json:"recent"` // Plan 16 layer 2 dossier — first lines of latest 5 leaf summaries } - -// 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"` -} diff --git a/engine/internal/summarizer/summarizer.go b/engine/internal/summarizer/summarizer.go index edeb7889..47188bdd 100644 --- a/engine/internal/summarizer/summarizer.go +++ b/engine/internal/summarizer/summarizer.go @@ -93,7 +93,7 @@ func (s *Summarizer) summarizeOne(ctx context.Context, nodeID string) { } // RefreshNow synchronously summarizes the node (and any stale descendants). -// Implements ai.SummaryRefresher so ContextBuilder can populate the +// Implements storycontext.SummaryRefresher so ContextBuilder can populate the // hierarchical layer without waiting on the background queue. func (s *Summarizer) RefreshNow(ctx context.Context, nodeID string) { s.summarizeOneDepth(ctx, nodeID, 0) From bbf249e007156744e01fe2d0b5c6b9b9642bb26b Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:34:00 +0900 Subject: [PATCH 03/25] refactor(engine): extract storyops applier from internal/companion Move the story-mutation vocabulary and its applier - Op/Proposal types, validation with lenient kind normalization, ApplyOps with all-or-nothing structural rollback, the one-step undo batches, and outline change counting - into a new internal/storyops package with zero tars dependencies (memory is injected via a MemoryRecorder interface, so a future MCP applier without companion memory fails the remember op with a clear message instead of a panic). The companion now delegates ApplyOps/UndoApply to storyops and keeps only what is chat-specific: proposal-fence parsing, intent gating, the tool registry, and the outline-approval preview. Type aliases keep the RPC handlers and existing tests unchanged, and those tests now exercise the delegation end-to-end; new storyops tests cover apply/undo/rollback, the companion-before snapshot, and the missing-memory guard directly. Part of the MCP-first pivot (#47), Phase 1 Task 1.2. Co-Authored-By: Claude Opus 5 --- engine/internal/companion/companion.go | 14 +- engine/internal/companion/outlinechange.go | 141 +-- .../internal/companion/outlinechange_test.go | 4 +- engine/internal/companion/proposal.go | 302 +------ engine/internal/companion/runner.go | 2 +- engine/internal/companion/tools.go | 716 +-------------- engine/internal/companion/tools_test.go | 7 +- engine/internal/storyops/apply.go | 839 ++++++++++++++++++ engine/internal/storyops/apply_test.go | 175 ++++ engine/internal/storyops/ops.go | 310 +++++++ engine/internal/storyops/undo.go | 150 ++++ 11 files changed, 1526 insertions(+), 1134 deletions(-) create mode 100644 engine/internal/storyops/apply.go create mode 100644 engine/internal/storyops/apply_test.go create mode 100644 engine/internal/storyops/ops.go create mode 100644 engine/internal/storyops/undo.go diff --git a/engine/internal/companion/companion.go b/engine/internal/companion/companion.go index 6ae246d4..746c3082 100644 --- a/engine/internal/companion/companion.go +++ b/engine/internal/companion/companion.go @@ -8,7 +8,6 @@ import ( "path/filepath" "strconv" "strings" - "sync" "time" "github.com/devlikebear/linetta/engine/internal/ai" @@ -24,6 +23,7 @@ import ( "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" ) @@ -88,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 @@ -112,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 } @@ -140,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 } diff --git a/engine/internal/companion/outlinechange.go b/engine/internal/companion/outlinechange.go index 85e3af85..5ecf64c2 100644 --- a/engine/internal/companion/outlinechange.go +++ b/engine/internal/companion/outlinechange.go @@ -2,46 +2,27 @@ package companion import ( "context" - "errors" "strings" - "github.com/devlikebear/linetta/engine/internal/node" - "github.com/google/uuid" + "github.com/devlikebear/linetta/engine/internal/storyops" ) -// ErrUndoBatchNotFound means the undo window has passed: the batch was already -// undone, or fell out of the in-memory list. -var ErrUndoBatchNotFound = errors.New("companion: undo batch not found") +// ErrUndoBatchNotFound re-exports the storyops sentinel so existing handler +// errors.Is checks keep matching. +var ErrUndoBatchNotFound = storyops.ErrUndoBatchNotFound + +// OutlineChangeCounts moved to internal/storyops with the applier. +type OutlineChangeCounts = storyops.OutlineChangeCounts // A batch that rearranges this many outline nodes is a structural change to the // work, not an edit: the writer sees it before it lands. Var so tests can move // the line. var largeOutlineChangeThreshold = 6 -// How many undo batches are kept in memory. The writer only ever undoes the -// change they just watched land, so a short list is enough. -const maxUndoBatches = 8 - // previewTreeLimit caps how many rows a preview carries; a 200-node rewrite is // already far past the point where more rows help the writer decide. const previewTreeLimit = 200 -// OutlineChangeCounts summarizes what a batch would do to the outline tree. -type OutlineChangeCounts struct { - Created int `json:"created"` - Renamed int `json:"renamed"` - Deleted int `json:"deleted"` - Moved int `json:"moved"` - // Other counts ops in the same batch that do not touch the tree (beats, - // storylines, world-building, memories). - Other int `json:"other"` -} - -// Structural reports how many ops rearrange the outline tree. -func (c OutlineChangeCounts) Structural() int { - return c.Created + c.Renamed + c.Deleted + c.Moved -} - // OutlinePreviewNode is one row of the preview tree. type OutlinePreviewNode struct { Ref string `json:"ref,omitempty"` @@ -62,45 +43,10 @@ type OutlineChangePreview struct { Ops []Op `json:"ops"` } -func outlineOpAction(opType string) string { - switch opType { - case "create_outline_node", "create_scene": - return "create" - case "rename_outline_node": - return "rename" - case "delete_outline_node": - return "delete" - case "move_outline_node": - return "move" - default: - return "" - } -} - -// countOutlineChanges tallies a proposal by what it does to the tree. -func countOutlineChanges(p Proposal) OutlineChangeCounts { - var c OutlineChangeCounts - for _, op := range p.Ops { - switch outlineOpAction(op.Type) { - case "create": - c.Created++ - case "rename": - c.Renamed++ - case "delete": - c.Deleted++ - case "move": - c.Moved++ - default: - c.Other++ - } - } - return c -} - // needsOutlineApproval reports whether a batch reshapes enough of the outline // that the writer should see it first. func needsOutlineApproval(p Proposal) bool { - return countOutlineChanges(p).Structural() >= largeOutlineChangeThreshold + return storyops.CountOutlineChanges(p).Structural() >= largeOutlineChangeThreshold } // buildOutlinePreview renders the batch as an indented list of what it would @@ -109,12 +55,12 @@ func needsOutlineApproval(p Proposal) bool { func (s *Service) buildOutlinePreview(ctx context.Context, projectID string, p Proposal) OutlineChangePreview { preview := OutlineChangePreview{ Summary: strings.TrimSpace(p.Summary), - Counts: countOutlineChanges(p), + Counts: storyops.CountOutlineChanges(p), Ops: p.Ops, } depthByRef := map[string]int{} for _, op := range p.Ops { - action := outlineOpAction(op.Type) + action := storyops.OutlineOpAction(op.Type) if action == "" { continue } @@ -173,72 +119,7 @@ func (s *Service) outlineNodeLabel(ctx context.Context, projectID, nodeID string return n.Label } -// undoBatch is the outline as it stood before an applied change. -type undoBatch struct { - projectID string - nodes []node.Node -} - -// rememberUndoBatch keeps the pre-change outline so the writer can put it back -// with one action. Batches live in memory only: undo is for the change you just -// watched land, not for history. -func (s *Service) rememberUndoBatch(projectID string, before []node.Node) string { - if len(before) == 0 { - return "" - } - id := uuid.NewString() - s.undoMu.Lock() - defer s.undoMu.Unlock() - if s.undoBatches == nil { - s.undoBatches = map[string]undoBatch{} - } - s.undoBatches[id] = undoBatch{projectID: projectID, nodes: before} - s.undoOrder = append(s.undoOrder, id) - for len(s.undoOrder) > maxUndoBatches { - delete(s.undoBatches, s.undoOrder[0]) - s.undoOrder = s.undoOrder[1:] - } - return id -} - -func (s *Service) takeUndoBatch(id string) (undoBatch, bool) { - s.undoMu.Lock() - defer s.undoMu.Unlock() - batch, ok := s.undoBatches[id] - if !ok { - return undoBatch{}, false - } - delete(s.undoBatches, id) - for i, existing := range s.undoOrder { - if existing == id { - s.undoOrder = append(s.undoOrder[:i], s.undoOrder[i+1:]...) - break - } - } - return batch, true -} - // UndoApply puts the outline back the way it was before the applied batch. func (s *Service) UndoApply(ctx context.Context, batchID string, now func() int64) error { - batch, ok := s.takeUndoBatch(strings.TrimSpace(batchID)) - if !ok { - return ErrUndoBatchNotFound - } - if s.nodes == nil { - return ErrUndoBatchNotFound - } - return s.nodes.RestoreOutline(ctx, batch.projectID, batch.nodes, now()) -} - -// snapshotOutline captures the tree so a failed batch can be rolled back and a -// finished one can be undone. -func (s *Service) snapshotOutline(ctx context.Context, projectID string) []node.Node { - if s.nodes == nil { - return nil - } - before, err := s.nodes.ListByProject(ctx, projectID) - if err != nil { - return nil - } - return before + return s.story.UndoApply(ctx, batchID, now) } diff --git a/engine/internal/companion/outlinechange_test.go b/engine/internal/companion/outlinechange_test.go index cc2f2046..c694ecf3 100644 --- a/engine/internal/companion/outlinechange_test.go +++ b/engine/internal/companion/outlinechange_test.go @@ -214,7 +214,7 @@ func TestApplyOpsUndoRestoresTheOutline(t *testing.T) { }, }, func() int64 { return 2 }) - if result.isError() || result.Applied != 2 { + if result.IsError() || result.Applied != 2 { t.Fatalf("apply should succeed: %+v", result) } if result.UndoBatchID == "" { @@ -253,7 +253,7 @@ func TestApplyOpsSkipsUndoForNonStructuralBatches(t *testing.T) { Ops: []Op{{Type: "set_outline", Outline: "복수 서사"}}, }, func() int64 { return 2 }) - if result.isError() || result.Applied != 1 { + if result.IsError() || result.Applied != 1 { t.Fatalf("apply should succeed: %+v", result) } if result.UndoBatchID != "" { diff --git a/engine/internal/companion/proposal.go b/engine/internal/companion/proposal.go index 3298b957..19aa6437 100644 --- a/engine/internal/companion/proposal.go +++ b/engine/internal/companion/proposal.go @@ -8,94 +8,20 @@ import ( "fmt" "strings" - "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/storyops" ) +// Op and Proposal moved to internal/storyops in the MCP-first pivot (#47); +// the aliases keep the chat-parsing layer and RPC handlers unchanged until +// this package is removed. +type Op = storyops.Op + +type Proposal = storyops.Proposal + // proposalFence is the fenced-block language tag the model must use to emit a // structured plot-edit proposal. const proposalFence = "linetta-proposal" -// Op is one proposed plot-core mutation. Only fields relevant to Type are set. -type Op struct { - Type string `json:"op"` - - // create_thread - Ref string `json:"ref,omitempty"` - Name string `json:"name,omitempty"` - Color string `json:"color,omitempty"` - Summary string `json:"summary,omitempty"` - - // update_thread / add_beat target - ThreadID string `json:"thread_id,omitempty"` - ThreadRef string `json:"thread_ref,omitempty"` - - // add_beat / update_beat - NodeID string `json:"node_id,omitempty"` - BeatID string `json:"beat_id,omitempty"` - Label string `json:"label,omitempty"` - Description string `json:"description,omitempty"` - Intensity int `json:"intensity,omitempty"` - - // set_outline - Outline string `json:"outline,omitempty"` - - // remember - Text string `json:"text,omitempty"` - AllowEmpty bool `json:"allow_empty,omitempty"` - Category string `json:"category,omitempty"` - - // create_entity / update_entity - Kind string `json:"kind,omitempty"` - Role string `json:"role,omitempty"` - EntityID string `json:"entity_id,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - - // create_scene - AfterNodeID string `json:"after_node_id,omitempty"` - AfterNodeRef string `json:"after_node_ref,omitempty"` - Title string `json:"title,omitempty"` - NodeRef string `json:"node_ref,omitempty"` - ParentNodeID string `json:"parent_node_id,omitempty"` - ParentNodeRef string `json:"parent_node_ref,omitempty"` - Direction string `json:"direction,omitempty"` - - // create_relationship - From string `json:"from,omitempty"` - FromRef string `json:"from_ref,omitempty"` - To string `json:"to,omitempty"` - ToRef string `json:"to_ref,omitempty"` - Notes string `json:"notes,omitempty"` - InverseLabel string `json:"inverse_label,omitempty"` - - // create_fact_card - Claim string `json:"claim,omitempty"` - Result string `json:"result,omitempty"` - Status string `json:"status,omitempty"` - Sources []fact.SourceInput `json:"sources,omitempty"` -} - -// Proposal is the parsed contents of a linetta-proposal block. -type Proposal struct { - Summary string `json:"summary"` - Ops []Op `json:"ops"` -} - -// knownOps lists the plot-core op types accepted in Phase 1. -var knownOps = map[string]bool{ - "create_thread": true, "update_thread": true, - "add_beat": true, "update_beat": true, "delete_beat": true, - "set_outline": true, - "set_scene_text": true, - "remember": true, - "create_entity": true, "update_entity": true, "create_relationship": true, - "create_scene": true, - "create_outline_node": true, - "rename_outline_node": true, - "delete_outline_node": true, - "move_outline_node": true, - "create_fact_card": true, -} - // ParseProposal scans full model output for a linetta-proposal fenced block. // Returns (proposal, blockPresent, error): // - no block: (Proposal{}, false, nil) @@ -114,7 +40,7 @@ func ParseProposal(full string) (Proposal, bool, error) { if err != nil { return p, true, err } - if err := validateProposal(p); err != nil { + if err := storyops.ValidateProposal(p); err != nil { return p, true, err } return p, true, nil @@ -128,216 +54,6 @@ func decodeProposal(body string) (Proposal, error) { return p, nil } -// normalizeEntityKind maps a raw create_entity kind to one of the canonical -// values (character|place|item|concept). It is lenient because the model does -// not always emit the exact token: an empty kind defaults to "character" (the -// dominant entity type), and common English/Korean synonyms are accepted. -// Returns (canonical, true) on success, or ("", false) for an unknown value. -func normalizeEntityKind(raw string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "", "character", "char", "person", "people", "인물", "캐릭터", "등장인물": - return "character", true - case "place", "location", "장소", "공간", "위치": - return "place", true - case "item", "object", "thing", "사물", "아이템", "물건": - return "item", true - case "concept", "idea", "theme", "skill", "magic", "ability", - "spell", "rule", "system", "개념", "주제", "스킬", "마법", "능력", - "주문", "규칙", "세계관": - return "concept", true - default: - return "", false - } -} - -func normalizeOutlineNodeKind(raw string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "", "leaf", "scene", "씬", "장면": - return "leaf", true - case "container", "chapter", "part", "장", "챕터", "부", "파트", "막": - return "container", true - default: - return "", false - } -} - -func validateProposal(p Proposal) error { - if len(p.Ops) == 0 { - return fmt.Errorf("proposal has no ops") - } - for i, op := range p.Ops { - if !knownOps[op.Type] { - return fmt.Errorf("op[%d]: unknown op %q", i, op.Type) - } - switch op.Type { - case "create_thread": - if strings.TrimSpace(op.Name) == "" { - return fmt.Errorf("op[%d] create_thread: name required", i) - } - case "update_thread": - if op.ThreadID == "" { - return fmt.Errorf("op[%d] update_thread: thread_id required", i) - } - case "add_beat": - hasID := op.ThreadID != "" - hasRef := op.ThreadRef != "" - if hasID == hasRef { - return fmt.Errorf("op[%d] add_beat: exactly one of thread_id/thread_ref required", i) - } - // An undeclared thread_ref is allowed here: the model often places a - // real thread id in thread_ref. Resolution (declared ref → real id → - // name) and any clear error happen at apply time. - if strings.TrimSpace(op.Label) == "" { - return fmt.Errorf("op[%d] add_beat: label required", i) - } - if op.NodeID != "" && op.NodeRef != "" { - return fmt.Errorf("op[%d] add_beat: node_id and node_ref are mutually exclusive", i) - } - case "create_scene": - if strings.TrimSpace(op.Label) == "" { - return fmt.Errorf("op[%d] create_scene: label required", i) - } - if op.AfterNodeID != "" && op.AfterNodeRef != "" { - return fmt.Errorf("op[%d] create_scene: after_node_id and after_node_ref are mutually exclusive", i) - } - case "create_outline_node": - if strings.TrimSpace(op.Label) == "" { - return fmt.Errorf("op[%d] create_outline_node: label required", i) - } - kind, ok := normalizeOutlineNodeKind(op.Kind) - if !ok { - return fmt.Errorf("op[%d] create_outline_node: kind must be container|leaf", i) - } - p.Ops[i].Kind = kind - if op.ParentNodeID != "" && op.ParentNodeRef != "" { - return fmt.Errorf("op[%d] create_outline_node: parent_node_id and parent_node_ref are mutually exclusive", i) - } - if op.AfterNodeID != "" && op.AfterNodeRef != "" { - return fmt.Errorf("op[%d] create_outline_node: after_node_id and after_node_ref are mutually exclusive", i) - } - hasParent := op.ParentNodeID != "" || op.ParentNodeRef != "" - hasAfter := op.AfterNodeID != "" || op.AfterNodeRef != "" - if hasParent && hasAfter { - return fmt.Errorf("op[%d] create_outline_node: parent_node_* and after_node_* are mutually exclusive", i) - } - case "rename_outline_node": - if op.NodeID != "" && op.NodeRef != "" { - return fmt.Errorf("op[%d] rename_outline_node: node_id and node_ref are mutually exclusive", i) - } - if op.NodeID == "" && op.NodeRef == "" { - return fmt.Errorf("op[%d] rename_outline_node: node_id or node_ref required", i) - } - if strings.TrimSpace(op.Label) == "" && strings.TrimSpace(op.Title) == "" { - return fmt.Errorf("op[%d] rename_outline_node: label or title required", i) - } - case "delete_outline_node": - if op.NodeID != "" && op.NodeRef != "" { - return fmt.Errorf("op[%d] delete_outline_node: node_id and node_ref are mutually exclusive", i) - } - if op.NodeID == "" && op.NodeRef == "" { - return fmt.Errorf("op[%d] delete_outline_node: node_id or node_ref required", i) - } - case "move_outline_node": - if op.NodeID != "" && op.NodeRef != "" { - return fmt.Errorf("op[%d] move_outline_node: node_id and node_ref are mutually exclusive", i) - } - if op.NodeID == "" && op.NodeRef == "" { - return fmt.Errorf("op[%d] move_outline_node: node_id or node_ref required", i) - } - direction := strings.ToLower(strings.TrimSpace(op.Direction)) - if direction != "up" && direction != "down" { - return fmt.Errorf("op[%d] move_outline_node: direction must be up|down", i) - } - p.Ops[i].Direction = direction - case "update_beat": - if op.BeatID == "" { - return fmt.Errorf("op[%d] update_beat: beat_id required", i) - } - case "delete_beat": - if op.BeatID == "" { - return fmt.Errorf("op[%d] delete_beat: beat_id required", i) - } - case "set_outline": - // outline may be empty (clears); no required field - case "set_scene_text": - if op.NodeID != "" && op.NodeRef != "" { - return fmt.Errorf("op[%d] set_scene_text: node_id and node_ref are mutually exclusive", i) - } - if strings.TrimSpace(op.Text) == "" && !op.AllowEmpty { - return fmt.Errorf("op[%d] set_scene_text: text required unless allow_empty is true", i) - } - case "remember": - if strings.TrimSpace(op.Text) == "" { - return fmt.Errorf("op[%d] remember: text required", i) - } - case "create_entity": - if strings.TrimSpace(op.Name) == "" { - return fmt.Errorf("op[%d] create_entity: name required", i) - } - kind, ok := normalizeEntityKind(op.Kind) - if !ok { - return fmt.Errorf("op[%d] create_entity: kind must be character|place|item|concept", i) - } - p.Ops[i].Kind = kind - case "update_entity": - if op.EntityID == "" { - return fmt.Errorf("op[%d] update_entity: entity_id required", i) - } - if strings.TrimSpace(op.Kind) != "" { - kind, ok := normalizeEntityKind(op.Kind) - if !ok { - return fmt.Errorf("op[%d] update_entity: kind must be character|place|item|concept", i) - } - p.Ops[i].Kind = kind - } - case "create_relationship": - if strings.TrimSpace(op.Label) == "" { - return fmt.Errorf("op[%d] create_relationship: label required", i) - } - hasFrom, hasFromRef := op.From != "", op.FromRef != "" - hasTo, hasToRef := op.To != "", op.ToRef != "" - if hasFrom == hasFromRef { - return fmt.Errorf("op[%d] create_relationship: exactly one of from/from_ref required", i) - } - if hasTo == hasToRef { - return fmt.Errorf("op[%d] create_relationship: exactly one of to/to_ref required", i) - } - // Undeclared from_ref/to_ref are allowed: the model often places a - // real entity id (or name) in the ref field. Resolution and any - // clear error happen at apply time. - case "create_fact_card": - if strings.TrimSpace(op.Claim) == "" { - return fmt.Errorf("op[%d] create_fact_card: claim required", i) - } - if strings.TrimSpace(op.Result) == "" { - return fmt.Errorf("op[%d] create_fact_card: result required", i) - } - status := strings.TrimSpace(op.Status) - if status == "" { - status = fact.StatusUncertain - p.Ops[i].Status = status - } - if !fact.ValidStatus(status) { - return fmt.Errorf("op[%d] create_fact_card: status must be verified|uncertain|intentional_fiction|stale", i) - } - hasSource := false - for _, src := range op.Sources { - if strings.TrimSpace(src.URL) != "" { - hasSource = true - break - } - } - if !hasSource { - return fmt.Errorf("op[%d] create_fact_card: at least one source URL required", i) - } - if op.NodeID != "" && op.NodeRef != "" { - return fmt.Errorf("op[%d] create_fact_card: node_id and node_ref are mutually exclusive", i) - } - } - } - return nil -} - // extractFencedBlocks returns the bodies of all ``` ... ``` blocks whose // info-string equals lang. func extractFencedBlocks(s, lang string) []string { diff --git a/engine/internal/companion/runner.go b/engine/internal/companion/runner.go index a66764f6..65740058 100644 --- a/engine/internal/companion/runner.go +++ b/engine/internal/companion/runner.go @@ -697,7 +697,7 @@ func (r *Runner) applyDirectProposalFallback(ctx context.Context, runID, project UndoBatchID: result.UndoBatchID, }) } - if result.Applied == 0 || result.isError() { + if result.Applied == 0 || result.IsError() { return result, false } _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: appliedStatusText(language)}) diff --git a/engine/internal/companion/tools.go b/engine/internal/companion/tools.go index a19728a2..bf963839 100644 --- a/engine/internal/companion/tools.go +++ b/engine/internal/companion/tools.go @@ -6,18 +6,9 @@ import ( "encoding/json" "fmt" "io" - "regexp" "strings" - "github.com/devlikebear/linetta/engine/internal/beat" - "github.com/devlikebear/linetta/engine/internal/entity" - "github.com/devlikebear/linetta/engine/internal/fact" - "github.com/devlikebear/linetta/engine/internal/node" - "github.com/devlikebear/linetta/engine/internal/project" - "github.com/devlikebear/linetta/engine/internal/ptrutil" - "github.com/devlikebear/linetta/engine/internal/relationship" - "github.com/devlikebear/linetta/engine/internal/snapshot" - "github.com/devlikebear/linetta/engine/internal/thread" + "github.com/devlikebear/linetta/engine/internal/storyops" tarstools "github.com/devlikebear/tars/pkg/tools" ) @@ -26,39 +17,24 @@ type webToolSource interface { WebSearchAPIKey() string } -type ApplyOpsResult struct { - Summary string `json:"summary,omitempty"` - Applied int `json:"applied"` - Created map[string]string `json:"created,omitempty"` - ChangedNodes []AppliedNodeChange `json:"changed_nodes,omitempty"` - Failures []ApplyOpsFailure `json:"failures,omitempty"` - // PendingApproval means the batch was large enough to show the writer first, - // so nothing was applied and the ops are waiting in a preview. - PendingApproval bool `json:"pending_approval,omitempty"` - // RolledBack means an op failed partway and the outline was put back. - RolledBack bool `json:"rolled_back,omitempty"` - // UndoBatchID identifies the pre-change outline kept for a one-step undo. - UndoBatchID string `json:"undo_batch_id,omitempty"` -} +// ApplyOps result types moved to internal/storyops with the applier. +type ApplyOpsResult = storyops.ApplyOpsResult -type ApplyOpsFailure struct { - Index int `json:"index"` - Op string `json:"op,omitempty"` - Error string `json:"error"` -} +type ApplyOpsFailure = storyops.ApplyOpsFailure -type AppliedNodeChange struct { - NodeID string `json:"node_id"` - Op string `json:"op"` - ContentVersion int `json:"content_version"` - CharCount int `json:"char_count"` - TextPreview string `json:"text_preview,omitempty"` -} +type AppliedNodeChange = storyops.AppliedNodeChange -func (r ApplyOpsResult) isError() bool { - return len(r.Failures) > 0 +// ApplyOps applies a validated proposal op list directly to project state. +// The applier lives in internal/storyops; the companion delegates so chat +// applies, RPC applies, and (later) MCP applies share one path. +func (s *Service) ApplyOps(ctx context.Context, projectID, nodeID string, p Proposal, now func() int64) ApplyOpsResult { + return s.story.ApplyOps(ctx, projectID, nodeID, p, now) } +// plainTextToTiptapDoc kept as an alias for this package's tests; the +// implementation moved to storyops with set_scene_text. +var plainTextToTiptapDoc = storyops.PlainTextToTiptapDoc + func (s *Service) buildToolRegistry(projectID, nodeID string, now func() int64, runIDAndUserText ...string) *tarstools.Registry { userText := "" if len(runIDAndUserText) > 1 { @@ -171,7 +147,7 @@ func (s *Service) buildApplyOpsTool(projectID, nodeID, scope, runID, userText st UndoBatchID: result.UndoBatchID, }) } - return tarstools.JSONTextResult(result, result.isError()), nil + return tarstools.JSONTextResult(result, result.IsError()), nil }, } } @@ -319,665 +295,3 @@ func validateApplyOpsIntent(p Proposal, intent applyOpsIntent) error { } return nil } - -// ApplyOps applies a validated proposal op list directly to project state. -func (s *Service) ApplyOps(ctx context.Context, projectID, nodeID string, p Proposal, now func() int64) ApplyOpsResult { - result := ApplyOpsResult{ - Summary: strings.TrimSpace(p.Summary), - Created: map[string]string{}, - } - if err := validateProposal(p); err != nil { - result.Failures = append(result.Failures, ApplyOpsFailure{Index: -1, Error: err.Error()}) - return result - } - - // Structural batches are all-or-nothing: the outline is captured first so a - // failure halfway through can be put back, and a clean run leaves the writer - // one undo away from where they started. - structural := countOutlineChanges(p).Structural() > 0 - var before []node.Node - if structural { - before = s.snapshotOutline(ctx, projectID) - } - - threadRefs := map[string]string{} - entityRefs := map[string]string{} - nodeRefs := map[string]string{} - nodeInsertCursor := "" - - for i, op := range p.Ops { - if err := s.applyOneOp(ctx, projectID, nodeID, op, now, threadRefs, entityRefs, nodeRefs, result.Created, &result.ChangedNodes, &nodeInsertCursor); err != nil { - result.Failures = append(result.Failures, ApplyOpsFailure{Index: i, Op: op.Type, Error: err.Error()}) - continue - } - result.Applied++ - } - if len(result.Created) == 0 { - result.Created = nil - } - if len(result.ChangedNodes) == 0 { - result.ChangedNodes = nil - } - if structural && len(before) > 0 { - if result.isError() { - // Half a restructured outline is worse than none, so put the tree back - // and report the failure against an unchanged project. - if err := s.nodes.RestoreOutline(ctx, projectID, before, now()); err == nil { - result.RolledBack = true - result.Applied = 0 - result.Created = nil - result.ChangedNodes = nil - } - } else if result.Applied > 0 { - result.UndoBatchID = s.rememberUndoBatch(projectID, before) - } - } - return result -} - -func (s *Service) applyOneOp( - ctx context.Context, - projectID string, - currentNodeID string, - op Op, - now func() int64, - threadRefs map[string]string, - entityRefs map[string]string, - nodeRefs map[string]string, - created map[string]string, - changedNodes *[]AppliedNodeChange, - nodeInsertCursor *string, -) error { - switch op.Type { - case "set_outline": - outline := op.Outline - _, err := s.projects.Update(ctx, now(), project.UpdateInput{ID: projectID, Outline: &outline}) - return err - case "set_scene_text": - targetNodeID, err := s.resolveOptionalNodeID(ctx, op.NodeID, op.NodeRef, currentNodeID, nodeRefs) - if err != nil { - return err - } - if targetNodeID == nil || strings.TrimSpace(*targetNodeID) == "" { - return fmt.Errorf("set_scene_text requires a current node or node_id") - } - before, err := s.nodes.Get(ctx, *targetNodeID) - if err != nil { - return err - } - if s.snaps != nil { - beforeDoc := "" - if before.ContentDoc != nil { - beforeDoc = *before.ContentDoc - } - if _, _, err := s.snaps.CreateIfChanged(ctx, *targetNodeID, beforeDoc, snapshot.ReasonCompanionBefore, now()); err != nil { - return fmt.Errorf("companion-before snapshot: %w", err) - } - } - doc, err := plainTextToTiptapDoc(op.Text) - if err != nil { - return err - } - if err := s.nodes.UpdateContent(ctx, *targetNodeID, doc, now()); err != nil { - return err - } - after, err := s.nodes.Get(ctx, *targetNodeID) - if err != nil { - return fmt.Errorf("verify set_scene_text: %w", err) - } - gotText := normalizeSceneTextForVerify(plainTextFromDoc(after.ContentDoc)) - wantText := normalizeSceneTextForVerify(op.Text) - if gotText != wantText { - return fmt.Errorf("verify set_scene_text: readback text mismatch") - } - if !op.AllowEmpty && strings.TrimSpace(gotText) == "" { - return fmt.Errorf("verify set_scene_text: readback text is empty") - } - if after.ContentVersion <= before.ContentVersion { - return fmt.Errorf("verify set_scene_text: content_version did not advance") - } - *changedNodes = append(*changedNodes, AppliedNodeChange{ - NodeID: after.ID, - Op: "set_scene_text", - ContentVersion: after.ContentVersion, - CharCount: after.WordCount, - TextPreview: trimRunesLocal(strings.TrimSpace(plainTextFromDoc(after.ContentDoc)), 120), - }) - return nil - case "create_thread": - th, err := s.threads.Create(ctx, thread.NewInput{ProjectID: projectID, Name: op.Name, Color: op.Color}) - if err != nil { - return err - } - if strings.TrimSpace(op.Summary) != "" { - if err := s.threads.Update(ctx, thread.UpdateInput{ID: th.ID, Summary: ptrutil.To(op.Summary)}); err != nil { - return err - } - } - if op.Ref != "" { - threadRefs[op.Ref] = th.ID - created["thread:"+op.Ref] = th.ID - } - return nil - case "update_thread": - in := thread.UpdateInput{ID: op.ThreadID} - if strings.TrimSpace(op.Name) != "" { - in.Name = ptrutil.To(op.Name) - } - if strings.TrimSpace(op.Color) != "" { - in.Color = ptrutil.To(op.Color) - } - if strings.TrimSpace(op.Summary) != "" { - in.Summary = ptrutil.To(op.Summary) - } - return s.threads.Update(ctx, in) - case "add_beat": - threadID, err := s.resolveThreadID(ctx, projectID, op.ThreadID, op.ThreadRef, threadRefs) - if err != nil { - return err - } - beatNodeID, err := s.resolveOptionalNodeID(ctx, op.NodeID, op.NodeRef, currentNodeID, nodeRefs) - if err != nil { - return err - } - _, err = s.beats.Create(ctx, beat.NewInput{ - ThreadID: threadID, - NodeID: beatNodeID, - Label: op.Label, - Description: op.Description, - Intensity: op.Intensity, - }) - return err - case "update_beat": - in := beat.UpdateInput{ID: op.BeatID, Label: op.Label, Intensity: op.Intensity} - if strings.TrimSpace(op.Description) != "" { - in.Description = &op.Description - } - return s.beats.Update(ctx, in) - case "delete_beat": - return s.beats.Delete(ctx, op.BeatID) - case "remember": - return s.Remember(projectID, op.Text, op.Category) - case "create_fact_card": - if s.facts == nil { - return fmt.Errorf("fact book is not available") - } - cardNodeID, err := s.resolveOptionalNodeID(ctx, op.NodeID, op.NodeRef, currentNodeID, nodeRefs) - if err != nil { - return err - } - card, err := s.facts.Create(ctx, now(), fact.NewInput{ - ProjectID: projectID, - NodeID: cardNodeID, - Claim: op.Claim, - Result: op.Result, - Status: op.Status, - Category: op.Category, - Sources: op.Sources, - }) - if err != nil { - return err - } - if op.Ref != "" { - created["fact:"+op.Ref] = card.ID - } - return nil - case "create_entity": - ent, err := s.entities.Create(ctx, now(), entity.NewInput{ - ProjectID: projectID, - Kind: op.Kind, - Name: op.Name, - Role: op.Role, - }) - if err != nil { - return err - } - attrs := cleanEntityAttributes(op.Attributes) - if strings.TrimSpace(op.Summary) != "" || len(attrs) > 0 { - in := entity.UpdateInput{ID: ent.ID, Attributes: optionalEntityAttributes(attrs)} - if strings.TrimSpace(op.Summary) != "" { - in.Summary = ptrutil.To(op.Summary) - } - if err := s.entities.Update(ctx, now(), in); err != nil { - return err - } - } - if op.Ref != "" { - entityRefs[op.Ref] = ent.ID - created["entity:"+op.Ref] = ent.ID - } - return nil - case "update_entity": - entityID, err := s.resolveEntityID(ctx, projectID, op.EntityID, "", entityRefs, "entity") - if err != nil { - return err - } - cur, err := s.entities.Get(ctx, entityID) - if err != nil { - return err - } - in := entity.UpdateInput{ID: entityID} - if strings.TrimSpace(op.Kind) != "" { - in.Kind = ptrutil.To(op.Kind) - } - if strings.TrimSpace(op.Name) != "" { - in.Name = ptrutil.To(op.Name) - } - if strings.TrimSpace(op.Role) != "" { - in.Role = ptrutil.To(op.Role) - } - if strings.TrimSpace(op.Summary) != "" { - in.Summary = ptrutil.To(op.Summary) - } - if attrs := cleanEntityAttributes(op.Attributes); len(attrs) > 0 { - merged := mergeEntityAttributes(cur.Attributes, attrs) - in.Attributes = &merged - } - return s.entities.Update(ctx, now(), in) - case "create_relationship": - fromID, err := s.resolveEntityID(ctx, projectID, op.From, op.FromRef, entityRefs, "from entity") - if err != nil { - return err - } - toID, err := s.resolveEntityID(ctx, projectID, op.To, op.ToRef, entityRefs, "to entity") - if err != nil { - return err - } - if strings.TrimSpace(op.InverseLabel) != "" { - _, err := s.relationships.CreatePair(ctx, relationship.NewPairInput{ - ProjectID: projectID, - FromID: fromID, - ToID: toID, - Label: op.Label, - InverseLabel: op.InverseLabel, - Notes: op.Notes, - }) - return err - } - _, err = s.relationships.CreateOne(ctx, relationship.NewInput{ - ProjectID: projectID, - FromID: fromID, - ToID: toID, - Label: op.Label, - Notes: op.Notes, - }) - return err - case "create_scene": - afterNodeID, err := s.resolveSceneAnchor(ctx, projectID, currentNodeID, op.AfterNodeID, op.AfterNodeRef, nodeRefs, *nodeInsertCursor) - if err != nil { - return err - } - n, err := s.nodes.CreateSibling(ctx, afterNodeID, node.KindLeaf, op.Label, op.Title, now()) - if err != nil { - return err - } - if op.Ref != "" { - nodeRefs[op.Ref] = n.ID - created["node:"+op.Ref] = n.ID - } - *nodeInsertCursor = n.ID - return nil - case "create_outline_node": - return s.applyCreateOutlineNode(ctx, projectID, currentNodeID, op, now, nodeRefs, created, nodeInsertCursor) - case "rename_outline_node": - nodeID, err := s.resolveRequiredNodeID(ctx, op.NodeID, op.NodeRef, nodeRefs) - if err != nil { - return err - } - cur, err := s.nodes.Get(ctx, nodeID) - if err != nil { - return err - } - label := cur.Label - if strings.TrimSpace(op.Label) != "" { - label = op.Label - } - title := cur.Title - if strings.TrimSpace(op.Title) != "" { - title = op.Title - } - return s.nodes.Rename(ctx, nodeID, label, title, now()) - case "delete_outline_node": - nodeID, err := s.resolveRequiredNodeID(ctx, op.NodeID, op.NodeRef, nodeRefs) - if err != nil { - return err - } - return s.nodes.Delete(ctx, nodeID, now()) - case "move_outline_node": - nodeID, err := s.resolveRequiredNodeID(ctx, op.NodeID, op.NodeRef, nodeRefs) - if err != nil { - return err - } - if strings.ToLower(strings.TrimSpace(op.Direction)) == "up" { - return s.nodes.MoveUp(ctx, nodeID, now()) - } - return s.nodes.MoveDown(ctx, nodeID, now()) - default: - return fmt.Errorf("unknown op %q", op.Type) - } -} - -func cleanEntityAttributes(attrs map[string]string) map[string]string { - if len(attrs) == 0 { - return nil - } - out := map[string]string{} - for key, value := range attrs { - key = strings.TrimSpace(key) - if key == "" { - continue - } - out[key] = strings.TrimSpace(value) - } - return out -} - -func optionalEntityAttributes(attrs map[string]string) *map[string]string { - if len(attrs) == 0 { - return nil - } - return &attrs -} - -type tiptapDoc struct { - Type string `json:"type"` - Content []tiptapBlock `json:"content"` -} - -type tiptapBlock struct { - Type string `json:"type"` - Content []tiptapInline `json:"content,omitempty"` -} - -type tiptapInline struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -var paragraphBreakRE = regexp.MustCompile(`\n{2,}`) - -func normalizeSceneTextForVerify(text string) string { - normalized := strings.ReplaceAll(text, "\r\n", "\n") - normalized = strings.ReplaceAll(normalized, "\r", "\n") - normalized = paragraphBreakRE.ReplaceAllString(normalized, "\n\n") - lines := strings.Split(normalized, "\n") - for i, line := range lines { - lines[i] = strings.TrimRight(line, " \t") - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} - -func plainTextToTiptapDoc(text string) (string, error) { - normalized := strings.ReplaceAll(text, "\r\n", "\n") - normalized = strings.ReplaceAll(normalized, "\r", "\n") - blocks := paragraphBreakRE.Split(normalized, -1) - paragraphs := make([]tiptapBlock, 0, len(blocks)) - for _, block := range blocks { - lines := strings.Split(block, "\n") - content := make([]tiptapInline, 0, len(lines)*2) - for i, line := range lines { - if i > 0 { - content = append(content, tiptapInline{Type: "hardBreak"}) - } - if line != "" { - content = append(content, tiptapInline{Type: "text", Text: line}) - } - } - paragraph := tiptapBlock{Type: "paragraph"} - if len(content) > 0 { - paragraph.Content = content - } - paragraphs = append(paragraphs, paragraph) - } - if len(paragraphs) == 0 { - paragraphs = append(paragraphs, tiptapBlock{Type: "paragraph"}) - } - raw, err := json.Marshal(tiptapDoc{Type: "doc", Content: paragraphs}) - if err != nil { - return "", err - } - return string(raw), nil -} - -func mergeEntityAttributes(base, next map[string]string) map[string]string { - merged := map[string]string{} - for key, value := range base { - merged[key] = value - } - for key, value := range next { - merged[key] = value - } - return merged -} - -func (s *Service) applyCreateOutlineNode( - ctx context.Context, - projectID string, - currentNodeID string, - op Op, - now func() int64, - nodeRefs map[string]string, - created map[string]string, - nodeInsertCursor *string, -) error { - kind := strings.TrimSpace(op.Kind) - if kind == "" { - kind = node.KindLeaf - } - if !node.ValidKind(kind) { - return fmt.Errorf("create_outline_node: kind must be container|leaf") - } - parentID, err := s.resolveOptionalNodeID(ctx, op.ParentNodeID, op.ParentNodeRef, "", nodeRefs) - if err != nil { - return err - } - var n node.Node - if parentID != nil { - parent, err := s.nodes.Get(ctx, *parentID) - if err != nil { - return err - } - if parent.Kind != node.KindContainer { - return fmt.Errorf("create_outline_node: parent must be a container node") - } - if existing, ok, err := s.findMatchingOutlineNode(ctx, projectID, parentID, kind, op.Label); err != nil { - return err - } else if ok { - n = existing - } else { - n, err = s.nodes.CreateChild(ctx, *parentID, kind, op.Label, op.Title, now()) - } - } else { - hasAfter := strings.TrimSpace(op.AfterNodeID) != "" || strings.TrimSpace(op.AfterNodeRef) != "" || strings.TrimSpace(*nodeInsertCursor) != "" - if hasAfter { - afterNodeID, err := s.resolveSceneAnchor(ctx, projectID, currentNodeID, op.AfterNodeID, op.AfterNodeRef, nodeRefs, *nodeInsertCursor) - if err != nil { - return err - } - after, err := s.nodes.Get(ctx, afterNodeID) - if err != nil { - return err - } - if existing, ok, err := s.findMatchingOutlineNode(ctx, projectID, after.ParentID, kind, op.Label); err != nil { - return err - } else if ok { - n = existing - } else { - n, err = s.nodes.CreateSibling(ctx, afterNodeID, kind, op.Label, op.Title, now()) - } - } else { - if existing, ok, err := s.findMatchingOutlineNode(ctx, projectID, nil, kind, op.Label); err != nil { - return err - } else if ok { - n = existing - } else { - n, err = s.nodes.CreateRoot(ctx, projectID, kind, op.Label, op.Title, now()) - } - } - } - if err != nil { - return err - } - if strings.TrimSpace(op.Title) != "" && strings.TrimSpace(n.Title) == "" { - if err := s.nodes.Rename(ctx, n.ID, n.Label, op.Title, now()); err != nil { - return err - } - n, err = s.nodes.Get(ctx, n.ID) - if err != nil { - return err - } - } - if op.Ref != "" { - nodeRefs[op.Ref] = n.ID - created["node:"+op.Ref] = n.ID - } - if parentID == nil { - *nodeInsertCursor = n.ID - } - return nil -} - -func (s *Service) findMatchingOutlineNode(ctx context.Context, projectID string, parentID *string, kind, label string) (node.Node, bool, error) { - label = strings.TrimSpace(label) - if label == "" { - return node.Node{}, false, nil - } - all, err := s.nodes.ListByProject(ctx, projectID) - if err != nil { - return node.Node{}, false, err - } - for _, n := range all { - if n.Kind != kind || strings.TrimSpace(n.Label) != label { - continue - } - if parentID == nil { - if n.ParentID == nil { - return n, true, nil - } - continue - } - if n.ParentID != nil && *n.ParentID == *parentID { - return n, true, nil - } - } - return node.Node{}, false, nil -} - -// resolveEntityID resolves a create_relationship endpoint to a real entity id. -// It tolerates the model's common mistakes: an entity may be referenced by a -// proposal ref (in from_ref or mistakenly in from), by a real entity id, or by -// name (case-insensitive). Returns a clear error instead of letting an -// unresolved value hit a FOREIGN KEY constraint at insert time. -func (s *Service) resolveEntityID(ctx context.Context, projectID, id, ref string, refs map[string]string, label string) (string, error) { - // ref and id are treated interchangeably: the model conflates them (a real - // entity id or name often lands in from_ref, and vice versa). Each is tried - // as a declared proposal ref, then a real entity id, then a name. - for _, candidate := range []string{strings.TrimSpace(ref), strings.TrimSpace(id)} { - if candidate == "" { - continue - } - if resolved, ok := refs[candidate]; ok { - return resolved, nil - } - if _, err := s.entities.Get(ctx, candidate); err == nil { - return candidate, nil - } - if matches, err := s.entities.Search(ctx, projectID, candidate, 20); err == nil { - for _, e := range matches { - if strings.EqualFold(strings.TrimSpace(e.Name), candidate) { - return e.ID, nil - } - } - } - } - return "", fmt.Errorf("%s could not be resolved to an entity (id, name, or ref)", label) -} - -// resolveThreadID resolves an add_beat thread endpoint, tolerating a real thread -// id (or name) placed in thread_ref and vice versa. -func (s *Service) resolveThreadID(ctx context.Context, projectID, id, ref string, refs map[string]string) (string, error) { - for _, candidate := range []string{strings.TrimSpace(ref), strings.TrimSpace(id)} { - if candidate == "" { - continue - } - if resolved, ok := refs[candidate]; ok { - return resolved, nil - } - if _, err := s.threads.Get(ctx, candidate); err == nil { - return candidate, nil - } - if list, err := s.threads.ListByProject(ctx, projectID, true); err == nil { - for _, th := range list { - if strings.EqualFold(strings.TrimSpace(th.Name), candidate) { - return th.ID, nil - } - } - } - } - return "", fmt.Errorf("thread could not be resolved (id, name, or ref)") -} - -// resolveOptionalNodeID resolves an optional scene/node endpoint. A real node id -// placed in node_ref (or a declared scene ref) both resolve; absent both, it -// falls back to the current node. -func (s *Service) resolveOptionalNodeID(ctx context.Context, id, ref, currentNodeID string, refs map[string]string) (*string, error) { - provided := strings.TrimSpace(ref) - if provided == "" { - provided = strings.TrimSpace(id) - } - if provided != "" { - if resolved, ok := refs[provided]; ok { - return &resolved, nil - } - if _, err := s.nodes.Get(ctx, provided); err == nil { - p := provided - return &p, nil - } - return nil, fmt.Errorf("scene ref/id %q not found", provided) - } - if strings.TrimSpace(currentNodeID) != "" { - return ¤tNodeID, nil - } - return nil, nil -} - -func (s *Service) resolveRequiredNodeID(ctx context.Context, id, ref string, refs map[string]string) (string, error) { - resolved, err := s.resolveOptionalNodeID(ctx, id, ref, "", refs) - if err != nil { - return "", err - } - if resolved == nil || strings.TrimSpace(*resolved) == "" { - return "", fmt.Errorf("outline node id/ref required") - } - return *resolved, nil -} - -func (s *Service) resolveSceneAnchor(ctx context.Context, projectID, currentNodeID, afterNodeID, afterNodeRef string, refs map[string]string, fallbackAfterNodeID string) (string, error) { - provided := strings.TrimSpace(afterNodeRef) - if provided == "" { - provided = strings.TrimSpace(afterNodeID) - } - if provided != "" { - if resolved, ok := refs[provided]; ok { - return resolved, nil - } - if _, err := s.nodes.Get(ctx, provided); err == nil { - return provided, nil - } - return "", fmt.Errorf("scene anchor ref/id %q not found", provided) - } - if strings.TrimSpace(fallbackAfterNodeID) != "" { - return fallbackAfterNodeID, nil - } - if strings.TrimSpace(currentNodeID) != "" { - return currentNodeID, nil - } - proj, err := s.projects.Get(ctx, projectID) - if err != nil { - return "", err - } - if proj.LastOpenedNodeID == nil || strings.TrimSpace(*proj.LastOpenedNodeID) == "" { - return "", fmt.Errorf("create_scene: after_node_id required") - } - return *proj.LastOpenedNodeID, nil -} diff --git a/engine/internal/companion/tools_test.go b/engine/internal/companion/tools_test.go index da0988f0..7c154f48 100644 --- a/engine/internal/companion/tools_test.go +++ b/engine/internal/companion/tools_test.go @@ -18,6 +18,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/relationship" "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/store" + "github.com/devlikebear/linetta/engine/internal/storyops" "github.com/devlikebear/linetta/engine/internal/thread" ) @@ -52,6 +53,10 @@ func newToolSvc(t *testing.T) (*Service, string, string) { snaps: snapshot.NewRepo(st), src: toolConfigSource{}, } + svc.story = storyops.New(projects, nodes, threads, beats, entities, rels). + WithFacts(facts). + WithSnapshots(svc.snaps). + WithMemory(svc) p, err := projects.Create(ctx, 1_000, project.NewInput{ Title: "도구 테스트", Genres: []string{"mystery"}, LengthTarget: "short", DefaultPOV: "first", }) @@ -757,7 +762,7 @@ func TestApplyOpsCompletesWhenTheRunContextIsCancelledMidApply(t *testing.T) { }, }, func() int64 { return 1 }) - if result.Applied != 2 || result.isError() { + if result.Applied != 2 || result.IsError() { t.Fatalf("apply should run to completion: %+v", result) } } diff --git a/engine/internal/storyops/apply.go b/engine/internal/storyops/apply.go new file mode 100644 index 00000000..e7745c30 --- /dev/null +++ b/engine/internal/storyops/apply.go @@ -0,0 +1,839 @@ +package storyops + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/devlikebear/linetta/engine/internal/beat" + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/ptrutil" + "github.com/devlikebear/linetta/engine/internal/relationship" + "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/thread" +) + +// MemoryRecorder persists a remembered fact for a project. The companion's +// keyword memory implements it today; callers without memory (e.g. a +// standalone MCP applier) may leave it unset and the remember op fails with +// a clear message instead of a nil-pointer panic. +type MemoryRecorder interface { + Remember(projectID, text, category string) error +} + +// Service applies validated op batches to project state. All mutations run +// through the same repos the UI uses, so mention resync, manuscript +// reindexing, and word counts happen exactly as if the writer typed them. +type Service struct { + projects *project.Repo + nodes *node.Repo + threads *thread.Repo + beats *beat.Repo + entities *entity.Repo + relationships *relationship.Repo + facts *fact.Repo + snaps *snapshot.Repo + memory MemoryRecorder + + undo undoState +} + +// New wires the applier over the required repos. Facts, snapshots, and memory +// are optional; see the With* setters. +func New( + projects *project.Repo, nodes *node.Repo, threads *thread.Repo, + beats *beat.Repo, entities *entity.Repo, relationships *relationship.Repo, +) *Service { + return &Service{ + projects: projects, nodes: nodes, threads: threads, + beats: beats, entities: entities, relationships: relationships, + } +} + +// WithFacts enables create_fact_card. +func (s *Service) WithFacts(repo *fact.Repo) *Service { + s.facts = repo + return s +} + +// WithSnapshots enables the companion-before snapshot on set_scene_text. +func (s *Service) WithSnapshots(snaps *snapshot.Repo) *Service { + s.snaps = snaps + return s +} + +// WithMemory enables the remember op. +func (s *Service) WithMemory(m MemoryRecorder) *Service { + s.memory = m + return s +} + +type ApplyOpsResult struct { + Summary string `json:"summary,omitempty"` + Applied int `json:"applied"` + Created map[string]string `json:"created,omitempty"` + ChangedNodes []AppliedNodeChange `json:"changed_nodes,omitempty"` + Failures []ApplyOpsFailure `json:"failures,omitempty"` + // PendingApproval means the batch was large enough to show the writer first, + // so nothing was applied and the ops are waiting in a preview. + PendingApproval bool `json:"pending_approval,omitempty"` + // RolledBack means an op failed partway and the outline was put back. + RolledBack bool `json:"rolled_back,omitempty"` + // UndoBatchID identifies the pre-change outline kept for a one-step undo. + UndoBatchID string `json:"undo_batch_id,omitempty"` +} + +type ApplyOpsFailure struct { + Index int `json:"index"` + Op string `json:"op,omitempty"` + Error string `json:"error"` +} + +type AppliedNodeChange struct { + NodeID string `json:"node_id"` + Op string `json:"op"` + ContentVersion int `json:"content_version"` + CharCount int `json:"char_count"` + TextPreview string `json:"text_preview,omitempty"` +} + +// IsError reports whether any op in the batch failed. +func (r ApplyOpsResult) IsError() bool { + return len(r.Failures) > 0 +} + +// ApplyOps applies a validated proposal op list directly to project state. +func (s *Service) ApplyOps(ctx context.Context, projectID, nodeID string, p Proposal, now func() int64) ApplyOpsResult { + result := ApplyOpsResult{ + Summary: strings.TrimSpace(p.Summary), + Created: map[string]string{}, + } + if err := ValidateProposal(p); err != nil { + result.Failures = append(result.Failures, ApplyOpsFailure{Index: -1, Error: err.Error()}) + return result + } + + // Structural batches are all-or-nothing: the outline is captured first so a + // failure halfway through can be put back, and a clean run leaves the writer + // one undo away from where they started. + structural := CountOutlineChanges(p).Structural() > 0 + var before []node.Node + if structural { + before = s.snapshotOutline(ctx, projectID) + } + + threadRefs := map[string]string{} + entityRefs := map[string]string{} + nodeRefs := map[string]string{} + nodeInsertCursor := "" + + for i, op := range p.Ops { + if err := s.applyOneOp(ctx, projectID, nodeID, op, now, threadRefs, entityRefs, nodeRefs, result.Created, &result.ChangedNodes, &nodeInsertCursor); err != nil { + result.Failures = append(result.Failures, ApplyOpsFailure{Index: i, Op: op.Type, Error: err.Error()}) + continue + } + result.Applied++ + } + if len(result.Created) == 0 { + result.Created = nil + } + if len(result.ChangedNodes) == 0 { + result.ChangedNodes = nil + } + if structural && len(before) > 0 { + if result.IsError() { + // Half a restructured outline is worse than none, so put the tree back + // and report the failure against an unchanged project. + if err := s.nodes.RestoreOutline(ctx, projectID, before, now()); err == nil { + result.RolledBack = true + result.Applied = 0 + result.Created = nil + result.ChangedNodes = nil + } + } else if result.Applied > 0 { + result.UndoBatchID = s.rememberUndoBatch(projectID, before) + } + } + return result +} + +func (s *Service) applyOneOp( + ctx context.Context, + projectID string, + currentNodeID string, + op Op, + now func() int64, + threadRefs map[string]string, + entityRefs map[string]string, + nodeRefs map[string]string, + created map[string]string, + changedNodes *[]AppliedNodeChange, + nodeInsertCursor *string, +) error { + switch op.Type { + case "set_outline": + outline := op.Outline + _, err := s.projects.Update(ctx, now(), project.UpdateInput{ID: projectID, Outline: &outline}) + return err + case "set_scene_text": + targetNodeID, err := s.resolveOptionalNodeID(ctx, op.NodeID, op.NodeRef, currentNodeID, nodeRefs) + if err != nil { + return err + } + if targetNodeID == nil || strings.TrimSpace(*targetNodeID) == "" { + return fmt.Errorf("set_scene_text requires a current node or node_id") + } + before, err := s.nodes.Get(ctx, *targetNodeID) + if err != nil { + return err + } + if s.snaps != nil { + beforeDoc := "" + if before.ContentDoc != nil { + beforeDoc = *before.ContentDoc + } + if _, _, err := s.snaps.CreateIfChanged(ctx, *targetNodeID, beforeDoc, snapshot.ReasonCompanionBefore, now()); err != nil { + return fmt.Errorf("companion-before snapshot: %w", err) + } + } + doc, err := PlainTextToTiptapDoc(op.Text) + if err != nil { + return err + } + if err := s.nodes.UpdateContent(ctx, *targetNodeID, doc, now()); err != nil { + return err + } + after, err := s.nodes.Get(ctx, *targetNodeID) + if err != nil { + return fmt.Errorf("verify set_scene_text: %w", err) + } + gotText := normalizeSceneTextForVerify(plainTextFromDoc(after.ContentDoc)) + wantText := normalizeSceneTextForVerify(op.Text) + if gotText != wantText { + return fmt.Errorf("verify set_scene_text: readback text mismatch") + } + if !op.AllowEmpty && strings.TrimSpace(gotText) == "" { + return fmt.Errorf("verify set_scene_text: readback text is empty") + } + if after.ContentVersion <= before.ContentVersion { + return fmt.Errorf("verify set_scene_text: content_version did not advance") + } + *changedNodes = append(*changedNodes, AppliedNodeChange{ + NodeID: after.ID, + Op: "set_scene_text", + ContentVersion: after.ContentVersion, + CharCount: after.WordCount, + TextPreview: trimRunes(strings.TrimSpace(plainTextFromDoc(after.ContentDoc)), 120), + }) + return nil + case "create_thread": + th, err := s.threads.Create(ctx, thread.NewInput{ProjectID: projectID, Name: op.Name, Color: op.Color}) + if err != nil { + return err + } + if strings.TrimSpace(op.Summary) != "" { + if err := s.threads.Update(ctx, thread.UpdateInput{ID: th.ID, Summary: ptrutil.To(op.Summary)}); err != nil { + return err + } + } + if op.Ref != "" { + threadRefs[op.Ref] = th.ID + created["thread:"+op.Ref] = th.ID + } + return nil + case "update_thread": + in := thread.UpdateInput{ID: op.ThreadID} + if strings.TrimSpace(op.Name) != "" { + in.Name = ptrutil.To(op.Name) + } + if strings.TrimSpace(op.Color) != "" { + in.Color = ptrutil.To(op.Color) + } + if strings.TrimSpace(op.Summary) != "" { + in.Summary = ptrutil.To(op.Summary) + } + return s.threads.Update(ctx, in) + case "add_beat": + threadID, err := s.resolveThreadID(ctx, projectID, op.ThreadID, op.ThreadRef, threadRefs) + if err != nil { + return err + } + beatNodeID, err := s.resolveOptionalNodeID(ctx, op.NodeID, op.NodeRef, currentNodeID, nodeRefs) + if err != nil { + return err + } + _, err = s.beats.Create(ctx, beat.NewInput{ + ThreadID: threadID, + NodeID: beatNodeID, + Label: op.Label, + Description: op.Description, + Intensity: op.Intensity, + }) + return err + case "update_beat": + in := beat.UpdateInput{ID: op.BeatID, Label: op.Label, Intensity: op.Intensity} + if strings.TrimSpace(op.Description) != "" { + in.Description = &op.Description + } + return s.beats.Update(ctx, in) + case "delete_beat": + return s.beats.Delete(ctx, op.BeatID) + case "remember": + if s.memory == nil { + return fmt.Errorf("memory is not available") + } + return s.memory.Remember(projectID, op.Text, op.Category) + case "create_fact_card": + if s.facts == nil { + return fmt.Errorf("fact book is not available") + } + cardNodeID, err := s.resolveOptionalNodeID(ctx, op.NodeID, op.NodeRef, currentNodeID, nodeRefs) + if err != nil { + return err + } + card, err := s.facts.Create(ctx, now(), fact.NewInput{ + ProjectID: projectID, + NodeID: cardNodeID, + Claim: op.Claim, + Result: op.Result, + Status: op.Status, + Category: op.Category, + Sources: op.Sources, + }) + if err != nil { + return err + } + if op.Ref != "" { + created["fact:"+op.Ref] = card.ID + } + return nil + case "create_entity": + ent, err := s.entities.Create(ctx, now(), entity.NewInput{ + ProjectID: projectID, + Kind: op.Kind, + Name: op.Name, + Role: op.Role, + }) + if err != nil { + return err + } + attrs := cleanEntityAttributes(op.Attributes) + if strings.TrimSpace(op.Summary) != "" || len(attrs) > 0 { + in := entity.UpdateInput{ID: ent.ID, Attributes: optionalEntityAttributes(attrs)} + if strings.TrimSpace(op.Summary) != "" { + in.Summary = ptrutil.To(op.Summary) + } + if err := s.entities.Update(ctx, now(), in); err != nil { + return err + } + } + if op.Ref != "" { + entityRefs[op.Ref] = ent.ID + created["entity:"+op.Ref] = ent.ID + } + return nil + case "update_entity": + entityID, err := s.resolveEntityID(ctx, projectID, op.EntityID, "", entityRefs, "entity") + if err != nil { + return err + } + cur, err := s.entities.Get(ctx, entityID) + if err != nil { + return err + } + in := entity.UpdateInput{ID: entityID} + if strings.TrimSpace(op.Kind) != "" { + in.Kind = ptrutil.To(op.Kind) + } + if strings.TrimSpace(op.Name) != "" { + in.Name = ptrutil.To(op.Name) + } + if strings.TrimSpace(op.Role) != "" { + in.Role = ptrutil.To(op.Role) + } + if strings.TrimSpace(op.Summary) != "" { + in.Summary = ptrutil.To(op.Summary) + } + if attrs := cleanEntityAttributes(op.Attributes); len(attrs) > 0 { + merged := mergeEntityAttributes(cur.Attributes, attrs) + in.Attributes = &merged + } + return s.entities.Update(ctx, now(), in) + case "create_relationship": + fromID, err := s.resolveEntityID(ctx, projectID, op.From, op.FromRef, entityRefs, "from entity") + if err != nil { + return err + } + toID, err := s.resolveEntityID(ctx, projectID, op.To, op.ToRef, entityRefs, "to entity") + if err != nil { + return err + } + if strings.TrimSpace(op.InverseLabel) != "" { + _, err := s.relationships.CreatePair(ctx, relationship.NewPairInput{ + ProjectID: projectID, + FromID: fromID, + ToID: toID, + Label: op.Label, + InverseLabel: op.InverseLabel, + Notes: op.Notes, + }) + return err + } + _, err = s.relationships.CreateOne(ctx, relationship.NewInput{ + ProjectID: projectID, + FromID: fromID, + ToID: toID, + Label: op.Label, + Notes: op.Notes, + }) + return err + case "create_scene": + afterNodeID, err := s.resolveSceneAnchor(ctx, projectID, currentNodeID, op.AfterNodeID, op.AfterNodeRef, nodeRefs, *nodeInsertCursor) + if err != nil { + return err + } + n, err := s.nodes.CreateSibling(ctx, afterNodeID, node.KindLeaf, op.Label, op.Title, now()) + if err != nil { + return err + } + if op.Ref != "" { + nodeRefs[op.Ref] = n.ID + created["node:"+op.Ref] = n.ID + } + *nodeInsertCursor = n.ID + return nil + case "create_outline_node": + return s.applyCreateOutlineNode(ctx, projectID, currentNodeID, op, now, nodeRefs, created, nodeInsertCursor) + case "rename_outline_node": + nodeID, err := s.resolveRequiredNodeID(ctx, op.NodeID, op.NodeRef, nodeRefs) + if err != nil { + return err + } + cur, err := s.nodes.Get(ctx, nodeID) + if err != nil { + return err + } + label := cur.Label + if strings.TrimSpace(op.Label) != "" { + label = op.Label + } + title := cur.Title + if strings.TrimSpace(op.Title) != "" { + title = op.Title + } + return s.nodes.Rename(ctx, nodeID, label, title, now()) + case "delete_outline_node": + nodeID, err := s.resolveRequiredNodeID(ctx, op.NodeID, op.NodeRef, nodeRefs) + if err != nil { + return err + } + return s.nodes.Delete(ctx, nodeID, now()) + case "move_outline_node": + nodeID, err := s.resolveRequiredNodeID(ctx, op.NodeID, op.NodeRef, nodeRefs) + if err != nil { + return err + } + if strings.ToLower(strings.TrimSpace(op.Direction)) == "up" { + return s.nodes.MoveUp(ctx, nodeID, now()) + } + return s.nodes.MoveDown(ctx, nodeID, now()) + default: + return fmt.Errorf("unknown op %q", op.Type) + } +} + +func (s *Service) applyCreateOutlineNode( + ctx context.Context, + projectID string, + currentNodeID string, + op Op, + now func() int64, + nodeRefs map[string]string, + created map[string]string, + nodeInsertCursor *string, +) error { + kind := strings.TrimSpace(op.Kind) + if kind == "" { + kind = node.KindLeaf + } + if !node.ValidKind(kind) { + return fmt.Errorf("create_outline_node: kind must be container|leaf") + } + parentID, err := s.resolveOptionalNodeID(ctx, op.ParentNodeID, op.ParentNodeRef, "", nodeRefs) + if err != nil { + return err + } + var n node.Node + if parentID != nil { + parent, err := s.nodes.Get(ctx, *parentID) + if err != nil { + return err + } + if parent.Kind != node.KindContainer { + return fmt.Errorf("create_outline_node: parent must be a container node") + } + if existing, ok, err := s.findMatchingOutlineNode(ctx, projectID, parentID, kind, op.Label); err != nil { + return err + } else if ok { + n = existing + } else { + n, err = s.nodes.CreateChild(ctx, *parentID, kind, op.Label, op.Title, now()) + if err != nil { + return err + } + } + } else { + hasAfter := strings.TrimSpace(op.AfterNodeID) != "" || strings.TrimSpace(op.AfterNodeRef) != "" || strings.TrimSpace(*nodeInsertCursor) != "" + if hasAfter { + afterNodeID, err := s.resolveSceneAnchor(ctx, projectID, currentNodeID, op.AfterNodeID, op.AfterNodeRef, nodeRefs, *nodeInsertCursor) + if err != nil { + return err + } + after, err := s.nodes.Get(ctx, afterNodeID) + if err != nil { + return err + } + if existing, ok, err := s.findMatchingOutlineNode(ctx, projectID, after.ParentID, kind, op.Label); err != nil { + return err + } else if ok { + n = existing + } else { + n, err = s.nodes.CreateSibling(ctx, afterNodeID, kind, op.Label, op.Title, now()) + if err != nil { + return err + } + } + } else { + if existing, ok, err := s.findMatchingOutlineNode(ctx, projectID, nil, kind, op.Label); err != nil { + return err + } else if ok { + n = existing + } else { + n, err = s.nodes.CreateRoot(ctx, projectID, kind, op.Label, op.Title, now()) + if err != nil { + return err + } + } + } + } + if strings.TrimSpace(op.Title) != "" && strings.TrimSpace(n.Title) == "" { + if err := s.nodes.Rename(ctx, n.ID, n.Label, op.Title, now()); err != nil { + return err + } + n, err = s.nodes.Get(ctx, n.ID) + if err != nil { + return err + } + } + if op.Ref != "" { + nodeRefs[op.Ref] = n.ID + created["node:"+op.Ref] = n.ID + } + if parentID == nil { + *nodeInsertCursor = n.ID + } + return nil +} + +func (s *Service) findMatchingOutlineNode(ctx context.Context, projectID string, parentID *string, kind, label string) (node.Node, bool, error) { + label = strings.TrimSpace(label) + if label == "" { + return node.Node{}, false, nil + } + all, err := s.nodes.ListByProject(ctx, projectID) + if err != nil { + return node.Node{}, false, err + } + for _, n := range all { + if n.Kind != kind || strings.TrimSpace(n.Label) != label { + continue + } + if parentID == nil { + if n.ParentID == nil { + return n, true, nil + } + continue + } + if n.ParentID != nil && *n.ParentID == *parentID { + return n, true, nil + } + } + return node.Node{}, false, nil +} + +// resolveEntityID resolves a create_relationship endpoint to a real entity id. +// It tolerates the model's common mistakes: an entity may be referenced by a +// proposal ref (in from_ref or mistakenly in from), by a real entity id, or by +// name (case-insensitive). Returns a clear error instead of letting an +// unresolved value hit a FOREIGN KEY constraint at insert time. +func (s *Service) resolveEntityID(ctx context.Context, projectID, id, ref string, refs map[string]string, label string) (string, error) { + // ref and id are treated interchangeably: the model conflates them (a real + // entity id or name often lands in from_ref, and vice versa). Each is tried + // as a declared proposal ref, then a real entity id, then a name. + for _, candidate := range []string{strings.TrimSpace(ref), strings.TrimSpace(id)} { + if candidate == "" { + continue + } + if resolved, ok := refs[candidate]; ok { + return resolved, nil + } + if _, err := s.entities.Get(ctx, candidate); err == nil { + return candidate, nil + } + if matches, err := s.entities.Search(ctx, projectID, candidate, 20); err == nil { + for _, e := range matches { + if strings.EqualFold(strings.TrimSpace(e.Name), candidate) { + return e.ID, nil + } + } + } + } + return "", fmt.Errorf("%s could not be resolved to an entity (id, name, or ref)", label) +} + +// resolveThreadID resolves an add_beat thread endpoint, tolerating a real thread +// id (or name) placed in thread_ref and vice versa. +func (s *Service) resolveThreadID(ctx context.Context, projectID, id, ref string, refs map[string]string) (string, error) { + for _, candidate := range []string{strings.TrimSpace(ref), strings.TrimSpace(id)} { + if candidate == "" { + continue + } + if resolved, ok := refs[candidate]; ok { + return resolved, nil + } + if _, err := s.threads.Get(ctx, candidate); err == nil { + return candidate, nil + } + if list, err := s.threads.ListByProject(ctx, projectID, true); err == nil { + for _, th := range list { + if strings.EqualFold(strings.TrimSpace(th.Name), candidate) { + return th.ID, nil + } + } + } + } + return "", fmt.Errorf("thread could not be resolved (id, name, or ref)") +} + +// resolveOptionalNodeID resolves an optional scene/node endpoint. A real node id +// placed in node_ref (or a declared scene ref) both resolve; absent both, it +// falls back to the current node. +func (s *Service) resolveOptionalNodeID(ctx context.Context, id, ref, currentNodeID string, refs map[string]string) (*string, error) { + provided := strings.TrimSpace(ref) + if provided == "" { + provided = strings.TrimSpace(id) + } + if provided != "" { + if resolved, ok := refs[provided]; ok { + return &resolved, nil + } + if _, err := s.nodes.Get(ctx, provided); err == nil { + p := provided + return &p, nil + } + return nil, fmt.Errorf("scene ref/id %q not found", provided) + } + if strings.TrimSpace(currentNodeID) != "" { + return ¤tNodeID, nil + } + return nil, nil +} + +func (s *Service) resolveRequiredNodeID(ctx context.Context, id, ref string, refs map[string]string) (string, error) { + resolved, err := s.resolveOptionalNodeID(ctx, id, ref, "", refs) + if err != nil { + return "", err + } + if resolved == nil || strings.TrimSpace(*resolved) == "" { + return "", fmt.Errorf("outline node id/ref required") + } + return *resolved, nil +} + +func (s *Service) resolveSceneAnchor(ctx context.Context, projectID, currentNodeID, afterNodeID, afterNodeRef string, refs map[string]string, fallbackAfterNodeID string) (string, error) { + provided := strings.TrimSpace(afterNodeRef) + if provided == "" { + provided = strings.TrimSpace(afterNodeID) + } + if provided != "" { + if resolved, ok := refs[provided]; ok { + return resolved, nil + } + if _, err := s.nodes.Get(ctx, provided); err == nil { + return provided, nil + } + return "", fmt.Errorf("scene anchor ref/id %q not found", provided) + } + if strings.TrimSpace(fallbackAfterNodeID) != "" { + return fallbackAfterNodeID, nil + } + if strings.TrimSpace(currentNodeID) != "" { + return currentNodeID, nil + } + proj, err := s.projects.Get(ctx, projectID) + if err != nil { + return "", err + } + if proj.LastOpenedNodeID == nil || strings.TrimSpace(*proj.LastOpenedNodeID) == "" { + return "", fmt.Errorf("create_scene: after_node_id required") + } + return *proj.LastOpenedNodeID, nil +} + +func cleanEntityAttributes(attrs map[string]string) map[string]string { + if len(attrs) == 0 { + return nil + } + out := map[string]string{} + for key, value := range attrs { + key = strings.TrimSpace(key) + if key == "" { + continue + } + out[key] = strings.TrimSpace(value) + } + return out +} + +func optionalEntityAttributes(attrs map[string]string) *map[string]string { + if len(attrs) == 0 { + return nil + } + return &attrs +} + +func mergeEntityAttributes(base, next map[string]string) map[string]string { + merged := map[string]string{} + for key, value := range base { + merged[key] = value + } + for key, value := range next { + merged[key] = value + } + return merged +} + +type tiptapDoc struct { + Type string `json:"type"` + Content []tiptapBlock `json:"content"` +} + +type tiptapBlock struct { + Type string `json:"type"` + Content []tiptapInline `json:"content,omitempty"` +} + +type tiptapInline struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +var paragraphBreakRE = regexp.MustCompile(`\n{2,}`) + +func normalizeSceneTextForVerify(text string) string { + normalized := strings.ReplaceAll(text, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + normalized = paragraphBreakRE.ReplaceAllString(normalized, "\n\n") + lines := strings.Split(normalized, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +// PlainTextToTiptapDoc converts plain scene text (paragraphs separated by +// blank lines, hard breaks inside paragraphs) into the Tiptap document JSON +// the editor stores. +func PlainTextToTiptapDoc(text string) (string, error) { + normalized := strings.ReplaceAll(text, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + blocks := paragraphBreakRE.Split(normalized, -1) + paragraphs := make([]tiptapBlock, 0, len(blocks)) + for _, block := range blocks { + lines := strings.Split(block, "\n") + content := make([]tiptapInline, 0, len(lines)*2) + for i, line := range lines { + if i > 0 { + content = append(content, tiptapInline{Type: "hardBreak"}) + } + if line != "" { + content = append(content, tiptapInline{Type: "text", Text: line}) + } + } + paragraph := tiptapBlock{Type: "paragraph"} + if len(content) > 0 { + paragraph.Content = content + } + paragraphs = append(paragraphs, paragraph) + } + if len(paragraphs) == 0 { + paragraphs = append(paragraphs, tiptapBlock{Type: "paragraph"}) + } + raw, err := json.Marshal(tiptapDoc{Type: "doc", Content: paragraphs}) + if err != nil { + return "", err + } + return string(raw), nil +} + +// plainTextFromDoc and trimRunes are duplicated from companion/query.go; the +// companion copies die with that package in the pivot's removal phase. +func plainTextFromDoc(raw *string) string { + if raw == nil || *raw == "" { + return "" + } + var v interface{} + if err := json.Unmarshal([]byte(*raw), &v); err != nil { + return "" + } + var sb strings.Builder + var walk func(x interface{}) + walk = func(x interface{}) { + switch t := x.(type) { + case map[string]interface{}: + if t["type"] == "mention" { + if attrs, ok := t["attrs"].(map[string]interface{}); ok { + if label, ok := attrs["label"].(string); ok { + sb.WriteString(label) + } + } + return + } + if t["type"] == "text" { + if s, ok := t["text"].(string); ok { + sb.WriteString(s) + } + } + if t["type"] == "hardBreak" { + sb.WriteString("\n") + } + if c, ok := t["content"].([]interface{}); ok { + for _, ch := range c { + walk(ch) + } + } + if k, _ := t["type"].(string); k == "paragraph" || k == "heading" { + sb.WriteString("\n\n") + } + case []interface{}: + for _, ch := range t { + walk(ch) + } + } + } + walk(v) + return strings.TrimSpace(sb.String()) +} + +func trimRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} diff --git a/engine/internal/storyops/apply_test.go b/engine/internal/storyops/apply_test.go new file mode 100644 index 00000000..a7448cb1 --- /dev/null +++ b/engine/internal/storyops/apply_test.go @@ -0,0 +1,175 @@ +package storyops + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/beat" + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/relationship" + "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/store" + "github.com/devlikebear/linetta/engine/internal/thread" +) + +func newTestService(t *testing.T) (*Service, *node.Repo, *snapshot.Repo, string, string) { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + projects := project.NewRepo(st) + nodes := node.NewRepo(st) + snaps := snapshot.NewRepo(st) + svc := New(projects, nodes, thread.NewRepo(st), beat.NewRepo(st), + entity.NewRepo(st), relationship.NewRepo(st)). + WithFacts(fact.NewRepo(st)). + WithSnapshots(snaps) + + p, err := projects.Create(ctx, 1_000, project.NewInput{ + Title: "적용기 테스트", Genres: []string{"fantasy"}, LengthTarget: "short", DefaultPOV: "first", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + return svc, nodes, snaps, p.ID, *p.LastOpenedNodeID +} + +func now() func() int64 { + var t int64 = 10_000 + return func() int64 { t++; return t } +} + +// A structural batch applies atomically and leaves the writer one undo away +// from the pre-change outline. +func TestApplyOpsStructuralBatchAndUndo(t *testing.T) { + svc, nodes, _, projectID, _ := newTestService(t) + ctx := context.Background() + clock := now() + + before, err := nodes.ListByProject(ctx, projectID) + if err != nil { + t.Fatalf("list before: %v", err) + } + + res := svc.ApplyOps(ctx, projectID, "", Proposal{ + Summary: "1부 뼈대", + Ops: []Op{ + {Type: "create_outline_node", Ref: "p1", Kind: "container", Label: "1부"}, + {Type: "create_outline_node", Ref: "s1", Kind: "leaf", ParentNodeRef: "p1", Label: "씬 1"}, + }, + }, clock) + if res.IsError() { + t.Fatalf("failures = %+v", res.Failures) + } + if res.Applied != 2 || res.UndoBatchID == "" { + t.Fatalf("applied = %d, undo = %q; want 2 applied with an undo id", res.Applied, res.UndoBatchID) + } + after, _ := nodes.ListByProject(ctx, projectID) + if len(after) != len(before)+2 { + t.Fatalf("node count = %d, want %d", len(after), len(before)+2) + } + + if err := svc.UndoApply(ctx, res.UndoBatchID, clock); err != nil { + t.Fatalf("UndoApply: %v", err) + } + restored, _ := nodes.ListByProject(ctx, projectID) + if len(restored) != len(before) { + t.Fatalf("after undo node count = %d, want %d", len(restored), len(before)) + } + if err := svc.UndoApply(ctx, res.UndoBatchID, clock); err != ErrUndoBatchNotFound { + t.Fatalf("second undo = %v, want ErrUndoBatchNotFound", err) + } +} + +// A structural batch with a failing op is rolled back wholesale: half a +// restructured outline is worse than none. +func TestApplyOpsRollsBackFailedStructuralBatch(t *testing.T) { + svc, nodes, _, projectID, _ := newTestService(t) + ctx := context.Background() + + before, _ := nodes.ListByProject(ctx, projectID) + res := svc.ApplyOps(ctx, projectID, "", Proposal{ + Ops: []Op{ + {Type: "create_outline_node", Kind: "container", Label: "1부"}, + {Type: "delete_outline_node", NodeID: "no-such-node"}, + }, + }, now()) + if !res.IsError() || !res.RolledBack { + t.Fatalf("result = %+v, want failure with rollback", res) + } + if res.Applied != 0 || res.UndoBatchID != "" { + t.Fatalf("rolled-back result must report nothing applied: %+v", res) + } + after, _ := nodes.ListByProject(ctx, projectID) + if len(after) != len(before) { + t.Fatalf("outline changed despite rollback: %d -> %d nodes", len(before), len(after)) + } +} + +// set_scene_text records a companion-before snapshot when snapshots are wired, +// so every applied body change stays one restore away. +func TestApplyOpsSetSceneTextSnapshots(t *testing.T) { + svc, nodes, snaps, projectID, sceneID := newTestService(t) + ctx := context.Background() + clock := now() + + seed, err := PlainTextToTiptapDoc("원래 본문") + if err != nil { + t.Fatalf("seed doc: %v", err) + } + if err := nodes.UpdateContent(ctx, sceneID, seed, clock()); err != nil { + t.Fatalf("seed content: %v", err) + } + + res := svc.ApplyOps(ctx, projectID, sceneID, Proposal{ + Ops: []Op{{Type: "set_scene_text", Text: "고쳐 쓴 본문"}}, + }, clock) + if res.IsError() { + t.Fatalf("failures = %+v", res.Failures) + } + if len(res.ChangedNodes) != 1 || res.ChangedNodes[0].NodeID != sceneID { + t.Fatalf("changed nodes = %+v", res.ChangedNodes) + } + + got, _ := nodes.Get(ctx, sceneID) + if !strings.Contains(*got.ContentDoc, "고쳐 쓴 본문") { + t.Fatalf("scene body not replaced: %s", *got.ContentDoc) + } + list, err := snaps.ListForNode(ctx, sceneID) + if err != nil { + t.Fatalf("list snapshots: %v", err) + } + found := false + for _, sn := range list { + if sn.Reason == snapshot.ReasonCompanionBefore { + found = true + } + } + if !found { + t.Fatalf("no companion-before snapshot recorded; got %+v", list) + } +} + +// Without a memory recorder the remember op must fail with a clear message, +// not a nil-pointer panic — the MCP applier is built without companion memory. +func TestApplyOpsRememberWithoutMemory(t *testing.T) { + svc, _, _, projectID, _ := newTestService(t) + res := svc.ApplyOps(context.Background(), projectID, "", Proposal{ + Ops: []Op{{Type: "remember", Text: "작가는 건조한 문체를 선호한다"}}, + }, now()) + if !res.IsError() { + t.Fatal("remember without memory should fail") + } + if !strings.Contains(res.Failures[0].Error, "memory is not available") { + t.Fatalf("failure = %+v, want a clear memory-unavailable message", res.Failures[0]) + } +} diff --git a/engine/internal/storyops/ops.go b/engine/internal/storyops/ops.go new file mode 100644 index 00000000..06a04fd1 --- /dev/null +++ b/engine/internal/storyops/ops.go @@ -0,0 +1,310 @@ +// Package storyops owns the structured story-mutation vocabulary and its +// applier: validated op batches over outline nodes, scenes, threads, beats, +// entities, relationships, facts, and memories, with all-or-nothing rollback +// and a one-step undo. It performs no LLM calls and must not import LLM +// client or agent-loop code. +// +// Extracted from internal/companion as part of the MCP-first pivot (#47): +// the companion delegates here today, and the MCP write tools build on the +// same applier so every external mutation shares one snapshot/undo path. +package storyops + +import ( + "fmt" + "strings" + + "github.com/devlikebear/linetta/engine/internal/fact" +) + +// Op is one proposed plot-core mutation. Only fields relevant to Type are set. +type Op struct { + Type string `json:"op"` + + // create_thread + Ref string `json:"ref,omitempty"` + Name string `json:"name,omitempty"` + Color string `json:"color,omitempty"` + Summary string `json:"summary,omitempty"` + + // update_thread / add_beat target + ThreadID string `json:"thread_id,omitempty"` + ThreadRef string `json:"thread_ref,omitempty"` + + // add_beat / update_beat + NodeID string `json:"node_id,omitempty"` + BeatID string `json:"beat_id,omitempty"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + Intensity int `json:"intensity,omitempty"` + + // set_outline + Outline string `json:"outline,omitempty"` + + // remember + Text string `json:"text,omitempty"` + AllowEmpty bool `json:"allow_empty,omitempty"` + Category string `json:"category,omitempty"` + + // create_entity / update_entity + Kind string `json:"kind,omitempty"` + Role string `json:"role,omitempty"` + EntityID string `json:"entity_id,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + + // create_scene + AfterNodeID string `json:"after_node_id,omitempty"` + AfterNodeRef string `json:"after_node_ref,omitempty"` + Title string `json:"title,omitempty"` + NodeRef string `json:"node_ref,omitempty"` + ParentNodeID string `json:"parent_node_id,omitempty"` + ParentNodeRef string `json:"parent_node_ref,omitempty"` + Direction string `json:"direction,omitempty"` + + // create_relationship + From string `json:"from,omitempty"` + FromRef string `json:"from_ref,omitempty"` + To string `json:"to,omitempty"` + ToRef string `json:"to_ref,omitempty"` + Notes string `json:"notes,omitempty"` + InverseLabel string `json:"inverse_label,omitempty"` + + // create_fact_card + Claim string `json:"claim,omitempty"` + Result string `json:"result,omitempty"` + Status string `json:"status,omitempty"` + Sources []fact.SourceInput `json:"sources,omitempty"` +} + +// Proposal is a validated batch of ops with a human-readable summary. +type Proposal struct { + Summary string `json:"summary"` + Ops []Op `json:"ops"` +} + +// knownOps lists the accepted op types. +var knownOps = map[string]bool{ + "create_thread": true, "update_thread": true, + "add_beat": true, "update_beat": true, "delete_beat": true, + "set_outline": true, + "set_scene_text": true, + "remember": true, + "create_entity": true, "update_entity": true, "create_relationship": true, + "create_scene": true, + "create_outline_node": true, + "rename_outline_node": true, + "delete_outline_node": true, + "move_outline_node": true, + "create_fact_card": true, +} + +// normalizeEntityKind maps a raw create_entity kind to one of the canonical +// values (character|place|item|concept). It is lenient because the model does +// not always emit the exact token: an empty kind defaults to "character" (the +// dominant entity type), and common English/Korean synonyms are accepted. +// Returns (canonical, true) on success, or ("", false) for an unknown value. +func normalizeEntityKind(raw string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "character", "char", "person", "people", "인물", "캐릭터", "등장인물": + return "character", true + case "place", "location", "장소", "공간", "위치": + return "place", true + case "item", "object", "thing", "사물", "아이템", "물건": + return "item", true + case "concept", "idea", "theme", "skill", "magic", "ability", + "spell", "rule", "system", "개념", "주제", "스킬", "마법", "능력", + "주문", "규칙", "세계관": + return "concept", true + default: + return "", false + } +} + +func normalizeOutlineNodeKind(raw string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "leaf", "scene", "씬", "장면": + return "leaf", true + case "container", "chapter", "part", "장", "챕터", "부", "파트", "막": + return "container", true + default: + return "", false + } +} + +// ValidateProposal checks every op for required fields and normalizes lenient +// values (entity kinds, node kinds, move directions, fact statuses) in place. +func ValidateProposal(p Proposal) error { + if len(p.Ops) == 0 { + return fmt.Errorf("proposal has no ops") + } + for i, op := range p.Ops { + if !knownOps[op.Type] { + return fmt.Errorf("op[%d]: unknown op %q", i, op.Type) + } + switch op.Type { + case "create_thread": + if strings.TrimSpace(op.Name) == "" { + return fmt.Errorf("op[%d] create_thread: name required", i) + } + case "update_thread": + if op.ThreadID == "" { + return fmt.Errorf("op[%d] update_thread: thread_id required", i) + } + case "add_beat": + hasID := op.ThreadID != "" + hasRef := op.ThreadRef != "" + if hasID == hasRef { + return fmt.Errorf("op[%d] add_beat: exactly one of thread_id/thread_ref required", i) + } + // An undeclared thread_ref is allowed here: the model often places a + // real thread id in thread_ref. Resolution (declared ref → real id → + // name) and any clear error happen at apply time. + if strings.TrimSpace(op.Label) == "" { + return fmt.Errorf("op[%d] add_beat: label required", i) + } + if op.NodeID != "" && op.NodeRef != "" { + return fmt.Errorf("op[%d] add_beat: node_id and node_ref are mutually exclusive", i) + } + case "create_scene": + if strings.TrimSpace(op.Label) == "" { + return fmt.Errorf("op[%d] create_scene: label required", i) + } + if op.AfterNodeID != "" && op.AfterNodeRef != "" { + return fmt.Errorf("op[%d] create_scene: after_node_id and after_node_ref are mutually exclusive", i) + } + case "create_outline_node": + if strings.TrimSpace(op.Label) == "" { + return fmt.Errorf("op[%d] create_outline_node: label required", i) + } + kind, ok := normalizeOutlineNodeKind(op.Kind) + if !ok { + return fmt.Errorf("op[%d] create_outline_node: kind must be container|leaf", i) + } + p.Ops[i].Kind = kind + if op.ParentNodeID != "" && op.ParentNodeRef != "" { + return fmt.Errorf("op[%d] create_outline_node: parent_node_id and parent_node_ref are mutually exclusive", i) + } + if op.AfterNodeID != "" && op.AfterNodeRef != "" { + return fmt.Errorf("op[%d] create_outline_node: after_node_id and after_node_ref are mutually exclusive", i) + } + hasParent := op.ParentNodeID != "" || op.ParentNodeRef != "" + hasAfter := op.AfterNodeID != "" || op.AfterNodeRef != "" + if hasParent && hasAfter { + return fmt.Errorf("op[%d] create_outline_node: parent_node_* and after_node_* are mutually exclusive", i) + } + case "rename_outline_node": + if op.NodeID != "" && op.NodeRef != "" { + return fmt.Errorf("op[%d] rename_outline_node: node_id and node_ref are mutually exclusive", i) + } + if op.NodeID == "" && op.NodeRef == "" { + return fmt.Errorf("op[%d] rename_outline_node: node_id or node_ref required", i) + } + if strings.TrimSpace(op.Label) == "" && strings.TrimSpace(op.Title) == "" { + return fmt.Errorf("op[%d] rename_outline_node: label or title required", i) + } + case "delete_outline_node": + if op.NodeID != "" && op.NodeRef != "" { + return fmt.Errorf("op[%d] delete_outline_node: node_id and node_ref are mutually exclusive", i) + } + if op.NodeID == "" && op.NodeRef == "" { + return fmt.Errorf("op[%d] delete_outline_node: node_id or node_ref required", i) + } + case "move_outline_node": + if op.NodeID != "" && op.NodeRef != "" { + return fmt.Errorf("op[%d] move_outline_node: node_id and node_ref are mutually exclusive", i) + } + if op.NodeID == "" && op.NodeRef == "" { + return fmt.Errorf("op[%d] move_outline_node: node_id or node_ref required", i) + } + direction := strings.ToLower(strings.TrimSpace(op.Direction)) + if direction != "up" && direction != "down" { + return fmt.Errorf("op[%d] move_outline_node: direction must be up|down", i) + } + p.Ops[i].Direction = direction + case "update_beat": + if op.BeatID == "" { + return fmt.Errorf("op[%d] update_beat: beat_id required", i) + } + case "delete_beat": + if op.BeatID == "" { + return fmt.Errorf("op[%d] delete_beat: beat_id required", i) + } + case "set_outline": + // outline may be empty (clears); no required field + case "set_scene_text": + if op.NodeID != "" && op.NodeRef != "" { + return fmt.Errorf("op[%d] set_scene_text: node_id and node_ref are mutually exclusive", i) + } + if strings.TrimSpace(op.Text) == "" && !op.AllowEmpty { + return fmt.Errorf("op[%d] set_scene_text: text required unless allow_empty is true", i) + } + case "remember": + if strings.TrimSpace(op.Text) == "" { + return fmt.Errorf("op[%d] remember: text required", i) + } + case "create_entity": + if strings.TrimSpace(op.Name) == "" { + return fmt.Errorf("op[%d] create_entity: name required", i) + } + kind, ok := normalizeEntityKind(op.Kind) + if !ok { + return fmt.Errorf("op[%d] create_entity: kind must be character|place|item|concept", i) + } + p.Ops[i].Kind = kind + case "update_entity": + if op.EntityID == "" { + return fmt.Errorf("op[%d] update_entity: entity_id required", i) + } + if strings.TrimSpace(op.Kind) != "" { + kind, ok := normalizeEntityKind(op.Kind) + if !ok { + return fmt.Errorf("op[%d] update_entity: kind must be character|place|item|concept", i) + } + p.Ops[i].Kind = kind + } + case "create_relationship": + if strings.TrimSpace(op.Label) == "" { + return fmt.Errorf("op[%d] create_relationship: label required", i) + } + hasFrom, hasFromRef := op.From != "", op.FromRef != "" + hasTo, hasToRef := op.To != "", op.ToRef != "" + if hasFrom == hasFromRef { + return fmt.Errorf("op[%d] create_relationship: exactly one of from/from_ref required", i) + } + if hasTo == hasToRef { + return fmt.Errorf("op[%d] create_relationship: exactly one of to/to_ref required", i) + } + // Undeclared from_ref/to_ref are allowed: the model often places a + // real entity id (or name) in the ref field. Resolution and any + // clear error happen at apply time. + case "create_fact_card": + if strings.TrimSpace(op.Claim) == "" { + return fmt.Errorf("op[%d] create_fact_card: claim required", i) + } + if strings.TrimSpace(op.Result) == "" { + return fmt.Errorf("op[%d] create_fact_card: result required", i) + } + status := strings.TrimSpace(op.Status) + if status == "" { + status = fact.StatusUncertain + p.Ops[i].Status = status + } + if !fact.ValidStatus(status) { + return fmt.Errorf("op[%d] create_fact_card: status must be verified|uncertain|intentional_fiction|stale", i) + } + hasSource := false + for _, src := range op.Sources { + if strings.TrimSpace(src.URL) != "" { + hasSource = true + break + } + } + if !hasSource { + return fmt.Errorf("op[%d] create_fact_card: at least one source URL required", i) + } + if op.NodeID != "" && op.NodeRef != "" { + return fmt.Errorf("op[%d] create_fact_card: node_id and node_ref are mutually exclusive", i) + } + } + } + return nil +} diff --git a/engine/internal/storyops/undo.go b/engine/internal/storyops/undo.go new file mode 100644 index 00000000..6be32a5c --- /dev/null +++ b/engine/internal/storyops/undo.go @@ -0,0 +1,150 @@ +package storyops + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/google/uuid" +) + +// ErrUndoBatchNotFound means the undo window has passed: the batch was already +// undone, or fell out of the in-memory list. +var ErrUndoBatchNotFound = errors.New("storyops: undo batch not found") + +// How many undo batches are kept in memory. The writer only ever undoes the +// change they just watched land, so a short list is enough. +const maxUndoBatches = 8 + +// OutlineChangeCounts summarizes what a batch would do to the outline tree. +type OutlineChangeCounts struct { + Created int `json:"created"` + Renamed int `json:"renamed"` + Deleted int `json:"deleted"` + Moved int `json:"moved"` + // Other counts ops in the same batch that do not touch the tree (beats, + // storylines, world-building, memories). + Other int `json:"other"` +} + +// Structural reports how many ops rearrange the outline tree. +func (c OutlineChangeCounts) Structural() int { + return c.Created + c.Renamed + c.Deleted + c.Moved +} + +// OutlineOpAction classifies an op type by what it does to the outline tree: +// "create", "rename", "delete", "move", or "" for ops that leave it alone. +func OutlineOpAction(opType string) string { + switch opType { + case "create_outline_node", "create_scene": + return "create" + case "rename_outline_node": + return "rename" + case "delete_outline_node": + return "delete" + case "move_outline_node": + return "move" + default: + return "" + } +} + +// CountOutlineChanges tallies a proposal by what it does to the tree. +func CountOutlineChanges(p Proposal) OutlineChangeCounts { + var c OutlineChangeCounts + for _, op := range p.Ops { + switch OutlineOpAction(op.Type) { + case "create": + c.Created++ + case "rename": + c.Renamed++ + case "delete": + c.Deleted++ + case "move": + c.Moved++ + default: + c.Other++ + } + } + return c +} + +// undoBatch is the outline as it stood before an applied change. +type undoBatch struct { + projectID string + nodes []node.Node +} + +// undoState holds the outline snapshots taken before structural applies, kept +// so the writer can undo the change that just landed. +type undoState struct { + mu sync.Mutex + batches map[string]undoBatch + order []string +} + +// rememberUndoBatch keeps the pre-change outline so the writer can put it back +// with one action. Batches live in memory only: undo is for the change you just +// watched land, not for history. +func (s *Service) rememberUndoBatch(projectID string, before []node.Node) string { + if len(before) == 0 { + return "" + } + id := uuid.NewString() + s.undo.mu.Lock() + defer s.undo.mu.Unlock() + if s.undo.batches == nil { + s.undo.batches = map[string]undoBatch{} + } + s.undo.batches[id] = undoBatch{projectID: projectID, nodes: before} + s.undo.order = append(s.undo.order, id) + for len(s.undo.order) > maxUndoBatches { + delete(s.undo.batches, s.undo.order[0]) + s.undo.order = s.undo.order[1:] + } + return id +} + +func (s *Service) takeUndoBatch(id string) (undoBatch, bool) { + s.undo.mu.Lock() + defer s.undo.mu.Unlock() + batch, ok := s.undo.batches[id] + if !ok { + return undoBatch{}, false + } + delete(s.undo.batches, id) + for i, existing := range s.undo.order { + if existing == id { + s.undo.order = append(s.undo.order[:i], s.undo.order[i+1:]...) + break + } + } + return batch, true +} + +// UndoApply puts the outline back the way it was before the applied batch. +func (s *Service) UndoApply(ctx context.Context, batchID string, now func() int64) error { + batch, ok := s.takeUndoBatch(strings.TrimSpace(batchID)) + if !ok { + return ErrUndoBatchNotFound + } + if s.nodes == nil { + return ErrUndoBatchNotFound + } + return s.nodes.RestoreOutline(ctx, batch.projectID, batch.nodes, now()) +} + +// snapshotOutline captures the tree so a failed batch can be rolled back and a +// finished one can be undone. +func (s *Service) snapshotOutline(ctx context.Context, projectID string) []node.Node { + if s.nodes == nil { + return nil + } + before, err := s.nodes.ListByProject(ctx, projectID) + if err != nil { + return nil + } + return before +} From 89fa1e0ee8c61303d413a43d1ff8a9a2ab8167af Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:42:31 +0900 Subject: [PATCH 04/25] feat(engine): merge facts, memories, and references into the story brief The engine has had two context assembly paths: the scene-centric ai.ContextBuilder (hierarchical summaries, prev-scene summary, related scenes) and the companion's gatherContext (the only place facts, memories, and references were collected) - so the ContextSelection toggles for those three sections existed without a builder that honored them. storycontext now completes the merge: optional FactSource / MemorySource / ReferenceSource interfaces populate Context fields, ApplyContextSelection clears them when toggled off, and the renderer carries the sections using the companion's established prompt format. Nothing wires the sources yet - engineapp is untouched, so ai.run and ai.preview_context behave exactly as before. The MCP story-context tool (Phase 2) is the first consumer. Tests cover section rendering, empty omission, toggle clearing, and a provider-less BuildFull returning a complete brief. Part of the MCP-first pivot (#47), Phase 1 Task 1.3. Co-Authored-By: Claude Opus 5 --- engine/internal/storycontext/builder.go | 85 ++++++++++++- engine/internal/storycontext/merge_test.go | 141 +++++++++++++++++++++ engine/internal/storycontext/render.go | 49 +++++++ engine/internal/storycontext/types.go | 28 ++++ 4 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 engine/internal/storycontext/merge_test.go diff --git a/engine/internal/storycontext/builder.go b/engine/internal/storycontext/builder.go index 1e4a7bca..f8509121 100644 --- a/engine/internal/storycontext/builder.go +++ b/engine/internal/storycontext/builder.go @@ -34,13 +34,16 @@ func (noopRefresher) RefreshNow(context.Context, string) {} // ContextBuilder gathers the Context payload from the repos. type ContextBuilder struct { - projects *project.Repo - nodes *node.Repo - mentions *mention.Repo - notes *note.Repo - relationships *relationship.Repo - plot *plot.Builder - refresher SummaryRefresher + factSource FactSource + memorySource MemorySource + referenceSource ReferenceSource + projects *project.Repo + nodes *node.Repo + mentions *mention.Repo + notes *note.Repo + relationships *relationship.Repo + plot *plot.Builder + refresher SummaryRefresher } // NewContextBuilder returns a builder that reads from the supplied repos. @@ -73,6 +76,42 @@ func (b *ContextBuilder) WithSummaryRefresher(r SummaryRefresher) *ContextBuilde // Build assembles the context for the given leaf node + user prompt + options, // then removes sections disabled by Options.Context. +// FactSource supplies Fact Book cards for the brief. Optional: without it +// the Facts section stays empty. Today only the companion gathers facts; this +// interface completes the existing ContextSelection toggles so the MCP story +// brief carries them too (pivot #47, Task 1.3). +type FactSource interface { + ContextFacts(ctx context.Context, projectID, nodeID string) ([]FactBrief, error) +} + +// MemorySource supplies remembered writer/world facts for the brief. Optional. +type MemorySource interface { + ContextMemories(projectID string) []string +} + +// ReferenceSource supplies writer-attached reference material. Optional. +type ReferenceSource interface { + ContextReferences(ctx context.Context, projectID, nodeID string) ([]ReferenceBrief, error) +} + +// WithFactSource wires the optional Fact Book section. +func (b *ContextBuilder) WithFactSource(s FactSource) *ContextBuilder { + b.factSource = s + return b +} + +// WithMemorySource wires the optional memories section. +func (b *ContextBuilder) WithMemorySource(s MemorySource) *ContextBuilder { + b.memorySource = s + return b +} + +// WithReferenceSource wires the optional references section. +func (b *ContextBuilder) WithReferenceSource(s ReferenceSource) *ContextBuilder { + b.referenceSource = s + return b +} + func (b *ContextBuilder) Build(ctx context.Context, nodeID, prompt, selectionText string, opts Options) (Context, error) { c, err := b.BuildFull(ctx, nodeID, prompt, selectionText, opts) if err != nil { @@ -161,6 +200,26 @@ func (b *ContextBuilder) BuildFull(ctx context.Context, nodeID, prompt, selectio } } + // Optional sections are best-effort, matching the companion's rule that + // partial context beats aborting the turn: per-source errors leave the + // section empty. + var facts []FactBrief + if b.factSource != nil { + if got, err := b.factSource.ContextFacts(ctx, n.ProjectID, nodeID); err == nil { + facts = got + } + } + var memories []string + if b.memorySource != nil { + memories = b.memorySource.ContextMemories(n.ProjectID) + } + var references []ReferenceBrief + if b.referenceSource != nil { + if got, err := b.referenceSource.ContextReferences(ctx, n.ProjectID, nodeID); err == nil { + references = got + } + } + return Context{ ProjectID: proj.ID, NodeID: n.ID, @@ -180,6 +239,9 @@ func (b *ContextBuilder) BuildFull(ctx context.Context, nodeID, prompt, selectio Relationships: relations, Plot: spine, Notes: noteBriefs, + Facts: facts, + Memories: memories, + References: references, StyleNotes: proj.StyleNotes, SelectionText: selectionText, UserPrompt: prompt, @@ -249,6 +311,15 @@ func ApplyContextSelection(c Context) Context { if !s.Enabled(ContextKeyStyleNotes) { c.StyleNotes = "" } + if !s.Enabled(ContextKeyFacts) { + c.Facts = nil + } + if !s.Enabled(ContextKeyMemories) { + c.Memories = nil + } + if !s.Enabled(ContextKeyReferences) { + c.References = nil + } return c } diff --git a/engine/internal/storycontext/merge_test.go b/engine/internal/storycontext/merge_test.go new file mode 100644 index 00000000..277c7ac9 --- /dev/null +++ b/engine/internal/storycontext/merge_test.go @@ -0,0 +1,141 @@ +package storycontext + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/beat" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/note" + "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/thread" +) + +// newTestBuilder opens a temp store with one project and returns a builder plus +// the project's first scene node id. No summary refresher is wired, matching a +// provider-less installation. +func newTestBuilder(t *testing.T) (*ContextBuilder, string) { + t.Helper() + s, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + pr := project.NewRepo(s) + p, err := pr.Create(context.Background(), 1000, project.NewInput{ + Title: "병합 테스트", Genres: []string{"판타지"}, LengthTarget: "short", DefaultPOV: "first", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + mr := mention.NewRepo(s) + nodes := node.NewRepo(s) + builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + return builder, *p.LastOpenedNodeID +} + +// Task 1.3 of the MCP-first pivot (#47): facts, memories, and references — +// which today only the companion's own prompt path gathers — become optional +// sections of the shared story brief, completing the ContextSelection toggles +// that already existed for them. + +type fakeFactSource struct{ facts []FactBrief } + +func (f fakeFactSource) ContextFacts(context.Context, string, string) ([]FactBrief, error) { + return f.facts, nil +} + +type fakeMemorySource struct{ memories []string } + +func (m fakeMemorySource) ContextMemories(string) []string { return m.memories } + +type fakeReferenceSource struct{ refs []ReferenceBrief } + +func (r fakeReferenceSource) ContextReferences(context.Context, string, string) ([]ReferenceBrief, error) { + return r.refs, nil +} + +func mergedContext() Context { + return Context{ + SceneLabel: "씬 1", + SceneText: "본문", + UserPrompt: "이어서", + Options: Options{Language: "ko"}, + Facts: []FactBrief{{ + ID: "f1", Status: "verified", Claim: "1920년대 경성에는 전차가 다녔다", + Sources: []FactSourceBrief{{Title: "경성 교통사", URL: "https://example.test/tram"}}, + }}, + Memories: []string{"작가는 건조한 문체를 선호한다"}, + References: []ReferenceBrief{{Title: "톤 레퍼런스", Purpose: "style", Body: "짧고 차가운 문장."}}, + } +} + +// The renderer must carry all three merged sections into the user prompt. +func TestRenderIncludesMergedSections(t *testing.T) { + _, user := Render(mergedContext()) + for _, want := range []string{ + "## 기억", "작가는 건조한 문체를 선호한다", + "## 팩트 자료집", "1920년대 경성에는 전차가 다녔다", "https://example.test/tram", + "## 추가 레퍼런스", "짧고 차가운 문장.", + } { + if !strings.Contains(user, want) { + t.Errorf("user prompt missing %q", want) + } + } +} + +// Empty sections must not leave stray headings behind. +func TestRenderOmitsEmptyMergedSections(t *testing.T) { + c := mergedContext() + c.Facts, c.Memories, c.References = nil, nil, nil + _, user := Render(c) + for _, heading := range []string{"## 기억", "## 팩트 자료집", "## 추가 레퍼런스"} { + if strings.Contains(user, heading) { + t.Errorf("empty section rendered heading %q", heading) + } + } +} + +// The pre-existing ContextSelection toggles must actually clear the sections. +func TestContextSelectionClearsMergedSections(t *testing.T) { + off := false + c := mergedContext() + c.Options.Context = ContextSelection{Facts: &off, Memories: &off, References: &off} + got := ApplyContextSelection(c) + if got.Facts != nil || got.Memories != nil || got.References != nil { + t.Fatalf("disabled sections survived: facts=%v memories=%v references=%v", + got.Facts, got.Memories, got.References) + } + _, user := Render(c) + if strings.Contains(user, "팩트 자료집") || strings.Contains(user, "## 기억") { + t.Error("disabled sections leaked into the rendered prompt") + } +} + +// Wired sources populate BuildFull output; the bring-your-own-agent premise +// also demands a complete, error-free brief when no LLM provider exists — +// summaries may be empty, everything else must be present. +func TestBuildFullMergesSourcesWithoutProvider(t *testing.T) { + b, nodeID := newTestBuilder(t) + b.WithFactSource(fakeFactSource{facts: []FactBrief{{ID: "f1", Status: "verified", Claim: "사실"}}}). + WithMemorySource(fakeMemorySource{memories: []string{"기억 한 줄"}}). + WithReferenceSource(fakeReferenceSource{refs: []ReferenceBrief{{Title: "참고", Body: "본문"}}}) + + c, err := b.BuildFull(context.Background(), nodeID, "이어서 써줘", "", Options{Language: "ko"}) + if err != nil { + t.Fatalf("BuildFull: %v", err) + } + if len(c.Facts) != 1 || len(c.Memories) != 1 || len(c.References) != 1 { + t.Fatalf("merged sections not populated: facts=%d memories=%d references=%d", + len(c.Facts), len(c.Memories), len(c.References)) + } + _, user := Render(c) + if !strings.Contains(user, "기억 한 줄") || !strings.Contains(user, "사실") { + t.Errorf("merged sections missing from rendered brief") + } +} diff --git a/engine/internal/storycontext/render.go b/engine/internal/storycontext/render.go index 606e665b..3ed00528 100644 --- a/engine/internal/storycontext/render.go +++ b/engine/internal/storycontext/render.go @@ -295,6 +295,55 @@ func buildUser(c Context) string { b.WriteString(c.StyleNotes) b.WriteString("\n\n") } + if len(c.Memories) > 0 { + b.WriteString(langPick(lang, "## 기억\n", "## Memories\n", "## 記憶\n")) + for _, m := range c.Memories { + b.WriteString("- " + m + "\n") + } + b.WriteString("\n") + } + if len(c.Facts) > 0 { + b.WriteString(langPick(lang, "## 팩트 자료집\n", "## Fact Dossier\n", "## ファクト資料集\n")) + for _, f := range c.Facts { + line := fmt.Sprintf("- [%s] (%s) %s", f.ID, f.Status, f.Claim) + if strings.TrimSpace(f.Category) != "" { + line += " / " + f.Category + } + if strings.TrimSpace(f.Result) != "" { + line += ": " + f.Result + } + b.WriteString(line + "\n") + for _, src := range f.Sources { + if strings.TrimSpace(src.URL) == "" { + continue + } + title := strings.TrimSpace(src.Title) + if title == "" { + title = src.URL + } + b.WriteString(fmt.Sprintf(" · %s — %s\n", title, src.URL)) + } + } + b.WriteString("\n") + } + if len(c.References) > 0 { + b.WriteString(langPick(lang, "## 추가 레퍼런스\n", "## Additional References\n", "## 追加リファレンス\n")) + b.WriteString(langPick(lang, + "작가가 이번 요청에 참고하라고 직접 추가한 자료입니다.\n", + "These materials were added by the writer for this request.\n", + "作家がこのリクエストのために直接追加した資料です。\n")) + for _, r := range c.References { + if strings.TrimSpace(r.Body) == "" { + continue + } + title := strings.TrimSpace(r.Title) + if p := strings.TrimSpace(r.Purpose); p != "" { + title = p + " — " + title + } + b.WriteString("### " + title + "\n") + b.WriteString(strings.TrimSpace(r.Body) + "\n\n") + } + } b.WriteString(langPick(lang, "## 작가의 지시\n", "## Writer's Instruction\n", "## 作家の指示\n")) b.WriteString(strings.TrimSpace(c.UserPrompt)) return b.String() diff --git a/engine/internal/storycontext/types.go b/engine/internal/storycontext/types.go index 363d4cda..070233a7 100644 --- a/engine/internal/storycontext/types.go +++ b/engine/internal/storycontext/types.go @@ -176,6 +176,9 @@ type Context struct { Relationships []RelationBrief `json:"relationships"` Plot plot.Spine `json:"plot"` Notes []NoteBrief `json:"notes"` + Facts []FactBrief `json:"facts,omitempty"` + Memories []string `json:"memories,omitempty"` + References []ReferenceBrief `json:"references,omitempty"` StyleNotes string `json:"style_notes"` SelectionText string `json:"selection_text"` UserPrompt string `json:"user_prompt"` @@ -224,3 +227,28 @@ type EntityBrief struct { Attributes map[string]string `json:"attributes"` Recent []string `json:"recent"` // Plan 16 layer 2 dossier — first lines of latest 5 leaf summaries } + +// FactBrief is one Fact Book card slice for the brief: the claim, its +// verification status, and the sources backing it. +type FactBrief struct { + ID string `json:"id"` + Status string `json:"status"` + Claim string `json:"claim"` + Category string `json:"category,omitempty"` + Result string `json:"result,omitempty"` + Sources []FactSourceBrief `json:"sources,omitempty"` +} + +// FactSourceBrief is one source line under a fact card. +type FactSourceBrief struct { + Title string `json:"title,omitempty"` + URL string `json:"url"` +} + +// ReferenceBrief is one writer-supplied reference: purpose-labelled material +// the writer attached for the current request. +type ReferenceBrief struct { + Title string `json:"title"` + Purpose string `json:"purpose,omitempty"` + Body string `json:"body"` +} From 4fb67a10dd7b75af92d7166eadb736f81239ac83 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:42:31 +0900 Subject: [PATCH 05/25] refactor(engine): isolate the summarizer's LLM surface in one file Split provider-driven summarization (client factory, Chat calls, both prompts) into llm_path.go behind a single summarizeViaLLM helper. The short-scene plain-text path and the queue/recursion orchestration stay in summarizer.go and no longer reference tars at all, so the pivot's removal phase deletes the LLM file wholesale while nodes.update_content keeps its postUpdate hook and short summaries keep working. Part of the MCP-first pivot (#47), Phase 1 Task 1.4. Co-Authored-By: Claude Opus 5 --- engine/internal/summarizer/llm_path.go | 50 ++++++++++++++++++++++++ engine/internal/summarizer/summarizer.go | 48 ++--------------------- 2 files changed, 54 insertions(+), 44 deletions(-) create mode 100644 engine/internal/summarizer/llm_path.go diff --git a/engine/internal/summarizer/llm_path.go b/engine/internal/summarizer/llm_path.go new file mode 100644 index 00000000..f7b9cc9a --- /dev/null +++ b/engine/internal/summarizer/llm_path.go @@ -0,0 +1,50 @@ +package summarizer + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/devlikebear/tars/pkg/llm" +) + +// This file is the summarizer's entire LLM surface. The MCP-first pivot (#47) +// replaces provider-generated summaries with agent-written ones +// (linetta_write_summary), so this file — and the provider plumbing it uses — +// is deleted wholesale in the removal phase. The short-scene plain-text path +// in summarizer.go stays. + +const systemPrompt = "다음 본문을 본문과 같은 언어로 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지. (Summarize the passage below in 3-5 sentences, in the same language as the passage. Preserve characters, places, and key events. Do not add new information.)" +const containerSystemPrompt = "다음은 소설의 하위 단위 요약들이다. 이 단위 전체를 요약들과 같은 언어로 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지. (The lines below are summaries of a fiction unit's children. Summarize the whole unit in 3-5 sentences, in the same language as those summaries. Preserve characters, places, and key events. Do not add new information.)" + +// summarizeViaLLM sends one summarize request to the configured provider and +// returns the trimmed summary. ok is false when the provider is unavailable, +// the request fails, or the model returns nothing; failures are logged and +// recorded against nodeID, and an empty response is silently skipped — +// preserving the summarizer's long-standing best-effort behavior. +func (s *Summarizer) summarizeViaLLM(ctx context.Context, nodeID, label, system, userText string) (string, bool) { + rp := s.src.Resolve() + provider := rp.Provider + client, err := s.factory(rp) + if err != nil { + fmt.Fprintf(os.Stderr, "summarizer: factory(%s): %v\n", provider, err) + s.recordError(ctx, nodeID, err.Error()) + return "", false + } + msgs := []llm.ChatMessage{ + {Role: "system", Content: system}, + {Role: "user", Content: userText}, + } + resp, err := client.Chat(ctx, msgs, llm.ChatOptions{}) + if err != nil { + fmt.Fprintf(os.Stderr, "summarizer: Chat%s %s: %v\n", label, nodeID, err) + s.recordError(ctx, nodeID, err.Error()) + return "", false + } + summary := strings.TrimSpace(resp.Message.Content) + if summary == "" { + return "", false + } + return summary, true +} diff --git a/engine/internal/summarizer/summarizer.go b/engine/internal/summarizer/summarizer.go index 47188bdd..1bc3d0a3 100644 --- a/engine/internal/summarizer/summarizer.go +++ b/engine/internal/summarizer/summarizer.go @@ -14,7 +14,6 @@ import ( "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/opsstatus" - "github.com/devlikebear/tars/pkg/llm" ) const queueSize = 256 @@ -24,8 +23,6 @@ const maxSummarizeDepth = 6 // Summaries run in the background with no UI-language signal, so the prompt // asks the model to follow the manuscript's own language instead. -const systemPrompt = "다음 본문을 본문과 같은 언어로 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지. (Summarize the passage below in 3-5 sentences, in the same language as the passage. Preserve characters, places, and key events. Do not add new information.)" -const containerSystemPrompt = "다음은 소설의 하위 단위 요약들이다. 이 단위 전체를 요약들과 같은 언어로 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지. (The lines below are summaries of a fiction unit's children. Summarize the whole unit in 3-5 sentences, in the same language as those summaries. Preserve characters, places, and key events. Do not add new information.)" type Summarizer struct { nodes *node.Repo @@ -146,27 +143,8 @@ func (s *Summarizer) summarizeLeaf(ctx context.Context, n node.Node) { return } - rp := s.src.Resolve() - provider := rp.Provider - client, err := s.factory(rp) - if err != nil { - fmt.Fprintf(os.Stderr, "summarizer: factory(%s): %v\n", provider, err) - s.recordError(ctx, n.ID, err.Error()) - return - } - - msgs := []llm.ChatMessage{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: plain}, - } - resp, err := client.Chat(ctx, msgs, llm.ChatOptions{}) - if err != nil { - fmt.Fprintf(os.Stderr, "summarizer: Chat %s: %v\n", n.ID, err) - s.recordError(ctx, n.ID, err.Error()) - return - } - summary := strings.TrimSpace(resp.Message.Content) - if summary == "" { + summary, ok := s.summarizeViaLLM(ctx, n.ID, "", systemPrompt, plain) + if !ok { return } if err := s.nodes.SetSummary(ctx, n.ID, summary, capturedVersion); err != nil { @@ -222,26 +200,8 @@ func (s *Summarizer) summarizeContainer(ctx context.Context, n node.Node, depth input = string(r[:containerSummaryMaxRunes]) } - rp := s.src.Resolve() - provider := rp.Provider - client, err := s.factory(rp) - if err != nil { - fmt.Fprintf(os.Stderr, "summarizer: factory(%s): %v\n", provider, err) - s.recordError(ctx, n.ID, err.Error()) - return - } - msgs := []llm.ChatMessage{ - {Role: "system", Content: containerSystemPrompt}, - {Role: "user", Content: input}, - } - resp, err := client.Chat(ctx, msgs, llm.ChatOptions{}) - if err != nil { - fmt.Fprintf(os.Stderr, "summarizer: Chat (container) %s: %v\n", n.ID, err) - s.recordError(ctx, n.ID, err.Error()) - return - } - summary := strings.TrimSpace(resp.Message.Content) - if summary == "" { + summary, ok := s.summarizeViaLLM(ctx, n.ID, " (container)", containerSystemPrompt, input) + if !ok { return } if err := s.nodes.SetSummary(ctx, n.ID, summary, capturedVersion); err != nil { From 505e5f2d202fd2e0b4c3e810784df385be9806f4 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:42:53 +0900 Subject: [PATCH 06/25] docs: mark MCP pivot Phase 1 complete in the implementation plan Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-22-mcp-first-pivot.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index 80f73046..ca693e0b 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -42,19 +42,19 @@ **파일:** `engine/internal/storycontext/*`(신규), `engine/internal/ai/*`(축소), 호출부 -- [ ] `Context`, `ContextSelection`, `ContextBuilder`, 프롬프트 렌더링(`buildSystem`/`buildUser` 계열)을 `internal/storycontext`로 이동한다. -- [ ] **렌더러는 평문 문자열을 반환하게 바꾼다** (`RenderSystem`/`RenderUser`). 현재 `ai.BuildMessages`는 반환 타입으로 `tars/pkg/llm.ChatMessage`를 쓰는데, 내부는 문자열 두 개를 만드는 것뿐이다. `internal/ai`에는 `llm.ChatMessage`로 감싸는 얇은 `BuildMessages` 어댑터만 남겨 컴패니언·AI 실행기가 전환 기간 동안 그대로 돌게 한다. -- [ ] `storycontext`가 `tars/pkg/llm`을 import하지 않는지 `go list -deps`로 검증한다(테스트 또는 CI 스크립트). -- [ ] 기존 `ai` 테스트를 함께 옮기고 전부 통과시킨다. +- [x] `Context`, `ContextSelection`, `ContextBuilder`, 프롬프트 렌더링(`buildSystem`/`buildUser` 계열)을 `internal/storycontext`로 이동한다. +- [x] **렌더러는 평문 문자열을 반환하게 바꾼다** (`RenderSystem`/`RenderUser`). 현재 `ai.BuildMessages`는 반환 타입으로 `tars/pkg/llm.ChatMessage`를 쓰는데, 내부는 문자열 두 개를 만드는 것뿐이다. `internal/ai`에는 `llm.ChatMessage`로 감싸는 얇은 `BuildMessages` 어댑터만 남겨 컴패니언·AI 실행기가 전환 기간 동안 그대로 돌게 한다. +- [x] `storycontext`가 `tars/pkg/llm`을 import하지 않는지 `go list -deps`로 검증한다(테스트 또는 CI 스크립트). +- [x] 기존 `ai` 테스트를 함께 옮기고 전부 통과시킨다. ### Task 1.2 — `internal/storyops` 추출 **파일:** `engine/internal/storyops/*`(신규), `engine/internal/companion/*`(축소), 호출부 -- [ ] `Proposal`, `validateProposal`, `ApplyOps`, undo 배치, 메모리 기록(`remember`) 경로를 `internal/storyops`로 이동한다. `remember`가 쓰는 `tars/pkg/memory` 의존은 유지된다. -- [ ] `companion.Service`는 새 `storyops`를 호출하도록 바꾼다. 컴패니언 동작은 변하지 않는다. -- [ ] `companion.apply_ops` / `companion.undo_apply` 핸들러는 그대로 두되 내부적으로 `storyops`를 쓴다. -- [ ] 기존 적용/되돌리기 테스트를 옮기고 전부 통과시킨다. +- [x] `Proposal`, `validateProposal`, `ApplyOps`, undo 배치, 메모리 기록(`remember`) 경로를 `internal/storyops`로 이동한다. `remember`가 쓰는 `tars/pkg/memory` 의존은 유지된다. +- [x] `companion.Service`는 새 `storyops`를 호출하도록 바꾼다. 컴패니언 동작은 변하지 않는다. +- [x] `companion.apply_ops` / `companion.undo_apply` 핸들러는 그대로 두되 내부적으로 `storyops`를 쓴다. +- [x] 기존 적용/되돌리기 테스트는 컴패니언에 남겨 위임 경로를 종단으로 검증하게 하고(이동보다 강한 검증), storyops에 적용/되돌리기/롤백/스냅샷/메모리 부재 가드를 직접 검증하는 단위 테스트를 새로 추가했다. ### Task 1.3 — 컨텍스트 병합: 팩트·메모리·레퍼런스 @@ -62,21 +62,23 @@ **파일:** `engine/internal/storycontext/*`, `+ 테스트` -- [ ] 컴패니언 `gatherContext`의 팩트(씬 필터 포함)·메모리(recall)·레퍼런스 수집을 `storycontext` 빌더의 선택적 섹션으로 이식한다. -- [ ] `Context` 구조체에 `Facts`/`Memories`/`References` 필드를 추가하고 렌더러가 해당 섹션을 출력하게 한다(빈 섹션은 생략 — 기존 관례). -- [ ] 기존 토글(`ContextSelection`)이 실제로 이 섹션들을 켜고 끄는지 테스트한다. -- [ ] 컴패니언의 기존 프롬프트 조립은 건드리지 않는다 — 이 병합은 MCP 툴을 위한 것이고, 컴패니언은 6단계까지 자기 경로를 유지한다. +- [x] 컴패니언 `gatherContext`의 팩트(씬 필터 포함)·메모리(recall)·레퍼런스 수집을 `storycontext` 빌더의 선택적 섹션으로 이식한다. +- [x] `Context` 구조체에 `Facts`/`Memories`/`References` 필드를 추가하고 렌더러가 해당 섹션을 출력하게 한다(빈 섹션은 생략 — 기존 관례). +- [x] 기존 토글(`ContextSelection`)이 실제로 이 섹션들을 켜고 끄는지 테스트한다. +- [x] 컴패니언의 기존 프롬프트 조립은 건드리지 않는다 — 이 병합은 MCP 툴을 위한 것이고, 컴패니언은 6단계까지 자기 경로를 유지한다. ### Task 1.4 — 요약기 경계 정리 **파일:** `engine/internal/summarizer/*` -- [ ] 비-LLM 경로(`minRunesForLLM` 미만 평문 요약)와 LLM 경로를 파일 단위로 분리한다. -- [ ] `nodes.SetSummary(id, summary, contentVersion)`를 외부에서 호출할 수 있는 형태로 정리한다(3단계의 `linetta_write_summary`가 쓴다). -- [ ] `nodes.update_content`의 `postUpdate` 훅 구조는 유지한다 — 6단계에서 훅이 부르는 대상만 비-LLM 요약기로 바뀐다. +- [x] 비-LLM 경로(`minRunesForLLM` 미만 평문 요약)와 LLM 경로를 파일 단위로 분리한다. +- [x] `nodes.SetSummary(id, summary, contentVersion)`를 외부에서 호출할 수 있는 형태로 정리한다(3단계의 `linetta_write_summary`가 쓴다). +- [x] `nodes.update_content`의 `postUpdate` 훅 구조는 유지한다 — 6단계에서 훅이 부르는 대상만 비-LLM 요약기로 바뀐다. **1단계 종료 조건:** `make test` 통과, 사용자에게 보이는 동작 변화 0, `storycontext`/`storyops`가 LLM 코드에 의존하지 않음이 `go list -deps`로 확인됨. +> **완료 (2026-08-22):** 엔진 40개 패키지 테스트 전부 통과, `mas`/`mobile` 태그 빌드 통과, 두 신규 패키지의 tars 의존 0 확인. 렌더러는 `Render(c) (system, user string)`로 평문화됐고 `internal/ai`에는 `BuildMessages` 어댑터만 남았다. 이 머신의 Smart App Control이 cgo(gcc cc1)와 일부 신규 테스트 바이너리를 간헐 차단해 `cmd/linetta-ffi` 검증은 CI에 맡긴다. + --- ## Phase 2 — MCP 호스트, 인증, 읽기 툴 From b4ee65fd83bdaa686e31b0c23676c40ac0e5016c Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 22 Aug 2026 23:46:26 +0900 Subject: [PATCH 07/25] chore: gate the story core against LLM dependencies in make test-go internal/storycontext and internal/storyops must stay free of tars llm/agentloop/session linkage so the MCP pivot's removal phase can delete the LLM loop without touching the code the MCP tools stand on. Part of the MCP-first pivot (#47), Phase 1. Co-Authored-By: Claude Opus 5 --- Makefile | 1 + scripts/validate-story-core-deps.sh | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 scripts/validate-story-core-deps.sh diff --git a/Makefile b/Makefile index 4987225b..a027b92a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/scripts/validate-story-core-deps.sh b/scripts/validate-story-core-deps.sh new file mode 100644 index 00000000..0e68499b --- /dev/null +++ b/scripts/validate-story-core-deps.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# The MCP-first pivot (#47) extracted internal/storycontext and +# internal/storyops as the LLM-free story core. This gate keeps them that way: +# if either package ever links tars' LLM client, agent loop, or chat sessions, +# the pivot's removal phase would delete code the MCP tools stand on. +set -euo pipefail +cd "$(dirname "$0")/../engine" + +banned='github.com/devlikebear/tars/pkg/(llm|agentloop|session)' +if go list -deps ./internal/storycontext ./internal/storyops | grep -E "$banned"; then + echo "error: internal/storycontext and internal/storyops must not depend on LLM/agent-loop/session code" >&2 + exit 1 +fi +echo "story core deps OK: no tars llm/agentloop/session linkage" From 7e32a97b5ab39f014eab96253c04d185d893dc15 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 09:08:49 +0900 Subject: [PATCH 08/25] docs: lock in Phase 0 decisions for the MCP-first pivot All five open questions resolved as recommended: remove the companion only after MCP is validated in real use (Phase 5 -> 6), accept that mobile loses AI entirely, drop the web_search setting while keeping web_fetch for Fact Book URL capture, defer a hand-written summary UI to Phase 7, and target 1.0.0. Downstream tasks that were written conditionally now state the decision outright. Part of the MCP-first pivot (#47), Phase 0. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-22-mcp-first-pivot.md | 17 ++++++++++------- .../specs/2026-08-22-mcp-first-pivot-design.md | 16 ++++++++-------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index ca693e0b..bd381e36 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -26,11 +26,14 @@ 구현 전에 설계 문서 10절의 항목을 확정한다. 코드 작업 없음. -- [ ] 컴패니언 제거 시점: MCP 실사용 검증 후 단계적 (권장) -- [ ] 모바일에서 AI 기능이 완전히 사라지는 것을 수용할지 확인 -- [ ] `web_search` 설정 제거 여부 (`web_fetch`는 유지) -- [ ] 목표 버전 1.0.0 확정 -- [ ] 결정 결과를 설계 문서 10절에 반영 +- [x] 컴패니언 제거 시점: **MCP 실사용 검증 후 단계적** (Phase 5 → 6) +- [x] 모바일에서 AI 기능이 완전히 사라지는 것을 **수용** +- [x] `web_search` 설정 **제거** (`web_fetch`는 유지) +- [x] 목표 버전 **1.0.0** 확정 +- [x] 손으로 쓰는 요약 UI는 **미추가로 시작** (Phase 7에서 재검토) +- [x] 결정 결과를 설계 문서 10절에 반영 + +> **완료 (2026-08-22):** 다섯 항목 모두 권장안대로 확정. 이후 단계는 조건부 서술 없이 이 결정을 전제로 진행한다. --- @@ -290,7 +293,7 @@ - [ ] **RPC `projects.rewrite_synopsis` 제거.** 프로바이더 없는 상태에서 이 메서드는 컨테이너 요약을 지우고 빈 문자열을 돌려주는 파괴적 동작이 된다(설계 문서 3.3절). `projects.clear_synopsis`는 무해하므로 유지 여부만 판단. - [ ] 설정에서 `provider`, `providers`, `ai_data_sharing_consent_*` 제거. 마이그레이션은 기존 값을 무시하되 파괴하지 않는다. - [ ] `tars` 의존성은 **유지한다** — `pkg/tools`의 `web_fetch`가 팩트북 URL 캡처에 쓰이고(`handlers/facts.go:108`), `storyops`의 `remember`가 `pkg/memory`를 쓴다. `pkg/llm`, `pkg/agentloop`, `pkg/session` 사용만 사라진다. -- [ ] Phase 0에서 `web_search` 제거를 택했다면 `handlers/websearch.go`, `web_search.test` RPC, `web_search_*` 설정도 함께 제거한다. +- [ ] `handlers/websearch.go`, `web_search.test` RPC, `web_search_*` 설정을 제거한다(Phase 0 확정). `web_fetch`(`handlers/facts.go:108`, 키 불필요)는 남긴다. ### Task 6.2 — 프론트엔드 제거 @@ -320,7 +323,7 @@ 제거로 확보한 여력을 집필 기능에 투자한다. 이 계획의 범위 밖이지만 방향을 적어 둔다. - [ ] `contextualedit`(설정 변경 → 관련 씬 일괄 수정) 같은 결정론적 파워 기능 확장 — LLM 없이 동작하며 이 제품 방향의 대표 기능이다. -- [ ] 손으로 쓰는 요약 UI (Phase 0 결정에 따라) +- [ ] 손으로 쓰는 요약 UI — Phase 0에서 미추가로 시작하기로 확정했으므로, MCP 없이 쓰는 사용자 비중을 보고 여기서 재검토한다 - [ ] 집필 통계, 원고 진행 관리, 퇴고 워크플로 - [ ] MCP 프롬프트("다음 씬 초고", "연속성 점검")와 리소스(`linetta://work/{id}/scene/{id}`) - [ ] `--headless` 엔진 모드 — 앱을 열지 않고도 에이전트가 작업 diff --git a/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md b/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md index fea6b00a..61668efd 100644 --- a/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md +++ b/docs/superpowers/specs/2026-08-22-mcp-first-pivot-design.md @@ -196,17 +196,17 @@ Claude Desktop은 현재 로컬 HTTP MCP 서버에 직접 붙지 못합니다. ` 3. 씬 단위로 지시합니다. "4-2 씬 컨텍스트를 읽고, 확립된 문체로 초고를 쓰고, 해소된 비트와 요약을 갱신해줘." 4. Linetta에서 검토합니다. 되돌리기는 툴 한 번 또는 클릭 한 번입니다. -## 10. 결정이 필요한 사항 +## 10. 확정된 결정 -계획서는 아래 항목에 권장안을 담되, 최종 판단은 사용자 몫입니다. +**2026-08-22 확정.** 아래 다섯 항목은 전부 권장안대로 결정됐습니다. 이후 단계는 이 결정을 전제로 진행합니다. -| 항목 | 선택지 | 권장 | +| 항목 | 결정 | 근거 | | --- | --- | --- | -| 컴패니언 제거 시점 | 즉시 / MCP 검증 후 단계적 | **검증 후.** MCP 경로가 실사용에서 컴패니언을 대체함이 확인되기 전에 지우면, 사용자 손에는 AI 없는 앱만 남습니다 | -| 모바일에서 AI 완전 소멸 | 수용 / 모바일만 컴패니언 유지 | **수용.** 두 갈래 유지는 전환의 목적을 무너뜨립니다. 다만 사용자 확인 필요 | -| `web_search` 설정 | 유지 / 제거 | **제거.** Brave/Perplexity 키도 결국 BYOK입니다. 검색은 에이전트가 더 잘합니다. `web_fetch`(키 불필요, 팩트북 URL 캡처)는 유지 | -| 손으로 쓰는 요약 UI | 추가 / 미추가 | 미추가로 시작. MCP 없이 쓰는 사용자 비중을 보고 판단 | -| 버전 | 0.10.x / 1.0.0 | **1.0.0.** 파괴적 변경이자 제품 정체성 전환입니다 | +| 컴패니언 제거 시점 | **MCP 실사용 검증 후 단계적** (Phase 5 → 6) | MCP 경로가 실사용에서 컴패니언을 대체함이 확인되기 전에 지우면, 사용자 손에는 AI 없는 앱만 남습니다 | +| 모바일에서 AI 완전 소멸 | **수용** | 모바일은 MCP 서버를 호스팅할 수 없어 대체 경로가 없지만, 두 갈래 유지는 전환의 목적을 무너뜨립니다. 모바일은 순수 집필 도구가 됩니다 | +| `web_search` 설정 | **제거** (`web_fetch`는 유지) | Brave/Perplexity 키도 결국 BYOK입니다. 검색은 에이전트가 더 잘합니다. `web_fetch`는 키가 필요 없고 팩트북 URL 캡처에 쓰이므로 남깁니다 | +| 손으로 쓰는 요약 UI | **미추가로 시작** (Phase 7에서 재검토) | MCP 없이 쓰는 사용자 비중을 보고 판단합니다 | +| 목표 버전 | **1.0.0** | 파괴적 변경이자 제품 정체성 전환입니다 | ## 11. 리스크 From 1e0a9c7525b6109b13ee03196d96c00feab0a048 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 09:23:08 +0900 Subject: [PATCH 09/25] feat(engine): add MCP settings keys and secret-stored bearer token mcp_mode (off|read_only|full, default off), mcp_port (default 7391), mcp_project_id, and a dedicated MCP consent pair. The bearer token follows the existing api_key convention: it lives in the secret store, settings.get returns only the mcp_token_set presence flag, and the flag is never written to settings.json. Two safety-by-construction choices: an unrecognized mode normalizes to off rather than to an open server, and the port is a fixed setting rather than ephemeral so saved client configs survive restarts - an out-of-range value falls back to the default instead of silently binding elsewhere. Also vendors github.com/modelcontextprotocol/go-sdk v1.7.0, first imported by the host in the next commit. Part of the MCP-first pivot (#47), Phase 2 Tasks 2.1-2.2. Co-Authored-By: Claude Opus 5 --- engine/go.mod | 1 + engine/go.sum | 2 + engine/internal/settings/mcp.go | 109 +++++++++++++++++ engine/internal/settings/mcp_test.go | 169 +++++++++++++++++++++++++++ engine/internal/settings/secrets.go | 4 + engine/internal/settings/settings.go | 52 +++++++++ 6 files changed, 337 insertions(+) create mode 100644 engine/internal/settings/mcp.go create mode 100644 engine/internal/settings/mcp_test.go diff --git a/engine/go.mod b/engine/go.mod index f95bcea7..0581ac2d 100644 --- a/engine/go.mod +++ b/engine/go.mod @@ -14,6 +14,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect diff --git a/engine/go.sum b/engine/go.sum index ee593464..0c61be8f 100644 --- a/engine/go.sum +++ b/engine/go.sum @@ -16,6 +16,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/engine/internal/settings/mcp.go b/engine/internal/settings/mcp.go new file mode 100644 index 00000000..47462b20 --- /dev/null +++ b/engine/internal/settings/mcp.go @@ -0,0 +1,109 @@ +package settings + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// MCP access modes. off is the default: no listener binds until the writer +// turns it on. read_only registers only the read tools, so a misbehaving agent +// cannot reach a write tool at all — the guarantee is "not registered", not +// "registered and refused". +const ( + MCPModeOff = "off" + MCPModeReadOnly = "read_only" + MCPModeFull = "full" +) + +// DefaultMCPPort is fixed rather than ephemeral: a client config is written +// once and reused for months, and Claude Code has no client-side way to absorb +// a moving URL. A busy port surfaces as a visible error instead of a silent +// fallback. +const DefaultMCPPort = 7391 + +// MCPConsentVersion is the current MCP data-sharing consent revision. Separate +// from the AI provider consent: that one covers text Linetta sends to a +// provider it configured, this one covers a third-party client Linetta does +// not control. +const MCPConsentVersion = 1 + +// ValidMCPModes returns the accepted mcp_mode values. +func ValidMCPModes() []string { + return []string{MCPModeOff, MCPModeReadOnly, MCPModeFull} +} + +// MCPMode returns the configured access mode, normalized. +func (s *Store) MCPMode() string { + s.mu.RLock() + defer s.mu.RUnlock() + mode := s.cfg.MCPMode + for _, valid := range ValidMCPModes() { + if mode == valid { + return mode + } + } + return MCPModeOff +} + +// MCPPort returns the configured loopback port, normalized. +func (s *Store) MCPPort() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.MCPPort < 1024 || s.cfg.MCPPort > 65535 { + return DefaultMCPPort + } + return s.cfg.MCPPort +} + +// MCPProjectID returns the work the server is restricted to, or "" for all. +func (s *Store) MCPProjectID() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.MCPProjectID +} + +// HasMCPConsent reports whether the writer accepted the current MCP consent +// revision. The host refuses to start without it. +func (s *Store) HasMCPConsent() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.MCPConsentVersion >= MCPConsentVersion +} + +// MCPToken returns the bearer token, or "" when none has been generated. +func (s *Store) MCPToken() string { + secret, ok, err := s.secrets.Get(mcpTokenSecretName) + if err != nil || !ok { + return "" + } + return secret +} + +// EnsureMCPToken returns the existing token, generating one on first use so +// enabling MCP never leaves the server unauthenticated. +func (s *Store) EnsureMCPToken() (string, error) { + if token := s.MCPToken(); token != "" { + return token, nil + } + return s.RegenerateMCPToken() +} + +// RegenerateMCPToken issues a fresh token, invalidating every client config +// that carried the old one. +func (s *Store) RegenerateMCPToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate mcp token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(buf) + if err := s.secrets.Set(mcpTokenSecretName, token); err != nil { + return "", fmt.Errorf("store mcp token: %w", err) + } + return token, nil +} + +// DeleteMCPToken removes the token entirely. +func (s *Store) DeleteMCPToken() error { + return s.secrets.Delete(mcpTokenSecretName) +} diff --git a/engine/internal/settings/mcp_test.go b/engine/internal/settings/mcp_test.go new file mode 100644 index 00000000..17bb62ee --- /dev/null +++ b/engine/internal/settings/mcp_test.go @@ -0,0 +1,169 @@ +package settings + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func configJSON(t *testing.T, c Config) string { + t.Helper() + raw, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + return string(raw) +} + +func newMCPStore(t *testing.T) *Store { + t.Helper() + t.Setenv("LINETTA_HOME", t.TempDir()) + s, err := NewWithSecretStore(NewMemorySecretStore()) + if err != nil { + t.Fatalf("NewWithSecretStore: %v", err) + } + return s +} + +// MCP must be inert until the writer turns it on. +func TestMCPDefaultsAreOff(t *testing.T) { + s := newMCPStore(t) + if got := s.MCPMode(); got != MCPModeOff { + t.Errorf("default mode = %q, want %q", got, MCPModeOff) + } + if got := s.MCPPort(); got != DefaultMCPPort { + t.Errorf("default port = %d, want %d", got, DefaultMCPPort) + } + if s.HasMCPConsent() { + t.Error("consent must not be granted by default") + } + if s.MCPToken() != "" { + t.Error("no token should exist before MCP is enabled") + } +} + +func TestMCPModeRoundTripsAndRejectsUnknown(t *testing.T) { + s := newMCPStore(t) + ctx := context.Background() + for _, mode := range ValidMCPModes() { + if _, err := s.Set(ctx, Patch{MCPMode: &mode}); err != nil { + t.Fatalf("Set(%q): %v", mode, err) + } + if got := s.MCPMode(); got != mode { + t.Errorf("mode = %q, want %q", got, mode) + } + } + bogus := "wide_open" + if _, err := s.Set(ctx, Patch{MCPMode: &bogus}); err == nil { + t.Fatal("an unknown mode must be rejected, not silently accepted") + } +} + +// A corrupt or hand-edited value must degrade to off, never to an open server. +func TestUnknownModeOnDiskFallsBackToOff(t *testing.T) { + c := normalizeMCPPreferences(Config{MCPMode: "full-access-please", MCPPort: DefaultMCPPort}) + if c.MCPMode != MCPModeOff { + t.Fatalf("mode = %q, want %q", c.MCPMode, MCPModeOff) + } +} + +func TestMCPPortValidation(t *testing.T) { + s := newMCPStore(t) + ctx := context.Background() + ok := 8123 + if _, err := s.Set(ctx, Patch{MCPPort: &ok}); err != nil { + t.Fatalf("Set(port): %v", err) + } + if got := s.MCPPort(); got != ok { + t.Errorf("port = %d, want %d", got, ok) + } + for _, bad := range []int{0, 80, 70000} { + if _, err := s.Set(ctx, Patch{MCPPort: &bad}); err == nil { + t.Errorf("port %d should be rejected", bad) + } + } + if c := normalizeMCPPreferences(Config{MCPMode: MCPModeOff, MCPPort: 42}); c.MCPPort != DefaultMCPPort { + t.Errorf("out-of-range disk value = %d, want default %d", c.MCPPort, DefaultMCPPort) + } +} + +// The token follows the api_key convention: stored in the secret store, never +// returned by settings.get, exposed only as a presence flag. +func TestMCPTokenIsRedactedAndPresenceOnly(t *testing.T) { + s := newMCPStore(t) + token, err := s.EnsureMCPToken() + if err != nil { + t.Fatalf("EnsureMCPToken: %v", err) + } + if len(token) < 32 { + t.Fatalf("token looks too short: %q", token) + } + if again, _ := s.EnsureMCPToken(); again != token { + t.Error("EnsureMCPToken must reuse the existing token") + } + + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !got.MCPTokenSet { + t.Error("mcp_token_set should be true once a token exists") + } + blob := configJSON(t, got) + if strings.Contains(blob, token) { + t.Fatal("settings.get leaked the raw MCP token") + } + + rotated, err := s.RegenerateMCPToken() + if err != nil { + t.Fatalf("RegenerateMCPToken: %v", err) + } + if rotated == token { + t.Error("regenerating must issue a different token") + } + if err := s.DeleteMCPToken(); err != nil { + t.Fatalf("DeleteMCPToken: %v", err) + } + if s.MCPToken() != "" { + t.Error("token should be gone after delete") + } +} + +// The disk file must never carry the presence flag (it is derived state). +func TestMCPTokenFlagNotPersisted(t *testing.T) { + c := sanitizeConfigForDisk(Config{MCPTokenSet: true}) + if c.MCPTokenSet { + t.Fatal("mcp_token_set must be cleared before writing settings.json") + } +} + +func TestMCPConsentGate(t *testing.T) { + s := newMCPStore(t) + ctx := context.Background() + if s.HasMCPConsent() { + t.Fatal("consent must start ungranted") + } + version := MCPConsentVersion + at := int64(1_700_000_000_000) + if _, err := s.Set(ctx, Patch{MCPConsentVersion: &version, MCPConsentedAt: &at}); err != nil { + t.Fatalf("Set(consent): %v", err) + } + if !s.HasMCPConsent() { + t.Error("consent should be granted after accepting the current revision") + } +} + +func TestMCPProjectRestriction(t *testing.T) { + s := newMCPStore(t) + if s.MCPProjectID() != "" { + t.Fatal("no restriction by default") + } + id := "proj-1" + if _, err := s.Set(context.Background(), Patch{MCPProjectID: &id}); err != nil { + t.Fatalf("Set(project): %v", err) + } + if got := s.MCPProjectID(); got != id { + t.Errorf("project = %q, want %q", got, id) + } +} diff --git a/engine/internal/settings/secrets.go b/engine/internal/settings/secrets.go index a7171c13..6ffce7f8 100644 --- a/engine/internal/settings/secrets.go +++ b/engine/internal/settings/secrets.go @@ -7,6 +7,10 @@ import ( const webSearchAPIKeySecretName = "web_search.api_key" +// mcpTokenSecretName holds the bearer token external MCP clients present to +// the local server. Kept in the secret store, never in settings.json. +const mcpTokenSecretName = "mcp.token" + func providerAPIKeySecretName(provider string) string { return "provider." + provider + ".api_key" } diff --git a/engine/internal/settings/settings.go b/engine/internal/settings/settings.go index d92a26b6..bb090317 100644 --- a/engine/internal/settings/settings.go +++ b/engine/internal/settings/settings.go @@ -131,6 +131,12 @@ type Config struct { WebSearchProvider string `json:"web_search_provider"` WebSearchAPIKey string `json:"web_search_api_key,omitempty"` // write-only in settings.set; redacted from settings.get and disk WebSearchAPIKeySet bool `json:"web_search_api_key_set,omitempty"` // read-only presence flag for settings.get + MCPMode string `json:"mcp_mode"` // off | read_only | full; off means no listener binds + MCPPort int `json:"mcp_port"` // fixed so saved client configs survive restarts + MCPProjectID string `json:"mcp_project_id"` // empty means every work is reachable + MCPConsentVersion int `json:"mcp_consent_version"` + MCPConsentedAt int64 `json:"mcp_consented_at"` + MCPTokenSet bool `json:"mcp_token_set,omitempty"` // read-only presence flag for settings.get } // Patch holds optional updates. Nil pointers mean "leave the field alone". @@ -156,6 +162,11 @@ type Patch struct { AIDataSharingConsentedAt *int64 `json:"ai_data_sharing_consented_at,omitempty"` WebSearchProvider *string `json:"web_search_provider,omitempty"` WebSearchAPIKey *string `json:"web_search_api_key,omitempty"` + MCPMode *string `json:"mcp_mode,omitempty"` + MCPPort *int `json:"mcp_port,omitempty"` + MCPProjectID *string `json:"mcp_project_id,omitempty"` + MCPConsentVersion *int `json:"mcp_consent_version,omitempty"` + MCPConsentedAt *int64 `json:"mcp_consented_at,omitempty"` } // Store reads and writes the settings file with internal locking. @@ -222,6 +233,8 @@ func defaults(home string) Config { BackupDir: filepath.Join(home, "backups"), OnboardingTourEnabled: true, WebSearchProvider: "brave", + MCPMode: MCPModeOff, + MCPPort: DefaultMCPPort, } } @@ -514,6 +527,27 @@ func (s *Store) Set(ctx context.Context, p Patch) (Config, error) { } next.WebSearchAPIKey = "" } + if p.MCPMode != nil { + if !slices.Contains(ValidMCPModes(), *p.MCPMode) { + return Config{}, fmt.Errorf("settings: unknown mcp_mode %q", *p.MCPMode) + } + next.MCPMode = *p.MCPMode + } + if p.MCPPort != nil { + if *p.MCPPort < 1024 || *p.MCPPort > 65535 { + return Config{}, fmt.Errorf("settings: mcp_port %d out of range (1024-65535)", *p.MCPPort) + } + next.MCPPort = *p.MCPPort + } + if p.MCPProjectID != nil { + next.MCPProjectID = *p.MCPProjectID + } + if p.MCPConsentVersion != nil { + next.MCPConsentVersion = *p.MCPConsentVersion + } + if p.MCPConsentedAt != nil { + next.MCPConsentedAt = *p.MCPConsentedAt + } if next.WebSearchProvider == "" { next.WebSearchProvider = "brave" } @@ -686,6 +720,20 @@ func normalizeEditorPreferences(c Config) Config { if !slices.Contains(validCopyProfiles(), c.CopyProfile) { c.CopyProfile = "plain" } + return normalizeMCPPreferences(c) +} + +// normalizeMCPPreferences keeps MCP settings safe by construction: an +// unrecognized mode falls back to off (never to an open server), and an +// out-of-range port falls back to the default so a bad value cannot make the +// server unreachable in a way the writer cannot see. +func normalizeMCPPreferences(c Config) Config { + if !slices.Contains(ValidMCPModes(), c.MCPMode) { + c.MCPMode = MCPModeOff + } + if c.MCPPort < 1024 || c.MCPPort > 65535 { + c.MCPPort = DefaultMCPPort + } return c } @@ -706,6 +754,9 @@ func (s *Store) redactedSettingsView(c Config) Config { if err == nil { c.WebSearchAPIKeySet = webKeySet } + if mcpTokenSet, err := s.secrets.Exists(mcpTokenSecretName); err == nil { + c.MCPTokenSet = mcpTokenSet + } return c } @@ -724,6 +775,7 @@ func sanitizeConfigForMemory(c Config) Config { func sanitizeConfigForDisk(c Config) Config { c = sanitizeConfigForMemory(c) c.WebSearchAPIKeySet = false + c.MCPTokenSet = false providers := map[string]ProviderConfig{} for id, cfg := range c.Providers { cfg.APIKeySet = false From c604964119ec52d25f2e69d799fc7c1855d40e8d Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 09:28:00 +0900 Subject: [PATCH 10/25] feat(engine): add the loopback MCP host with bearer auth and discovery internal/mcphost serves the MCP Streamable HTTP endpoint on 127.0.0.1: inside the running app, gated //go:build !mobile (MAS is included on purpose: once the companion goes away, MCP is the MAS build's only AI path). Safety properties the tests pin down: - mode off binds nothing and writes nothing; - starting without MCP consent fails with ErrConsentRequired; - a busy port returns ErrPortInUse instead of silently binding elsewhere, because every saved client config points at the configured port; - the bearer token is compared in constant time; - a non-loopback Origin or Host is rejected with 403 even when the token is valid - a web page on any site can otherwise POST to 127.0.0.1 (the DNS-rebinding case the MCP spec calls out for HTTP transports); - an authorized initialize returns 200 from the real MCP handler; - the 0600 discovery file carries port/token/pid and is removed on Stop so a stale endpoint is never advertised. Build-tag linkage verified: mas and default link the SDK, mobile links zero of it. Part of the MCP-first pivot (#47), Phase 2 Tasks 2.1 and 2.3. Co-Authored-By: Claude Opus 5 --- engine/go.mod | 9 +- engine/go.sum | 16 ++ engine/internal/mcphost/auth.go | 113 +++++++++++ engine/internal/mcphost/discovery.go | 73 +++++++ engine/internal/mcphost/host.go | 186 ++++++++++++++++++ engine/internal/mcphost/host_test.go | 272 +++++++++++++++++++++++++++ 6 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 engine/internal/mcphost/auth.go create mode 100644 engine/internal/mcphost/discovery.go create mode 100644 engine/internal/mcphost/host.go create mode 100644 engine/internal/mcphost/host_test.go diff --git a/engine/go.mod b/engine/go.mod index 0581ac2d..fab8a619 100644 --- a/engine/go.mod +++ b/engine/go.mod @@ -5,6 +5,7 @@ go 1.26.6 require ( github.com/devlikebear/tars v0.34.3 github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.7.0 golang.org/x/sys v0.44.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 @@ -12,13 +13,19 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rs/zerolog v1.33.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/time v0.15.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/engine/go.sum b/engine/go.sum index 0c61be8f..01d52f3d 100644 --- a/engine/go.sum +++ b/engine/go.sum @@ -4,6 +4,12 @@ github.com/devlikebear/tars v0.34.3/go.mod h1:VO3aJQ+y1ou9pWuhU76AbAOOR8MEb4XWYV github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -28,8 +34,16 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -37,6 +51,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/engine/internal/mcphost/auth.go b/engine/internal/mcphost/auth.go new file mode 100644 index 00000000..33057a3d --- /dev/null +++ b/engine/internal/mcphost/auth.go @@ -0,0 +1,113 @@ +//go:build !mobile + +package mcphost + +import ( + "crypto/subtle" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "syscall" +) + +// authMiddleware gates the MCP endpoint. Two independent checks: +// +// 1. A bearer token, compared in constant time. This is what actually +// authorizes the caller. +// 2. Origin/Host validation. Without it any web page the writer visits could +// POST to 127.0.0.1 and drive their manuscript — the DNS-rebinding case +// the MCP spec calls out for HTTP transports. A browser always sends +// Origin on cross-origin requests; a legitimate MCP client sends none. +func authMiddleware(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !originAllowed(r.Header.Get("Origin")) { + http.Error(w, "forbidden origin", http.StatusForbidden) + return + } + if !hostAllowed(r.Host) { + http.Error(w, "forbidden host", http.StatusForbidden) + return + } + if !tokenMatches(token, r.Header.Get("Authorization")) { + w.Header().Set("WWW-Authenticate", `Bearer realm="linetta"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// tokenMatches accepts only "Bearer " with an exact, constant-time match. +func tokenMatches(want, header string) bool { + if want == "" { + return false + } + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return false + } + got := strings.TrimSpace(header[len(prefix):]) + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// originAllowed permits a missing Origin (native MCP clients send none) and +// loopback origins; everything else is rejected. +func originAllowed(origin string) bool { + origin = strings.TrimSpace(origin) + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return isLoopbackHost(u.Hostname()) +} + +// hostAllowed rejects a Host header pointing anywhere but loopback, so a +// rebound DNS name cannot be used to reach the server. +func hostAllowed(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + name, _, err := net.SplitHostPort(host) + if err != nil { + name = host + } + return isLoopbackHost(name) +} + +func isLoopbackHost(name string) bool { + name = strings.TrimSpace(strings.Trim(name, "[]")) + if name == "" { + return false + } + if strings.EqualFold(name, "localhost") { + return true + } + ip := net.ParseIP(name) + return ip != nil && ip.IsLoopback() +} + +// isAddrInUse reports whether err is the OS "address already in use" error. +// Windows uses WSAEADDRINUSE (10048) rather than the POSIX constant. +func isAddrInUse(err error) bool { + if errors.Is(err, syscall.EADDRINUSE) { + return true + } + var errno syscall.Errno + if errors.As(err, &errno) && uintptr(errno) == 10048 { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "address already in use") || + strings.Contains(strings.ToLower(err.Error()), "only one usage of each socket address") +} + +func logf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "mcphost: "+format+"\n", args...) +} diff --git a/engine/internal/mcphost/discovery.go b/engine/internal/mcphost/discovery.go new file mode 100644 index 00000000..03350d74 --- /dev/null +++ b/engine/internal/mcphost/discovery.go @@ -0,0 +1,73 @@ +//go:build !mobile + +package mcphost + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// DiscoveryFileName is the file the stdio bridge reads to find the running +// server. It sits next to library.db and settings.json in $LINETTA_HOME. +// +// Trust boundary: a process running as this user can already read library.db +// and the secret store directly, so carrying the token here does not lower the +// bar — it just spares the writer from pasting it into a bridge config. +const DiscoveryFileName = "mcp.json" + +// Discovery is the on-disk contents of the discovery file. +type Discovery struct { + Port int `json:"port"` + Token string `json:"token"` + PID int `json:"pid"` + StartedAt int64 `json:"started_at"` +} + +func discoveryPath(home string) string { + return filepath.Join(home, DiscoveryFileName) +} + +// writeDiscoveryFile records the live endpoint at 0600. Written after the +// listener is up so a reader that finds the file can connect. +func writeDiscoveryFile(home string, port int, token string) error { + if home == "" { + return nil + } + raw, err := json.Marshal(Discovery{ + Port: port, + Token: token, + PID: os.Getpid(), + StartedAt: time.Now().UnixMilli(), + }) + if err != nil { + return err + } + return os.WriteFile(discoveryPath(home), raw, 0o600) +} + +// removeDiscoveryFile deletes the file on shutdown so a stale endpoint is +// never advertised. A missing file is not an error. +func removeDiscoveryFile(home string) { + if home == "" { + return + } + if err := os.Remove(discoveryPath(home)); err != nil && !os.IsNotExist(err) { + logf("remove discovery file: %v", err) + } +} + +// ReadDiscoveryFile loads the endpoint written by a running app. The bridge +// binary uses this; exported so cmd/linetta-mcp does not duplicate the format. +func ReadDiscoveryFile(home string) (Discovery, error) { + raw, err := os.ReadFile(discoveryPath(home)) + if err != nil { + return Discovery{}, err + } + var d Discovery + if err := json.Unmarshal(raw, &d); err != nil { + return Discovery{}, err + } + return d, nil +} diff --git a/engine/internal/mcphost/host.go b/engine/internal/mcphost/host.go new file mode 100644 index 00000000..5b74c863 --- /dev/null +++ b/engine/internal/mcphost/host.go @@ -0,0 +1,186 @@ +//go:build !mobile + +// Package mcphost serves Linetta's story tools to external MCP clients +// (Claude Code, Claude Desktop) over a loopback-only Streamable HTTP endpoint. +// +// It is hosted inside the running app rather than in a separate process +// because engineapp.Open unconditionally starts background jobs (backup, +// snapshot thinning, summarizer, folder/git sync) and because the UI refresh +// path — Go notifier → C callback → Tauri emit → useEngineEvent — is +// in-process only. A second process would double the jobs and leave the writer +// staring at a stale scene. +// +// Part of the MCP-first pivot (#47). +package mcphost + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/settings" +) + +// ServerName and ServerVersion identify this server to clients. +const ( + ServerName = "linetta" + ServerVersion = "0.1.0" +) + +// shutdownGrace bounds how long Stop waits for in-flight tool calls. +const shutdownGrace = 3 * time.Second + +// ErrPortInUse means the configured port is taken. The writer must pick +// another one — the host never silently falls back to a different port, +// because every saved client config points at the configured one. +var ErrPortInUse = errors.New("mcphost: port already in use") + +// ErrConsentRequired means MCP access has not been accepted yet. +var ErrConsentRequired = errors.New("mcphost: MCP consent is required before starting the server") + +// Deps are the collaborators the host needs. Tools are registered separately +// (see tools_read.go) so this file stays about lifecycle and auth. +type Deps struct { + Settings *settings.Store + // Tools registers the tool set for the current mode on a fresh server. + Tools func(s *mcp.Server, mode string) + // Home is $LINETTA_HOME, where the discovery file lives. + Home string +} + +// Host owns the listener, the MCP server, and the discovery file. +type Host struct { + deps Deps + + mu sync.Mutex + httpSrv *http.Server + port int + token string + running bool +} + +// New returns a Host. Nothing binds until Start is called. +func New(deps Deps) *Host { return &Host{deps: deps} } + +// Status reports whether the server is listening and on which port. +type Status struct { + Running bool `json:"running"` + Mode string `json:"mode"` + Port int `json:"port,omitempty"` + ProjectID string `json:"project_id,omitempty"` + TokenSet bool `json:"token_set"` +} + +// Status returns the current listener state. +func (h *Host) Status() Status { + h.mu.Lock() + defer h.mu.Unlock() + st := Status{ + Running: h.running, + Mode: h.deps.Settings.MCPMode(), + ProjectID: h.deps.Settings.MCPProjectID(), + TokenSet: h.deps.Settings.MCPToken() != "", + } + if h.running { + st.Port = h.port + } + return st +} + +// Start binds the loopback listener and writes the discovery file. It is a +// no-op when the mode is off or the server is already running. +func (h *Host) Start(ctx context.Context) error { + mode := h.deps.Settings.MCPMode() + if mode == settings.MCPModeOff { + return nil + } + if !h.deps.Settings.HasMCPConsent() { + return ErrConsentRequired + } + + h.mu.Lock() + defer h.mu.Unlock() + if h.running { + return nil + } + + token, err := h.deps.Settings.EnsureMCPToken() + if err != nil { + return err + } + port := h.deps.Settings.MCPPort() + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + if isAddrInUse(err) { + return fmt.Errorf("%w: %d", ErrPortInUse, port) + } + return fmt.Errorf("mcphost: listen on 127.0.0.1:%d: %w", port, err) + } + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + srv := mcp.NewServer(&mcp.Implementation{ + Name: ServerName, + Title: "Linetta", + Version: ServerVersion, + }, nil) + if h.deps.Tools != nil { + h.deps.Tools(srv, mode) + } + return srv + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", authMiddleware(token, handler)) + + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + h.httpSrv = srv + h.port = port + h.token = token + h.running = true + + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + logf("serve: %v", err) + } + }() + + if err := writeDiscoveryFile(h.deps.Home, port, token); err != nil { + logf("discovery file: %v", err) + } + return nil +} + +// Stop shuts the listener down and removes the discovery file. Safe to call +// when not running. +func (h *Host) Stop() error { + h.mu.Lock() + srv := h.httpSrv + h.httpSrv = nil + h.running = false + h.port = 0 + h.token = "" + h.mu.Unlock() + + removeDiscoveryFile(h.deps.Home) + if srv == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace) + defer cancel() + return srv.Shutdown(ctx) +} + +// Restart applies changed settings (mode, port, token) by cycling the listener. +func (h *Host) Restart(ctx context.Context) error { + if err := h.Stop(); err != nil { + return err + } + return h.Start(ctx) +} diff --git a/engine/internal/mcphost/host_test.go b/engine/internal/mcphost/host_test.go new file mode 100644 index 00000000..3b716afa --- /dev/null +++ b/engine/internal/mcphost/host_test.go @@ -0,0 +1,272 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/settings" +) + +func newHost(t *testing.T, mode string, consent bool) (*Host, *settings.Store, string) { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + st, err := settings.NewWithSecretStore(settings.NewMemorySecretStore()) + if err != nil { + t.Fatalf("settings: %v", err) + } + patch := settings.Patch{MCPMode: &mode, MCPPort: freePort(t)} + if consent { + version := settings.MCPConsentVersion + at := int64(1) + patch.MCPConsentVersion = &version + patch.MCPConsentedAt = &at + } + if _, err := st.Set(context.Background(), patch); err != nil { + t.Fatalf("settings.Set: %v", err) + } + h := New(Deps{Settings: st, Home: home}) + t.Cleanup(func() { _ = h.Stop() }) + return h, st, home +} + +// freePort grabs a port the OS just handed out, then releases it, so parallel +// test runs do not collide on the fixed default. +func freePort(t *testing.T) *int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return &port +} + +func endpoint(h *Host) string { + return fmt.Sprintf("http://127.0.0.1:%d/mcp", h.Status().Port) +} + +// A POST that should reach the handler; the body is a valid initialize call so +// only auth decides the outcome. +func post(t *testing.T, url string, headers map[string]string) *http.Response { + t.Helper() + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize",` + + `"params":{"protocolVersion":"2025-06-18","capabilities":{},` + + `"clientInfo":{"name":"test","version":"1"}}}`) + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp +} + +// Mode off must leave the machine untouched: nothing binds, nothing is written. +func TestStartIsNoopWhenModeOff(t *testing.T) { + h, _, home := newHost(t, settings.MCPModeOff, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if h.Status().Running { + t.Fatal("mode off must not bind a listener") + } + if _, err := os.Stat(filepath.Join(home, DiscoveryFileName)); !errors.Is(err, fs.ErrNotExist) { + t.Fatal("mode off must not write a discovery file") + } +} + +// Enabling without consent must fail loudly rather than quietly serving. +func TestStartRequiresConsent(t *testing.T) { + h, _, _ := newHost(t, settings.MCPModeReadOnly, false) + err := h.Start(context.Background()) + if !errors.Is(err, ErrConsentRequired) { + t.Fatalf("Start without consent = %v, want ErrConsentRequired", err) + } + if h.Status().Running { + t.Fatal("server must not run without consent") + } +} + +func TestStartWritesDiscoveryFileAndStopRemovesIt(t *testing.T) { + h, st, home := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + status := h.Status() + if !status.Running || status.Port == 0 { + t.Fatalf("status = %+v, want a running server with a port", status) + } + + path := filepath.Join(home, DiscoveryFileName) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat discovery file: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("discovery file mode = %o, want 600", perm) + } + } + d, err := ReadDiscoveryFile(home) + if err != nil { + t.Fatalf("ReadDiscoveryFile: %v", err) + } + if d.Port != status.Port || d.Token != st.MCPToken() || d.PID != os.Getpid() { + t.Fatalf("discovery = %+v, want port %d and the live token/pid", d, status.Port) + } + + if err := h.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { + t.Error("Stop must remove the discovery file so a stale endpoint is never advertised") + } + if h.Status().Running { + t.Error("status must report stopped after Stop") + } +} + +// The port is the writer's setting: a busy one is an error they can see and +// act on, never a silent bind somewhere else that breaks saved configs. +func TestStartReportsPortInUse(t *testing.T) { + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + st, err := settings.NewWithSecretStore(settings.NewMemorySecretStore()) + if err != nil { + t.Fatalf("settings: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("occupy port: %v", err) + } + defer ln.Close() + busy := ln.Addr().(*net.TCPAddr).Port + + mode := settings.MCPModeReadOnly + version := settings.MCPConsentVersion + at := int64(1) + if _, err := st.Set(context.Background(), settings.Patch{ + MCPMode: &mode, MCPPort: &busy, MCPConsentVersion: &version, MCPConsentedAt: &at, + }); err != nil { + t.Fatalf("settings.Set: %v", err) + } + + h := New(Deps{Settings: st, Home: home}) + t.Cleanup(func() { _ = h.Stop() }) + err = h.Start(context.Background()) + if !errors.Is(err, ErrPortInUse) { + t.Fatalf("Start on a busy port = %v, want ErrPortInUse", err) + } + if h.Status().Running { + t.Fatal("a failed bind must not leave the host marked running") + } +} + +func TestAuthRejectsMissingAndWrongToken(t *testing.T) { + h, _, _ := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + url := endpoint(h) + + if resp := post(t, url, nil); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("no token: status = %d, want 401", resp.StatusCode) + } + if resp := post(t, url, map[string]string{"Authorization": "Bearer nope"}); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("wrong token: status = %d, want 401", resp.StatusCode) + } + if resp := post(t, url, map[string]string{"Authorization": "nope"}); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("malformed scheme: status = %d, want 401", resp.StatusCode) + } +} + +// A web page on any site can POST to 127.0.0.1; a non-loopback Origin is the +// DNS-rebinding signature the MCP spec tells servers to reject. +func TestAuthRejectsForeignOrigin(t *testing.T) { + h, st, _ := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + auth := "Bearer " + st.MCPToken() + resp := post(t, endpoint(h), map[string]string{ + "Authorization": auth, + "Origin": "https://evil.test", + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("foreign origin: status = %d, want 403 even with a valid token", resp.StatusCode) + } +} + +func TestAuthAllowsLoopbackOriginWithToken(t *testing.T) { + h, st, _ := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + auth := "Bearer " + st.MCPToken() + for _, origin := range []string{"", "http://localhost:3000", "http://127.0.0.1:5173"} { + headers := map[string]string{"Authorization": auth} + if origin != "" { + headers["Origin"] = origin + } + resp := post(t, endpoint(h), headers) + // 200 proves the request reached the MCP handler and initialize + // succeeded, not merely that auth declined to reject it. + if resp.StatusCode != http.StatusOK { + t.Errorf("origin %q: status = %d, want 200 from the MCP handler", origin, resp.StatusCode) + } + } +} + +func TestUnitOriginAndHostChecks(t *testing.T) { + for _, tc := range []struct { + origin string + want bool + }{ + {"", true}, + {"http://localhost", true}, + {"http://127.0.0.1:7391", true}, + {"http://[::1]:7391", true}, + {"https://evil.test", false}, + {"http://127.0.0.1.evil.test", false}, + {"not a url at all ::::", false}, + } { + if got := originAllowed(tc.origin); got != tc.want { + t.Errorf("originAllowed(%q) = %v, want %v", tc.origin, got, tc.want) + } + } + for _, tc := range []struct { + host string + want bool + }{ + {"127.0.0.1:7391", true}, + {"localhost:7391", true}, + {"[::1]:7391", true}, + {"linetta.evil.test", false}, + {"", false}, + } { + if got := hostAllowed(tc.host); got != tc.want { + t.Errorf("hostAllowed(%q) = %v, want %v", tc.host, got, tc.want) + } + } +} From e2d3d4ddb59317e68df0f4783c435c8964c923d8 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 09:31:34 +0900 Subject: [PATCH 11/25] feat(engine): wire the MCP host into engineapp behind a build-tag pair mcp_enabled.go (!mobile) starts the host honoring the persisted mode and registers mcp.status/enable/disable/regenerate_token/activity; mcp_disabled.go (mobile) supplies a controller that reports a disabled state, so the SDK is never linked into the mobile engine (verified: 0 deps). Unlike git sync, MAS is NOT excluded - after the companion is removed, MCP is the MAS build's only AI path. diagnostics now reports mcp_available so the settings pane can hide itself on builds without MCP, and the host's Stop joins app.closers. Port-in-use and missing-consent travel to the renderer as reason codes (mcp_port_in_use / mcp_consent_required) rather than raw English, using the mechanism added in #43. Tests drive real JSONRPC through the app: a fresh engine binds nothing, enabling without consent is refused with its reason code, enable actually binds the configured port, app.Close releases it, and mcp.disable works as the kill switch. Part of the MCP-first pivot (#47), Phase 2 Task 2.4. Co-Authored-By: Claude Opus 5 --- engine/internal/engineapp/engineapp.go | 12 ++ engine/internal/engineapp/mcp_disabled.go | 50 ++++++ engine/internal/engineapp/mcp_enabled.go | 101 +++++++++++ engine/internal/engineapp/mcp_wiring_test.go | 177 +++++++++++++++++++ engine/internal/rpc/handlers/diagnostics.go | 3 + engine/internal/rpc/handlers/mcp.go | 119 +++++++++++++ 6 files changed, 462 insertions(+) create mode 100644 engine/internal/engineapp/mcp_disabled.go create mode 100644 engine/internal/engineapp/mcp_enabled.go create mode 100644 engine/internal/engineapp/mcp_wiring_test.go create mode 100644 engine/internal/rpc/handlers/mcp.go diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 81553452..2e37f1ff 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -218,9 +218,16 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error WithManuscript(manuscriptSearcher). WithSnapshots(snaps) + // The MCP host serves story tools to external agents. Tools are registered + // per session in a later task; the host binds only when the writer has + // turned MCP on and accepted its consent. + mcpCtrl, stopMCP := setupMCP(mcpDeps{settingsStore: settingsStore, home: home}) + a.closers = append(a.closers, stopMCP) + caps := handlers.Capabilities{ UnavailableProviders: ai.UnavailableProviders(), GitSyncAvailable: gitSyncAvailable, + MCPAvailable: mcpAvailable, } s.Handle("ping", handlers.Ping) s.Handle("diagnostics.version", handlers.DiagnosticsVersion(st, home, DefaultVersion, caps)) @@ -319,6 +326,11 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error s.Handle("companion.references.delete", handlers.CompanionReferencesDelete(companionSvc)) s.Handle("settings.get", handlers.GetSettings(settingsStore)) s.Handle("settings.set", handlers.SetSettings(settingsStore)) + s.Handle("mcp.status", handlers.MCPStatus(mcpCtrl)) + s.Handle("mcp.enable", handlers.MCPEnable(mcpCtrl)) + s.Handle("mcp.disable", handlers.MCPDisable(mcpCtrl)) + s.Handle("mcp.regenerate_token", handlers.MCPRegenerateToken(mcpCtrl)) + s.Handle("mcp.activity", handlers.MCPActivity(mcpCtrl)) openRouterOAuth := openrouter.NewOAuthManager(openrouter.OAuthConfig{}) s.Handle("providers.list_models", handlers.ListModels(settingsStore, modelcatalog.Default())) s.Handle("providers.detect_cli", handlers.DetectCLI()) diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go new file mode 100644 index 00000000..f03dcff7 --- /dev/null +++ b/engine/internal/engineapp/mcp_disabled.go @@ -0,0 +1,50 @@ +//go:build mobile + +package engineapp + +import ( + "context" + "encoding/json" + "errors" + + "github.com/devlikebear/linetta/engine/internal/settings" +) + +// Mobile builds cannot host a local server, so MCP is compiled out entirely — +// the SDK is never linked into the mobile engine. +const mcpAvailable = false + +type mcpDeps struct { + settingsStore *settings.Store + home string + tools any +} + +// mcpController answers status queries with a disabled state and refuses +// mutations, so the frontend gets a clear message rather than a missing method +// if it ever calls through on a build where the pane should be hidden. +type mcpController struct{} + +var errMCPUnavailable = errors.New("mcp is not available in this build") + +func setupMCP(mcpDeps) (*mcpController, func() error) { + return &mcpController{}, func() error { return nil } +} + +func (c *mcpController) Status() (json.RawMessage, error) { + return json.Marshal(struct { + Running bool `json:"running"` + Mode string `json:"mode"` + }{Running: false, Mode: "off"}) +} + +func (c *mcpController) Enable(context.Context) error { return errMCPUnavailable } +func (c *mcpController) Disable(context.Context) error { return nil } + +func (c *mcpController) RegenerateToken(context.Context) (json.RawMessage, error) { + return nil, errMCPUnavailable +} + +func (c *mcpController) Activity(context.Context, int) (json.RawMessage, error) { + return json.Marshal([]struct{}{}) +} diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go new file mode 100644 index 00000000..8948edea --- /dev/null +++ b/engine/internal/engineapp/mcp_enabled.go @@ -0,0 +1,101 @@ +//go:build !mobile + +package engineapp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/mcphost" + "github.com/devlikebear/linetta/engine/internal/rpc/handlers" + "github.com/devlikebear/linetta/engine/internal/settings" +) + +// MCP ships on desktop and on the Mac App Store. It is deliberately NOT gated +// on `mas` the way git sync is: once the companion is removed, MCP is the only +// AI path a MAS build has. Mobile cannot host a server, so it gets the +// disabled twin. +const mcpAvailable = true + +// mcpDeps is what setupMCP needs from register(). +type mcpDeps struct { + settingsStore *settings.Store + home string + tools func(s *mcp.Server, mode string) +} + +// mcpController adapts *mcphost.Host to handlers.MCPController, translating +// host errors into the sentinels the RPC layer turns into reason codes. +type mcpController struct { + host *mcphost.Host + set *settings.Store +} + +func setupMCP(deps mcpDeps) (*mcpController, func() error) { + host := mcphost.New(mcphost.Deps{ + Settings: deps.settingsStore, + Home: deps.home, + Tools: deps.tools, + }) + ctrl := &mcpController{host: host, set: deps.settingsStore} + // Start honors the persisted mode: a writer who left MCP on finds it + // running after a restart, and mode off binds nothing. + if err := host.Start(context.Background()); err != nil { + fmt.Printf("mcp: start skipped: %v\n", err) + } + return ctrl, host.Stop +} + +func (c *mcpController) Status() (json.RawMessage, error) { + return json.Marshal(c.host.Status()) +} + +func (c *mcpController) Enable(ctx context.Context) error { + if err := c.host.Restart(ctx); err != nil { + return translateMCPError(err) + } + return nil +} + +func (c *mcpController) Disable(ctx context.Context) error { + return c.host.Stop() +} + +func (c *mcpController) RegenerateToken(ctx context.Context) (json.RawMessage, error) { + token, err := c.set.RegenerateMCPToken() + if err != nil { + return nil, err + } + // The listener holds the old token in memory, so it must be cycled for the + // new one to take effect. + if c.host.Status().Running { + if err := c.host.Restart(ctx); err != nil { + return nil, translateMCPError(err) + } + } + return json.Marshal(struct { + Token string `json:"token"` + Status mcphost.Status `json:"status"` + }{Token: token, Status: c.host.Status()}) +} + +func (c *mcpController) Activity(ctx context.Context, limit int) (json.RawMessage, error) { + // The activity log lands with the tool layer (Task 2.6); until then this + // reports an empty list rather than failing the settings pane. + return json.Marshal([]struct{}{}) +} + +func translateMCPError(err error) error { + switch { + case errors.Is(err, mcphost.ErrPortInUse): + return fmt.Errorf("%w: %v", handlers.ErrMCPPortInUse, err) + case errors.Is(err, mcphost.ErrConsentRequired): + return fmt.Errorf("%w: %v", handlers.ErrMCPConsentRequired, err) + default: + return err + } +} diff --git a/engine/internal/engineapp/mcp_wiring_test.go b/engine/internal/engineapp/mcp_wiring_test.go new file mode 100644 index 00000000..94252f1b --- /dev/null +++ b/engine/internal/engineapp/mcp_wiring_test.go @@ -0,0 +1,177 @@ +//go:build !mobile + +package engineapp + +import ( + "context" + "encoding/json" + "fmt" + "net" + "testing" +) + +// call sends one JSONRPC request through the app and returns the raw result. +func call(t *testing.T, app *App, method string, params string) (json.RawMessage, *rpcError) { + t.Helper() + if params == "" { + params = "null" + } + req := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":%q,"params":%s}`, method, params) + raw, err := app.Handle(context.Background(), []byte(req)) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + var envelope struct { + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode %s response: %v", method, err) + } + return envelope.Result, envelope.Error +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +type mcpStatus struct { + Running bool `json:"running"` + Mode string `json:"mode"` + Port int `json:"port"` + TokenSet bool `json:"token_set"` +} + +func openApp(t *testing.T) *App { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = app.Close() }) + return app +} + +func portFree(t *testing.T, port int) bool { + t.Helper() + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return false + } + _ = ln.Close() + return true +} + +// A fresh install must not open a port. MCP is opt-in. +func TestMCPDefaultsToNoListener(t *testing.T) { + app := openApp(t) + result, rpcErr := call(t, app, "mcp.status", "") + if rpcErr != nil { + t.Fatalf("mcp.status: %+v", rpcErr) + } + var st mcpStatus + if err := json.Unmarshal(result, &st); err != nil { + t.Fatalf("decode status: %v", err) + } + if st.Running { + t.Fatal("a fresh engine must not be serving MCP") + } + if st.Mode != "off" { + t.Fatalf("mode = %q, want off", st.Mode) + } +} + +// Enabling without consent must be refused with a reason code the UI can +// localize, not a generic internal error. +func TestMCPEnableWithoutConsentIsRefused(t *testing.T) { + app := openApp(t) + if _, rpcErr := call(t, app, "settings.set", `{"mcp_mode":"read_only"}`); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + _, rpcErr := call(t, app, "mcp.enable", "") + if rpcErr == nil { + t.Fatal("enable without consent should fail") + } + if got := string(rpcErr.Data); got != `{"reason":"mcp_consent_required"}` { + t.Fatalf("error data = %s, want an mcp_consent_required reason", got) + } +} + +// The full loop: consent + mode, enable, verify the port is really bound, then +// confirm Close releases it — a leaked listener would block the next launch. +func TestMCPEnableBindsAndCloseReleasesPort(t *testing.T) { + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + + free := freeTestPort(t) + patch := fmt.Sprintf(`{"mcp_mode":"read_only","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, free) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + + result, rpcErr := call(t, app, "mcp.enable", "") + if rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + var st mcpStatus + if err := json.Unmarshal(result, &st); err != nil { + t.Fatalf("decode status: %v", err) + } + if !st.Running || st.Port != free { + t.Fatalf("status = %+v, want running on port %d", st, free) + } + if !st.TokenSet { + t.Error("enabling must ensure a bearer token exists") + } + if portFree(t, free) { + t.Fatal("mcp.enable reported running but nothing is listening") + } + + if err := app.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !portFree(t, free) { + t.Fatal("Close must release the MCP port") + } +} + +// mcp.disable is the kill switch: the listener goes away immediately. +func TestMCPDisableStopsListener(t *testing.T) { + app := openApp(t) + free := freeTestPort(t) + patch := fmt.Sprintf(`{"mcp_mode":"full","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, free) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + if _, rpcErr := call(t, app, "mcp.enable", ""); rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + if portFree(t, free) { + t.Fatal("expected a bound port after enable") + } + if _, rpcErr := call(t, app, "mcp.disable", ""); rpcErr != nil { + t.Fatalf("mcp.disable: %+v", rpcErr) + } + if !portFree(t, free) { + t.Fatal("mcp.disable must drop the listener") + } +} + +func freeTestPort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return port +} diff --git a/engine/internal/rpc/handlers/diagnostics.go b/engine/internal/rpc/handlers/diagnostics.go index defd1ea2..c6fb8e01 100644 --- a/engine/internal/rpc/handlers/diagnostics.go +++ b/engine/internal/rpc/handlers/diagnostics.go @@ -16,6 +16,7 @@ import ( type Capabilities struct { UnavailableProviders []string GitSyncAvailable bool + MCPAvailable bool } type diagnosticsPayload struct { @@ -26,6 +27,7 @@ type diagnosticsPayload struct { MigrationCount int `json:"migration_count"` UnavailableProviders []string `json:"unavailable_providers,omitempty"` GitSyncAvailable bool `json:"git_sync_available"` + MCPAvailable bool `json:"mcp_available"` } type diagnosticsGetPayload struct { @@ -56,6 +58,7 @@ func DiagnosticsVersion(st *store.Store, home string, version string, caps Capab MigrationCount: int(count.Int64), UnavailableProviders: caps.UnavailableProviders, GitSyncAvailable: caps.GitSyncAvailable, + MCPAvailable: caps.MCPAvailable, } return json.Marshal(payload) } diff --git a/engine/internal/rpc/handlers/mcp.go b/engine/internal/rpc/handlers/mcp.go new file mode 100644 index 00000000..7050f80f --- /dev/null +++ b/engine/internal/rpc/handlers/mcp.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + + "github.com/devlikebear/linetta/engine/internal/rpc" +) + +// MCPController is the slice of the MCP host the RPC layer needs. Declared as +// an interface so this file compiles on every build tag — the host itself is +// //go:build !mobile. +type MCPController interface { + Status() (json.RawMessage, error) + Enable(ctx context.Context) error + Disable(ctx context.Context) error + RegenerateToken(ctx context.Context) (json.RawMessage, error) + Activity(ctx context.Context, limit int) (json.RawMessage, error) +} + +// ErrMCPPortInUse lets the host report a taken port without the RPC layer +// importing mcphost. The renderer turns the reason code into a localized +// "port is in use, pick another" message. +var ErrMCPPortInUse = errors.New("mcp port in use") + +// ErrMCPConsentRequired means MCP access has not been accepted yet. +var ErrMCPConsentRequired = errors.New("mcp consent required") + +func mcpError(err error) error { + switch { + case errors.Is(err, ErrMCPPortInUse): + return &rpc.MethodError{ + Code: rpc.CodeInvalidParams, + Message: err.Error(), + Data: rpc.ReasonData("mcp_port_in_use"), + } + case errors.Is(err, ErrMCPConsentRequired): + return &rpc.MethodError{ + Code: rpc.CodeInvalidParams, + Message: err.Error(), + Data: rpc.ReasonData("mcp_consent_required"), + } + default: + return &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + } +} + +// MCPStatus returns a handler for mcp.status. +func MCPStatus(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + out, err := ctrl.Status() + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +// MCPEnable returns a handler for mcp.enable. +func MCPEnable(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + if err := ctrl.Enable(ctx); err != nil { + return nil, mcpError(err) + } + out, err := ctrl.Status() + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +// MCPDisable returns a handler for mcp.disable. This is the kill switch: it +// drops the listener immediately. +func MCPDisable(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + if err := ctrl.Disable(ctx); err != nil { + return nil, mcpError(err) + } + out, err := ctrl.Status() + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +// MCPRegenerateToken returns a handler for mcp.regenerate_token. The new token +// is returned once so the settings pane can render a fresh client snippet; +// settings.get never exposes it again. +func MCPRegenerateToken(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + out, err := ctrl.RegenerateToken(ctx) + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +type mcpActivityParams struct { + Limit int `json:"limit,omitempty"` +} + +// MCPActivity returns a handler for mcp.activity: what external agents did. +func MCPActivity(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) { + var p mcpActivityParams + if len(params) > 0 { + _ = json.Unmarshal(params, &p) + } + out, err := ctrl.Activity(ctx, p.Limit) + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} From ecaf07e195d2b0f8e4ca6fbf8e40bcfb0b8948b3 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 09:41:25 +0900 Subject: [PATCH 12/25] feat(engine): serve the nine MCP read tools with an audit trail The read surface external agents build against: list_works, get_outline, get_story_context, read_scene, search_manuscript, list_characters, where_does_appear, get_plot, get_fact_cards. Every payload carries stable ids, read_scene returns plain prose plus the content_version a later write must present, and get_story_context returns the rendered brief with an included/empty section report so an agent can tell that summaries are missing and offer to write them. Scoping runs through one requireProject/requireNode pair, so the single-work restriction cannot be bypassed by reaching a scene by id. Bad arguments come back as tool errors with actionable text rather than transport failures. The MCP tool layer gets its own storycontext builder wired with fact, memory, and reference sources (adapters added to companion, which owns those repos today and hands them over in the removal phase). The AI runner's builder is untouched, so ai.run prompts do not change. Task 2.6 lands with it rather than after: every tool is registered through a recording decorator, so no tool can forget to report itself, and the mcp_activity table trims itself to 500 rows instead of leaning on the nightly job. mcp.activity now returns real entries. Tests drive the live HTTP endpoint as an external client would - initialize, tools/list, tools/call over SSE - covering the exact nine-tool surface, a full read round trip, tool errors for unknown ids, cross-work refusal under restriction, and both outcomes reaching the audit trail. Part of the MCP-first pivot (#47), Phase 2 Tasks 2.5 and 2.6. Co-Authored-By: Claude Opus 5 --- engine/internal/companion/context_sources.go | 104 +++ engine/internal/engineapp/engineapp.go | 31 +- engine/internal/engineapp/mcp_disabled.go | 25 +- engine/internal/engineapp/mcp_enabled.go | 66 +- engine/internal/engineapp/mcp_tools_test.go | 352 ++++++++++ engine/internal/mcphost/activity.go | 113 ++++ engine/internal/mcphost/tools.go | 167 +++++ engine/internal/mcphost/tools_read.go | 619 ++++++++++++++++++ .../store/migrations/0016_mcp_activity.sql | 18 + engine/internal/storycontext/builder.go | 5 + 10 files changed, 1483 insertions(+), 17 deletions(-) create mode 100644 engine/internal/companion/context_sources.go create mode 100644 engine/internal/engineapp/mcp_tools_test.go create mode 100644 engine/internal/mcphost/activity.go create mode 100644 engine/internal/mcphost/tools.go create mode 100644 engine/internal/mcphost/tools_read.go create mode 100644 engine/internal/store/migrations/0016_mcp_activity.sql diff --git a/engine/internal/companion/context_sources.go b/engine/internal/companion/context_sources.go new file mode 100644 index 00000000..81dfc86f --- /dev/null +++ b/engine/internal/companion/context_sources.go @@ -0,0 +1,104 @@ +package companion + +import ( + "context" + "strings" + + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// referenceContextLimit matches gatherContext's own cap on injected references. +const referenceContextLimit = 40 + +// The companion is currently the only place that gathers Fact Book cards, +// remembered facts, and writer-attached references. storycontext grew optional +// source interfaces for those sections in Phase 1 of the MCP-first pivot (#47); +// these adapters connect the two so the MCP story brief carries everything the +// companion's own prompt does. +// +// When this package is removed (pivot Phase 6), these three methods move to +// whatever owns the underlying repos — the fact and reference repos are +// already independent, and memory recall follows the remember op into +// storyops. + +var ( + _ storycontext.FactSource = (*Service)(nil) + _ storycontext.MemorySource = (*Service)(nil) + _ storycontext.ReferenceSource = (*Service)(nil) +) + +// ContextFacts returns Fact Book cards for the brief, preferring cards +// attached to the current scene, exactly as gatherContext does. +func (s *Service) ContextFacts(ctx context.Context, projectID, nodeID string) ([]storycontext.FactBrief, error) { + if s.facts == nil { + return nil, nil + } + filter := fact.ListFilter{ProjectID: projectID, Limit: factContextLimit} + if strings.TrimSpace(nodeID) != "" { + filter.NodeID = &nodeID + } + cards, err := s.facts.List(ctx, filter) + if err != nil { + return nil, err + } + out := make([]storycontext.FactBrief, 0, len(cards)) + for _, c := range cards { + brief := storycontext.FactBrief{ + ID: c.ID, + Status: c.Status, + Claim: c.Claim, + Category: c.Category, + Result: c.Result, + } + for _, src := range c.Sources { + if strings.TrimSpace(src.URL) == "" { + continue + } + brief.Sources = append(brief.Sources, storycontext.FactSourceBrief{ + Title: src.Title, + URL: src.URL, + }) + } + out = append(out, brief) + } + return out, nil +} + +// ContextMemories returns recent remembered facts for the brief. +func (s *Service) ContextMemories(projectID string) []string { + return s.Recall(projectID, "", recallLimit) +} + +// ContextReferences returns the writer-attached material for the brief, +// skipping disabled entries and using the same prompt text (summary vs full +// content) the companion sends. +func (s *Service) ContextReferences(ctx context.Context, projectID, nodeID string) ([]storycontext.ReferenceBrief, error) { + if s.references == nil { + return nil, nil + } + refs, err := s.ListReferences(ctx, ReferenceQuery{ + ProjectID: projectID, + NodeID: nodeID, + Limit: referenceContextLimit, + }) + if err != nil { + return nil, err + } + out := make([]storycontext.ReferenceBrief, 0, len(refs)) + for _, r := range refs { + if r.Status == ReferenceStatusDisabled { + continue + } + text := strings.TrimSpace(referencePromptText(r)) + if text == "" { + continue + } + out = append(out, storycontext.ReferenceBrief{ + Title: strings.TrimSpace(r.Title), + Purpose: strings.TrimSpace(r.Purpose), + Body: text, + }) + } + return out, nil +} diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 2e37f1ff..e90a9ea5 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -218,10 +218,33 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error WithManuscript(manuscriptSearcher). WithSnapshots(snaps) - // The MCP host serves story tools to external agents. Tools are registered - // per session in a later task; the host binds only when the writer has - // turned MCP on and accepted its consent. - mcpCtrl, stopMCP := setupMCP(mcpDeps{settingsStore: settingsStore, home: home}) + // The MCP host serves story tools to external agents. It binds only when + // the writer has turned MCP on and accepted its consent. + // + // The tool layer gets its OWN context builder, wired with the fact, + // memory, and reference sources. The builder above stays untouched so + // ai.run and ai.preview_context keep producing byte-identical prompts. + mcpContextBuilder := storycontext.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships). + WithSummaryRefresher(summ). + WithFactSource(companionSvc). + WithMemorySource(companionSvc). + WithReferenceSource(companionSvc) + + mcpCtrl, stopMCP := setupMCP(mcpDeps{ + settingsStore: settingsStore, + home: home, + repos: mcpToolRepos{ + projects: projects, + nodes: nodes, + entities: entities, + mentions: mentions, + facts: facts, + plot: plotBuilder, + manuscript: manuscriptSearcher, + context: mcpContextBuilder, + db: st.DB(), + }, + }) a.closers = append(a.closers, stopMCP) caps := handlers.Capabilities{ diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go index f03dcff7..d1edce9d 100644 --- a/engine/internal/engineapp/mcp_disabled.go +++ b/engine/internal/engineapp/mcp_disabled.go @@ -4,10 +4,19 @@ package engineapp import ( "context" + "database/sql" "encoding/json" "errors" + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/storycontext" ) // Mobile builds cannot host a local server, so MCP is compiled out entirely — @@ -17,7 +26,21 @@ const mcpAvailable = false type mcpDeps struct { settingsStore *settings.Store home string - tools any + repos mcpToolRepos +} + +// mcpToolRepos mirrors the enabled build's shape so register() compiles +// unchanged; mobile never reads these. +type mcpToolRepos struct { + projects *project.Repo + nodes *node.Repo + entities *entity.Repo + mentions *mention.Repo + facts *fact.Repo + plot *plot.Builder + manuscript *manuscript.Searcher + context *storycontext.ContextBuilder + db *sql.DB } // mcpController answers status queries with a disabled state and refuses diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go index 8948edea..20eaed43 100644 --- a/engine/internal/engineapp/mcp_enabled.go +++ b/engine/internal/engineapp/mcp_enabled.go @@ -4,15 +4,22 @@ package engineapp import ( "context" + "database/sql" "encoding/json" "errors" "fmt" - "github.com/modelcontextprotocol/go-sdk/mcp" - + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/manuscript" "github.com/devlikebear/linetta/engine/internal/mcphost" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/rpc/handlers" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/storycontext" ) // MCP ships on desktop and on the Mac App Store. It is deliberately NOT gated @@ -25,23 +32,52 @@ const mcpAvailable = true type mcpDeps struct { settingsStore *settings.Store home string - tools func(s *mcp.Server, mode string) + repos mcpToolRepos +} + +// mcpToolRepos collects what the tool layer reads from. The context builder is +// a second instance wired with fact/memory/reference sources — the builder the +// AI runner uses stays untouched so its prompts do not change. +type mcpToolRepos struct { + projects *project.Repo + nodes *node.Repo + entities *entity.Repo + mentions *mention.Repo + facts *fact.Repo + plot *plot.Builder + manuscript *manuscript.Searcher + context *storycontext.ContextBuilder + db *sql.DB } // mcpController adapts *mcphost.Host to handlers.MCPController, translating // host errors into the sentinels the RPC layer turns into reason codes. type mcpController struct { - host *mcphost.Host - set *settings.Store + host *mcphost.Host + set *settings.Store + activity *mcphost.ActivityRepo } func setupMCP(deps mcpDeps) (*mcpController, func() error) { + activity := mcphost.NewActivityRepo(deps.repos.db) + tools := mcphost.ToolDeps{ + Projects: deps.repos.projects, + Nodes: deps.repos.nodes, + Entities: deps.repos.entities, + Mentions: deps.repos.mentions, + Facts: deps.repos.facts, + Plot: deps.repos.plot, + Manuscript: deps.repos.manuscript, + Context: deps.repos.context, + Settings: deps.settingsStore, + Activity: activity, + } host := mcphost.New(mcphost.Deps{ Settings: deps.settingsStore, Home: deps.home, - Tools: deps.tools, + Tools: tools.Register, }) - ctrl := &mcpController{host: host, set: deps.settingsStore} + ctrl := &mcpController{host: host, set: deps.settingsStore, activity: activity} // Start honors the persisted mode: a writer who left MCP on finds it // running after a restart, and mode off binds nothing. if err := host.Start(context.Background()); err != nil { @@ -70,8 +106,9 @@ func (c *mcpController) RegenerateToken(ctx context.Context) (json.RawMessage, e if err != nil { return nil, err } - // The listener holds the old token in memory, so it must be cycled for the - // new one to take effect. + // The listener holds the old token in memory and the discovery file still + // advertises it, so a running server must be cycled for the new token to + // take effect everywhere. if c.host.Status().Running { if err := c.host.Restart(ctx); err != nil { return nil, translateMCPError(err) @@ -84,9 +121,14 @@ func (c *mcpController) RegenerateToken(ctx context.Context) (json.RawMessage, e } func (c *mcpController) Activity(ctx context.Context, limit int) (json.RawMessage, error) { - // The activity log lands with the tool layer (Task 2.6); until then this - // reports an empty list rather than failing the settings pane. - return json.Marshal([]struct{}{}) + if c.activity == nil { + return json.Marshal([]mcphost.ActivityEntry{}) + } + entries, err := c.activity.List(ctx, limit) + if err != nil { + return nil, err + } + return json.Marshal(entries) } func translateMCPError(err error) error { diff --git a/engine/internal/engineapp/mcp_tools_test.go b/engine/internal/engineapp/mcp_tools_test.go new file mode 100644 index 00000000..2b48c4c0 --- /dev/null +++ b/engine/internal/engineapp/mcp_tools_test.go @@ -0,0 +1,352 @@ +//go:build !mobile + +package engineapp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/mcphost" +) + +// mcpClient drives the live HTTP endpoint the way an external agent does: +// initialize, then real MCP calls. Responses come back as SSE, so each call +// reads the first data: line. +type mcpClient struct { + t *testing.T + url string + token string + sessionID string + nextID int +} + +func startMCPServer(t *testing.T) (*App, *mcpClient) { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = app.Close() }) + + port := freeTestPort(t) + patch := fmt.Sprintf( + `{"mcp_mode":"read_only","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, port) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + if _, rpcErr := call(t, app, "mcp.enable", ""); rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + d, err := mcphost.ReadDiscoveryFile(home) + if err != nil { + t.Fatalf("ReadDiscoveryFile: %v", err) + } + c := &mcpClient{t: t, url: fmt.Sprintf("http://127.0.0.1:%d/mcp", d.Port), token: d.Token} + c.initialize() + return app, c +} + +func (c *mcpClient) rpc(method string, params any) map[string]any { + c.t.Helper() + c.nextID++ + payload := map[string]any{"jsonrpc": "2.0", "id": c.nextID, "method": method} + if params != nil { + payload["params"] = params + } + raw, err := json.Marshal(payload) + if err != nil { + c.t.Fatalf("marshal %s: %v", method, err) + } + req, err := http.NewRequest(http.MethodPost, c.url, strings.NewReader(string(raw))) + if err != nil { + c.t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+c.token) + if c.sessionID != "" { + req.Header.Set("Mcp-Session-Id", c.sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.t.Fatalf("%s: %v", method, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + c.t.Fatalf("%s: status %d", method, resp.StatusCode) + } + if id := resp.Header.Get("Mcp-Session-Id"); id != "" { + c.sessionID = id + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(line[len("data:"):])), &envelope); err != nil { + c.t.Fatalf("decode %s event: %v", method, err) + } + return envelope + } + c.t.Fatalf("%s: no data event in response", method) + return nil +} + +func (c *mcpClient) initialize() { + c.t.Helper() + c.rpc("initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "engineapp-test", "version": "1"}, + }) +} + +func (c *mcpClient) toolNames() []string { + c.t.Helper() + envelope := c.rpc("tools/list", map[string]any{}) + result, _ := envelope["result"].(map[string]any) + tools, _ := result["tools"].([]any) + names := make([]string, 0, len(tools)) + for _, raw := range tools { + tool, _ := raw.(map[string]any) + if name, ok := tool["name"].(string); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func (c *mcpClient) callTool(name string, args map[string]any) map[string]any { + c.t.Helper() + envelope := c.rpc("tools/call", map[string]any{"name": name, "arguments": args}) + if errObj, ok := envelope["error"]; ok { + c.t.Fatalf("tools/call %s transport error: %v", name, errObj) + } + result, _ := envelope["result"].(map[string]any) + return result +} + +// The read surface is a contract external agents build against: it must be +// exactly the nine documented tools, and read_only must expose no others. +func TestMCPReadOnlyExposesExactlyTheReadTools(t *testing.T) { + _, c := startMCPServer(t) + + got := c.toolNames() + want := append([]string{}, mcphost.ReadToolNames...) + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("tools/list returned %d tools (%v), want %d", len(got), got, len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("tools/list = %v, want %v", got, want) + } + } +} + +// An end-to-end read: list works, walk the outline, read a scene, and confirm +// the content_version a later write would have to present. +func TestMCPReadToolsRoundTrip(t *testing.T) { + app, c := startMCPServer(t) + + created, rpcErr := call(t, app, "projects.create", `{"title":"MCP 왕복","genres":["fantasy"],"length_target":"short","default_pov":"first"}`) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var proj struct { + ID string `json:"id"` + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(created, &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + + works := c.callTool("linetta_list_works", map[string]any{}) + if isToolError(works) { + t.Fatalf("linetta_list_works errored: %v", works) + } + if !strings.Contains(structuredJSON(t, works), proj.ID) { + t.Fatalf("new work missing from linetta_list_works: %s", structuredJSON(t, works)) + } + + outline := c.callTool("linetta_get_outline", map[string]any{"project_id": proj.ID}) + if isToolError(outline) { + t.Fatalf("linetta_get_outline errored: %v", outline) + } + + scene := c.callTool("linetta_read_scene", map[string]any{"node_id": *proj.LastOpenedNodeID}) + if isToolError(scene) { + t.Fatalf("linetta_read_scene errored: %v", scene) + } + var readOut struct { + NodeID string `json:"node_id"` + ContentVersion int `json:"content_version"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, scene)), &readOut); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + if readOut.NodeID != *proj.LastOpenedNodeID { + t.Fatalf("read_scene returned node %q, want %q", readOut.NodeID, *proj.LastOpenedNodeID) + } + + // The brief must come back complete and error-free with no LLM provider + // reachable — the whole bring-your-own-agent premise rests on this. + brief := c.callTool("linetta_get_story_context", map[string]any{"node_id": *proj.LastOpenedNodeID}) + if isToolError(brief) { + t.Fatalf("linetta_get_story_context errored: %v", brief) + } + var briefOut struct { + Brief string `json:"brief"` + IncludedSections []string `json:"included_sections"` + EmptySections []string `json:"empty_sections"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, brief)), &briefOut); err != nil { + t.Fatalf("decode story context: %v", err) + } + if len(briefOut.IncludedSections)+len(briefOut.EmptySections) == 0 { + t.Fatal("story context reported no sections at all") + } +} + +// A bad id is a tool error the agent can act on, never a transport failure. +func TestMCPUnknownIDsReturnToolErrors(t *testing.T) { + _, c := startMCPServer(t) + for _, tc := range []struct { + tool string + args map[string]any + }{ + {"linetta_get_outline", map[string]any{"project_id": "no-such-work"}}, + {"linetta_read_scene", map[string]any{"node_id": "no-such-scene"}}, + {"linetta_get_story_context", map[string]any{"node_id": "no-such-scene"}}, + {"linetta_where_does_appear", map[string]any{"entity_id": "no-such-entity"}}, + } { + result := c.callTool(tc.tool, tc.args) + if !isToolError(result) { + t.Errorf("%s with a bad id should return a tool error, got %v", tc.tool, result) + } + } +} + +// Every call lands in the audit trail, successes and failures alike. +func TestMCPCallsAreRecordedInActivity(t *testing.T) { + app, c := startMCPServer(t) + c.callTool("linetta_list_works", map[string]any{}) + c.callTool("linetta_read_scene", map[string]any{"node_id": "no-such-scene"}) + + raw, rpcErr := call(t, app, "mcp.activity", `{"limit":10}`) + if rpcErr != nil { + t.Fatalf("mcp.activity: %+v", rpcErr) + } + var entries []struct { + Tool string `json:"tool"` + OK bool `json:"ok"` + } + if err := json.Unmarshal(raw, &entries); err != nil { + t.Fatalf("decode activity: %v", err) + } + if len(entries) < 2 { + t.Fatalf("activity has %d entries, want the two calls just made", len(entries)) + } + sawOK, sawFail := false, false + for _, e := range entries { + if e.Tool == "linetta_list_works" && e.OK { + sawOK = true + } + if e.Tool == "linetta_read_scene" && !e.OK { + sawFail = true + } + } + if !sawOK || !sawFail { + t.Fatalf("activity must record both outcomes; entries = %+v", entries) + } +} + +func isToolError(result map[string]any) bool { + v, _ := result["isError"].(bool) + return v +} + +// structuredJSON returns the tool's structured output as JSON text. +func structuredJSON(t *testing.T, result map[string]any) string { + t.Helper() + if sc, ok := result["structuredContent"]; ok { + raw, err := json.Marshal(sc) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + return string(raw) + } + content, _ := result["content"].([]any) + for _, raw := range content { + block, _ := raw.(map[string]any) + if text, ok := block["text"].(string); ok { + return text + } + } + return "" +} + +// A server restricted to one work must not read another work's data, even +// when the agent supplies a valid id for it. +func TestMCPProjectRestrictionBlocksOtherWorks(t *testing.T) { + app, c := startMCPServer(t) + + mk := func(title string) (string, string) { + t.Helper() + raw, rpcErr := call(t, app, "projects.create", + fmt.Sprintf(`{"title":%q,"genres":["fantasy"],"length_target":"short","default_pov":"first"}`, title)) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var p struct { + ID string `json:"id"` + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode project: %v", err) + } + return p.ID, *p.LastOpenedNodeID + } + allowed, _ := mk("허용된 작품") + blocked, blockedNode := mk("차단된 작품") + + if _, rpcErr := call(t, app, "settings.set", + fmt.Sprintf(`{"mcp_project_id":%q}`, allowed)); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + + if result := c.callTool("linetta_get_outline", map[string]any{"project_id": blocked}); !isToolError(result) { + t.Error("a restricted server must refuse another work's outline") + } + // Node ids must be checked too: reaching a scene by id would bypass the + // project-level check entirely. + if result := c.callTool("linetta_read_scene", map[string]any{"node_id": blockedNode}); !isToolError(result) { + t.Error("a restricted server must refuse a scene from another work") + } + if result := c.callTool("linetta_get_outline", map[string]any{"project_id": allowed}); isToolError(result) { + t.Errorf("the allowed work must still be readable: %v", result) + } + + works := c.callTool("linetta_list_works", map[string]any{}) + body := structuredJSON(t, works) + if strings.Contains(body, blocked) { + t.Errorf("linetta_list_works leaked a restricted work: %s", body) + } + if !strings.Contains(body, allowed) { + t.Errorf("linetta_list_works dropped the allowed work: %s", body) + } +} diff --git a/engine/internal/mcphost/activity.go b/engine/internal/mcphost/activity.go new file mode 100644 index 00000000..50226074 --- /dev/null +++ b/engine/internal/mcphost/activity.go @@ -0,0 +1,113 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +// activityRetention caps how many rows the log keeps. The table trims itself +// after each insert rather than leaning on the nightly snapshot-thinning job: +// one fewer moving part, and the cap holds even if the app never idles. +const activityRetention = 500 + +// DefaultActivityLimit is how many entries mcp.activity returns when the +// caller does not ask for a specific count. +const DefaultActivityLimit = 100 + +// ActivityEntry is one recorded tool call. +type ActivityEntry struct { + ID string `json:"id"` + At int64 `json:"at"` + Tool string `json:"tool"` + ProjectID string `json:"project_id,omitempty"` + TargetID string `json:"target_id,omitempty"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` +} + +// ActivityRepo persists the MCP audit trail. +type ActivityRepo struct { + db *sql.DB + now func() int64 +} + +// NewActivityRepo returns a repo over db. +func NewActivityRepo(db *sql.DB) *ActivityRepo { + return &ActivityRepo{db: db, now: func() int64 { return time.Now().UnixMilli() }} +} + +// Record appends one entry and trims the table to the retention cap. Recording +// is best-effort from the caller's perspective — a logging failure must never +// fail the tool call itself — so callers log the error and continue. +func (r *ActivityRepo) Record(ctx context.Context, e ActivityEntry) error { + if r == nil || r.db == nil { + return nil + } + if e.ID == "" { + e.ID = uuid.NewString() + } + if e.At == 0 { + e.At = r.now() + } + if _, err := r.db.ExecContext(ctx, + `INSERT INTO mcp_activity (id, at, tool, project_id, target_id, ok, detail) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.At, e.Tool, e.ProjectID, e.TargetID, boolToInt(e.OK), truncate(e.Detail, 500), + ); err != nil { + return err + } + _, err := r.db.ExecContext(ctx, + `DELETE FROM mcp_activity WHERE id NOT IN ( + SELECT id FROM mcp_activity ORDER BY at DESC, id DESC LIMIT ? + )`, activityRetention) + return err +} + +// List returns the most recent entries, newest first. +func (r *ActivityRepo) List(ctx context.Context, limit int) ([]ActivityEntry, error) { + if r == nil || r.db == nil { + return []ActivityEntry{}, nil + } + if limit <= 0 || limit > activityRetention { + limit = DefaultActivityLimit + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, at, tool, project_id, target_id, ok, detail + FROM mcp_activity ORDER BY at DESC, id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ActivityEntry{} + for rows.Next() { + var e ActivityEntry + var ok int + if err := rows.Scan(&e.ID, &e.At, &e.Tool, &e.ProjectID, &e.TargetID, &ok, &e.Detail); err != nil { + return nil, err + } + e.OK = ok != 0 + out = append(out, e) + } + return out, rows.Err() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go new file mode 100644 index 00000000..e675ffa8 --- /dev/null +++ b/engine/internal/mcphost/tools.go @@ -0,0 +1,167 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// ToolDeps carries everything the tool layer reads from. Every field is a repo +// the UI already uses, so an agent sees exactly what the writer sees. +type ToolDeps struct { + Projects *project.Repo + Nodes *node.Repo + Entities *entity.Repo + Mentions *mention.Repo + Facts *fact.Repo + Plot *plot.Builder + Manuscript *manuscript.Searcher + Context *storycontext.ContextBuilder + Settings *settings.Store + Activity *ActivityRepo +} + +// Register installs the tool set for a mode. Read tools are always present; +// write tools (Phase 3) are registered only for settings.MCPModeFull, so +// read_only does not merely refuse writes — the tools are absent from +// tools/list and cannot be called at all. +// +// The mode is captured when the listener starts. Changing it goes through +// Host.Restart (see mcpController.Enable), which builds a fresh server, so a +// running server never serves a stale tool set. +func (d ToolDeps) Register(s *mcp.Server, mode string) { + d.registerReadTools(s) + _ = mode // write tools land in Phase 3 +} + +// scopedInput is implemented by tool inputs that name a work and/or a target, +// so the activity log can record what was touched without every tool repeating it. +type scopedInput interface { + scope() (projectID, targetID string) +} + +// record wraps a typed tool handler so every call — success or failure — lands +// in the activity log the writer can inspect. Wrapping at registration time +// means no tool can forget to report itself. +func record[In, Out any](d ToolDeps, tool string, h mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { + res, out, err := h(ctx, req, in) + + projectID, targetID := "", "" + if s, ok := any(in).(scopedInput); ok { + projectID, targetID = s.scope() + } + ok := err == nil && (res == nil || !res.IsError) + detail := "" + if err != nil { + detail = err.Error() + } else if res != nil && res.IsError { + detail = firstText(res) + } + d.recordActivity(ctx, tool, projectID, targetID, ok, detail) + return res, out, err + } +} + +func (d ToolDeps) recordActivity(ctx context.Context, tool, projectID, targetID string, ok bool, detail string) { + if d.Activity == nil { + return + } + if err := d.Activity.Record(ctx, ActivityEntry{ + Tool: tool, + ProjectID: projectID, + TargetID: targetID, + OK: ok, + Detail: detail, + }); err != nil { + logf("activity log: %v", err) + } +} + +func firstText(res *mcp.CallToolResult) string { + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + return tc.Text + } + } + return "" +} + +// toolErr returns a tool-level error result. Agents recover from these; a Go +// error would surface as a transport failure they cannot act on. +func toolErr(format string, args ...any) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, args...)}}, + } +} + +// requireProject resolves the work a call targets and enforces the optional +// single-work restriction. Every tool funnels through here so the restriction +// cannot be bypassed by a tool that forgot to check. +func (d ToolDeps) requireProject(ctx context.Context, projectID string) (project.Project, *mcp.CallToolResult) { + projectID = strings.TrimSpace(projectID) + restricted := "" + if d.Settings != nil { + restricted = strings.TrimSpace(d.Settings.MCPProjectID()) + } + if restricted != "" { + if projectID == "" { + projectID = restricted + } else if projectID != restricted { + return project.Project{}, toolErr( + "this Linetta server is restricted to a single work; work %q is not available", projectID) + } + } + if projectID == "" { + return project.Project{}, toolErr("project_id is required; call linetta_list_works first") + } + p, err := d.Projects.Get(ctx, projectID) + if err != nil { + return project.Project{}, toolErr("work %q not found", projectID) + } + return p, nil +} + +// requireNode resolves a node and verifies it belongs to an allowed work, so a +// node id from another work cannot be read through a restricted server. +func (d ToolDeps) requireNode(ctx context.Context, nodeID string) (node.Node, *mcp.CallToolResult) { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + return node.Node{}, toolErr("node_id is required; call linetta_get_outline to find one") + } + n, err := d.Nodes.Get(ctx, nodeID) + if err != nil { + return node.Node{}, toolErr("scene or outline node %q not found", nodeID) + } + if _, errResult := d.requireProject(ctx, n.ProjectID); errResult != nil { + return node.Node{}, errResult + } + return n, nil +} + +// allowedProjectID returns the restriction, or "" when every work is reachable. +func (d ToolDeps) allowedProjectID() string { + if d.Settings == nil { + return "" + } + return strings.TrimSpace(d.Settings.MCPProjectID()) +} + +func entityKindFilter(kind string) string { + return strings.ToLower(strings.TrimSpace(kind)) +} diff --git a/engine/internal/mcphost/tools_read.go b/engine/internal/mcphost/tools_read.go new file mode 100644 index 00000000..b06cd6d2 --- /dev/null +++ b/engine/internal/mcphost/tools_read.go @@ -0,0 +1,619 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// ReadToolNames lists the read tools, in registration order. Tests assert +// tools/list against this so the surface cannot drift silently. +var ReadToolNames = []string{ + "linetta_list_works", + "linetta_get_outline", + "linetta_get_story_context", + "linetta_read_scene", + "linetta_search_manuscript", + "linetta_list_characters", + "linetta_where_does_appear", + "linetta_get_plot", + "linetta_get_fact_cards", +} + +const defaultSearchLimit = 20 + +// ---------- linetta_list_works ---------- + +type listWorksInput struct { + IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived works as well as active ones"` +} + +func (listWorksInput) scope() (string, string) { return "", "" } + +type workSummary struct { + ProjectID string `json:"project_id"` + Title string `json:"title"` + Status string `json:"status"` + Synopsis string `json:"synopsis,omitempty"` + Genres []string `json:"genres,omitempty"` + SceneCount int `json:"scene_count"` +} + +type listWorksOutput struct { + Works []workSummary `json:"works"` +} + +// ---------- linetta_get_outline ---------- + +type getOutlineInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work, from linetta_list_works"` +} + +func (in getOutlineInput) scope() (string, string) { return in.ProjectID, "" } + +type outlineRow struct { + NodeID string `json:"node_id"` + ParentID string `json:"parent_id,omitempty"` + Depth int `json:"depth"` + Kind string `json:"kind"` + Label string `json:"label"` + Title string `json:"title,omitempty"` + Status string `json:"status"` + WordCount int `json:"word_count"` + HasFreshSummary bool `json:"has_fresh_summary"` +} + +type getOutlineOutput struct { + ProjectID string `json:"project_id"` + Title string `json:"title"` + Outline []outlineRow `json:"outline"` +} + +// ---------- linetta_get_story_context ---------- + +type getStoryContextInput struct { + NodeID string `json:"node_id" jsonschema:"id of the scene to build the brief for"` + // Section toggles map onto the writer's own context checklist. Omit them + // to get everything. + IncludeFacts *bool `json:"include_facts,omitempty"` + IncludeMemories *bool `json:"include_memories,omitempty"` + IncludeReferences *bool `json:"include_references,omitempty"` + IncludePlot *bool `json:"include_plot,omitempty"` +} + +func (in getStoryContextInput) scope() (string, string) { return "", in.NodeID } + +type getStoryContextOutput struct { + ProjectID string `json:"project_id"` + NodeID string `json:"node_id"` + SceneLabel string `json:"scene_label"` + Brief string `json:"brief"` + IncludedSections []string `json:"included_sections"` + EmptySections []string `json:"empty_sections"` +} + +// ---------- linetta_read_scene ---------- + +type readSceneInput struct { + NodeID string `json:"node_id" jsonschema:"id of the scene to read"` +} + +func (in readSceneInput) scope() (string, string) { return "", in.NodeID } + +type readSceneOutput struct { + NodeID string `json:"node_id"` + ProjectID string `json:"project_id"` + Label string `json:"label"` + Title string `json:"title,omitempty"` + Status string `json:"status"` + WordCount int `json:"word_count"` + ContentVersion int `json:"content_version"` + Body string `json:"body"` + Summary string `json:"summary,omitempty"` + SummaryIsStale bool `json:"summary_is_stale"` +} + +// ---------- linetta_search_manuscript ---------- + +type searchManuscriptInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work to search"` + Query string `json:"query" jsonschema:"words or phrase to find in the manuscript"` + Limit int `json:"limit,omitempty"` +} + +func (in searchManuscriptInput) scope() (string, string) { return in.ProjectID, "" } + +type searchHit struct { + NodeID string `json:"node_id"` + Label string `json:"label"` + Snippet string `json:"snippet"` +} + +type searchManuscriptOutput struct { + Hits []searchHit `json:"hits"` +} + +// ---------- linetta_list_characters ---------- + +type listCharactersInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work"` + Kind string `json:"kind,omitempty" jsonschema:"filter by character, place, item, or concept; omit for all"` +} + +func (in listCharactersInput) scope() (string, string) { return in.ProjectID, "" } + +type entityRow struct { + EntityID string `json:"entity_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Role string `json:"role,omitempty"` + Summary string `json:"summary,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +type listCharactersOutput struct { + Entities []entityRow `json:"entities"` +} + +// ---------- linetta_where_does_appear ---------- + +type whereAppearsInput struct { + EntityID string `json:"entity_id" jsonschema:"id of the character, place, item, or concept"` +} + +func (in whereAppearsInput) scope() (string, string) { return "", in.EntityID } + +type appearanceRow struct { + NodeID string `json:"node_id"` + Label string `json:"label"` + Status string `json:"status"` +} + +type whereAppearsOutput struct { + EntityID string `json:"entity_id"` + Scenes []appearanceRow `json:"scenes"` +} + +// ---------- linetta_get_plot ---------- + +type getPlotInput struct { + NodeID string `json:"node_id" jsonschema:"a scene in the work; the plot spine is built around it"` +} + +func (in getPlotInput) scope() (string, string) { return "", in.NodeID } + +type getPlotOutput struct { + NodeID string `json:"node_id"` + Spine plot.Spine `json:"spine"` +} + +// ---------- linetta_get_fact_cards ---------- + +type getFactCardsInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work"` + NodeID string `json:"node_id,omitempty" jsonschema:"restrict to cards attached to this scene"` + Limit int `json:"limit,omitempty"` +} + +func (in getFactCardsInput) scope() (string, string) { return in.ProjectID, in.NodeID } + +type factRow struct { + FactID string `json:"fact_id"` + Status string `json:"status"` + Claim string `json:"claim"` + Result string `json:"result,omitempty"` + Category string `json:"category,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type getFactCardsOutput struct { + Cards []factRow `json:"cards"` +} + +// registerReadTools installs every read tool, each wrapped so the call lands +// in the activity log. +func (d ToolDeps) registerReadTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_list_works", + Description: "List the writer's works (novels) with their ids, titles, and scene counts. " + + "Start here to find the project_id other tools need.", + }, record(d, "linetta_list_works", d.listWorks)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_outline", + Description: "Return the work's outline tree: parts, chapters, and scenes with their node ids, " + + "status, and word counts. Use it to locate the scene you need to read or write.", + }, record(d, "linetta_get_outline", d.getOutline)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_story_context", + Description: "Build the curated brief for one scene: outline, chapter summaries, the previous " + + "scene's summary, character and relationship briefs, plot beats, fact cards, memories, and the " + + "writer's style and POV targets. Call this before drafting or revising so the text stays " + + "consistent with the rest of the work. Empty summary sections mean nobody has summarized those " + + "scenes yet.", + }, record(d, "linetta_get_story_context", d.getStoryContext)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_read_scene", + Description: "Read one scene's text as plain prose, with its content_version. Any later write to " + + "this scene must pass the content_version you got here, so the writer's own edits are never " + + "silently overwritten.", + }, record(d, "linetta_read_scene", d.readScene)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_search_manuscript", + Description: "Full-text search across the work's manuscript. Returns matching scenes with snippets.", + }, record(d, "linetta_search_manuscript", d.searchManuscript)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_list_characters", + Description: "List the work's story elements — characters by default, or places, items, and " + + "concepts via the kind filter — with their roles, summaries, and attributes.", + }, record(d, "linetta_list_characters", d.listCharacters)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_where_does_appear", + Description: "List the scenes where one character, place, item, or concept is mentioned.", + }, record(d, "linetta_where_does_appear", d.whereAppears)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_plot", + Description: "Return the plot spine around a scene: storylines and their beats, in order.", + }, record(d, "linetta_get_plot", d.getPlot)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_fact_cards", + Description: "Return the work's Fact Book cards: source-backed research notes with their " + + "verification status. Use them for real-world details instead of inventing facts.", + }, record(d, "linetta_get_fact_cards", d.getFactCards)) +} + +func (d ToolDeps) listWorks(ctx context.Context, _ *mcp.CallToolRequest, in listWorksInput) (*mcp.CallToolResult, listWorksOutput, error) { + projects, err := d.Projects.List(ctx, project.ListFilter{IncludeArchived: in.IncludeArchived}) + if err != nil { + return toolErr("could not list works: %v", err), listWorksOutput{}, nil + } + restricted := d.allowedProjectID() + out := listWorksOutput{Works: []workSummary{}} + for _, p := range projects { + if restricted != "" && p.ID != restricted { + continue + } + scenes := 0 + if all, err := d.Nodes.ListByProject(ctx, p.ID); err == nil { + for _, n := range all { + if n.Kind == node.KindLeaf { + scenes++ + } + } + } + status := "active" + if p.ArchivedAt != nil { + status = "archived" + } + out.Works = append(out.Works, workSummary{ + ProjectID: p.ID, + Title: p.Title, + Status: status, + Synopsis: p.Synopsis, + Genres: p.Genres, + SceneCount: scenes, + }) + } + return nil, out, nil +} + +func (d ToolDeps) getOutline(ctx context.Context, _ *mcp.CallToolRequest, in getOutlineInput) (*mcp.CallToolResult, getOutlineOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, getOutlineOutput{}, nil + } + all, err := d.Nodes.ListByProject(ctx, p.ID) + if err != nil { + return toolErr("could not read the outline: %v", err), getOutlineOutput{}, nil + } + out := getOutlineOutput{ProjectID: p.ID, Title: p.Title, Outline: outlineRows(all)} + return nil, out, nil +} + +// outlineRows flattens the tree in document order with a depth column, which +// is what an agent needs to understand structure without reconstructing it. +func outlineRows(all []node.Node) []outlineRow { + children := map[string][]node.Node{} + for _, n := range all { + key := "" + if n.ParentID != nil { + key = *n.ParentID + } + children[key] = append(children[key], n) + } + for key := range children { + sort.SliceStable(children[key], func(i, j int) bool { + return children[key][i].Ordinal < children[key][j].Ordinal + }) + } + rows := []outlineRow{} + var walk func(parent string, depth int) + walk = func(parent string, depth int) { + for _, n := range children[parent] { + parentID := "" + if n.ParentID != nil { + parentID = *n.ParentID + } + rows = append(rows, outlineRow{ + NodeID: n.ID, + ParentID: parentID, + Depth: depth, + Kind: n.Kind, + Label: n.Label, + Title: n.Title, + Status: n.Status, + WordCount: n.WordCount, + HasFreshSummary: n.Summary != "" && n.SummaryForVersion == n.ContentVersion, + }) + walk(n.ID, depth+1) + } + } + walk("", 0) + return rows +} + +func (d ToolDeps) getStoryContext(ctx context.Context, _ *mcp.CallToolRequest, in getStoryContextInput) (*mcp.CallToolResult, getStoryContextOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, getStoryContextOutput{}, nil + } + if d.Context == nil { + return toolErr("story context is unavailable in this build"), getStoryContextOutput{}, nil + } + opts := storycontext.Options{ + Context: storycontext.ContextSelection{ + Facts: in.IncludeFacts, + Memories: in.IncludeMemories, + References: in.IncludeReferences, + Plot: in.IncludePlot, + }, + } + c, err := d.Context.BuildFull(ctx, n.ID, "", "", opts) + if err != nil { + return toolErr("could not build the story brief: %v", err), getStoryContextOutput{}, nil + } + // Only the user half of the render is the brief; the system half carries + // tone and instruction scaffolding meant for Linetta's own runner. + _, brief := storycontext.Render(c) + included, empty := sectionReport(c) + return nil, getStoryContextOutput{ + ProjectID: n.ProjectID, + NodeID: n.ID, + SceneLabel: c.SceneLabel, + Brief: brief, + IncludedSections: included, + EmptySections: empty, + }, nil +} + +// sectionReport tells the agent what the brief actually carries. An empty +// summary section is the signal to go write one with linetta_write_summary. +func sectionReport(c storycontext.Context) (included, empty []string) { + c = storycontext.ApplyContextSelection(c) + checks := []struct { + name string + present bool + }{ + {"current_scene", strings.TrimSpace(c.SceneText) != ""}, + {"overview", strings.TrimSpace(c.Outline) != ""}, + {"synopsis", strings.TrimSpace(c.Hierarchical.ProjectSynopsis) != "" || strings.TrimSpace(c.Project.Synopsis) != ""}, + {"nearby_scene_summaries", len(c.Hierarchical.NearbyLeafSummaries) > 0}, + {"related_scenes", len(c.RelatedScenes) > 0}, + {"entities", len(c.Entities) > 0}, + {"relationships", len(c.Relationships) > 0}, + {"plot", spineHasBeats(c.Plot)}, + {"notes", len(c.Notes) > 0}, + {"facts", len(c.Facts) > 0}, + {"memories", len(c.Memories) > 0}, + {"references", len(c.References) > 0}, + {"style_notes", strings.TrimSpace(c.StyleNotes) != ""}, + } + included, empty = []string{}, []string{} + for _, ch := range checks { + if ch.present { + included = append(included, ch.name) + } else { + empty = append(empty, ch.name) + } + } + return included, empty +} + +func (d ToolDeps) readScene(ctx context.Context, _ *mcp.CallToolRequest, in readSceneInput) (*mcp.CallToolResult, readSceneOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, readSceneOutput{}, nil + } + if n.Kind != node.KindLeaf { + return toolErr("node %q is a container (%s), not a scene; only scenes have body text", n.ID, n.Label), + readSceneOutput{}, nil + } + return nil, readSceneOutput{ + NodeID: n.ID, + ProjectID: n.ProjectID, + Label: n.Label, + Title: n.Title, + Status: n.Status, + WordCount: n.WordCount, + ContentVersion: n.ContentVersion, + Body: storycontext.PlainText(n.ContentDoc), + Summary: n.Summary, + SummaryIsStale: n.Summary == "" || n.SummaryForVersion != n.ContentVersion, + }, nil +} + +func (d ToolDeps) searchManuscript(ctx context.Context, _ *mcp.CallToolRequest, in searchManuscriptInput) (*mcp.CallToolResult, searchManuscriptOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, searchManuscriptOutput{}, nil + } + q := strings.TrimSpace(in.Query) + if q == "" { + return toolErr("query is required"), searchManuscriptOutput{}, nil + } + if d.Manuscript == nil { + return toolErr("manuscript search is unavailable in this build"), searchManuscriptOutput{}, nil + } + limit := in.Limit + if limit <= 0 || limit > 100 { + limit = defaultSearchLimit + } + hits, err := d.Manuscript.Query(ctx, p.ID, q, limit) + if err != nil { + return toolErr("search failed: %v", err), searchManuscriptOutput{}, nil + } + out := searchManuscriptOutput{Hits: []searchHit{}} + for _, h := range hits { + out.Hits = append(out.Hits, searchHit{NodeID: h.NodeID, Label: h.Breadcrumb, Snippet: h.Snippet}) + } + return nil, out, nil +} + +func (d ToolDeps) listCharacters(ctx context.Context, _ *mcp.CallToolRequest, in listCharactersInput) (*mcp.CallToolResult, listCharactersOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, listCharactersOutput{}, nil + } + all, err := d.Entities.ListByProject(ctx, p.ID) + if err != nil { + return toolErr("could not list story elements: %v", err), listCharactersOutput{}, nil + } + kind := entityKindFilter(in.Kind) + out := listCharactersOutput{Entities: []entityRow{}} + for _, e := range all { + if kind != "" && !strings.EqualFold(e.Kind, kind) { + continue + } + out.Entities = append(out.Entities, entityRow{ + EntityID: e.ID, + Kind: e.Kind, + Name: e.Name, + Role: e.Role, + Summary: e.Summary, + Attributes: e.Attributes, + }) + } + return nil, out, nil +} + +func (d ToolDeps) whereAppears(ctx context.Context, _ *mcp.CallToolRequest, in whereAppearsInput) (*mcp.CallToolResult, whereAppearsOutput, error) { + entityID := strings.TrimSpace(in.EntityID) + if entityID == "" { + return toolErr("entity_id is required; call linetta_list_characters first"), whereAppearsOutput{}, nil + } + ent, err := d.Entities.Get(ctx, entityID) + if err != nil { + return toolErr("story element %q not found", entityID), whereAppearsOutput{}, nil + } + if _, errResult := d.requireProject(ctx, ent.ProjectID); errResult != nil { + return errResult, whereAppearsOutput{}, nil + } + ids, _, err := d.Mentions.MentionedNodeIDs(ctx, entityID) + if err != nil { + return toolErr("could not read mentions: %v", err), whereAppearsOutput{}, nil + } + out := whereAppearsOutput{EntityID: entityID, Scenes: []appearanceRow{}} + if len(ids) == 0 { + return nil, out, nil + } + mentioned := make(map[string]bool, len(ids)) + for _, id := range ids { + mentioned[id] = true + } + all, err := d.Nodes.ListByProject(ctx, ent.ProjectID) + if err != nil { + return toolErr("could not read the outline: %v", err), whereAppearsOutput{}, nil + } + for _, row := range outlineRows(all) { + if !mentioned[row.NodeID] { + continue + } + out.Scenes = append(out.Scenes, appearanceRow{NodeID: row.NodeID, Label: row.Label, Status: row.Status}) + } + return nil, out, nil +} + +func (d ToolDeps) getPlot(ctx context.Context, _ *mcp.CallToolRequest, in getPlotInput) (*mcp.CallToolResult, getPlotOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, getPlotOutput{}, nil + } + if d.Plot == nil { + return toolErr("plot is unavailable in this build"), getPlotOutput{}, nil + } + spine, err := d.Plot.Build(ctx, n.ID) + if err != nil { + return toolErr("could not build the plot spine: %v", err), getPlotOutput{}, nil + } + return nil, getPlotOutput{NodeID: n.ID, Spine: spine}, nil +} + +func (d ToolDeps) getFactCards(ctx context.Context, _ *mcp.CallToolRequest, in getFactCardsInput) (*mcp.CallToolResult, getFactCardsOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, getFactCardsOutput{}, nil + } + if d.Facts == nil { + return toolErr("the Fact Book is unavailable in this build"), getFactCardsOutput{}, nil + } + filter := fact.ListFilter{ProjectID: p.ID, Limit: in.Limit} + if filter.Limit <= 0 || filter.Limit > 200 { + filter.Limit = 50 + } + if nodeID := strings.TrimSpace(in.NodeID); nodeID != "" { + if _, errResult := d.requireNode(ctx, nodeID); errResult != nil { + return errResult, getFactCardsOutput{}, nil + } + filter.NodeID = &nodeID + } + cards, err := d.Facts.List(ctx, filter) + if err != nil { + return toolErr("could not read the Fact Book: %v", err), getFactCardsOutput{}, nil + } + out := getFactCardsOutput{Cards: []factRow{}} + for _, c := range cards { + row := factRow{ + FactID: c.ID, + Status: c.Status, + Claim: c.Claim, + Result: c.Result, + Category: c.Category, + } + for _, src := range c.Sources { + if strings.TrimSpace(src.URL) != "" { + row.Sources = append(row.Sources, src.URL) + } + } + out.Cards = append(out.Cards, row) + } + return nil, out, nil +} + +// spineHasBeats mirrors the renderer's own emptiness check so the section +// report agrees with what the brief actually contains. +func spineHasBeats(s plot.Spine) bool { + if len(s.Current.Beats) > 0 { + return true + } + if s.Prev != nil && len(s.Prev.Beats) > 0 { + return true + } + return s.Next != nil && len(s.Next.Beats) > 0 +} diff --git a/engine/internal/store/migrations/0016_mcp_activity.sql b/engine/internal/store/migrations/0016_mcp_activity.sql new file mode 100644 index 00000000..219d6279 --- /dev/null +++ b/engine/internal/store/migrations/0016_mcp_activity.sql @@ -0,0 +1,18 @@ +-- Audit trail for tools called by external MCP clients. This is the writer's +-- answer to "what did the agent do while I was asleep": every tool call, read +-- or write, success or failure, lands here and is shown in Settings. +-- +-- project_id is intentionally NOT a foreign key: the log must survive the work +-- it refers to, so deleting a project never erases the record of what was done +-- to it. +CREATE TABLE mcp_activity ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + tool TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + target_id TEXT NOT NULL DEFAULT '', + ok INTEGER NOT NULL DEFAULT 1, + detail TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_mcp_activity_at ON mcp_activity(at DESC); diff --git a/engine/internal/storycontext/builder.go b/engine/internal/storycontext/builder.go index f8509121..8c2655a9 100644 --- a/engine/internal/storycontext/builder.go +++ b/engine/internal/storycontext/builder.go @@ -628,6 +628,11 @@ func (b *ContextBuilder) findPreviousLeaf(ctx context.Context, cur node.Node) (* // docToPlainText walks a Tiptap doc and concatenates text content. Mentions are // rendered as `@label`. Block boundaries become newlines. +// PlainText renders a stored Tiptap document as plain text, the same way the +// story brief does. Exported so MCP tools return prose rather than editor JSON +// without duplicating the walker. +func PlainText(rawDoc *string) string { return docToPlainText(rawDoc) } + func docToPlainText(rawDoc *string) string { if rawDoc == nil || *rawDoc == "" { return "" From 8fe8eb6cb443ae9c4f05e630382f22ff0add3114 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 09:41:42 +0900 Subject: [PATCH 13/25] docs: mark MCP pivot Phase 2 implementation complete Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-22-mcp-first-pivot.md | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index bd381e36..918a4ae8 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -92,60 +92,64 @@ **파일:** `engine/go.mod`, `engine/go.sum` -- [ ] `cd engine && go get github.com/modelcontextprotocol/go-sdk@v1.7.0` -- [ ] `go build -tags mas ./...`가 SDK를 **링크하는지** 확인한다(MAS도 MCP를 쓴다). -- [ ] `go test -tags mobile ./...`는 SDK를 링크하지 않아야 한다. `go list -deps -tags mobile ./... | grep modelcontextprotocol`이 비어야 한다. +- [x] `cd engine && go get github.com/modelcontextprotocol/go-sdk@v1.7.0` +- [x] `go build -tags mas ./...`가 SDK를 **링크하는지** 확인한다(MAS도 MCP를 쓴다). +- [x] `go test -tags mobile ./...`는 SDK를 링크하지 않아야 한다. `go list -deps -tags mobile ./... | grep modelcontextprotocol`이 비어야 한다. **확인: 0건.** ### Task 2.2 — 설정 키와 시크릿 토큰 **파일:** `engine/internal/settings/settings.go`, `secrets.go`, `+ 테스트` -- [ ] `MCPMode`(`off`|`read_only`|`full`, 기본 `off`), `MCPPort`(기본 `7391`), `MCPProjectID`, `MCPConsentVersion`, `MCPConsentedAt`를 `Settings`와 `SettingsPatch`에 추가한다. -- [ ] `MCPTokenSet bool`(읽기용 존재 플래그)과 시크릿 저장소를 통해 쓰는 `RegenerateMCPToken()`을 추가한다. 토큰 값 자체는 `settings.get`이 절대 반환하지 않는다 — `api_key` 처리 방식과 동일하다. -- [ ] 테스트: `settings.get`이 토큰을 가리고 존재 플래그만 노출한다. 모드가 왕복한다. 알 수 없는 모드는 `off`로 떨어진다. +- [x] `MCPMode`(`off`|`read_only`|`full`, 기본 `off`), `MCPPort`(기본 `7391`), `MCPProjectID`, `MCPConsentVersion`, `MCPConsentedAt`를 `Settings`와 `SettingsPatch`에 추가한다. +- [x] `MCPTokenSet bool`(읽기용 존재 플래그)과 시크릿 저장소를 통해 쓰는 `RegenerateMCPToken()`을 추가한다. 토큰 값 자체는 `settings.get`이 절대 반환하지 않는다 — `api_key` 처리 방식과 동일하다. +- [x] 테스트: `settings.get`이 토큰을 가리고 존재 플래그만 노출한다. 모드가 왕복한다. 알 수 없는 모드는 `off`로 떨어진다. ### Task 2.3 — `mcphost` 골격, 인증, 수명 주기 **파일:** `engine/internal/mcphost/host.go`, `auth.go`, `discovery.go`, `+ 테스트` -- [ ] `mcphost.New(deps)`가 `*mcp.Server`와 `http.Server`를 만들고 설정된 포트로 `net.Listen("tcp", "127.0.0.1:"+port)` 한다. 저장된 클라이언트 설정이 재시작을 견디도록 포트는 고정이다. -- [ ] 포트가 이미 사용 중이면 설정 화면이 "7391 포트가 사용 중입니다 — 다른 포트를 선택하세요"로 렌더링할 수 있는 타입 에러를 반환한다. **다른 포트로 조용히 넘어가지 않는다.** -- [ ] 인증 미들웨어: 상수 시간 베어러 비교, `Origin`이 있는데 루프백이 아니면 거부, `Host`가 루프백이 아니면 거부. -- [ ] `Start()`가 `$LINETTA_HOME/mcp.json`(권한 0600, `{port, token, pid, started_at}`)을 쓰고, `Stop()`이 삭제하며 리스너를 내린다. 설정 파일 `settings.json`과는 별개 파일이다. -- [ ] 테스트: 토큰 없음 → 401, 토큰 틀림 → 401, `Origin: https://evil.test` → 403, 포트 점유 → 타입 에러, POSIX에서 디스커버리 파일 권한 0600, `Stop` 후 파일 삭제. +- [x] `mcphost.New(deps)`가 `*mcp.Server`와 `http.Server`를 만들고 설정된 포트로 `net.Listen("tcp", "127.0.0.1:"+port)` 한다. 저장된 클라이언트 설정이 재시작을 견디도록 포트는 고정이다. +- [x] 포트가 이미 사용 중이면 설정 화면이 "7391 포트가 사용 중입니다 — 다른 포트를 선택하세요"로 렌더링할 수 있는 타입 에러를 반환한다. **다른 포트로 조용히 넘어가지 않는다.** +- [x] 인증 미들웨어: 상수 시간 베어러 비교, `Origin`이 있는데 루프백이 아니면 거부, `Host`가 루프백이 아니면 거부. +- [x] `Start()`가 `$LINETTA_HOME/mcp.json`(권한 0600, `{port, token, pid, started_at}`)을 쓰고, `Stop()`이 삭제하며 리스너를 내린다. 설정 파일 `settings.json`과는 별개 파일이다. +- [x] 테스트: 토큰 없음 → 401, 토큰 틀림 → 401, `Origin: https://evil.test` → 403, 포트 점유 → 타입 에러, POSIX에서 디스커버리 파일 권한 0600, `Stop` 후 파일 삭제. ### Task 2.4 — `engineapp` 연결 **파일:** `engine/internal/engineapp/mcp_enabled.go`(`//go:build !mobile`), `mcp_disabled.go`(`//go:build mobile`), `engineapp.go`, `+ 테스트` -- [ ] `gitsync_enabled.go` / `gitsync_disabled.go` 패턴을 그대로 따른다: `const mcpAvailable`, `setupMCP(deps) mcpController`. -- [ ] RPC `mcp.status`, `mcp.enable`, `mcp.disable`, `mcp.regenerate_token`, `mcp.activity`를 등록한다. 비활성 쌍둥이는 `CodeMethodNotFound`를 반환한다. -- [ ] 호스트의 `Stop`을 `a.closers`에 넣어 앱과 함께 리스너가 죽게 한다. -- [ ] `handlers.Capabilities`에 `MCPAvailable`을 추가하고 `diagnostics.version` / `diagnostics.get`으로 노출한다. -- [ ] 테스트: 모드 `off`면 아무것도 바인딩하지 않음, `mcp.enable` 후 `mcp.status`가 포트를 보고함, `Close()`가 포트를 반납함. +- [x] `gitsync_enabled.go` / `gitsync_disabled.go` 패턴을 그대로 따른다: `const mcpAvailable`, `setupMCP(deps) mcpController`. +- [x] RPC `mcp.status`, `mcp.enable`, `mcp.disable`, `mcp.regenerate_token`, `mcp.activity`를 등록한다. 비활성 쌍둥이는 `CodeMethodNotFound`를 반환한다. +- [x] 호스트의 `Stop`을 `a.closers`에 넣어 앱과 함께 리스너가 죽게 한다. +- [x] `handlers.Capabilities`에 `MCPAvailable`을 추가하고 `diagnostics.version` / `diagnostics.get`으로 노출한다. +- [x] 테스트: 모드 `off`면 아무것도 바인딩하지 않음, `mcp.enable` 후 `mcp.status`가 포트를 보고함, `Close()`가 포트를 반납함. ### Task 2.5 — 읽기 툴 9개 **파일:** `engine/internal/mcphost/tools_read.go`, `+ 테스트` -- [ ] 설계 문서 5절의 읽기 툴 9개를 `mcp.AddTool`로 등록한다. 입출력을 타입 구조체로 선언해 스키마가 생성되게 한다. -- [ ] `linetta_get_story_context`는 병합된 `storycontext` 빌더(Task 1.3 완료가 전제)로 브리프를 조립하고, 평문 렌더러로 마크다운을 만들어 "무엇이 포함됐는지" 요약과 함께 반환한다. -- [ ] `linetta_read_scene`은 `content_version`을 반환하고, 설명에 쓰기에는 이 값이 필요하다고 명시한다. -- [ ] `MCPProjectID` 범위 제한은 툴마다가 아니라 공용 헬퍼 한 곳에서 강제한다. -- [ ] 테스트: 씨드된 임시 스토어로 각 툴 검증, 범위 밖 `project_id` 차단, `read_only` 모드에서 정확히 이 9개만 등록됨, **LLM 프로바이더가 설정되지 않은 상태에서 `linetta_get_story_context`가 요약만 빈 채 팩트·메모리를 포함한 완전한 브리프를 에러 없이 반환함**(전환의 전제가 이 테스트에 달려 있다). +- [x] 설계 문서 5절의 읽기 툴 9개를 `mcp.AddTool`로 등록한다. 입출력을 타입 구조체로 선언해 스키마가 생성되게 한다. +- [x] `linetta_get_story_context`는 병합된 `storycontext` 빌더(Task 1.3 완료가 전제)로 브리프를 조립하고, 평문 렌더러로 마크다운을 만들어 "무엇이 포함됐는지" 요약과 함께 반환한다. +- [x] `linetta_read_scene`은 `content_version`을 반환하고, 설명에 쓰기에는 이 값이 필요하다고 명시한다. +- [x] `MCPProjectID` 범위 제한은 툴마다가 아니라 공용 헬퍼 한 곳에서 강제한다. +- [x] 테스트: 씨드된 임시 스토어로 각 툴 검증, 범위 밖 `project_id` 차단, `read_only` 모드에서 정확히 이 9개만 등록됨, **LLM 프로바이더가 설정되지 않은 상태에서 `linetta_get_story_context`가 요약만 빈 채 팩트·메모리를 포함한 완전한 브리프를 에러 없이 반환함**(전환의 전제가 이 테스트에 달려 있다). ### Task 2.6 — 활동 로그 **파일:** `engine/internal/store/migrations/*`, `engine/internal/mcphost/activity.go`, `+ 테스트` -- [ ] `mcp_activity` 테이블 마이그레이션(`id, at, tool, project_id, target_id, ok, detail`). -- [ ] 성공·실패 관계없이 모든 툴 호출을 기록하고, 기존 스냅샷 정리 잡에 보존 한도를 얹는다. -- [ ] `mcp.activity` RPC가 최근 기록을 반환한다. +- [x] `mcp_activity` 테이블 마이그레이션(`id, at, tool, project_id, target_id, ok, detail`). +- [x] 성공·실패 관계없이 모든 툴 호출을 기록한다. **계획에서 이탈:** 보존 한도를 스냅샷 정리 잡에 얹지 않고 삽입 직후 자체 트리밍(500행)으로 처리했다 — 움직이는 부품이 하나 줄고, 앱이 유휴 상태가 되지 않아도 상한이 지켜진다. +- [x] `mcp.activity` RPC가 최근 기록을 반환한다. **2단계 종료 조건:** `claude mcp add --transport http linetta http://127.0.0.1:7391/mcp --header "Authorization: Bearer "`로 연결되고, 실제 Claude Code 세션이 작품 구조를 설명할 수 있다. --- +> **완료 (2026-08-22):** 엔진 42개 패키지 통과, `mas`/`mobile` 빌드 통과, mobile의 SDK 의존 0건. 테스트가 실제 HTTP 엔드포인트를 외부 클라이언트처럼 구동한다(initialize → tools/list → tools/call, SSE 파싱). 툴 등록은 활동 로그 데코레이터로 감싸 Task 2.6을 같이 끝냈고, 범위 제한은 씬 id 우회까지 막는 것을 확인했다. +> +> **남은 종료 조건:** 실제 Claude Code에서 `claude mcp add`로 붙여 작품 구조를 읽는 왕복 — 사용자 기기에서 직접 해야 하는 단계다. + ## Phase 3 — 쓰기 툴과 안전장치 ### Task 3.1 — `linetta_write_scene` From f700dbf4708a45cc54e4db2b8691f396901ab08c Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 12:58:09 +0900 Subject: [PATCH 14/25] fix(settings): persist and reload MCP settings settings.Set returned the new mode in its response while persist() and load() silently dropped every mcp_* field - both copy an explicit field list and the new keys were never added to either. MCP came back off after each restart, which is how it surfaced: a live server on real data reported mode=off the next time the engine started. No in-memory assertion could catch this, so the regression test writes through Set and then reads back with a freshly constructed Store. load() also normalizes on the way in, keeping the guarantee that an unrecognized mode degrades to off rather than to an open server. Part of the MCP-first pivot (#47), Phase 2. Co-Authored-By: Claude Opus 5 --- engine/internal/settings/mcp_test.go | 44 ++++++++++++++++++++++++++++ engine/internal/settings/settings.go | 19 ++++++++++++ 2 files changed, 63 insertions(+) diff --git a/engine/internal/settings/mcp_test.go b/engine/internal/settings/mcp_test.go index 17bb62ee..688967d0 100644 --- a/engine/internal/settings/mcp_test.go +++ b/engine/internal/settings/mcp_test.go @@ -167,3 +167,47 @@ func TestMCPProjectRestriction(t *testing.T) { t.Errorf("project = %q, want %q", got, id) } } + +// Regression: settings.Set returned the new mode in its response while +// persist() silently dropped it, because persist copies an explicit field +// list. MCP would come back off after every app restart, and no in-memory +// assertion could catch it — the check has to survive a reload from disk. +func TestMCPSettingsSurviveReload(t *testing.T) { + t.Setenv("LINETTA_HOME", t.TempDir()) + secrets := NewMemorySecretStore() + s, err := NewWithSecretStore(secrets) + if err != nil { + t.Fatalf("NewWithSecretStore: %v", err) + } + mode := MCPModeReadOnly + port := 8321 + projectID := "work-1" + version := MCPConsentVersion + at := int64(1_700_000_000_000) + if _, err := s.Set(context.Background(), Patch{ + MCPMode: &mode, + MCPPort: &port, + MCPProjectID: &projectID, + MCPConsentVersion: &version, + MCPConsentedAt: &at, + }); err != nil { + t.Fatalf("Set: %v", err) + } + + reloaded, err := NewWithSecretStore(secrets) + if err != nil { + t.Fatalf("reload: %v", err) + } + if got := reloaded.MCPMode(); got != mode { + t.Errorf("after reload mode = %q, want %q", got, mode) + } + if got := reloaded.MCPPort(); got != port { + t.Errorf("after reload port = %d, want %d", got, port) + } + if got := reloaded.MCPProjectID(); got != projectID { + t.Errorf("after reload project = %q, want %q", got, projectID) + } + if !reloaded.HasMCPConsent() { + t.Error("after reload consent was lost") + } +} diff --git a/engine/internal/settings/settings.go b/engine/internal/settings/settings.go index bb090317..473fd5b0 100644 --- a/engine/internal/settings/settings.go +++ b/engine/internal/settings/settings.go @@ -294,6 +294,20 @@ func (s *Store) load() error { if disk.WebSearchProvider != "" { s.cfg.WebSearchProvider = disk.WebSearchProvider } + // MCP settings written by a newer build must survive a reload. Blank or + // out-of-range values (including a file written by a build that predates + // these keys) keep the defaults, and normalizeMCPPreferences below is the + // final guard that an unrecognized mode never becomes an open server. + if disk.MCPMode != "" { + s.cfg.MCPMode = disk.MCPMode + } + if disk.MCPPort != 0 { + s.cfg.MCPPort = disk.MCPPort + } + s.cfg.MCPProjectID = disk.MCPProjectID + s.cfg.MCPConsentVersion = disk.MCPConsentVersion + s.cfg.MCPConsentedAt = disk.MCPConsentedAt + s.cfg = normalizeMCPPreferences(s.cfg) migratedProviderKeys, migratedWebKey, err := s.migrateLegacySecrets(&disk) if err != nil { s.mu.Unlock() @@ -600,6 +614,11 @@ func (s *Store) persist(next Config) error { AIDataSharingConsentVersion: next.AIDataSharingConsentVersion, AIDataSharingConsentedAt: next.AIDataSharingConsentedAt, WebSearchProvider: next.WebSearchProvider, + MCPMode: next.MCPMode, + MCPPort: next.MCPPort, + MCPProjectID: next.MCPProjectID, + MCPConsentVersion: next.MCPConsentVersion, + MCPConsentedAt: next.MCPConsentedAt, } body, err := json.MarshalIndent(persistable, "", " ") if err != nil { From a8ba48e0f7b4c9c4cd8000820ee73af51c048e50 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 13:11:45 +0900 Subject: [PATCH 15/25] fix(mcp): return an empty body for an untouched scene The brief's plaintext walker appends a newline per paragraph, so a scene that has never been written reads as "\n" - an agent can mistake that for content. read_scene trims at the tool boundary rather than in PlainText, because the brief renderer depends on that function's exact output and Phase 1 promised its prompts would not change. Found by driving the live server against real data. Part of the MCP-first pivot (#47), Phase 2. Co-Authored-By: Claude Opus 5 --- engine/internal/engineapp/mcp_tools_test.go | 29 +++++++++++++++++++++ engine/internal/mcphost/tools_read.go | 5 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/engine/internal/engineapp/mcp_tools_test.go b/engine/internal/engineapp/mcp_tools_test.go index 2b48c4c0..e8c863fc 100644 --- a/engine/internal/engineapp/mcp_tools_test.go +++ b/engine/internal/engineapp/mcp_tools_test.go @@ -350,3 +350,32 @@ func TestMCPProjectRestrictionBlocksOtherWorks(t *testing.T) { t.Errorf("linetta_list_works dropped the allowed work: %s", body) } } + +// An untouched scene holds a doc with one empty paragraph, which the brief's +// walker renders as "\n". read_scene must hand the agent "" instead, or an +// empty scene reads as if it had content. +func TestMCPReadSceneTrimsEmptyBody(t *testing.T) { + app, c := startMCPServer(t) + created, rpcErr := call(t, app, "projects.create", + `{"title":"빈 씬","genres":["fantasy"],"length_target":"short","default_pov":"first"}`) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var proj struct { + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(created, &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + + result := c.callTool("linetta_read_scene", map[string]any{"node_id": *proj.LastOpenedNodeID}) + var out struct { + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + if out.Body != "" { + t.Fatalf("empty scene body = %q, want an empty string", out.Body) + } +} diff --git a/engine/internal/mcphost/tools_read.go b/engine/internal/mcphost/tools_read.go index b06cd6d2..fb258e4c 100644 --- a/engine/internal/mcphost/tools_read.go +++ b/engine/internal/mcphost/tools_read.go @@ -453,7 +453,10 @@ func (d ToolDeps) readScene(ctx context.Context, _ *mcp.CallToolRequest, in read Status: n.Status, WordCount: n.WordCount, ContentVersion: n.ContentVersion, - Body: storycontext.PlainText(n.ContentDoc), + // Trimmed at the tool boundary, not in PlainText: the brief's renderer + // depends on that function's exact output. An untouched empty scene + // otherwise arrives as "\n", which an agent can misread as content. + Body: strings.TrimSpace(storycontext.PlainText(n.ContentDoc)), Summary: n.Summary, SummaryIsStale: n.Summary == "" || n.SummaryForVersion != n.ContentVersion, }, nil From 8cd4e3b73cb2467b37c1d16b05e114931aca1fb2 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 14:26:22 +0900 Subject: [PATCH 16/25] fix(mcp): only the owning host retracts the discovery file Stop() removed the discovery file unconditionally, so any engine that shared LINETTA_HOME erased a live server's endpoint on its way out - even one with MCP off that never bound a listener. The server kept serving while the bridge had nothing left to read. Two guards: Stop only retracts when this host actually served, and removeDiscoveryFile refuses to delete a file whose pid is not ours. Found in operation, not in review: a second engine instance left running against the same data directory deleted the live server's mcp.json when its process exited. Part of the MCP-first pivot (#47), Phase 2. Co-Authored-By: Claude Opus 5 --- engine/internal/mcphost/discovery.go | 7 +++- engine/internal/mcphost/host.go | 9 ++++- engine/internal/mcphost/host_test.go | 55 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/engine/internal/mcphost/discovery.go b/engine/internal/mcphost/discovery.go index 03350d74..67aee618 100644 --- a/engine/internal/mcphost/discovery.go +++ b/engine/internal/mcphost/discovery.go @@ -48,11 +48,16 @@ func writeDiscoveryFile(home string, port int, token string) error { } // removeDiscoveryFile deletes the file on shutdown so a stale endpoint is -// never advertised. A missing file is not an error. +// never advertised — but only when this process is the one it points at. +// Another engine instance shutting down must not retract a live server's +// endpoint. A missing file is not an error. func removeDiscoveryFile(home string) { if home == "" { return } + if d, err := ReadDiscoveryFile(home); err == nil && d.PID != os.Getpid() { + return + } if err := os.Remove(discoveryPath(home)); err != nil && !os.IsNotExist(err) { logf("remove discovery file: %v", err) } diff --git a/engine/internal/mcphost/host.go b/engine/internal/mcphost/host.go index 5b74c863..b3b16d73 100644 --- a/engine/internal/mcphost/host.go +++ b/engine/internal/mcphost/host.go @@ -162,13 +162,20 @@ func (h *Host) Start(ctx context.Context) error { func (h *Host) Stop() error { h.mu.Lock() srv := h.httpSrv + wasRunning := h.running h.httpSrv = nil h.running = false h.port = 0 h.token = "" h.mu.Unlock() - removeDiscoveryFile(h.deps.Home) + // Only a host that actually served may retract the discovery file. An + // engine that never started MCP — mode off, or a second instance — would + // otherwise erase a live server's endpoint on its way out, leaving the + // bridge with nothing to read while the server is still up. + if wasRunning { + removeDiscoveryFile(h.deps.Home) + } if srv == nil { return nil } diff --git a/engine/internal/mcphost/host_test.go b/engine/internal/mcphost/host_test.go index 3b716afa..db02811b 100644 --- a/engine/internal/mcphost/host_test.go +++ b/engine/internal/mcphost/host_test.go @@ -270,3 +270,58 @@ func TestUnitOriginAndHostChecks(t *testing.T) { } } } + +// Regression: Stop removed the discovery file unconditionally, so an engine +// that never served MCP — mode off, or a second instance sharing the home — +// erased a live server's endpoint on its way out. The server kept serving +// while the bridge had nothing left to read. +func TestStopKeepsAnotherHostsDiscoveryFile(t *testing.T) { + live, _, home := newHost(t, settings.MCPModeReadOnly, true) + if err := live.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + path := filepath.Join(home, DiscoveryFileName) + if _, err := os.Stat(path); err != nil { + t.Fatalf("live server should have written a discovery file: %v", err) + } + + // A second host over the same home that never starts (mode off). + idle := New(Deps{Settings: idleSettings(t, home), Home: home}) + if err := idle.Start(context.Background()); err != nil { + t.Fatalf("idle Start: %v", err) + } + if err := idle.Stop(); err != nil { + t.Fatalf("idle Stop: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatal("an idle host's shutdown must not remove the live server's discovery file") + } + if !live.Status().Running { + t.Fatal("the live server should still be serving") + } + + // The owner still retracts its own file. + if err := live.Stop(); err != nil { + t.Fatalf("live Stop: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { + t.Fatal("the owning host must remove its discovery file on shutdown") + } +} + +// idleSettings returns a store over the same home with MCP off, standing in +// for an engine instance that shares the data directory but never serves. +func idleSettings(t *testing.T, home string) *settings.Store { + t.Helper() + t.Setenv("LINETTA_HOME", home) + s, err := settings.NewWithSecretStore(settings.NewMemorySecretStore()) + if err != nil { + t.Fatalf("settings: %v", err) + } + off := settings.MCPModeOff + if _, err := s.Set(context.Background(), settings.Patch{MCPMode: &off}); err != nil { + t.Fatalf("settings.Set(off): %v", err) + } + return s +} From cac84edfbfbb85e880c810b209abd943fd4a0cf6 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 16:38:18 +0900 Subject: [PATCH 17/25] docs: correct the Phase 3 undo contract before building the write tools RestoreOutline - what storyops.UndoApply calls - restores parent/ordinal/ label/title/status and leaves content_doc alone, so undoing a structural batch does not revert scene prose. The plan's exit criterion claimed it did, which would have shipped a false promise to agents. write_scene now carries snapshot_id and undo_last_change accepts both a batch_id (structure) and a snapshot_id (prose). Also records two decisions found while reading the write path: apply_story_ops rejects set_scene_text so version checking cannot be bypassed, and write_summary requires content_version for scenes only. Part of the MCP-first pivot (#47), Phase 3. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-08-22-mcp-first-pivot.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index 918a4ae8..f3091b1d 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -169,6 +169,7 @@ ### Task 3.3 — `linetta_apply_story_ops` - [ ] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다. +- [ ] **`set_scene_text` 옵은 거부하고 `linetta_write_scene`으로 안내한다.** 적용기의 `set_scene_text`는 `nodes.UpdateContent`(무조건 덮어쓰기)를 쓰므로, 이 툴로 통과시키면 `write_scene`의 버전 검사 계약을 우회하게 된다. 변경 종류마다 문은 하나여야 한다. - [ ] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다. - [ ] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함. @@ -177,13 +178,16 @@ **전환의 급소다.** 설계 문서 6절 참조. - [ ] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유). +- [ ] **버전 계약 확정:** 씬(leaf)만 `content_version`을 요구한다. 컨테이너와 시놉시스는 자식 편집을 추적하는 버전이 없으므로 요구하지 않고 마지막 쓰기가 이긴다 — 툴 설명에 명시한다. - [ ] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다. - [ ] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨. ### Task 3.5 — 체크포인트와 되돌리기 - [ ] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다. -- [ ] `linetta_undo_last_change`는 `storyops.UndoApply`를 감싸고, 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. +- [ ] `linetta_undo_last_change`는 `batch_id`(구조 변경)와 `snapshot_id`(본문 변경) 두 가지를 받는다. 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. + +> **구현 중 확정 (계획 정정):** `storyops.UndoApply` → `nodes.RestoreOutline`은 `parent_id·ordinal·label·title·status`만 되돌리고 **`content_doc`은 건드리지 않는다**(삭제됐다가 복원되는 노드만 예외). 즉 구조 변경 undo는 씬 본문을 되돌리지 못한다. 본문 되돌리기는 스냅샷(버전 기록) 경로다. 툴이 이 차이를 감추면 에이전트에게 거짓 약속을 하게 되므로, `linetta_write_scene`은 결과에 `snapshot_id`를 실어 보내고 `linetta_undo_last_change`가 두 종류를 모두 받는다. ### Task 3.6 — 호출 한도와 모드 강제 @@ -199,7 +203,9 @@ - [ ] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.** - [ ] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest. -**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`를 구동하고 원고가 원래 바이트로 돌아온다. +- [ ] 씬 쓰기 전 스냅샷은 당분간 `snapshot.ReasonCompanionBefore`를 재사용한다. 새 reason을 추가하려면 `ValidReason`과 프론트엔드 버전 시트 라벨을 함께 손봐야 하고, 컴패니언이 Phase 6에서 사라지면 이 reason은 사실상 "에이전트 변경 전"이 된다. 전용 reason 도입은 Phase 4의 UI 작업과 함께 판단한다. + +**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`(반환된 `snapshot_id`로)를 구동하고 원고가 원래 바이트로 돌아온다. 구조 변경은 `linetta_apply_story_ops` → `undo_last_change`(`batch_id`)로 별도 검증한다. --- From bbd64bd6269a720db5635d602ee2f92da5c35043 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 16:43:00 +0900 Subject: [PATCH 18/25] feat(engine): add the MCP scene and summary write tools linetta_write_scene replaces a scene body; linetta_write_summary writes a scene, chapter, or synopsis summary. Both register only in settings.MCPModeFull, so read_only does not merely refuse writes - the tools are absent from tools/list. write_scene is built directly on nodes.UpdateContentIfVersion rather than routed through storyops. That looks like it contradicts the reuse-the-applier rule but does not: the applier's set_scene_text calls UpdateContent unconditionally (last writer wins) because the companion targets the scene the writer is looking at. The MCP contract is the opposite - expected_content_version is required and a stale write is refused with a message telling the agent to re-read and merge. The pre-write snapshot, the readback verify, and the summarizer enqueue all still happen. expected_content_version is a pointer, not an int: a scene that has never been written has version 0, so absent and zero must stay distinguishable or the first draft could never be written. Caught by the tests, which drive real tools/call traffic. Scene summaries require the version too - a summary of text that has since changed would make the story brief lie. Chapters and the synopsis have no version tracking their children, so they take none and last write wins, stated in the tool description. write_scene returns snapshot_id: reverting prose goes through the snapshot, because undoing a structural batch restores the outline and leaves bodies alone (see the plan's corrected undo contract). Part of the MCP-first pivot (#47), Phase 3 Tasks 3.1 and 3.4. Co-Authored-By: Claude Opus 5 --- engine/internal/engineapp/engineapp.go | 4 + engine/internal/engineapp/mcp_disabled.go | 5 + engine/internal/engineapp/mcp_enabled.go | 10 + engine/internal/engineapp/mcp_write_test.go | 299 ++++++++++++++++++++ engine/internal/mcphost/tools.go | 40 ++- engine/internal/mcphost/tools_write.go | 227 +++++++++++++++ 6 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 engine/internal/engineapp/mcp_write_test.go create mode 100644 engine/internal/mcphost/tools_write.go diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index e90a9ea5..ad6150e8 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -242,6 +242,10 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error plot: plotBuilder, manuscript: manuscriptSearcher, context: mcpContextBuilder, + snapshots: snaps, + enqueue: summ.Enqueue, + notify: func(method string, params any) { _ = s.Notifier().Notify(method, params) }, + clock: clock, db: st.DB(), }, }) diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go index d1edce9d..16b6cd6c 100644 --- a/engine/internal/engineapp/mcp_disabled.go +++ b/engine/internal/engineapp/mcp_disabled.go @@ -16,6 +16,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" ) @@ -40,6 +41,10 @@ type mcpToolRepos struct { plot *plot.Builder manuscript *manuscript.Searcher context *storycontext.ContextBuilder + snapshots *snapshot.Repo + enqueue func(nodeID string) + notify func(method string, params any) + clock func() int64 db *sql.DB } diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go index 20eaed43..664291c9 100644 --- a/engine/internal/engineapp/mcp_enabled.go +++ b/engine/internal/engineapp/mcp_enabled.go @@ -19,6 +19,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/rpc/handlers" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" ) @@ -47,6 +48,10 @@ type mcpToolRepos struct { plot *plot.Builder manuscript *manuscript.Searcher context *storycontext.ContextBuilder + snapshots *snapshot.Repo + enqueue func(nodeID string) + notify func(method string, params any) + clock func() int64 db *sql.DB } @@ -71,6 +76,11 @@ func setupMCP(deps mcpDeps) (*mcpController, func() error) { Context: deps.repos.context, Settings: deps.settingsStore, Activity: activity, + + Snapshots: deps.repos.snapshots, + EnqueueSummary: deps.repos.enqueue, + Notify: deps.repos.notify, + Clock: deps.repos.clock, } host := mcphost.New(mcphost.Deps{ Settings: deps.settingsStore, diff --git a/engine/internal/engineapp/mcp_write_test.go b/engine/internal/engineapp/mcp_write_test.go new file mode 100644 index 00000000..ae984117 --- /dev/null +++ b/engine/internal/engineapp/mcp_write_test.go @@ -0,0 +1,299 @@ +//go:build !mobile + +package engineapp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/mcphost" +) + +// startWritableMCP brings up a server in full mode with one seeded scene. +func startWritableMCP(t *testing.T) (*App, *mcpClient, string, string) { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = app.Close() }) + + port := freeTestPort(t) + patch := fmt.Sprintf( + `{"mcp_mode":"full","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, port) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + if _, rpcErr := call(t, app, "mcp.enable", ""); rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + d, err := mcphost.ReadDiscoveryFile(home) + if err != nil { + t.Fatalf("ReadDiscoveryFile: %v", err) + } + c := &mcpClient{t: t, url: fmt.Sprintf("http://127.0.0.1:%d/mcp", d.Port), token: d.Token} + c.initialize() + + created, rpcErr := call(t, app, "projects.create", + `{"title":"쓰기 테스트","genres":["fantasy"],"length_target":"short","default_pov":"first"}`) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var proj struct { + ID string `json:"id"` + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(created, &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + return app, c, proj.ID, *proj.LastOpenedNodeID +} + +func readScene(t *testing.T, c *mcpClient, nodeID string) (body string, version int) { + t.Helper() + result := c.callTool("linetta_read_scene", map[string]any{"node_id": nodeID}) + if isToolError(result) { + t.Fatalf("read_scene: %v", result) + } + var out struct { + Body string `json:"body"` + ContentVersion int `json:"content_version"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + return out.Body, out.ContentVersion +} + +// read_only must not merely refuse writes — the tools must be absent, so a +// misbehaving agent cannot call one at all. +func TestMCPReadOnlyHidesWriteTools(t *testing.T) { + _, c := startMCPServer(t) // read_only + names := strings.Join(c.toolNames(), ",") + for _, w := range mcphost.WriteToolNames { + if strings.Contains(names, w) { + t.Errorf("read_only exposed the write tool %q", w) + } + } + if len(c.toolNames()) != len(mcphost.ReadToolNames) { + t.Errorf("read_only tool count = %d, want %d", len(c.toolNames()), len(mcphost.ReadToolNames)) + } +} + +func TestMCPFullModeExposesWriteTools(t *testing.T) { + _, c, _, _ := startWritableMCP(t) + names := strings.Join(c.toolNames(), ",") + for _, w := range mcphost.WriteToolNames { + if !strings.Contains(names, w) { + t.Errorf("full mode is missing the write tool %q", w) + } + } + want := len(mcphost.ReadToolNames) + len(mcphost.WriteToolNames) + if got := len(c.toolNames()); got != want { + t.Errorf("full mode tool count = %d, want %d", got, want) + } +} + +// The happy path: read, write, and the prose is really there — plus a +// snapshot id the agent can revert with. +func TestMCPWriteSceneWritesAndSnapshots(t *testing.T) { + app, c, _, nodeID := startWritableMCP(t) + _, version := readScene(t, c, nodeID) + + const prose = "비가 그친 골목에서 그는 그림자를 잃었다.\n\n가로등은 켜져 있었다." + result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": prose, "expected_content_version": version, + }) + if isToolError(result) { + t.Fatalf("write_scene: %v", result) + } + var out struct { + ContentVersion int `json:"content_version"` + WordCount int `json:"word_count"` + SnapshotID string `json:"snapshot_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode write_scene: %v", err) + } + if out.ContentVersion <= version { + t.Errorf("content_version did not advance: %d -> %d", version, out.ContentVersion) + } + if out.WordCount == 0 { + t.Error("word count was not recomputed") + } + + body, _ := readScene(t, c, nodeID) + if !strings.Contains(body, "그림자를 잃었다") { + t.Fatalf("prose not stored: %q", body) + } + + // The snapshot is the revert path for prose; undo_last_change's batch id + // restores the outline and leaves bodies alone. + raw, rpcErr := call(t, app, "snapshots.list_for_node", fmt.Sprintf(`{"node_id":%q}`, nodeID)) + if rpcErr != nil { + t.Fatalf("snapshots.list_for_node: %+v", rpcErr) + } + if !strings.Contains(string(raw), "companion-before") { + t.Errorf("no pre-write snapshot recorded: %s", raw) + } +} + +// The whole point of expected_content_version: a write against stale state is +// refused instead of silently overwriting the writer's own edits. +func TestMCPWriteSceneRefusesStaleVersion(t *testing.T) { + app, c, _, nodeID := startWritableMCP(t) + _, stale := readScene(t, c, nodeID) + + // The writer edits in the app, so the agent's version is now behind. + if _, rpcErr := call(t, app, "nodes.update_content", + fmt.Sprintf(`{"id":%q,"doc":"{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"작가가 직접 쓴 문장\"}]}]}"}`, nodeID)); rpcErr != nil { + t.Fatalf("nodes.update_content: %+v", rpcErr) + } + + result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "에이전트가 덮어쓰려는 문장", "expected_content_version": stale, + }) + if !isToolError(result) { + t.Fatal("a stale write must be refused") + } + if msg := toolErrorText(result); !strings.Contains(msg, "read") { + t.Errorf("the conflict message should tell the agent to re-read: %s", msg) + } + body, _ := readScene(t, c, nodeID) + if !strings.Contains(body, "작가가 직접 쓴 문장") { + t.Fatalf("the writer's text was lost: %q", body) + } +} + +func TestMCPWriteSceneRejectsMissingVersionAndContainers(t *testing.T) { + app, c, projectID, nodeID := startWritableMCP(t) + + if result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "버전 없이", + }); !isToolError(result) { + t.Error("a write without expected_content_version must be refused") + } + + // A container has no body; writing to one is a mistake worth naming. + raw, rpcErr := call(t, app, "nodes.create_child", + fmt.Sprintf(`{"parent_id":%q,"kind":"container","label":"1부"}`, nodeID)) + if rpcErr != nil { + // The seeded node is a leaf, so create a container at the root instead. + raw, rpcErr = call(t, app, "nodes.create_sibling", + fmt.Sprintf(`{"sibling_id":%q,"kind":"container","label":"1부"}`, nodeID)) + if rpcErr != nil { + t.Skipf("could not create a container to test with: %+v", rpcErr) + } + } + var container struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &container); err != nil || container.ID == "" { + t.Skip("could not decode the container node") + } + _ = projectID + if result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": container.ID, "text": "본문", "expected_content_version": 1, + }); !isToolError(result) { + t.Error("writing a body to a container must be refused") + } +} + +// write_summary is what fills the empty summary sections the brief reports. +func TestMCPWriteSummaryForScene(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + _, version := readScene(t, c, nodeID) + writeResult := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "그는 골목에서 그림자를 잃었다.", "expected_content_version": version, + }) + if isToolError(writeResult) { + t.Fatalf("write_scene: %v", writeResult) + } + _, fresh := readScene(t, c, nodeID) + + const summary = "서린이 골목에서 자신의 그림자가 사라진 것을 처음 자각한다." + result := c.callTool("linetta_write_summary", map[string]any{ + "node_id": nodeID, "summary": summary, "expected_content_version": fresh, + }) + if isToolError(result) { + t.Fatalf("write_summary: %v", result) + } + + // A fresh summary must read back as fresh, not stale. + scene := c.callTool("linetta_read_scene", map[string]any{"node_id": nodeID}) + var out struct { + Summary string `json:"summary"` + SummaryIsStale bool `json:"summary_is_stale"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, scene)), &out); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + if out.Summary != summary { + t.Errorf("summary = %q, want %q", out.Summary, summary) + } + if out.SummaryIsStale { + t.Error("a summary written against the current version must not read as stale") + } +} + +// A summary of text that has since changed would make the brief lie, so a +// stale version is refused. +func TestMCPWriteSummaryRefusesStaleVersion(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + _, stale := readScene(t, c, nodeID) + if result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "새 본문", "expected_content_version": stale, + }); isToolError(result) { + t.Fatalf("write_scene: %v", result) + } + + result := c.callTool("linetta_write_summary", map[string]any{ + "node_id": nodeID, "summary": "낡은 본문에 대한 요약", "expected_content_version": stale, + }) + if !isToolError(result) { + t.Fatal("a summary written against stale text must be refused") + } +} + +func TestMCPWriteSummarySynopsisAndArgumentChecks(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + + const synopsis = "존재가 지워진 남자가 자신을 지운 조직을 추적한다." + if result := c.callTool("linetta_write_summary", map[string]any{ + "project_id": projectID, "summary": synopsis, + }); isToolError(result) { + t.Fatalf("synopsis write: %v", result) + } + works := c.callTool("linetta_list_works", map[string]any{}) + if !strings.Contains(structuredJSON(t, works), "자신을 지운 조직") { + t.Errorf("synopsis not stored: %s", structuredJSON(t, works)) + } + + if result := c.callTool("linetta_write_summary", map[string]any{"summary": "대상 없음"}); !isToolError(result) { + t.Error("a summary with no target must be refused") + } + if result := c.callTool("linetta_write_summary", map[string]any{ + "node_id": nodeID, "project_id": projectID, "summary": "둘 다", + }); !isToolError(result) { + t.Error("passing both node_id and project_id must be refused") + } +} + +// toolErrorText returns the human-readable text of a tool error, which is what +// the agent actually reads (structuredContent holds the zero value on errors). +func toolErrorText(result map[string]any) string { + content, _ := result["content"].([]any) + parts := []string{} + for _, raw := range content { + block, _ := raw.(map[string]any) + if text, ok := block["text"].(string); ok { + parts = append(parts, text) + } + } + return strings.Join(parts, " ") +} diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go index e675ffa8..c020d0b3 100644 --- a/engine/internal/mcphost/tools.go +++ b/engine/internal/mcphost/tools.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -17,6 +18,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" ) @@ -33,6 +35,40 @@ type ToolDeps struct { Context *storycontext.ContextBuilder Settings *settings.Store Activity *ActivityRepo + + // Write-side collaborators. Snapshots make every body change revertible; + // EnqueueSummary keeps agent prose in the summarizer's queue; Notify tells + // the running UI that something outside it changed the manuscript. + Snapshots *snapshot.Repo + EnqueueSummary func(nodeID string) + Notify func(method string, params any) + Clock func() int64 +} + +// now returns the wall clock the tools stamp writes with. +func (d ToolDeps) now() int64 { + if d.Clock != nil { + return d.Clock() + } + return time.Now().UnixMilli() +} + +// ChangedPayload is the body of an "mcp.changed" notification: what an external +// agent just altered, so the UI can refetch instead of showing stale text. +type ChangedPayload struct { + ProjectID string `json:"project_id"` + Tool string `json:"tool"` + NodeIDs []string `json:"node_ids,omitempty"` + BatchID string `json:"batch_id,omitempty"` +} + +func (d ToolDeps) notifyChanged(projectID, tool string, nodeIDs []string, batchID string) { + if d.Notify == nil { + return + } + d.Notify("mcp.changed", ChangedPayload{ + ProjectID: projectID, Tool: tool, NodeIDs: nodeIDs, BatchID: batchID, + }) } // Register installs the tool set for a mode. Read tools are always present; @@ -45,7 +81,9 @@ type ToolDeps struct { // running server never serves a stale tool set. func (d ToolDeps) Register(s *mcp.Server, mode string) { d.registerReadTools(s) - _ = mode // write tools land in Phase 3 + if mode == settings.MCPModeFull { + d.registerWriteTools(s) + } } // scopedInput is implemented by tool inputs that name a work and/or a target, diff --git a/engine/internal/mcphost/tools_write.go b/engine/internal/mcphost/tools_write.go new file mode 100644 index 00000000..4b94b20c --- /dev/null +++ b/engine/internal/mcphost/tools_write.go @@ -0,0 +1,227 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "errors" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/storyops" +) + +// WriteToolNames lists the write tools, in registration order. They are +// registered only in settings.MCPModeFull, so read_only does not merely refuse +// writes — the tools are absent from tools/list. +var WriteToolNames = []string{ + "linetta_write_scene", + "linetta_write_summary", +} + +// maxSceneRunes caps one scene body. A runaway agent should hit a wall with a +// clear message rather than commit a megabyte to the manuscript. +const maxSceneRunes = 60000 + +// ---------- linetta_write_scene ---------- + +type writeSceneInput struct { + NodeID string `json:"node_id" jsonschema:"id of the scene to write"` + Text string `json:"text" jsonschema:"the full scene body as plain prose; blank lines separate paragraphs"` + // ExpectedContentVersion is the content_version from linetta_read_scene. + // Required: it is what stops an agent from overwriting edits the writer + // made after the agent last read the scene. + // A pointer, not an int: a scene that has never been written has version 0, + // so "absent" and "zero" must stay distinguishable or the first draft can + // never be written. + ExpectedContentVersion *int `json:"expected_content_version" jsonschema:"the content_version returned by linetta_read_scene"` +} + +func (in writeSceneInput) scope() (string, string) { return "", in.NodeID } + +type writeSceneOutput struct { + NodeID string `json:"node_id"` + ContentVersion int `json:"content_version"` + WordCount int `json:"word_count"` + // SnapshotID is the pre-write version. Reverting prose goes through this, + // not through linetta_undo_last_change's batch id: undoing a structural + // batch restores the outline and leaves scene bodies alone. + SnapshotID string `json:"snapshot_id,omitempty"` +} + +// ---------- linetta_write_summary ---------- + +type writeSummaryInput struct { + // Exactly one target. A scene or container summary feeds the story brief; + // the synopsis is the work-level blurb. + NodeID string `json:"node_id,omitempty" jsonschema:"scene or container to summarize"` + ProjectID string `json:"project_id,omitempty" jsonschema:"work whose synopsis to write; omit when node_id is set"` + Summary string `json:"summary" jsonschema:"3-5 sentences preserving characters, places, and key events"` + // ExpectedContentVersion is required for scenes only — containers and the + // synopsis have no version tracking their children's edits. + ExpectedContentVersion *int `json:"expected_content_version,omitempty" jsonschema:"for a scene, the content_version from linetta_read_scene"` +} + +func (in writeSummaryInput) scope() (string, string) { return in.ProjectID, in.NodeID } + +type writeSummaryOutput struct { + Target string `json:"target"` // scene | container | synopsis + NodeID string `json:"node_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Summary string `json:"summary"` +} + +// registerWriteTools installs the mutating tools. Only called for +// settings.MCPModeFull. +func (d ToolDeps) registerWriteTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_write_scene", + Description: "Replace a scene's body with new prose. Call linetta_read_scene first and pass the " + + "content_version it returned: if the writer edited the scene since then the write is refused, " + + "so their work is never silently overwritten. The previous text is snapshotted first and the " + + "returned snapshot_id restores it.", + }, record(d, "linetta_write_scene", d.writeScene)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_write_summary", + Description: "Write the summary Linetta shows for a scene or chapter, or the work's synopsis. " + + "Summaries feed linetta_get_story_context, so writing one after drafting keeps later briefs " + + "accurate — when a brief reports an empty summary section, this is the tool that fills it. " + + "A scene summary needs the content_version from linetta_read_scene; chapters and the synopsis " + + "do not.", + }, record(d, "linetta_write_summary", d.writeSummary)) +} + +func (d ToolDeps) writeScene(ctx context.Context, _ *mcp.CallToolRequest, in writeSceneInput) (*mcp.CallToolResult, writeSceneOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, writeSceneOutput{}, nil + } + if n.Kind != node.KindLeaf { + return toolErr("node %q is a container (%s), not a scene; only scenes hold body text", n.ID, n.Label), + writeSceneOutput{}, nil + } + if in.ExpectedContentVersion == nil { + return toolErr("expected_content_version is required; call linetta_read_scene first and pass the value it returns"), + writeSceneOutput{}, nil + } + expected := *in.ExpectedContentVersion + if expected < 0 { + return toolErr("expected_content_version must not be negative"), writeSceneOutput{}, nil + } + if count := len([]rune(in.Text)); count > maxSceneRunes { + return toolErr("scene text is %d characters; the limit is %d — split it across scenes", count, maxSceneRunes), + writeSceneOutput{}, nil + } + + // Snapshot before touching anything, so the previous text survives even if + // the write itself fails partway. + snapshotID := "" + if d.Snapshots != nil { + beforeDoc := "" + if n.ContentDoc != nil { + beforeDoc = *n.ContentDoc + } + snap, created, err := d.Snapshots.CreateIfChanged(ctx, n.ID, beforeDoc, snapshot.ReasonCompanionBefore, d.now()) + if err != nil { + return toolErr("could not snapshot the current text: %v", err), writeSceneOutput{}, nil + } + if created { + snapshotID = snap.ID + } + } + + doc, err := storyops.PlainTextToTiptapDoc(in.Text) + if err != nil { + return toolErr("could not convert the text: %v", err), writeSceneOutput{}, nil + } + if err := d.Nodes.UpdateContentIfVersion(ctx, n.ID, doc, expected, d.now()); err != nil { + if errors.Is(err, node.ErrContentConflict) { + return toolErr( + "the scene changed since you read it (you passed version %d). Call linetta_read_scene again, "+ + "merge your changes into the current text, and retry with the fresh content_version.", + expected), writeSceneOutput{}, nil + } + return toolErr("could not write the scene: %v", err), writeSceneOutput{}, nil + } + + after, err := d.Nodes.Get(ctx, n.ID) + if err != nil { + return toolErr("wrote the scene but could not read it back: %v", err), writeSceneOutput{}, nil + } + // The summarizer keeps the story brief honest; without this an agent's + // prose would never even get the short-scene plaintext summary. + if d.EnqueueSummary != nil { + d.EnqueueSummary(n.ID) + } + d.notifyChanged(n.ProjectID, "linetta_write_scene", []string{n.ID}, "") + + return nil, writeSceneOutput{ + NodeID: after.ID, + ContentVersion: after.ContentVersion, + WordCount: after.WordCount, + SnapshotID: snapshotID, + }, nil +} + +func (d ToolDeps) writeSummary(ctx context.Context, _ *mcp.CallToolRequest, in writeSummaryInput) (*mcp.CallToolResult, writeSummaryOutput, error) { + summary := strings.TrimSpace(in.Summary) + if summary == "" { + return toolErr("summary is required"), writeSummaryOutput{}, nil + } + nodeID := strings.TrimSpace(in.NodeID) + projectID := strings.TrimSpace(in.ProjectID) + if nodeID == "" && projectID == "" { + return toolErr("pass node_id to summarize a scene or chapter, or project_id to write the work's synopsis"), + writeSummaryOutput{}, nil + } + if nodeID != "" && projectID != "" { + return toolErr("pass either node_id or project_id, not both"), writeSummaryOutput{}, nil + } + + if nodeID == "" { + p, errResult := d.requireProject(ctx, projectID) + if errResult != nil { + return errResult, writeSummaryOutput{}, nil + } + if _, err := d.Projects.Update(ctx, d.now(), project.UpdateInput{ID: p.ID, Synopsis: &summary}); err != nil { + return toolErr("could not write the synopsis: %v", err), writeSummaryOutput{}, nil + } + d.notifyChanged(p.ID, "linetta_write_summary", nil, "") + return nil, writeSummaryOutput{Target: "synopsis", ProjectID: p.ID, Summary: summary}, nil + } + + n, errResult := d.requireNode(ctx, nodeID) + if errResult != nil { + return errResult, writeSummaryOutput{}, nil + } + target := "container" + forVersion := n.ContentVersion + if n.Kind == node.KindLeaf { + target = "scene" + // Scenes carry a version that tracks their own edits, so a summary + // written against stale text must be refused — otherwise the brief + // would report a fresh summary of prose that has since changed. + if in.ExpectedContentVersion == nil { + return toolErr("expected_content_version is required for a scene summary; call linetta_read_scene first"), + writeSummaryOutput{}, nil + } + if *in.ExpectedContentVersion != n.ContentVersion { + return toolErr( + "the scene changed since you read it (you passed version %d, current is %d). "+ + "Re-read the scene and summarize the current text.", + *in.ExpectedContentVersion, n.ContentVersion), writeSummaryOutput{}, nil + } + forVersion = *in.ExpectedContentVersion + } + + if err := d.Nodes.SetSummary(ctx, n.ID, summary, forVersion); err != nil { + return toolErr("could not write the summary: %v", err), writeSummaryOutput{}, nil + } + d.notifyChanged(n.ProjectID, "linetta_write_summary", []string{n.ID}, "") + return nil, writeSummaryOutput{Target: target, NodeID: n.ID, Summary: summary}, nil +} From 7b974cf59eb5c8de440636866c1859362436ce30 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 17:10:01 +0900 Subject: [PATCH 19/25] feat(engine): add the MCP batch, checkpoint, and undo tools linetta_apply_story_ops routes the existing Proposal vocabulary through storyops.ApplyOps, so structural batches stay all-or-nothing and return an undo_batch_id. linetta_create_checkpoint saves a restore point; linetta_undo_last_change takes either a batch_id or a snapshot_id. Two decisions worth naming: set_scene_text is refused by the batch tool with a message pointing at linetta_write_scene. The applier writes scene bodies unconditionally because the companion targets the scene the writer is looking at; letting that through here would route around write_scene's version check entirely. One door per mutation type. The MCP tool layer gets its own storyops instance. Undo batches live in memory on the service, so an agent can undo only what it applied and never the writer's own companion batch. The phase exit criterion is now a real test: write prose, undo with the returned snapshot_id, and the scene matches the original bytes. Undoing a structural batch is tested separately, because it restores the outline and leaves bodies alone. Part of the MCP-first pivot (#47), Phase 3 Tasks 3.3 and 3.5. Co-Authored-By: Claude Opus 5 --- engine/internal/engineapp/engineapp.go | 10 + engine/internal/engineapp/mcp_batch_test.go | 212 +++++++++++++++++ engine/internal/engineapp/mcp_disabled.go | 2 + engine/internal/engineapp/mcp_enabled.go | 3 + engine/internal/mcphost/tools.go | 2 + engine/internal/mcphost/tools_batch.go | 246 ++++++++++++++++++++ engine/internal/mcphost/tools_write.go | 5 + 7 files changed, 480 insertions(+) create mode 100644 engine/internal/engineapp/mcp_batch_test.go create mode 100644 engine/internal/mcphost/tools_batch.go diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index ad6150e8..659492a3 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -38,6 +38,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/stats" "github.com/devlikebear/linetta/engine/internal/store" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" "github.com/devlikebear/linetta/engine/internal/summarizer" "github.com/devlikebear/linetta/engine/internal/thread" ) @@ -224,6 +225,14 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error // The tool layer gets its OWN context builder, wired with the fact, // memory, and reference sources. The builder above stays untouched so // ai.run and ai.preview_context keep producing byte-identical prompts. + // A storyops instance of its own: undo batches live in memory on the + // service, so an agent can undo only what it applied — never the writer's + // own companion batch. + mcpStory := storyops.New(projects, nodes, threads, beats, entities, relationships). + WithFacts(facts). + WithSnapshots(snaps). + WithMemory(companionSvc) + mcpContextBuilder := storycontext.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships). WithSummaryRefresher(summ). WithFactSource(companionSvc). @@ -243,6 +252,7 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error manuscript: manuscriptSearcher, context: mcpContextBuilder, snapshots: snaps, + story: mcpStory, enqueue: summ.Enqueue, notify: func(method string, params any) { _ = s.Notifier().Notify(method, params) }, clock: clock, diff --git a/engine/internal/engineapp/mcp_batch_test.go b/engine/internal/engineapp/mcp_batch_test.go new file mode 100644 index 00000000..032bd4f2 --- /dev/null +++ b/engine/internal/engineapp/mcp_batch_test.go @@ -0,0 +1,212 @@ +//go:build !mobile + +package engineapp + +import ( + "encoding/json" + "strings" + "testing" +) + +// The exit criterion for the write phase: an agent writes prose and can put it +// back. Body reverts go through the snapshot, not the batch id. +func TestMCPWriteSceneThenUndoRestoresTheOriginalBytes(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + + const original = "원래 있던 문장이다.\n\n두 번째 문단." + _, v0 := readScene(t, c, nodeID) + if r := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": original, "expected_content_version": v0, + }); isToolError(r) { + t.Fatalf("seed write: %v", r) + } + before, v1 := readScene(t, c, nodeID) + + result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "에이전트가 완전히 새로 쓴 원고.", "expected_content_version": v1, + }) + if isToolError(result) { + t.Fatalf("write_scene: %v", result) + } + var wrote struct { + SnapshotID string `json:"snapshot_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &wrote); err != nil { + t.Fatalf("decode write_scene: %v", err) + } + if wrote.SnapshotID == "" { + t.Fatal("write_scene must return the snapshot id that restores the previous text") + } + + undo := c.callTool("linetta_undo_last_change", map[string]any{"snapshot_id": wrote.SnapshotID}) + if isToolError(undo) { + t.Fatalf("undo: %v", undo) + } + var undone struct { + Reverted string `json:"reverted"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, undo)), &undone); err != nil { + t.Fatalf("decode undo: %v", err) + } + if undone.Reverted != "scene" { + t.Errorf("reverted = %q, want scene", undone.Reverted) + } + + after, _ := readScene(t, c, nodeID) + if after != before { + t.Fatalf("undo did not restore the original bytes:\n got %q\nwant %q", after, before) + } +} + +// A structural batch applies atomically and its batch id puts the outline back. +func TestMCPApplyStoryOpsAndUndoBatch(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + + countScenes := func() int { + t.Helper() + outline := c.callTool("linetta_get_outline", map[string]any{"project_id": projectID}) + var out struct { + Outline []struct { + Kind string `json:"kind"` + } `json:"outline"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, outline)), &out); err != nil { + t.Fatalf("decode outline: %v", err) + } + return len(out.Outline) + } + before := countScenes() + + result := c.callTool("linetta_apply_story_ops", map[string]any{ + "project_id": projectID, + "node_id": nodeID, + "summary": "2화와 인물 추가", + "ops": []map[string]any{ + {"op": "create_scene", "ref": "s2", "after_node_id": nodeID, "label": "2화", "title": "기록보관실"}, + {"op": "create_entity", "ref": "hayun", "kind": "character", "name": "하윤", "role": "조력자"}, + }, + }) + if isToolError(result) { + t.Fatalf("apply_story_ops: %v", result) + } + var applied struct { + Applied int `json:"applied"` + UndoBatchID string `json:"undo_batch_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &applied); err != nil { + t.Fatalf("decode apply: %v", err) + } + if applied.Applied != 2 { + t.Fatalf("applied = %d, want 2", applied.Applied) + } + if applied.UndoBatchID == "" { + t.Fatal("a structural batch must return an undo_batch_id") + } + if countScenes() != before+1 { + t.Fatalf("outline did not gain the new scene: %d -> %d", before, countScenes()) + } + + chars := c.callTool("linetta_list_characters", map[string]any{"project_id": projectID}) + if !strings.Contains(structuredJSON(t, chars), "하윤") { + t.Error("the created character is not readable through the read tools") + } + + if undo := c.callTool("linetta_undo_last_change", map[string]any{ + "batch_id": applied.UndoBatchID, + }); isToolError(undo) { + t.Fatalf("undo batch: %v", undo) + } + if got := countScenes(); got != before { + t.Fatalf("undo did not restore the outline: %d, want %d", got, before) + } +} + +// One door per mutation type: set_scene_text through the batch tool would skip +// write_scene's version check entirely. +func TestMCPApplyStoryOpsRejectsSceneText(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + result := c.callTool("linetta_apply_story_ops", map[string]any{ + "project_id": projectID, + "node_id": nodeID, + "summary": "본문 덮어쓰기 시도", + "ops": []map[string]any{ + {"op": "set_scene_text", "node_id": nodeID, "text": "버전 검사를 우회한 본문"}, + }, + }) + if !isToolError(result) { + t.Fatal("set_scene_text must be refused by the batch tool") + } + if msg := toolErrorText(result); !strings.Contains(msg, "linetta_write_scene") { + t.Errorf("the refusal should point at write_scene: %s", msg) + } + body, _ := readScene(t, c, nodeID) + if strings.Contains(body, "우회한 본문") { + t.Fatal("the rejected op still changed the scene") + } +} + +// A failing op rolls the whole batch back: half a restructured outline is +// worse than none. +func TestMCPApplyStoryOpsRollsBackOnFailure(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + result := c.callTool("linetta_apply_story_ops", map[string]any{ + "project_id": projectID, + "node_id": nodeID, + "summary": "실패하는 배치", + "ops": []map[string]any{ + {"op": "create_outline_node", "kind": "container", "label": "1부"}, + {"op": "delete_outline_node", "node_id": "no-such-node"}, + }, + }) + if !isToolError(result) { + t.Fatal("a batch with a failing op must report an error") + } + var out struct { + Applied int `json:"applied"` + RolledBack bool `json:"rolled_back"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode apply: %v", err) + } + if !out.RolledBack || out.Applied != 0 { + t.Fatalf("result = %+v, want a rollback with nothing applied", out) + } +} + +func TestMCPCheckpointAndArgumentChecks(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + _, v := readScene(t, c, nodeID) + if r := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "체크포인트 대상 원고.", "expected_content_version": v, + }); isToolError(r) { + t.Fatalf("write: %v", r) + } + + result := c.callTool("linetta_create_checkpoint", map[string]any{"node_id": nodeID}) + if isToolError(result) { + t.Fatalf("create_checkpoint: %v", result) + } + var cp struct { + SnapshotID string `json:"snapshot_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &cp); err != nil { + t.Fatalf("decode checkpoint: %v", err) + } + if cp.SnapshotID == "" { + t.Fatal("a checkpoint must return a snapshot id even when the text is unchanged") + } + + if r := c.callTool("linetta_undo_last_change", map[string]any{}); !isToolError(r) { + t.Error("undo with no id must be refused") + } + if r := c.callTool("linetta_undo_last_change", map[string]any{ + "batch_id": "a", "snapshot_id": "b", + }); !isToolError(r) { + t.Error("undo with both ids must be refused") + } + if r := c.callTool("linetta_undo_last_change", map[string]any{ + "snapshot_id": "no-such-snapshot", + }); !isToolError(r) { + t.Error("undo with an unknown snapshot must be refused") + } +} diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go index 16b6cd6c..36355204 100644 --- a/engine/internal/engineapp/mcp_disabled.go +++ b/engine/internal/engineapp/mcp_disabled.go @@ -18,6 +18,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/settings" "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" ) // Mobile builds cannot host a local server, so MCP is compiled out entirely — @@ -42,6 +43,7 @@ type mcpToolRepos struct { manuscript *manuscript.Searcher context *storycontext.ContextBuilder snapshots *snapshot.Repo + story *storyops.Service enqueue func(nodeID string) notify func(method string, params any) clock func() int64 diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go index 664291c9..7d46fa77 100644 --- a/engine/internal/engineapp/mcp_enabled.go +++ b/engine/internal/engineapp/mcp_enabled.go @@ -21,6 +21,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/settings" "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" ) // MCP ships on desktop and on the Mac App Store. It is deliberately NOT gated @@ -49,6 +50,7 @@ type mcpToolRepos struct { manuscript *manuscript.Searcher context *storycontext.ContextBuilder snapshots *snapshot.Repo + story *storyops.Service enqueue func(nodeID string) notify func(method string, params any) clock func() int64 @@ -78,6 +80,7 @@ func setupMCP(deps mcpDeps) (*mcpController, func() error) { Activity: activity, Snapshots: deps.repos.snapshots, + Story: deps.repos.story, EnqueueSummary: deps.repos.enqueue, Notify: deps.repos.notify, Clock: deps.repos.clock, diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go index c020d0b3..1cba4bbf 100644 --- a/engine/internal/mcphost/tools.go +++ b/engine/internal/mcphost/tools.go @@ -20,6 +20,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/settings" "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" ) // ToolDeps carries everything the tool layer reads from. Every field is a repo @@ -40,6 +41,7 @@ type ToolDeps struct { // EnqueueSummary keeps agent prose in the summarizer's queue; Notify tells // the running UI that something outside it changed the manuscript. Snapshots *snapshot.Repo + Story *storyops.Service EnqueueSummary func(nodeID string) Notify func(method string, params any) Clock func() int64 diff --git a/engine/internal/mcphost/tools_batch.go b/engine/internal/mcphost/tools_batch.go new file mode 100644 index 00000000..1d502e60 --- /dev/null +++ b/engine/internal/mcphost/tools_batch.go @@ -0,0 +1,246 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/storyops" +) + +// ---------- linetta_apply_story_ops ---------- + +type applyStoryOpsInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work to change"` + NodeID string `json:"node_id,omitempty" jsonschema:"the scene ops without an explicit target apply to"` + Summary string `json:"summary" jsonschema:"one line describing the change, shown to the writer"` + Ops []storyops.Op `json:"ops" jsonschema:"the mutations to apply as one batch"` +} + +func (in applyStoryOpsInput) scope() (string, string) { return in.ProjectID, in.NodeID } + +type applyStoryOpsOutput struct { + Applied int `json:"applied"` + Created map[string]string `json:"created,omitempty"` + Failures []opFailure `json:"failures,omitempty"` + RolledBack bool `json:"rolled_back,omitempty"` + UndoBatchID string `json:"undo_batch_id,omitempty"` + ChangedNodes []string `json:"changed_nodes,omitempty"` +} + +type opFailure struct { + Index int `json:"index"` + Op string `json:"op,omitempty"` + Error string `json:"error"` +} + +// ---------- linetta_create_checkpoint ---------- + +type createCheckpointInput struct { + NodeID string `json:"node_id" jsonschema:"scene to checkpoint"` +} + +func (in createCheckpointInput) scope() (string, string) { return "", in.NodeID } + +type createCheckpointOutput struct { + SnapshotID string `json:"snapshot_id"` + NodeID string `json:"node_id"` + Created bool `json:"created"` +} + +// ---------- linetta_undo_last_change ---------- + +type undoInput struct { + // Exactly one. A batch id undoes a structural change from + // linetta_apply_story_ops; a snapshot id restores a scene's prose. + BatchID string `json:"batch_id,omitempty" jsonschema:"undo_batch_id from linetta_apply_story_ops"` + SnapshotID string `json:"snapshot_id,omitempty" jsonschema:"snapshot_id from linetta_write_scene or linetta_create_checkpoint"` +} + +func (in undoInput) scope() (string, string) { return "", in.SnapshotID } + +type undoOutput struct { + Reverted string `json:"reverted"` // outline | scene + NodeID string `json:"node_id,omitempty"` +} + +func (d ToolDeps) registerBatchTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_apply_story_ops", + Description: "Apply a batch of story changes: outline nodes, storylines and beats, characters, " + + "places, relationships, Fact Book cards, and memories. Structural batches are all-or-nothing — " + + "if one op fails the outline is put back — and a clean run returns undo_batch_id. " + + "Scene prose is NOT written here; use linetta_write_scene, which checks the version first.", + }, record(d, "linetta_apply_story_ops", d.applyStoryOps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_create_checkpoint", + Description: "Save a restore point for a scene before a large rewrite. Returns a snapshot_id " + + "linetta_undo_last_change can restore.", + }, record(d, "linetta_create_checkpoint", d.createCheckpoint)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_undo_last_change", + Description: "Undo a change you just made. Pass batch_id to revert a structural batch from " + + "linetta_apply_story_ops, or snapshot_id to restore a scene's previous text. These are " + + "different paths: undoing a batch restores the outline and leaves scene bodies alone.", + }, record(d, "linetta_undo_last_change", d.undoLastChange)) +} + +func (d ToolDeps) applyStoryOps(ctx context.Context, _ *mcp.CallToolRequest, in applyStoryOpsInput) (*mcp.CallToolResult, applyStoryOpsOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, applyStoryOpsOutput{}, nil + } + if len(in.Ops) == 0 { + return toolErr("ops is required"), applyStoryOpsOutput{}, nil + } + // One door per mutation type. The applier's set_scene_text overwrites + // unconditionally, so letting it through here would route around + // linetta_write_scene's version check entirely. + for i, op := range in.Ops { + if op.Type == "set_scene_text" { + return toolErr( + "ops[%d] set_scene_text is not accepted here; use linetta_write_scene, which requires the "+ + "content_version so the writer's own edits cannot be overwritten", i), + applyStoryOpsOutput{}, nil + } + } + nodeID := strings.TrimSpace(in.NodeID) + if nodeID != "" { + if _, errResult := d.requireNode(ctx, nodeID); errResult != nil { + return errResult, applyStoryOpsOutput{}, nil + } + } + if d.Story == nil { + return toolErr("story operations are unavailable in this build"), applyStoryOpsOutput{}, nil + } + + result := d.Story.ApplyOps(ctx, p.ID, nodeID, storyops.Proposal{ + Summary: strings.TrimSpace(in.Summary), Ops: in.Ops, + }, d.now) + + out := applyStoryOpsOutput{ + Applied: result.Applied, + Created: result.Created, + RolledBack: result.RolledBack, + UndoBatchID: result.UndoBatchID, + } + for _, ch := range result.ChangedNodes { + out.ChangedNodes = append(out.ChangedNodes, ch.NodeID) + } + for _, f := range result.Failures { + out.Failures = append(out.Failures, opFailure{Index: f.Index, Op: f.Op, Error: f.Error}) + } + if result.IsError() { + // Reported as a tool error so the agent notices, with the structured + // detail kept so it can see which op failed and why. + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: applyFailureText(result)}}, + }, out, nil + } + if result.Applied > 0 { + d.notifyChanged(p.ID, "linetta_apply_story_ops", out.ChangedNodes, result.UndoBatchID) + } + return nil, out, nil +} + +func applyFailureText(r storyops.ApplyOpsResult) string { + var b strings.Builder + if r.RolledBack { + b.WriteString("the batch failed and the outline was put back; nothing was applied. ") + } + for _, f := range r.Failures { + if f.Index >= 0 { + fmt.Fprintf(&b, "ops[%d] %s: %s. ", f.Index, f.Op, f.Error) + continue + } + fmt.Fprintf(&b, "%s. ", f.Error) + } + return strings.TrimSpace(b.String()) +} + +func (d ToolDeps) createCheckpoint(ctx context.Context, _ *mcp.CallToolRequest, in createCheckpointInput) (*mcp.CallToolResult, createCheckpointOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, createCheckpointOutput{}, nil + } + if d.Snapshots == nil { + return toolErr("version history is unavailable in this build"), createCheckpointOutput{}, nil + } + doc := "" + if n.ContentDoc != nil { + doc = *n.ContentDoc + } + snap, created, err := d.Snapshots.CreateIfChanged(ctx, n.ID, doc, snapshot.ReasonManual, d.now()) + if err != nil { + return toolErr("could not save a checkpoint: %v", err), createCheckpointOutput{}, nil + } + if !created { + // Nothing changed since the last snapshot, so the newest one already is + // this checkpoint. Hand back its id rather than a confusing empty value. + if latest, err := d.Snapshots.LatestForNode(ctx, n.ID); err == nil { + return nil, createCheckpointOutput{SnapshotID: latest.ID, NodeID: n.ID, Created: false}, nil + } + } + return nil, createCheckpointOutput{SnapshotID: snap.ID, NodeID: n.ID, Created: created}, nil +} + +func (d ToolDeps) undoLastChange(ctx context.Context, _ *mcp.CallToolRequest, in undoInput) (*mcp.CallToolResult, undoOutput, error) { + batchID := strings.TrimSpace(in.BatchID) + snapshotID := strings.TrimSpace(in.SnapshotID) + switch { + case batchID == "" && snapshotID == "": + return toolErr("pass batch_id to undo a structural batch, or snapshot_id to restore a scene's text"), + undoOutput{}, nil + case batchID != "" && snapshotID != "": + return toolErr("pass either batch_id or snapshot_id, not both"), undoOutput{}, nil + case batchID != "": + if d.Story == nil { + return toolErr("story operations are unavailable in this build"), undoOutput{}, nil + } + if err := d.Story.UndoApply(ctx, batchID, d.now); err != nil { + if errors.Is(err, storyops.ErrUndoBatchNotFound) { + return toolErr("undo is no longer available for that change"), undoOutput{}, nil + } + return toolErr("could not undo the change: %v", err), undoOutput{}, nil + } + d.notifyChanged("", "linetta_undo_last_change", nil, batchID) + return nil, undoOutput{Reverted: "outline"}, nil + } + + if d.Snapshots == nil { + return toolErr("version history is unavailable in this build"), undoOutput{}, nil + } + snap, err := d.Snapshots.GetByID(ctx, snapshotID) + if err != nil { + return toolErr("snapshot %q not found", snapshotID), undoOutput{}, nil + } + n, errResult := d.requireNode(ctx, snap.NodeID) + if errResult != nil { + return errResult, undoOutput{}, nil + } + // Snapshot the current text first, so restoring is itself revertible. + curDoc := "" + if n.ContentDoc != nil { + curDoc = *n.ContentDoc + } + if _, _, err := d.Snapshots.CreateIfChanged(ctx, n.ID, curDoc, snapshot.ReasonManual, d.now()); err != nil { + return toolErr("could not snapshot before restoring: %v", err), undoOutput{}, nil + } + if err := d.Nodes.UpdateContent(ctx, n.ID, snap.ContentDoc, d.now()); err != nil { + return toolErr("could not restore the scene: %v", err), undoOutput{}, nil + } + if d.EnqueueSummary != nil { + d.EnqueueSummary(n.ID) + } + d.notifyChanged(n.ProjectID, "linetta_undo_last_change", []string{n.ID}, "") + return nil, undoOutput{Reverted: "scene", NodeID: n.ID}, nil +} diff --git a/engine/internal/mcphost/tools_write.go b/engine/internal/mcphost/tools_write.go index 4b94b20c..28d41779 100644 --- a/engine/internal/mcphost/tools_write.go +++ b/engine/internal/mcphost/tools_write.go @@ -21,6 +21,9 @@ import ( var WriteToolNames = []string{ "linetta_write_scene", "linetta_write_summary", + "linetta_apply_story_ops", + "linetta_create_checkpoint", + "linetta_undo_last_change", } // maxSceneRunes caps one scene body. A runaway agent should hit a wall with a @@ -94,6 +97,8 @@ func (d ToolDeps) registerWriteTools(s *mcp.Server) { "A scene summary needs the content_version from linetta_read_scene; chapters and the synopsis " + "do not.", }, record(d, "linetta_write_summary", d.writeSummary)) + + d.registerBatchTools(s) } func (d ToolDeps) writeScene(ctx context.Context, _ *mcp.CallToolRequest, in writeSceneInput) (*mcp.CallToolResult, writeSceneOutput, error) { From 998ffba9a869b4ce17b9d39a9346510b1a963a82 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 17:13:58 +0900 Subject: [PATCH 20/25] feat: cap MCP tool call rate and refresh the UI on agent changes The rate limit lives inside the record decorator, next to the activity log, so a tool cannot be registered without one. A single token bucket covers reads and writes at 120/minute: generous for a human-paced session, a wall for a runaway loop. Refill is capped so a long idle cannot bank an unbounded burst. mcp.changed now travels engine -> ffi.rs -> useMcpChanges. The rule that matters: when the editor holds unsaved edits for the scene the agent touched, the buffer is never replaced - the writer's in-progress sentence outranks the agent's version, so the change surfaces as a banner instead. Changes to another work are ignored, and a structural batch that names no scenes refreshes only the outline. Part of the MCP-first pivot (#47), Phase 3 Tasks 3.6 and 3.7. Co-Authored-By: Claude Opus 5 --- apps/desktop/src-tauri/src/ffi.rs | 11 +++ apps/desktop/src/hooks/useMcpChanges.test.tsx | 90 +++++++++++++++++++ apps/desktop/src/hooks/useMcpChanges.ts | 64 +++++++++++++ engine/internal/engineapp/mcp_enabled.go | 1 + engine/internal/mcphost/limits.go | 80 +++++++++++++++++ engine/internal/mcphost/limits_test.go | 52 +++++++++++ engine/internal/mcphost/tools.go | 2 + 7 files changed, 300 insertions(+) create mode 100644 apps/desktop/src/hooks/useMcpChanges.test.tsx create mode 100644 apps/desktop/src/hooks/useMcpChanges.ts create mode 100644 engine/internal/mcphost/limits.go create mode 100644 engine/internal/mcphost/limits_test.go diff --git a/apps/desktop/src-tauri/src/ffi.rs b/apps/desktop/src-tauri/src/ffi.rs index c3b5ad6c..199c325b 100644 --- a/apps/desktop/src-tauri/src/ffi.rs +++ b/apps/desktop/src-tauri/src/ffi.rs @@ -230,6 +230,9 @@ fn notification_event(method: &str) -> Option<&'static str> { "companion.preview" => Some("companion-preview"), "companion.thinking" => Some("companion-thinking"), "companion.reasoning" => Some("companion-reasoning"), + // An external MCP client changed the manuscript. Without this the + // writer would keep looking at text the agent already replaced. + "mcp.changed" => Some("mcp-changed"), _ => None, } } @@ -395,6 +398,14 @@ mod tests { assert!(error.request_id.is_some()); } + #[test] + fn mcp_changed_is_forwarded_to_the_renderer() { + // A notification the renderer never receives is a writer staring at + // text an external agent already replaced. + assert_eq!(notification_event("mcp.changed"), Some("mcp-changed")); + assert_eq!(notification_event("mcp.unmapped"), None); + } + #[cfg(target_os = "windows")] #[test] fn windows_engine_dll_is_discoverable() { diff --git a/apps/desktop/src/hooks/useMcpChanges.test.tsx b/apps/desktop/src/hooks/useMcpChanges.test.tsx new file mode 100644 index 00000000..c3fed42c --- /dev/null +++ b/apps/desktop/src/hooks/useMcpChanges.test.tsx @@ -0,0 +1,90 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ev = vi.hoisted(() => ({ + listeners: new Map void>(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: (event: string, cb: (e: { payload: unknown }) => void) => { + ev.listeners.set(event, cb); + return Promise.resolve(() => ev.listeners.delete(event)); + }, +})); + +import { useMcpChanges, type McpChangedPayload } from "./useMcpChanges"; + +async function emit(payload: McpChangedPayload) { + const cb = ev.listeners.get("mcp-changed"); + if (!cb) throw new Error("mcp-changed listener was never registered"); + await act(async () => { + cb({ payload }); + }); +} + +function setup(overrides: Partial[0]> = {}) { + const onOutlineChanged = vi.fn(); + const onSceneChanged = vi.fn(); + const view = renderHook(() => + useMcpChanges({ + projectId: "p1", + openNodeId: "n1", + editorDirty: false, + onOutlineChanged, + onSceneChanged, + ...overrides, + }), + ); + return { view, onOutlineChanged, onSceneChanged }; +} + +describe("useMcpChanges", () => { + beforeEach(() => { + ev.listeners.clear(); + }); + + it("refetches the outline and reloads the open scene when the buffer is clean", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["n1"] }); + + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).toHaveBeenCalledWith("n1"); + }); + + it("never replaces a dirty buffer; it surfaces the change instead", async () => { + const { view, onOutlineChanged, onSceneChanged } = setup({ editorDirty: true }); + await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["n1"] }); + + // The outline is still safe to refresh — it is not what the writer is typing into. + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).not.toHaveBeenCalled(); + expect(view.result.current.conflictNodeId).toBe("n1"); + + act(() => view.result.current.dismissConflict()); + expect(view.result.current.conflictNodeId).toBeNull(); + }); + + it("ignores a change to a scene the writer does not have open", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["other"] }); + + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).not.toHaveBeenCalled(); + }); + + it("ignores changes to another work entirely", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p2", tool: "linetta_write_scene", node_ids: ["n1"] }); + + expect(onOutlineChanged).not.toHaveBeenCalled(); + expect(onSceneChanged).not.toHaveBeenCalled(); + }); + + it("refetches the outline for a structural batch that names no scenes", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p1", tool: "linetta_apply_story_ops", batch_id: "b1" }); + + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/hooks/useMcpChanges.ts b/apps/desktop/src/hooks/useMcpChanges.ts new file mode 100644 index 00000000..6fa66c98 --- /dev/null +++ b/apps/desktop/src/hooks/useMcpChanges.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from "react"; + +import { useEngineEvent } from "./useEngineEvent"; + +/** What an external MCP client just changed. Emitted by the engine after every + * applied mutation so the workspace can refetch instead of showing text the + * agent already replaced. */ +export type McpChangedPayload = { + project_id?: string; + tool?: string; + node_ids?: string[]; + batch_id?: string; +}; + +export type McpChangeOptions = { + /** The work currently open, so changes to other works are ignored. */ + projectId: string | null; + /** The scene currently open in the editor, or null. */ + openNodeId: string | null; + /** Whether the editor holds unsaved edits for the open scene. */ + editorDirty: boolean; + /** Refetch the outline tree. */ + onOutlineChanged: () => void; + /** Reload the open scene's body from the engine. */ + onSceneChanged: (nodeId: string) => void; +}; + +/** Keeps the workspace in step with an external agent. + * + * The one rule that matters: when the editor has unsaved edits for the scene + * the agent touched, the buffer is NOT replaced. The writer's in-progress + * sentence outranks the agent's version, so the change is surfaced as a + * banner they can act on instead. */ +export function useMcpChanges({ + projectId, + openNodeId, + editorDirty, + onOutlineChanged, + onSceneChanged, +}: McpChangeOptions) { + const [conflictNodeId, setConflictNodeId] = useState(null); + + const dismissConflict = useCallback(() => setConflictNodeId(null), []); + + useEngineEvent("mcp-changed", (payload) => { + // A change to a work the writer is not looking at needs no UI reaction. + if (payload.project_id && projectId && payload.project_id !== projectId) { + return; + } + onOutlineChanged(); + + const touched = payload.node_ids ?? []; + if (!openNodeId || !touched.includes(openNodeId)) { + return; + } + if (editorDirty) { + setConflictNodeId(openNodeId); + return; + } + onSceneChanged(openNodeId); + }); + + return { conflictNodeId, dismissConflict }; +} diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go index 7d46fa77..e9c90d19 100644 --- a/engine/internal/engineapp/mcp_enabled.go +++ b/engine/internal/engineapp/mcp_enabled.go @@ -81,6 +81,7 @@ func setupMCP(deps mcpDeps) (*mcpController, func() error) { Snapshots: deps.repos.snapshots, Story: deps.repos.story, + Limiter: mcphost.NewLimiter(), EnqueueSummary: deps.repos.enqueue, Notify: deps.repos.notify, Clock: deps.repos.clock, diff --git a/engine/internal/mcphost/limits.go b/engine/internal/mcphost/limits.go new file mode 100644 index 00000000..5c4077dc --- /dev/null +++ b/engine/internal/mcphost/limits.go @@ -0,0 +1,80 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// callsPerMinute caps how fast one server instance will serve tool calls. A +// runaway agent loop should hit a wall the writer can see in the activity log, +// not rewrite forty scenes. The limit is deliberately generous for a human- +// paced session and only bites on a loop. +const callsPerMinute = 120 + +// limiter is a simple token bucket refilled continuously. One bucket covers +// reads and writes alike: an agent stuck in a read loop is a problem too, and +// two buckets would be two things to reason about. +type limiter struct { + mu sync.Mutex + tokens float64 + capacity float64 + perSec float64 + last time.Time + now func() time.Time +} + +func newLimiter(perMinute int) *limiter { + return &limiter{ + tokens: float64(perMinute), + capacity: float64(perMinute), + perSec: float64(perMinute) / 60, + now: time.Now, + } +} + +// allow reports whether a call may proceed, consuming one token if so. +func (l *limiter) allow() bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + if l.last.IsZero() { + l.last = now + } + l.tokens += now.Sub(l.last).Seconds() * l.perSec + if l.tokens > l.capacity { + l.tokens = l.capacity + } + l.last = now + if l.tokens < 1 { + return false + } + l.tokens-- + return true +} + +// limited wraps a typed tool handler with the rate limit. Applied at +// registration next to the activity decorator, so no tool can be added without +// one — the same "cannot forget" property. +func limited[In, Out any](l *limiter, h mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + if l == nil { + return h + } + return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { + if !l.allow() { + var zero Out + return toolErr( + "too many Linetta tool calls in a short window (limit %d per minute). "+ + "Slow down, or ask the writer to review what you have changed so far.", + callsPerMinute), zero, nil + } + return h(ctx, req, in) + } +} + +// NewLimiter returns the shared rate limiter for one engine tool layer. +func NewLimiter() *limiter { return newLimiter(callsPerMinute) } diff --git a/engine/internal/mcphost/limits_test.go b/engine/internal/mcphost/limits_test.go new file mode 100644 index 00000000..f9fb0293 --- /dev/null +++ b/engine/internal/mcphost/limits_test.go @@ -0,0 +1,52 @@ +//go:build !mobile + +package mcphost + +import ( + "testing" + "time" +) + +// A runaway loop must hit a wall; a human-paced session must not. +func TestLimiterStopsABurstAndRefills(t *testing.T) { + now := time.Unix(0, 0) + l := newLimiter(60) + l.now = func() time.Time { return now } + + for i := 0; i < 60; i++ { + if !l.allow() { + t.Fatalf("call %d was refused while the bucket should still be full", i+1) + } + } + if l.allow() { + t.Fatal("the 61st call in the same instant should be refused") + } + + // One second of refill buys exactly one more call at 60/min. + now = now.Add(time.Second) + if !l.allow() { + t.Fatal("the bucket should refill over time") + } + if l.allow() { + t.Fatal("refill must not hand out more than it earned") + } +} + +// The bucket must not accumulate an unbounded burst while nobody is calling. +func TestLimiterCapsRefill(t *testing.T) { + now := time.Unix(0, 0) + l := newLimiter(10) + l.now = func() time.Time { return now } + l.allow() + + now = now.Add(time.Hour) + granted := 0 + for i := 0; i < 100; i++ { + if l.allow() { + granted++ + } + } + if granted != 10 { + t.Fatalf("after a long idle the bucket granted %d calls, want the %d-call capacity", granted, 10) + } +} diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go index 1cba4bbf..41b4316d 100644 --- a/engine/internal/mcphost/tools.go +++ b/engine/internal/mcphost/tools.go @@ -42,6 +42,7 @@ type ToolDeps struct { // the running UI that something outside it changed the manuscript. Snapshots *snapshot.Repo Story *storyops.Service + Limiter *limiter EnqueueSummary func(nodeID string) Notify func(method string, params any) Clock func() int64 @@ -98,6 +99,7 @@ type scopedInput interface { // in the activity log the writer can inspect. Wrapping at registration time // means no tool can forget to report itself. func record[In, Out any](d ToolDeps, tool string, h mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + h = limited(d.Limiter, h) return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { res, out, err := h(ctx, req, in) From 575a7c0dca2b6a6a3896f23c24796474dbf1419c Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 17:16:36 +0900 Subject: [PATCH 21/25] feat(engine): add linetta_revise_scene for targeted text edits Wraps manuscriptedit's plan/apply pair so an agent can rename a character or correct a term across scenes without resending whole bodies. Every touched scene is snapshotted by the existing applier. dry_run returns the matches and the resulting text without changing anything, which is how an agent checks the blast radius of a common phrase before committing. Named node_ids are checked against the allowed work, so a restricted server cannot be steered into another work by id. No match returns a tool error pointing at linetta_search_manuscript rather than silently succeeding. This completes the 15-tool surface: nine read, six write. Part of the MCP-first pivot (#47), Phase 3 Task 3.2. Co-Authored-By: Claude Opus 5 --- engine/internal/engineapp/engineapp.go | 1 + engine/internal/engineapp/mcp_disabled.go | 2 + engine/internal/engineapp/mcp_enabled.go | 3 + engine/internal/engineapp/mcp_revise_test.go | 135 +++++++++++++++++++ engine/internal/mcphost/tools.go | 2 + engine/internal/mcphost/tools_revise.go | 135 +++++++++++++++++++ engine/internal/mcphost/tools_write.go | 2 + 7 files changed, 280 insertions(+) create mode 100644 engine/internal/engineapp/mcp_revise_test.go create mode 100644 engine/internal/mcphost/tools_revise.go diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 659492a3..99171ed4 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -253,6 +253,7 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error context: mcpContextBuilder, snapshots: snaps, story: mcpStory, + msEdit: manuscriptEditor, enqueue: summ.Enqueue, notify: func(method string, params any) { _ = s.Notifier().Notify(method, params) }, clock: clock, diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go index 36355204..0f49fa00 100644 --- a/engine/internal/engineapp/mcp_disabled.go +++ b/engine/internal/engineapp/mcp_disabled.go @@ -11,6 +11,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" @@ -44,6 +45,7 @@ type mcpToolRepos struct { context *storycontext.ContextBuilder snapshots *snapshot.Repo story *storyops.Service + msEdit *manuscriptedit.Service enqueue func(nodeID string) notify func(method string, params any) clock func() int64 diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go index e9c90d19..8fc02807 100644 --- a/engine/internal/engineapp/mcp_enabled.go +++ b/engine/internal/engineapp/mcp_enabled.go @@ -12,6 +12,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/mcphost" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" @@ -51,6 +52,7 @@ type mcpToolRepos struct { context *storycontext.ContextBuilder snapshots *snapshot.Repo story *storyops.Service + msEdit *manuscriptedit.Service enqueue func(nodeID string) notify func(method string, params any) clock func() int64 @@ -81,6 +83,7 @@ func setupMCP(deps mcpDeps) (*mcpController, func() error) { Snapshots: deps.repos.snapshots, Story: deps.repos.story, + ManuscriptEdit: deps.repos.msEdit, Limiter: mcphost.NewLimiter(), EnqueueSummary: deps.repos.enqueue, Notify: deps.repos.notify, diff --git a/engine/internal/engineapp/mcp_revise_test.go b/engine/internal/engineapp/mcp_revise_test.go new file mode 100644 index 00000000..de29f36e --- /dev/null +++ b/engine/internal/engineapp/mcp_revise_test.go @@ -0,0 +1,135 @@ +//go:build !mobile + +package engineapp + +import ( + "encoding/json" + "strings" + "testing" +) + +// seedScene writes prose through the MCP write path and returns the scene id. +func seedScene(t *testing.T, c *mcpClient, nodeID, text string) { + t.Helper() + _, v := readScene(t, c, nodeID) + if r := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": text, "expected_content_version": v, + }); isToolError(r) { + t.Fatalf("seed write: %v", r) + } +} + +// A rename is the case this tool exists for: change a name everywhere without +// resending whole scene bodies. +func TestMCPReviseSceneReplacesAcrossScenes(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + seedScene(t, c, nodeID, "서린은 골목에 서 있었다. 서린의 그림자는 없었다.") + + result := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "서린", "replace": "지한", + }) + if isToolError(result) { + t.Fatalf("revise_scene: %v", result) + } + var out struct { + Applied int `json:"applied"` + ChangedNodes []string `json:"changed_nodes"` + Matches []struct { + Occurrences int `json:"occurrences"` + } `json:"matches"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode revise: %v", err) + } + if out.Applied != 1 || len(out.ChangedNodes) != 1 { + t.Fatalf("result = %+v, want one scene revised", out) + } + if len(out.Matches) == 0 || out.Matches[0].Occurrences < 2 { + t.Errorf("both occurrences should be reported: %+v", out.Matches) + } + + body, _ := readScene(t, c, nodeID) + if strings.Contains(body, "서린") { + t.Fatalf("the old name survived: %q", body) + } + if !strings.Contains(body, "지한") { + t.Fatalf("the new name is missing: %q", body) + } +} + +// dry_run is how an agent checks the blast radius of a common phrase before +// committing to it. +func TestMCPReviseSceneDryRunChangesNothing(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + const original = "그는 문을 열었다. 문은 잠겨 있지 않았다." + seedScene(t, c, nodeID, original) + + result := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "문", "replace": "창", "dry_run": true, + }) + if isToolError(result) { + t.Fatalf("dry run: %v", result) + } + var out struct { + Applied int `json:"applied"` + DryRun bool `json:"dry_run"` + Matches []struct { + After string `json:"after"` + } `json:"matches"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode dry run: %v", err) + } + if !out.DryRun || out.Applied != 0 { + t.Fatalf("result = %+v, want a preview that applied nothing", out) + } + if len(out.Matches) == 0 || out.Matches[0].After == "" { + t.Error("a dry run must show what the text would become") + } + + body, _ := readScene(t, c, nodeID) + if body != original { + t.Fatalf("dry run changed the scene:\n got %q\nwant %q", body, original) + } +} + +func TestMCPReviseSceneReportsNoMatch(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + seedScene(t, c, nodeID, "짧은 본문.") + + result := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "존재하지않는문구", "replace": "무엇이든", + }) + if !isToolError(result) { + t.Fatal("a revision with no match must report an error") + } + if msg := toolErrorText(result); !strings.Contains(msg, "linetta_search_manuscript") { + t.Errorf("the message should point at the search tool: %s", msg) + } +} + +// The scene is snapshotted before a revision, so the writer can restore it. +func TestMCPReviseSceneSnapshots(t *testing.T) { + app, c, projectID, nodeID := startWritableMCP(t) + seedScene(t, c, nodeID, "고쳐질 문장이 여기 있다.") + + if r := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "고쳐질", "replace": "고쳐진", + }); isToolError(r) { + t.Fatalf("revise: %v", r) + } + + raw, rpcErr := call(t, app, "snapshots.list_for_node", `{"node_id":"`+nodeID+`"}`) + if rpcErr != nil { + t.Fatalf("snapshots.list_for_node: %+v", rpcErr) + } + var entries []struct { + Reason string `json:"reason"` + } + if err := json.Unmarshal(raw, &entries); err != nil { + t.Fatalf("decode snapshots: %v", err) + } + if len(entries) < 2 { + t.Fatalf("expected a snapshot from the write and one from the revision, got %d", len(entries)) + } +} diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go index 41b4316d..e469b8f2 100644 --- a/engine/internal/mcphost/tools.go +++ b/engine/internal/mcphost/tools.go @@ -13,6 +13,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" @@ -42,6 +43,7 @@ type ToolDeps struct { // the running UI that something outside it changed the manuscript. Snapshots *snapshot.Repo Story *storyops.Service + ManuscriptEdit *manuscriptedit.Service Limiter *limiter EnqueueSummary func(nodeID string) Notify func(method string, params any) diff --git a/engine/internal/mcphost/tools_revise.go b/engine/internal/mcphost/tools_revise.go new file mode 100644 index 00000000..1791a675 --- /dev/null +++ b/engine/internal/mcphost/tools_revise.go @@ -0,0 +1,135 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" +) + +// ---------- linetta_revise_scene ---------- + +type reviseSceneInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work"` + Find string `json:"find" jsonschema:"the exact text to replace"` + Replace string `json:"replace" jsonschema:"what to put in its place"` + // NodeIDs narrows the edit to specific scenes. Omit to sweep the work — + // useful for a rename, dangerous for a common phrase, so the tool reports + // what it touched. + NodeIDs []string `json:"node_ids,omitempty" jsonschema:"scenes to edit; omit to search the whole work"` + MatchCase bool `json:"match_case,omitempty"` + WholeWord bool `json:"whole_word,omitempty"` + // DryRun returns the matches without changing anything. + DryRun bool `json:"dry_run,omitempty" jsonschema:"preview the matches without applying them"` +} + +func (in reviseSceneInput) scope() (string, string) { return in.ProjectID, "" } + +type reviseMatch struct { + NodeID string `json:"node_id"` + Label string `json:"label,omitempty"` + Occurrences int `json:"occurrences"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` +} + +type reviseSceneOutput struct { + Applied int `json:"applied"` + DryRun bool `json:"dry_run"` + Matches []reviseMatch `json:"matches"` + ChangedNodes []string `json:"changed_nodes,omitempty"` + Failures []string `json:"failures,omitempty"` +} + +func (d ToolDeps) registerReviseTool(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_revise_scene", + Description: "Replace exact text across one or more scenes — a renamed character, a corrected " + + "term, a reworded line — without resending whole scene bodies. Every touched scene is " + + "snapshotted first. Pass dry_run to see what would change before committing; omit node_ids " + + "only when you mean to sweep the entire work.", + }, record(d, "linetta_revise_scene", d.reviseScene)) +} + +func (d ToolDeps) reviseScene(ctx context.Context, _ *mcp.CallToolRequest, in reviseSceneInput) (*mcp.CallToolResult, reviseSceneOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, reviseSceneOutput{}, nil + } + find := strings.TrimSpace(in.Find) + if find == "" { + return toolErr("find is required"), reviseSceneOutput{}, nil + } + if strings.TrimSpace(in.Replace) == "" { + return toolErr("replace is required; to delete text, replace it with the surrounding wording you want"), + reviseSceneOutput{}, nil + } + if d.Manuscript == nil || d.ManuscriptEdit == nil { + return toolErr("manuscript editing is unavailable in this build"), reviseSceneOutput{}, nil + } + // Every named scene must belong to an allowed work, or a restricted server + // could be steered into another work by id. + for _, nodeID := range in.NodeIDs { + if _, errResult := d.requireNode(ctx, nodeID); errResult != nil { + return errResult, reviseSceneOutput{}, nil + } + } + + plan, err := d.ManuscriptEdit.PlanReplace(ctx, manuscriptedit.ReplacePlanRequest{ + ProjectID: p.ID, + Query: find, + Replacement: in.Replace, + NodeIDs: in.NodeIDs, + MatchCase: in.MatchCase, + WholeWord: in.WholeWord, + }) + if err != nil { + return toolErr("could not plan the revision: %v", err), reviseSceneOutput{}, nil + } + if len(plan.Candidates) == 0 { + return toolErr("no scene contains %q; check the exact wording with linetta_search_manuscript", find), + reviseSceneOutput{}, nil + } + + out := reviseSceneOutput{DryRun: in.DryRun} + candidateIDs := make([]string, 0, len(plan.Candidates)) + for _, c := range plan.Candidates { + out.Matches = append(out.Matches, reviseMatch{ + NodeID: c.NodeID, + Label: c.Breadcrumb, + Occurrences: c.Occurrences, + Before: c.Before, + After: c.After, + }) + candidateIDs = append(candidateIDs, c.ID) + } + if in.DryRun { + return nil, out, nil + } + + result, err := d.ManuscriptEdit.ApplyReplace(ctx, plan, candidateIDs, d.now()) + if err != nil { + return toolErr("could not apply the revision: %v", err), reviseSceneOutput{}, nil + } + out.Applied = result.Applied + out.ChangedNodes = result.ChangedNodeIDs + for _, f := range result.Failures { + out.Failures = append(out.Failures, f.Message) + } + for _, nodeID := range result.ChangedNodeIDs { + if d.EnqueueSummary != nil { + d.EnqueueSummary(nodeID) + } + } + if result.Applied > 0 { + d.notifyChanged(p.ID, "linetta_revise_scene", result.ChangedNodeIDs, "") + } + if result.Applied == 0 && len(result.Failures) > 0 { + return toolErr("nothing was revised: %s", strings.Join(out.Failures, "; ")), out, nil + } + return nil, out, nil +} diff --git a/engine/internal/mcphost/tools_write.go b/engine/internal/mcphost/tools_write.go index 28d41779..65b76e4f 100644 --- a/engine/internal/mcphost/tools_write.go +++ b/engine/internal/mcphost/tools_write.go @@ -21,6 +21,7 @@ import ( var WriteToolNames = []string{ "linetta_write_scene", "linetta_write_summary", + "linetta_revise_scene", "linetta_apply_story_ops", "linetta_create_checkpoint", "linetta_undo_last_change", @@ -98,6 +99,7 @@ func (d ToolDeps) registerWriteTools(s *mcp.Server) { "do not.", }, record(d, "linetta_write_summary", d.writeSummary)) + d.registerReviseTool(s) d.registerBatchTools(s) } From a6f3593737cf2b6b12f931ccbfef622dfe35c030 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 17:17:48 +0900 Subject: [PATCH 22/25] docs: mark MCP pivot Phase 3 complete Records the two deviations found while building: expected_content_version is a pointer so a brand-new scene (version 0) can receive its first draft, and the pre-write snapshot reuses the companion-before reason rather than adding one the frontend version sheet cannot label yet. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-22-mcp-first-pivot.md | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index f3091b1d..5eefca6b 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -156,59 +156,67 @@ **파일:** `engine/internal/mcphost/tools_write.go`, `+ 테스트` -- [ ] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다. -- [ ] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다. -- [ ] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다. -- [ ] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부. +- [x] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다. +- [x] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다. +- [x] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다. +- [x] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부. ### Task 3.2 — `linetta_revise_scene` -- [ ] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다. -- [ ] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환. +- [x] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다. +- [x] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환. ### Task 3.3 — `linetta_apply_story_ops` -- [ ] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다. -- [ ] **`set_scene_text` 옵은 거부하고 `linetta_write_scene`으로 안내한다.** 적용기의 `set_scene_text`는 `nodes.UpdateContent`(무조건 덮어쓰기)를 쓰므로, 이 툴로 통과시키면 `write_scene`의 버전 검사 계약을 우회하게 된다. 변경 종류마다 문은 하나여야 한다. -- [ ] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다. -- [ ] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함. +- [x] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다. +- [x] **`set_scene_text` 옵은 거부하고 `linetta_write_scene`으로 안내한다.** 적용기의 `set_scene_text`는 `nodes.UpdateContent`(무조건 덮어쓰기)를 쓰므로, 이 툴로 통과시키면 `write_scene`의 버전 검사 계약을 우회하게 된다. 변경 종류마다 문은 하나여야 한다. +- [x] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다. +- [x] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함. ### Task 3.4 — `linetta_write_summary` **전환의 급소다.** 설계 문서 6절 참조. -- [ ] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유). -- [ ] **버전 계약 확정:** 씬(leaf)만 `content_version`을 요구한다. 컨테이너와 시놉시스는 자식 편집을 추적하는 버전이 없으므로 요구하지 않고 마지막 쓰기가 이긴다 — 툴 설명에 명시한다. -- [ ] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다. -- [ ] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨. +- [x] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유). +- [x] **버전 계약 확정:** 씬(leaf)만 `content_version`을 요구한다. 컨테이너와 시놉시스는 자식 편집을 추적하는 버전이 없으므로 요구하지 않고 마지막 쓰기가 이긴다 — 툴 설명에 명시한다. +- [x] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다. +- [x] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨. ### Task 3.5 — 체크포인트와 되돌리기 -- [ ] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다. -- [ ] `linetta_undo_last_change`는 `batch_id`(구조 변경)와 `snapshot_id`(본문 변경) 두 가지를 받는다. 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. +- [x] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다. +- [x] `linetta_undo_last_change`는 `batch_id`(구조 변경)와 `snapshot_id`(본문 변경) 두 가지를 받는다. 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. > **구현 중 확정 (계획 정정):** `storyops.UndoApply` → `nodes.RestoreOutline`은 `parent_id·ordinal·label·title·status`만 되돌리고 **`content_doc`은 건드리지 않는다**(삭제됐다가 복원되는 노드만 예외). 즉 구조 변경 undo는 씬 본문을 되돌리지 못한다. 본문 되돌리기는 스냅샷(버전 기록) 경로다. 툴이 이 차이를 감추면 에이전트에게 거짓 약속을 하게 되므로, `linetta_write_scene`은 결과에 `snapshot_id`를 실어 보내고 `linetta_undo_last_change`가 두 종류를 모두 받는다. ### Task 3.6 — 호출 한도와 모드 강제 -- [ ] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다. -- [ ] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다. +- [x] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다. +- [x] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다. ### Task 3.7 — 변경 알림 **파일:** `engine/internal/mcphost/*`, `apps/desktop/src-tauri/src/ffi.rs`, `apps/desktop/src/hooks/*` -- [ ] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다. -- [ ] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다. -- [ ] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.** -- [ ] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest. +- [x] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다. +- [x] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다. +- [x] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.** +- [x] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest. -- [ ] 씬 쓰기 전 스냅샷은 당분간 `snapshot.ReasonCompanionBefore`를 재사용한다. 새 reason을 추가하려면 `ValidReason`과 프론트엔드 버전 시트 라벨을 함께 손봐야 하고, 컴패니언이 Phase 6에서 사라지면 이 reason은 사실상 "에이전트 변경 전"이 된다. 전용 reason 도입은 Phase 4의 UI 작업과 함께 판단한다. +- [x] 씬 쓰기 전 스냅샷은 당분간 `snapshot.ReasonCompanionBefore`를 재사용한다. 새 reason을 추가하려면 `ValidReason`과 프론트엔드 버전 시트 라벨을 함께 손봐야 하고, 컴패니언이 Phase 6에서 사라지면 이 reason은 사실상 "에이전트 변경 전"이 된다. 전용 reason 도입은 Phase 4의 UI 작업과 함께 판단한다. **3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`(반환된 `snapshot_id`로)를 구동하고 원고가 원래 바이트로 돌아온다. 구조 변경은 `linetta_apply_story_ops` → `undo_last_change`(`batch_id`)로 별도 검증한다. --- +> **완료 (2026-08-23):** 툴 15개 완성(읽기 9 + 쓰기 6). `make test` 전체 통과, 엔진 42/42, `mas`/`mobile` 빌드 통과, mobile SDK 의존 0건. +> +> **계획에서 이탈한 두 가지:** +> - `expected_content_version`을 `int`가 아니라 `*int`로 받는다. 새 씬은 버전이 0이라 "미제공"과 "0"을 구분하지 못하면 **첫 원고를 영영 쓸 수 없다.** 테스트가 잡았다. +> - 씬 쓰기 전 스냅샷 reason은 `companion-before`를 재사용했다(위 결정 참조). +> +> **아직 남은 것:** `linetta_where_does_appear`는 명시적 @멘션만 집계하므로, 에이전트가 쓴 평문 원고에서는 등장인물 추적이 비게 된다. 이슈 #32와 같은 문제이고 Phase 3 이후 별도로 다룬다. + ## Phase 4 — 브리지, 설정 UX, MAS ### Task 4.1 — `cmd/linetta-mcp` 브리지 From 2100cb82a4bdea1ceab4ad1f401ca568ad9949eb Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 19:01:08 +0900 Subject: [PATCH 23/25] fix(settings): keep MCP usable on platforms without a secure secret store Linux has no secret backend (secrets_unsupported.go), so EnsureMCPToken failed with "secure secret storage is only available on macOS and Windows" and the MCP server could not start at all. Linux ships as AppImage/deb/rpm, so this was a broken feature on a shipping platform, not just a red test. The MCP token falls back to a 0600 file in LINETTA_HOME. That is acceptable for this secret specifically: while the server runs, mcp.json already carries the same token at 0600 so the bridge can find it, and any process running as this user can read library.db directly. Provider API keys deliberately do NOT get this fallback - they are long-lived third-party credentials, and quietly storing them in plaintext is not a change a writer opted into. The presence flag goes through a stat-only check, preserving the existing invariant that settings.get never reads secret values (on macOS that can prompt the Keychain). Caught by CI on Linux; a Windows-only local run could never have found it. Part of the MCP-first pivot (#47), Phase 2. Co-Authored-By: Claude Opus 5 --- engine/internal/settings/mcp.go | 53 ++++++++++++++++++++++++---- engine/internal/settings/mcp_test.go | 50 ++++++++++++++++++++++++++ engine/internal/settings/settings.go | 6 ++-- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/engine/internal/settings/mcp.go b/engine/internal/settings/mcp.go index 47462b20..e7bb5711 100644 --- a/engine/internal/settings/mcp.go +++ b/engine/internal/settings/mcp.go @@ -4,6 +4,9 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "os" + "path/filepath" + "strings" ) // MCP access modes. off is the default: no listener binds until the writer @@ -71,13 +74,42 @@ func (s *Store) HasMCPConsent() bool { return s.cfg.MCPConsentVersion >= MCPConsentVersion } +// mcpTokenFileName holds the bearer token on platforms with no secure secret +// backend (Linux, as of today — see secrets_unsupported.go). +// +// Plaintext at 0600 is acceptable for THIS secret specifically: while the +// server runs, mcp.json already carries the same token at 0600 so the bridge +// can find it, and any process running as this user can read library.db +// directly. Provider API keys deliberately do NOT get this fallback — they are +// long-lived credentials for third-party accounts, and quietly starting to +// store them in plaintext is not a change a writer opted into. +const mcpTokenFileName = "mcp-token" + +func (s *Store) mcpTokenPath() string { + return filepath.Join(s.dir, mcpTokenFileName) +} + // MCPToken returns the bearer token, or "" when none has been generated. func (s *Store) MCPToken() string { - secret, ok, err := s.secrets.Get(mcpTokenSecretName) - if err != nil || !ok { + if secret, ok, err := s.secrets.Get(mcpTokenSecretName); err == nil && ok { + return secret + } + raw, err := os.ReadFile(s.mcpTokenPath()) + if err != nil { return "" } - return secret + return strings.TrimSpace(string(raw)) +} + +// MCPTokenExists reports whether a token has been minted, WITHOUT reading its +// value. settings.get must never read secret values — on macOS that can prompt +// the Keychain, and the redacted view only needs presence. +func (s *Store) MCPTokenExists() bool { + if ok, err := s.secrets.Exists(mcpTokenSecretName); err == nil && ok { + return true + } + _, err := os.Stat(s.mcpTokenPath()) + return err == nil } // EnsureMCPToken returns the existing token, generating one on first use so @@ -98,12 +130,21 @@ func (s *Store) RegenerateMCPToken() (string, error) { } token := base64.RawURLEncoding.EncodeToString(buf) if err := s.secrets.Set(mcpTokenSecretName, token); err != nil { - return "", fmt.Errorf("store mcp token: %w", err) + // No secure backend on this platform. Fall back to a 0600 file rather + // than leaving MCP unusable — Linux is a shipping platform, and a + // server that cannot mint a token cannot start at all. + if writeErr := os.WriteFile(s.mcpTokenPath(), []byte(token), 0o600); writeErr != nil { + return "", fmt.Errorf("store mcp token: %w", err) + } } return token, nil } -// DeleteMCPToken removes the token entirely. +// DeleteMCPToken removes the token entirely, from both possible locations. func (s *Store) DeleteMCPToken() error { - return s.secrets.Delete(mcpTokenSecretName) + err := s.secrets.Delete(mcpTokenSecretName) + if rmErr := os.Remove(s.mcpTokenPath()); rmErr != nil && !os.IsNotExist(rmErr) && err == nil { + err = rmErr + } + return err } diff --git a/engine/internal/settings/mcp_test.go b/engine/internal/settings/mcp_test.go index 688967d0..9728fb27 100644 --- a/engine/internal/settings/mcp_test.go +++ b/engine/internal/settings/mcp_test.go @@ -3,6 +3,9 @@ package settings import ( "context" "encoding/json" + "os" + "path/filepath" + "runtime" "strings" "testing" ) @@ -211,3 +214,50 @@ func TestMCPSettingsSurviveReload(t *testing.T) { t.Error("after reload consent was lost") } } + +// Regression: Linux has no secure secret backend, so EnsureMCPToken failed and +// MCP could not start at all. CI caught it; a Windows-only run never would. +// The fallback keeps the server usable on that platform. +func TestMCPTokenFallsBackToAFileWithoutASecureStore(t *testing.T) { + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + s, err := NewWithSecretStore(unsupportedSecretStore{}) + if err != nil { + t.Fatalf("NewWithSecretStore: %v", err) + } + + token, err := s.EnsureMCPToken() + if err != nil { + t.Fatalf("EnsureMCPToken without a secure store: %v", err) + } + if token == "" { + t.Fatal("a token must be minted even without a secure backend") + } + if got := s.MCPToken(); got != token { + t.Fatalf("MCPToken() = %q, want the token just minted", got) + } + if again, _ := s.EnsureMCPToken(); again != token { + t.Error("the fallback token must be reused, not reminted on every call") + } + + path := filepath.Join(home, mcpTokenFileName) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat token file: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("token file mode = %o, want 600", perm) + } + } + + if err := s.DeleteMCPToken(); err != nil { + t.Fatalf("DeleteMCPToken: %v", err) + } + if s.MCPToken() != "" { + t.Error("the fallback token should be gone after delete") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("the token file should be removed too") + } +} diff --git a/engine/internal/settings/settings.go b/engine/internal/settings/settings.go index 473fd5b0..ccdf6e5c 100644 --- a/engine/internal/settings/settings.go +++ b/engine/internal/settings/settings.go @@ -773,9 +773,9 @@ func (s *Store) redactedSettingsView(c Config) Config { if err == nil { c.WebSearchAPIKeySet = webKeySet } - if mcpTokenSet, err := s.secrets.Exists(mcpTokenSecretName); err == nil { - c.MCPTokenSet = mcpTokenSet - } + // Presence only — never the value: settings.get must not read secrets, and + // the check has to see the 0600 file fallback too. + c.MCPTokenSet = s.MCPTokenExists() return c } From 793a03197b669e0a0923d0260452d530ebd96142 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 19:04:49 +0900 Subject: [PATCH 24/25] refactor(engine): share the plaintext helpers instead of duplicating them The storyops extraction copied plainTextFromDoc and trimRunes out of companion/query.go with a comment saying the companion copy would die in the removal phase. SonarCloud failed the quality gate on it (9.3% duplication on new code, limit 3%), and it was right to: carrying two byte-identical implementations through three phases is a real maintenance hazard, not just a metric. storyops now exports PlainTextFromDoc and TrimRunes as the canonical pair; the companion keeps one-line local wrappers so none of its call sites change. Behavior is identical - the implementations were already the same bytes. Part of the MCP-first pivot (#47), Phase 1. Co-Authored-By: Claude Opus 5 --- engine/internal/companion/query.go | 59 +++--------------------------- engine/internal/storyops/apply.go | 10 ++++- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/engine/internal/companion/query.go b/engine/internal/companion/query.go index f5ab71c8..97f81e50 100644 --- a/engine/internal/companion/query.go +++ b/engine/internal/companion/query.go @@ -9,6 +9,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/beat" "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/storyops" ) const queryFence = "linetta-query" @@ -183,57 +184,9 @@ func parseQueryLimit(raw string, fallback, max int) int { return n } -func plainTextFromDoc(raw *string) string { - if raw == nil || *raw == "" { - return "" - } - var v interface{} - if err := json.Unmarshal([]byte(*raw), &v); err != nil { - return "" - } - var sb strings.Builder - var walk func(x interface{}) - walk = func(x interface{}) { - switch t := x.(type) { - case map[string]interface{}: - if t["type"] == "mention" { - if attrs, ok := t["attrs"].(map[string]interface{}); ok { - if label, ok := attrs["label"].(string); ok { - sb.WriteString(label) - } - } - return - } - if t["type"] == "text" { - if s, ok := t["text"].(string); ok { - sb.WriteString(s) - } - } - if t["type"] == "hardBreak" { - sb.WriteString("\n") - } - if c, ok := t["content"].([]interface{}); ok { - for _, ch := range c { - walk(ch) - } - } - if k, _ := t["type"].(string); k == "paragraph" || k == "heading" { - sb.WriteString("\n\n") - } - case []interface{}: - for _, ch := range t { - walk(ch) - } - } - } - walk(v) - return strings.TrimSpace(sb.String()) -} +// plainTextFromDoc and trimRunesLocal delegate to storyops, which owns the +// canonical implementations. Local wrappers keep every call site unchanged +// while the two packages coexist. +func plainTextFromDoc(raw *string) string { return storyops.PlainTextFromDoc(raw) } -func trimRunesLocal(s string, max int) string { - r := []rune(s) - if len(r) <= max { - return s - } - return string(r[:max]) + "…" -} +func trimRunesLocal(s string, max int) string { return storyops.TrimRunes(s, max) } diff --git a/engine/internal/storyops/apply.go b/engine/internal/storyops/apply.go index e7745c30..bf23bf41 100644 --- a/engine/internal/storyops/apply.go +++ b/engine/internal/storyops/apply.go @@ -781,8 +781,14 @@ func PlainTextToTiptapDoc(text string) (string, error) { return string(raw), nil } -// plainTextFromDoc and trimRunes are duplicated from companion/query.go; the -// companion copies die with that package in the pivot's removal phase. +// PlainTextFromDoc renders a stored Tiptap document as plain text, resolving +// mentions to their labels. Exported so the companion shares one copy rather +// than carrying a byte-identical duplicate through the transition. +func PlainTextFromDoc(raw *string) string { return plainTextFromDoc(raw) } + +// TrimRunes shortens s to max runes, appending an ellipsis when it cuts. +func TrimRunes(s string, max int) string { return trimRunes(s, max) } + func plainTextFromDoc(raw *string) string { if raw == nil || *raw == "" { return "" From e6c91bd093a8c92b1b912459c55610d583c02217 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sun, 23 Aug 2026 19:17:20 +0900 Subject: [PATCH 25/25] test(mcp): wait for the OS to release the port instead of asserting instantly TestMCPDisableStopsListener failed on the Windows CI runner while passing five consecutive local runs. The assertion was the problem: http.Server.Shutdown returning does not guarantee the socket is immediately rebindable, and on a loaded machine that teardown lags. An instantaneous check is a flake, not a stronger guarantee. Both port assertions now poll with a 3s deadline, so a port that never frees still fails the test. Part of the MCP-first pivot (#47), Phase 2. Co-Authored-By: Claude Opus 5 --- engine/internal/engineapp/mcp_wiring_test.go | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/engine/internal/engineapp/mcp_wiring_test.go b/engine/internal/engineapp/mcp_wiring_test.go index 94252f1b..91d2d5ea 100644 --- a/engine/internal/engineapp/mcp_wiring_test.go +++ b/engine/internal/engineapp/mcp_wiring_test.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "testing" + "time" ) // call sends one JSONRPC request through the app and returns the raw result. @@ -66,6 +67,24 @@ func portFree(t *testing.T, port int) bool { return true } +// waitPortFree polls until the OS has actually released the port. Shutdown +// returning does not guarantee the socket is instantly rebindable — on a +// loaded machine the teardown lags — so asserting instantaneously is a flake, +// not a stronger check. A port that never frees still fails here. +func waitPortFree(t *testing.T, port int) bool { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + if portFree(t, port) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(20 * time.Millisecond) + } +} + // A fresh install must not open a port. MCP is opt-in. func TestMCPDefaultsToNoListener(t *testing.T) { app := openApp(t) @@ -138,7 +157,7 @@ func TestMCPEnableBindsAndCloseReleasesPort(t *testing.T) { if err := app.Close(); err != nil { t.Fatalf("Close: %v", err) } - if !portFree(t, free) { + if !waitPortFree(t, free) { t.Fatal("Close must release the MCP port") } } @@ -160,7 +179,7 @@ func TestMCPDisableStopsListener(t *testing.T) { if _, rpcErr := call(t, app, "mcp.disable", ""); rpcErr != nil { t.Fatalf("mcp.disable: %+v", rpcErr) } - if !portFree(t, free) { + if !waitPortFree(t, free) { t.Fatal("mcp.disable must drop the listener") } }