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/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..bd381e36 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -0,0 +1,343 @@ +# 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절의 항목을 확정한다. 코드 작업 없음. + +- [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):** 다섯 항목 모두 권장안대로 확정. 이후 단계는 조건부 서술 없이 이 결정을 전제로 진행한다. + +--- + +## Phase 1 — 스토리 코어 추출 (LLM에서 분리) + +**이 단계에 사용자에게 보이는 새 기능은 없다.** 원칙은 "동작 변경 없는 이동"이고, 예외는 딱 두 가지 — 렌더러의 평문화(Task 1.1)와 팩트·메모리 병합(Task 1.3) — 이며 각각 명시적 작업으로 분리한다. + +### Task 1.1 — `internal/storycontext` 추출과 렌더러 평문화 + +**파일:** `engine/internal/storycontext/*`(신규), `engine/internal/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/*`(축소), 호출부 + +- [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 — 컨텍스트 병합: 팩트·메모리·레퍼런스 + +설계 문서 3.1절. `ai.ContextSelection`에는 `Facts`/`Memories`/`References` 토글이 이미 있지만 실제 수집은 컴패니언 `gatherContext`에만 있다. 이 작업이 빠지면 MCP 브리프에 팩트북과 메모리가 빠진다. + +**파일:** `engine/internal/storycontext/*`, `+ 테스트` + +- [x] 컴패니언 `gatherContext`의 팩트(씬 필터 포함)·메모리(recall)·레퍼런스 수집을 `storycontext` 빌더의 선택적 섹션으로 이식한다. +- [x] `Context` 구조체에 `Facts`/`Memories`/`References` 필드를 추가하고 렌더러가 해당 섹션을 출력하게 한다(빈 섹션은 생략 — 기존 관례). +- [x] 기존 토글(`ContextSelection`)이 실제로 이 섹션들을 켜고 끄는지 테스트한다. +- [x] 컴패니언의 기존 프롬프트 조립은 건드리지 않는다 — 이 병합은 MCP 툴을 위한 것이고, 컴패니언은 6단계까지 자기 경로를 유지한다. + +### Task 1.4 — 요약기 경계 정리 + +**파일:** `engine/internal/summarizer/*` + +- [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 호스트, 인증, 읽기 툴 + +읽기 전용 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` 사용만 사라진다. +- [ ] `handlers/websearch.go`, `web_search.test` RPC, `web_search_*` 설정을 제거한다(Phase 0 확정). `web_fetch`(`handlers/facts.go:108`, 키 불필요)는 남긴다. + +### 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 없이 쓰는 사용자 비중을 보고 여기서 재검토한다 +- [ ] 집필 통계, 원고 진행 관리, 퇴고 워크플로 +- [ ] 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..61668efd --- /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. 확정된 결정 + +**2026-08-22 확정.** 아래 다섯 항목은 전부 권장안대로 결정됐습니다. 이후 단계는 이 결정을 전제로 진행합니다. + +| 항목 | 결정 | 근거 | +| --- | --- | --- | +| 컴패니언 제거 시점 | **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. 리스크 + +| 리스크 | 완화 | +| --- | --- | +| MCP 검증 전에 컴패니언을 지워 사용자가 빈손이 됨 | 단계 순서를 강제: 추출 → 구축 → 강등 → 검증 → 제거 | +| 추출 중 `ApplyOps`/컨텍스트 빌더 동작이 미묘하게 깨짐 | 추출은 **동작 변경 없는 이동**으로만 진행하고 기존 테스트를 그대로 통과시킴. 유일한 예외(팩트·메모리 병합, 렌더러 평문화)는 명시적 작업으로 분리 | +| 폭주 에이전트가 원고를 뒤엎음 | 모드 설정, 호출·크기 한도, 호출별 스냅샷, 되돌리기, 활동 로그, 킬 스위치 | +| 사람과 에이전트가 같은 씬을 편집 | `expected_content_version`, `-32009` 노출, 에디터 배너 | +| 에이전트가 요약 갱신을 빼먹어 브리프 품질 저하 | 쓰기 툴 설명에 요약 갱신 지시 포함, Phase 5 실사용 검증에서 실제 호출 여부 측정 | +| MAS 심사에서 로컬 서버가 문제됨 | 엔타이틀먼트만 사용하고 브리지는 번들 밖으로. 문제 시 MAS는 HTTP 직접 연결만 지원 | +| 기존 컴패니언 사용자의 데이터 | 히스토리·메모리 데이터는 **삭제하지 않고** 읽기 또는 내보내기로 보존 | +| Claude Desktop의 로컬 서버 정책 변화 | 브리지는 얇고 유지 비용이 낮음. HTTP 경로는 명세 표준 | + +## 12. 비목표 + +- 원격/LAN 접속, 터널, OAuth, 다중 사용자. +- Linetta가 MCP **클라이언트**가 되는 방향(외부 MCP 서버를 앱이 소비). 이 전환의 정반대입니다. +- 모바일 MCP 호스팅. +- 집필 기능 자체의 재설계. 이 전환은 AI 경계를 옮기는 것이지 에디터를 다시 만드는 것이 아닙니다. 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..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" @@ -23,6 +22,8 @@ import ( "github.com/devlikebear/linetta/engine/internal/relationship" "github.com/devlikebear/linetta/engine/internal/rpc" "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" "github.com/devlikebear/linetta/engine/internal/thread" "github.com/devlikebear/tars/pkg/session" ) @@ -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 @@ -87,11 +88,9 @@ type Service struct { manuscript *manuscript.Searcher snaps *snapshot.Repo - // Outline snapshots taken before a structural apply, kept so the writer can - // undo the change that just landed. - undoMu sync.Mutex - undoBatches map[string]undoBatch - undoOrder []string + // story applies validated op batches and owns rollback/undo state; the + // companion delegates every mutation to it (see internal/storyops). + story *storyops.Service } // NewService constructs the companion service. sessionsDir is passed to @@ -111,6 +110,8 @@ func NewService( notify: notify, factory: factory, src: src, workDir: workDir, memBase: filepath.Join(sessionsDir, "mem"), } + s.story = storyops.New(projects, nodes, threads, beats, entities, relationships). + WithMemory(s) s.runner = newRunner(s) return s } @@ -139,11 +140,13 @@ func (s *Service) WithManuscript(searcher *manuscript.Searcher) *Service { // companion-before checkpoint before mutating scene text. func (s *Service) WithSnapshots(snaps *snapshot.Repo) *Service { s.snaps = snaps + s.story.WithSnapshots(snaps) return s } func (s *Service) WithFacts(repo *fact.Repo) *Service { s.facts = repo + s.story.WithFacts(repo) return s } @@ -526,10 +529,10 @@ func (s *Service) DeleteProjectData(ctx context.Context, projectID string) error // PreviewContext returns the same context sections a companion turn can inject, // with selected flags derived from the writer's current checklist choices. -func (s *Service) PreviewContext(ctx context.Context, projectID, nodeID string, selection ai.ContextSelection) (ai.ContextPreview, error) { +func (s *Service) PreviewContext(ctx context.Context, projectID, nodeID string, selection storycontext.ContextSelection) (storycontext.ContextPreview, error) { data, err := s.gatherContext(ctx, projectID, nodeID, "") if err != nil { - return ai.ContextPreview{}, err + return storycontext.ContextPreview{}, err } return previewFromPromptData(data, selection), nil } @@ -710,24 +713,24 @@ func normalizeImageAttachments(images []ImageAttachment) ([]ImageAttachment, err // Send starts a companion turn; returns the run id. Streaming + proposal arrive // via notifications. func (s *Service) Send(ctx context.Context, projectID, nodeID, text string, now func() int64) (string, error) { - return s.SendWithContext(ctx, projectID, nodeID, text, ai.DefaultContextSelection(), now) + return s.SendWithContext(ctx, projectID, nodeID, text, storycontext.DefaultContextSelection(), now) } // SendWithContext starts a companion turn using the writer-selected context // checklist state. -func (s *Service) SendWithContext(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, now func() int64) (string, error) { +func (s *Service) SendWithContext(ctx context.Context, projectID, nodeID, text string, selection storycontext.ContextSelection, now func() int64) (string, error) { return s.SendWithContextAndImages(ctx, projectID, nodeID, text, selection, nil, now) } // SendWithContextAndImages starts a companion turn with transient multimodal // images attached to the latest user message. -func (s *Service) SendWithContextAndImages(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, images []ImageAttachment, now func() int64) (string, error) { - return s.SendWithOptionsAndImages(ctx, projectID, nodeID, text, ai.Options{Context: selection}, images, now) +func (s *Service) SendWithContextAndImages(ctx context.Context, projectID, nodeID, text string, selection storycontext.ContextSelection, images []ImageAttachment, now func() int64) (string, error) { + return s.SendWithOptionsAndImages(ctx, projectID, nodeID, text, storycontext.Options{Context: selection}, images, now) } // SendWithOptionsAndImages starts a companion turn with the full per-call // option payload used by the desktop client. -func (s *Service) SendWithOptionsAndImages(ctx context.Context, projectID, nodeID, text string, opts ai.Options, images []ImageAttachment, now func() int64) (string, error) { +func (s *Service) SendWithOptionsAndImages(ctx context.Context, projectID, nodeID, text string, opts storycontext.Options, images []ImageAttachment, now func() int64) (string, error) { return s.SendWithCompanionOptionsAndImages(ctx, projectID, nodeID, text, SendOptions{ Context: opts.Context, OutlineStructure: opts.OutlineStructure, 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/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/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/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/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/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..65740058 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 @@ -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/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 90% rename from engine/internal/ai/context.go rename to engine/internal/storycontext/builder.go index 5425e1ca..f8509121 100644 --- a/engine/internal/ai/context.go +++ b/engine/internal/storycontext/builder.go @@ -1,4 +1,4 @@ -package ai +package storycontext import ( "context" @@ -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/ai/context_test.go b/engine/internal/storycontext/builder_test.go similarity index 85% rename from engine/internal/ai/context_test.go rename to engine/internal/storycontext/builder_test.go index b9bd14ac..fd9c41ea 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" @@ -20,25 +20,71 @@ import ( "github.com/devlikebear/linetta/engine/internal/thread" ) -func TestBuildContext_projectMetaPopulated(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) +// ctxFixture is the store-and-repos setup every builder test needs. Sixteen +// tests were each opening a store, wiring the mention resyncer, and +// constructing the same seven repos by hand; the block is identical in twelve +// of them and differs only in which repo handles a test keeps a name for. +type ctxFixture struct { + store *store.Store + projects *project.Repo + nodes *node.Repo + mentions *mention.Repo + threads *thread.Repo + beats *beat.Repo + notes *note.Repo + rels *relationship.Repo +} + +// newCtxFixture opens a temp store with the mention resyncer wired, exactly as +// engineapp does, so entity mentions land the way they do in the real app. +func newCtxFixture(t *testing.T) *ctxFixture { + t.Helper() + s, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } - defer s.Close() + t.Cleanup(func() { _ = s.Close() }) - pr := project.NewRepo(s) - p, _ := pr.Create(context.Background(), 1000, project.NewInput{ - Title: "t", Genres: []string{"판타지", "미스터리"}, LengthTarget: "novel", DefaultPOV: "first", - }) mr := mention.NewRepo(s) nodes := node.NewRepo(s) nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) }) + return &ctxFixture{ + store: s, projects: project.NewRepo(s), nodes: nodes, mentions: mr, + threads: thread.NewRepo(s), beats: beat.NewRepo(s), + notes: note.NewRepo(s), rels: relationship.NewRepo(s), + } +} + +// builder returns a ContextBuilder over the fixture's repos. +func (f *ctxFixture) builder() *ContextBuilder { + return NewContextBuilder(f.projects, f.nodes, f.mentions, f.threads, f.beats, f.notes, f.rels) +} + +// project creates a work with the given options applied to a sane default. +func (f *ctxFixture) project(t *testing.T, in project.NewInput) project.Project { + t.Helper() + if in.Title == "" { + in.Title = "t" + } + p, err := f.projects.Create(context.Background(), 1000, in) + if err != nil { + t.Fatalf("create project: %v", err) + } + return p +} + +func TestBuildContext_projectMetaPopulated(t *testing.T) { + f := newCtxFixture(t) + s := f.store + + pr := project.NewRepo(s) + p, _ := pr.Create(context.Background(), 1000, project.NewInput{ + Title: "t", Genres: []string{"판타지", "미스터리"}, LengthTarget: "novel", DefaultPOV: "first", + }) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() c, err := builder.Build(context.Background(), *p.LastOpenedNodeID, "user prompt", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -55,12 +101,8 @@ func TestBuildContext_projectMetaPopulated(t *testing.T) { } func TestBuildContext_includesSceneEntitiesAndStyleNotes(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ @@ -71,11 +113,7 @@ func TestBuildContext_includesSceneEntitiesAndStyleNotes(t *testing.T) { _, _ = s.DB().ExecContext(context.Background(), `UPDATE projects SET style_notes = ? WHERE id = ?`, "단문 위주", p.ID) er := entity.NewRepo(s) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes e, _ := er.Create(context.Background(), 1100, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "해진", Role: "POV"}) @@ -89,7 +127,7 @@ func TestBuildContext_includesSceneEntitiesAndStyleNotes(t *testing.T) { t.Fatalf("UpdateContent: %v", err) } - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), *p.LastOpenedNodeID, "재작성", "", Options{Tone: TonePresetMy}) if err != nil { t.Fatalf("Build: %v", err) @@ -164,22 +202,14 @@ func TestBuildContext_includesCoreEntitiesEvenWhenNotMentioned(t *testing.T) { } func TestBuildContext_prevSummary_trims300chars(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes // First leaf "씬 1" gets long content; add a second leaf "씬 2" and build // context for it — should pull a 300-char trim of 씬 1 as prev_summary. @@ -192,7 +222,7 @@ func TestBuildContext_prevSummary_trims300chars(t *testing.T) { second, _ := nodes.CreateSibling(context.Background(), *p.LastOpenedNodeID, "leaf", "씬 2", "", 1200) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), second.ID, "확장", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -206,22 +236,14 @@ func TestBuildContext_prevSummary_trims300chars(t *testing.T) { } func TestBuildContext_plotBeatsForCurrentNode(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + mr, nodes := f.mentions, f.nodes tr := thread.NewRepo(s) br := beat.NewRepo(s) @@ -252,22 +274,13 @@ func TestBuildContext_plotBeatsForCurrentNode(t *testing.T) { // the second leaf's id — shared by the three cache-path tests below. func setupPrevSummaryFixture(t *testing.T) (*store.Store, *project.Repo, *node.Repo, *mention.Repo, *thread.Repo, *beat.Repo, *note.Repo, *relationship.Repo, string, string) { t.Helper() - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - t.Cleanup(func() { _ = s.Close() }) - - pr := project.NewRepo(s) - p, _ := pr.Create(context.Background(), 1000, project.NewInput{ + f := newCtxFixture(t) + s := f.store + pr := f.projects + p := f.project(t, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + mr, nodes := f.mentions, f.nodes var long strings.Builder for i := 0; i < 400; i++ { long.WriteString("가") @@ -330,22 +343,14 @@ func TestBuildContext_prevSummary_fallsBackWhenEmpty(t *testing.T) { } func TestBuildContext_hierarchical_populatesNearbyAndSynopsis(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes // 1부 → 1장 → {씬 1, 씬 2, 씬 3 (current), 씬 4}, plus 2부 → 2장 → 씬 5. part1, _ := nodes.CreateSibling(context.Background(), *p.LastOpenedNodeID, "container", "1부", "", 1100) @@ -383,7 +388,7 @@ func TestBuildContext_hierarchical_populatesNearbyAndSynopsis(t *testing.T) { seedFresh(chap2.ID, "2장 요약") seedFresh(part2.ID, "2부 요약") - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), s3.ID, "확장", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -417,23 +422,15 @@ func TestBuildContext_hierarchical_populatesNearbyAndSynopsis(t *testing.T) { } func TestBuildContext_entityDossier_populatesRecentFromOtherLeaves(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) er := entity.NewRepo(s) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes e, _ := er.Create(context.Background(), 1050, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "해진"}) @@ -451,7 +448,7 @@ func TestBuildContext_entityDossier_populatesRecentFromOtherLeaves(t *testing.T) second, _ := nodes.CreateSibling(context.Background(), first, "leaf", "씬 2", "", 1200) _ = nodes.UpdateContent(context.Background(), second.ID, doc("씬 2의 현재"), 1300) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), second.ID, "확장", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -465,23 +462,15 @@ func TestBuildContext_entityDossier_populatesRecentFromOtherLeaves(t *testing.T) } func TestBuildContext_relatedScenes_returnsTopCoMentionScenes(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) er := entity.NewRepo(s) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes e1, _ := er.Create(context.Background(), 1050, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "해진"}) e2, _ := er.Create(context.Background(), 1060, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "민호"}) @@ -526,7 +515,7 @@ func TestBuildContext_relatedScenes_returnsTopCoMentionScenes(t *testing.T) { cur := curN.ID _ = nodes.UpdateContent(context.Background(), cur, withBoth("현재 — "), 1310) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), cur, "확장", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -537,22 +526,14 @@ func TestBuildContext_relatedScenes_returnsTopCoMentionScenes(t *testing.T) { } func TestBuildContext_includesNotesForNode(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + mr, nodes := f.mentions, f.nodes nr := note.NewRepo(s) _, _ = nr.Create(context.Background(), note.NewInput{NodeID: *p.LastOpenedNodeID, Anchor: 7, Body: "톤 바꾸기"}, 1000) @@ -591,22 +572,14 @@ func (f *fakeRefresher) RefreshNow(ctx context.Context, nodeID string) { // leaves every container summary empty. The injected fakeRefresher fills the // stale container rollups synchronously, simulating the summarizer. func TestBuildContext_hierarchicalRetrieval(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes // 1부 → {1장 → [씬1, 씬2-current], 2장 → [씬3, 씬4]} // 2부 → {3장 → [씬5, 씬6]} @@ -652,7 +625,7 @@ func TestBuildContext_hierarchicalRetrieval(t *testing.T) { }, } - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)). + builder := f.builder(). WithSummaryRefresher(ref) got, err := builder.Build(context.Background(), cur.ID, "확장", "", Options{}) if err != nil { @@ -690,23 +663,15 @@ func TestBuildContext_hierarchicalRetrieval(t *testing.T) { // Three past leaves all mention 해진; build context against a 4th leaf that also // mentions 해진. Recent should hold the 3 prior first-lines, most-recent first. func TestBuildContext_entityDossier(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) er := entity.NewRepo(s) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes e, _ := er.Create(context.Background(), 1050, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "해진"}) @@ -738,7 +703,7 @@ func TestBuildContext_entityDossier(t *testing.T) { leaf4, _ := nodes.CreateSibling(context.Background(), leaf3.ID, "leaf", "씬 4", "", 1130) _ = nodes.UpdateContent(context.Background(), leaf4.ID, doc("현재"), 1400) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), leaf4.ID, "확장", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -761,23 +726,15 @@ func TestBuildContext_entityDossier(t *testing.T) { // Two entities. Past leaf A mentions both. Current leaf mentions both. Other // past leaves mention only one (should NOT surface — k < 2). func TestBuildContext_topologyRAG(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) er := entity.NewRepo(s) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) + nodes := f.nodes e1, _ := er.Create(context.Background(), 1050, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "해진"}) e2, _ := er.Create(context.Background(), 1060, entity.NewInput{ProjectID: p.ID, Kind: "character", Name: "민호"}) @@ -824,7 +781,7 @@ func TestBuildContext_topologyRAG(t *testing.T) { curN, _ := nodes.CreateSibling(context.Background(), filler2.ID, "leaf", "현재", "", 1300) _ = nodes.UpdateContent(context.Background(), curN.ID, withBoth("cur — "), 1310) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() got, err := builder.Build(context.Background(), curN.ID, "확장", "", Options{}) if err != nil { t.Fatalf("Build: %v", err) @@ -838,24 +795,15 @@ func TestBuildContext_topologyRAG(t *testing.T) { } func TestBuildContext_selectionTextPassesThrough(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - s, err := store.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("store.Open: %v", err) - } - defer s.Close() + f := newCtxFixture(t) + s := f.store pr := project.NewRepo(s) p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "novel", DefaultPOV: "first", }) - mr := mention.NewRepo(s) - nodes := node.NewRepo(s) - nodes.SetMentionResyncer(func(ctx context.Context, nodeID, doc string) error { - return mr.ResyncForNode(ctx, nodeID, mention.Collect([]byte(doc))) - }) - builder := NewContextBuilder(pr, nodes, mr, thread.NewRepo(s), beat.NewRepo(s), note.NewRepo(s), relationship.NewRepo(s)) + builder := f.builder() selectionText := "그녀는 천천히 고개를 들었다." c, err := builder.Build(context.Background(), *p.LastOpenedNodeID, "더 감각적으로 다시 써줘", selectionText, Options{}) if err != nil { 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/ai/prompts.go b/engine/internal/storycontext/render.go similarity index 88% rename from engine/internal/ai/prompts.go rename to engine/internal/storycontext/render.go index b8f09402..3ed00528 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 { @@ -308,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/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 87% rename from engine/internal/ai/ai.go rename to engine/internal/storycontext/types.go index ef8e2b86..070233a7 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" @@ -173,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"` @@ -222,34 +228,27 @@ type EntityBrief struct { 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"` +// 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"` } -// DonePayload is the body of an "ai.done" notification. -type DonePayload struct { - RunID string `json:"run_id"` - FullText string `json:"full_text"` +// FactSourceBrief is one source line under a fact card. +type FactSourceBrief struct { + Title string `json:"title,omitempty"` + URL string `json:"url"` } -// 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"` +// 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"` } diff --git a/engine/internal/storyops/apply.go b/engine/internal/storyops/apply.go new file mode 100644 index 00000000..bf23bf41 --- /dev/null +++ b/engine/internal/storyops/apply.go @@ -0,0 +1,845 @@ +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 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 "" + } + 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 +} 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 edeb7889..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 @@ -93,7 +90,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) @@ -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 { 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"