diff --git a/apps/desktop/src-tauri/src/ffi.rs b/apps/desktop/src-tauri/src/ffi.rs index c3b5ad6c..199c325b 100644 --- a/apps/desktop/src-tauri/src/ffi.rs +++ b/apps/desktop/src-tauri/src/ffi.rs @@ -230,6 +230,9 @@ fn notification_event(method: &str) -> Option<&'static str> { "companion.preview" => Some("companion-preview"), "companion.thinking" => Some("companion-thinking"), "companion.reasoning" => Some("companion-reasoning"), + // An external MCP client changed the manuscript. Without this the + // writer would keep looking at text the agent already replaced. + "mcp.changed" => Some("mcp-changed"), _ => None, } } @@ -395,6 +398,14 @@ mod tests { assert!(error.request_id.is_some()); } + #[test] + fn mcp_changed_is_forwarded_to_the_renderer() { + // A notification the renderer never receives is a writer staring at + // text an external agent already replaced. + assert_eq!(notification_event("mcp.changed"), Some("mcp-changed")); + assert_eq!(notification_event("mcp.unmapped"), None); + } + #[cfg(target_os = "windows")] #[test] fn windows_engine_dll_is_discoverable() { diff --git a/apps/desktop/src/hooks/useMcpChanges.test.tsx b/apps/desktop/src/hooks/useMcpChanges.test.tsx new file mode 100644 index 00000000..c3fed42c --- /dev/null +++ b/apps/desktop/src/hooks/useMcpChanges.test.tsx @@ -0,0 +1,90 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ev = vi.hoisted(() => ({ + listeners: new Map void>(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: (event: string, cb: (e: { payload: unknown }) => void) => { + ev.listeners.set(event, cb); + return Promise.resolve(() => ev.listeners.delete(event)); + }, +})); + +import { useMcpChanges, type McpChangedPayload } from "./useMcpChanges"; + +async function emit(payload: McpChangedPayload) { + const cb = ev.listeners.get("mcp-changed"); + if (!cb) throw new Error("mcp-changed listener was never registered"); + await act(async () => { + cb({ payload }); + }); +} + +function setup(overrides: Partial[0]> = {}) { + const onOutlineChanged = vi.fn(); + const onSceneChanged = vi.fn(); + const view = renderHook(() => + useMcpChanges({ + projectId: "p1", + openNodeId: "n1", + editorDirty: false, + onOutlineChanged, + onSceneChanged, + ...overrides, + }), + ); + return { view, onOutlineChanged, onSceneChanged }; +} + +describe("useMcpChanges", () => { + beforeEach(() => { + ev.listeners.clear(); + }); + + it("refetches the outline and reloads the open scene when the buffer is clean", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["n1"] }); + + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).toHaveBeenCalledWith("n1"); + }); + + it("never replaces a dirty buffer; it surfaces the change instead", async () => { + const { view, onOutlineChanged, onSceneChanged } = setup({ editorDirty: true }); + await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["n1"] }); + + // The outline is still safe to refresh — it is not what the writer is typing into. + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).not.toHaveBeenCalled(); + expect(view.result.current.conflictNodeId).toBe("n1"); + + act(() => view.result.current.dismissConflict()); + expect(view.result.current.conflictNodeId).toBeNull(); + }); + + it("ignores a change to a scene the writer does not have open", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["other"] }); + + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).not.toHaveBeenCalled(); + }); + + it("ignores changes to another work entirely", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p2", tool: "linetta_write_scene", node_ids: ["n1"] }); + + expect(onOutlineChanged).not.toHaveBeenCalled(); + expect(onSceneChanged).not.toHaveBeenCalled(); + }); + + it("refetches the outline for a structural batch that names no scenes", async () => { + const { onOutlineChanged, onSceneChanged } = setup(); + await emit({ project_id: "p1", tool: "linetta_apply_story_ops", batch_id: "b1" }); + + expect(onOutlineChanged).toHaveBeenCalledTimes(1); + expect(onSceneChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/hooks/useMcpChanges.ts b/apps/desktop/src/hooks/useMcpChanges.ts new file mode 100644 index 00000000..6fa66c98 --- /dev/null +++ b/apps/desktop/src/hooks/useMcpChanges.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from "react"; + +import { useEngineEvent } from "./useEngineEvent"; + +/** What an external MCP client just changed. Emitted by the engine after every + * applied mutation so the workspace can refetch instead of showing text the + * agent already replaced. */ +export type McpChangedPayload = { + project_id?: string; + tool?: string; + node_ids?: string[]; + batch_id?: string; +}; + +export type McpChangeOptions = { + /** The work currently open, so changes to other works are ignored. */ + projectId: string | null; + /** The scene currently open in the editor, or null. */ + openNodeId: string | null; + /** Whether the editor holds unsaved edits for the open scene. */ + editorDirty: boolean; + /** Refetch the outline tree. */ + onOutlineChanged: () => void; + /** Reload the open scene's body from the engine. */ + onSceneChanged: (nodeId: string) => void; +}; + +/** Keeps the workspace in step with an external agent. + * + * The one rule that matters: when the editor has unsaved edits for the scene + * the agent touched, the buffer is NOT replaced. The writer's in-progress + * sentence outranks the agent's version, so the change is surfaced as a + * banner they can act on instead. */ +export function useMcpChanges({ + projectId, + openNodeId, + editorDirty, + onOutlineChanged, + onSceneChanged, +}: McpChangeOptions) { + const [conflictNodeId, setConflictNodeId] = useState(null); + + const dismissConflict = useCallback(() => setConflictNodeId(null), []); + + useEngineEvent("mcp-changed", (payload) => { + // A change to a work the writer is not looking at needs no UI reaction. + if (payload.project_id && projectId && payload.project_id !== projectId) { + return; + } + onOutlineChanged(); + + const touched = payload.node_ids ?? []; + if (!openNodeId || !touched.includes(openNodeId)) { + return; + } + if (editorDirty) { + setConflictNodeId(openNodeId); + return; + } + onSceneChanged(openNodeId); + }); + + return { conflictNodeId, dismissConflict }; +} diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index 918a4ae8..5eefca6b 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -156,53 +156,67 @@ **파일:** `engine/internal/mcphost/tools_write.go`, `+ 테스트` -- [ ] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다. -- [ ] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다. -- [ ] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다. -- [ ] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부. +- [x] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다. +- [x] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다. +- [x] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다. +- [x] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부. ### Task 3.2 — `linetta_revise_scene` -- [ ] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다. -- [ ] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환. +- [x] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다. +- [x] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환. ### Task 3.3 — `linetta_apply_story_ops` -- [ ] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다. -- [ ] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다. -- [ ] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함. +- [x] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다. +- [x] **`set_scene_text` 옵은 거부하고 `linetta_write_scene`으로 안내한다.** 적용기의 `set_scene_text`는 `nodes.UpdateContent`(무조건 덮어쓰기)를 쓰므로, 이 툴로 통과시키면 `write_scene`의 버전 검사 계약을 우회하게 된다. 변경 종류마다 문은 하나여야 한다. +- [x] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다. +- [x] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함. ### Task 3.4 — `linetta_write_summary` **전환의 급소다.** 설계 문서 6절 참조. -- [ ] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유). -- [ ] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다. -- [ ] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨. +- [x] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유). +- [x] **버전 계약 확정:** 씬(leaf)만 `content_version`을 요구한다. 컨테이너와 시놉시스는 자식 편집을 추적하는 버전이 없으므로 요구하지 않고 마지막 쓰기가 이긴다 — 툴 설명에 명시한다. +- [x] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다. +- [x] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨. ### Task 3.5 — 체크포인트와 되돌리기 -- [ ] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다. -- [ ] `linetta_undo_last_change`는 `storyops.UndoApply`를 감싸고, 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. +- [x] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다. +- [x] `linetta_undo_last_change`는 `batch_id`(구조 변경)와 `snapshot_id`(본문 변경) 두 가지를 받는다. 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다. + +> **구현 중 확정 (계획 정정):** `storyops.UndoApply` → `nodes.RestoreOutline`은 `parent_id·ordinal·label·title·status`만 되돌리고 **`content_doc`은 건드리지 않는다**(삭제됐다가 복원되는 노드만 예외). 즉 구조 변경 undo는 씬 본문을 되돌리지 못한다. 본문 되돌리기는 스냅샷(버전 기록) 경로다. 툴이 이 차이를 감추면 에이전트에게 거짓 약속을 하게 되므로, `linetta_write_scene`은 결과에 `snapshot_id`를 실어 보내고 `linetta_undo_last_change`가 두 종류를 모두 받는다. ### Task 3.6 — 호출 한도와 모드 강제 -- [ ] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다. -- [ ] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다. +- [x] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다. +- [x] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다. ### Task 3.7 — 변경 알림 **파일:** `engine/internal/mcphost/*`, `apps/desktop/src-tauri/src/ffi.rs`, `apps/desktop/src/hooks/*` -- [ ] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다. -- [ ] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다. -- [ ] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.** -- [ ] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest. +- [x] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다. +- [x] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다. +- [x] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.** +- [x] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest. + +- [x] 씬 쓰기 전 스냅샷은 당분간 `snapshot.ReasonCompanionBefore`를 재사용한다. 새 reason을 추가하려면 `ValidReason`과 프론트엔드 버전 시트 라벨을 함께 손봐야 하고, 컴패니언이 Phase 6에서 사라지면 이 reason은 사실상 "에이전트 변경 전"이 된다. 전용 reason 도입은 Phase 4의 UI 작업과 함께 판단한다. -**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`를 구동하고 원고가 원래 바이트로 돌아온다. +**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`(반환된 `snapshot_id`로)를 구동하고 원고가 원래 바이트로 돌아온다. 구조 변경은 `linetta_apply_story_ops` → `undo_last_change`(`batch_id`)로 별도 검증한다. --- +> **완료 (2026-08-23):** 툴 15개 완성(읽기 9 + 쓰기 6). `make test` 전체 통과, 엔진 42/42, `mas`/`mobile` 빌드 통과, mobile SDK 의존 0건. +> +> **계획에서 이탈한 두 가지:** +> - `expected_content_version`을 `int`가 아니라 `*int`로 받는다. 새 씬은 버전이 0이라 "미제공"과 "0"을 구분하지 못하면 **첫 원고를 영영 쓸 수 없다.** 테스트가 잡았다. +> - 씬 쓰기 전 스냅샷 reason은 `companion-before`를 재사용했다(위 결정 참조). +> +> **아직 남은 것:** `linetta_where_does_appear`는 명시적 @멘션만 집계하므로, 에이전트가 쓴 평문 원고에서는 등장인물 추적이 비게 된다. 이슈 #32와 같은 문제이고 Phase 3 이후 별도로 다룬다. + ## Phase 4 — 브리지, 설정 UX, MAS ### Task 4.1 — `cmd/linetta-mcp` 브리지 diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index e90a9ea5..99171ed4 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -38,6 +38,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/stats" "github.com/devlikebear/linetta/engine/internal/store" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" "github.com/devlikebear/linetta/engine/internal/summarizer" "github.com/devlikebear/linetta/engine/internal/thread" ) @@ -224,6 +225,14 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error // The tool layer gets its OWN context builder, wired with the fact, // memory, and reference sources. The builder above stays untouched so // ai.run and ai.preview_context keep producing byte-identical prompts. + // A storyops instance of its own: undo batches live in memory on the + // service, so an agent can undo only what it applied — never the writer's + // own companion batch. + mcpStory := storyops.New(projects, nodes, threads, beats, entities, relationships). + WithFacts(facts). + WithSnapshots(snaps). + WithMemory(companionSvc) + mcpContextBuilder := storycontext.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships). WithSummaryRefresher(summ). WithFactSource(companionSvc). @@ -242,6 +251,12 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error plot: plotBuilder, manuscript: manuscriptSearcher, context: mcpContextBuilder, + snapshots: snaps, + story: mcpStory, + msEdit: manuscriptEditor, + enqueue: summ.Enqueue, + notify: func(method string, params any) { _ = s.Notifier().Notify(method, params) }, + clock: clock, db: st.DB(), }, }) diff --git a/engine/internal/engineapp/mcp_batch_test.go b/engine/internal/engineapp/mcp_batch_test.go new file mode 100644 index 00000000..032bd4f2 --- /dev/null +++ b/engine/internal/engineapp/mcp_batch_test.go @@ -0,0 +1,212 @@ +//go:build !mobile + +package engineapp + +import ( + "encoding/json" + "strings" + "testing" +) + +// The exit criterion for the write phase: an agent writes prose and can put it +// back. Body reverts go through the snapshot, not the batch id. +func TestMCPWriteSceneThenUndoRestoresTheOriginalBytes(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + + const original = "원래 있던 문장이다.\n\n두 번째 문단." + _, v0 := readScene(t, c, nodeID) + if r := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": original, "expected_content_version": v0, + }); isToolError(r) { + t.Fatalf("seed write: %v", r) + } + before, v1 := readScene(t, c, nodeID) + + result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "에이전트가 완전히 새로 쓴 원고.", "expected_content_version": v1, + }) + if isToolError(result) { + t.Fatalf("write_scene: %v", result) + } + var wrote struct { + SnapshotID string `json:"snapshot_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &wrote); err != nil { + t.Fatalf("decode write_scene: %v", err) + } + if wrote.SnapshotID == "" { + t.Fatal("write_scene must return the snapshot id that restores the previous text") + } + + undo := c.callTool("linetta_undo_last_change", map[string]any{"snapshot_id": wrote.SnapshotID}) + if isToolError(undo) { + t.Fatalf("undo: %v", undo) + } + var undone struct { + Reverted string `json:"reverted"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, undo)), &undone); err != nil { + t.Fatalf("decode undo: %v", err) + } + if undone.Reverted != "scene" { + t.Errorf("reverted = %q, want scene", undone.Reverted) + } + + after, _ := readScene(t, c, nodeID) + if after != before { + t.Fatalf("undo did not restore the original bytes:\n got %q\nwant %q", after, before) + } +} + +// A structural batch applies atomically and its batch id puts the outline back. +func TestMCPApplyStoryOpsAndUndoBatch(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + + countScenes := func() int { + t.Helper() + outline := c.callTool("linetta_get_outline", map[string]any{"project_id": projectID}) + var out struct { + Outline []struct { + Kind string `json:"kind"` + } `json:"outline"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, outline)), &out); err != nil { + t.Fatalf("decode outline: %v", err) + } + return len(out.Outline) + } + before := countScenes() + + result := c.callTool("linetta_apply_story_ops", map[string]any{ + "project_id": projectID, + "node_id": nodeID, + "summary": "2화와 인물 추가", + "ops": []map[string]any{ + {"op": "create_scene", "ref": "s2", "after_node_id": nodeID, "label": "2화", "title": "기록보관실"}, + {"op": "create_entity", "ref": "hayun", "kind": "character", "name": "하윤", "role": "조력자"}, + }, + }) + if isToolError(result) { + t.Fatalf("apply_story_ops: %v", result) + } + var applied struct { + Applied int `json:"applied"` + UndoBatchID string `json:"undo_batch_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &applied); err != nil { + t.Fatalf("decode apply: %v", err) + } + if applied.Applied != 2 { + t.Fatalf("applied = %d, want 2", applied.Applied) + } + if applied.UndoBatchID == "" { + t.Fatal("a structural batch must return an undo_batch_id") + } + if countScenes() != before+1 { + t.Fatalf("outline did not gain the new scene: %d -> %d", before, countScenes()) + } + + chars := c.callTool("linetta_list_characters", map[string]any{"project_id": projectID}) + if !strings.Contains(structuredJSON(t, chars), "하윤") { + t.Error("the created character is not readable through the read tools") + } + + if undo := c.callTool("linetta_undo_last_change", map[string]any{ + "batch_id": applied.UndoBatchID, + }); isToolError(undo) { + t.Fatalf("undo batch: %v", undo) + } + if got := countScenes(); got != before { + t.Fatalf("undo did not restore the outline: %d, want %d", got, before) + } +} + +// One door per mutation type: set_scene_text through the batch tool would skip +// write_scene's version check entirely. +func TestMCPApplyStoryOpsRejectsSceneText(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + result := c.callTool("linetta_apply_story_ops", map[string]any{ + "project_id": projectID, + "node_id": nodeID, + "summary": "본문 덮어쓰기 시도", + "ops": []map[string]any{ + {"op": "set_scene_text", "node_id": nodeID, "text": "버전 검사를 우회한 본문"}, + }, + }) + if !isToolError(result) { + t.Fatal("set_scene_text must be refused by the batch tool") + } + if msg := toolErrorText(result); !strings.Contains(msg, "linetta_write_scene") { + t.Errorf("the refusal should point at write_scene: %s", msg) + } + body, _ := readScene(t, c, nodeID) + if strings.Contains(body, "우회한 본문") { + t.Fatal("the rejected op still changed the scene") + } +} + +// A failing op rolls the whole batch back: half a restructured outline is +// worse than none. +func TestMCPApplyStoryOpsRollsBackOnFailure(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + result := c.callTool("linetta_apply_story_ops", map[string]any{ + "project_id": projectID, + "node_id": nodeID, + "summary": "실패하는 배치", + "ops": []map[string]any{ + {"op": "create_outline_node", "kind": "container", "label": "1부"}, + {"op": "delete_outline_node", "node_id": "no-such-node"}, + }, + }) + if !isToolError(result) { + t.Fatal("a batch with a failing op must report an error") + } + var out struct { + Applied int `json:"applied"` + RolledBack bool `json:"rolled_back"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode apply: %v", err) + } + if !out.RolledBack || out.Applied != 0 { + t.Fatalf("result = %+v, want a rollback with nothing applied", out) + } +} + +func TestMCPCheckpointAndArgumentChecks(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + _, v := readScene(t, c, nodeID) + if r := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "체크포인트 대상 원고.", "expected_content_version": v, + }); isToolError(r) { + t.Fatalf("write: %v", r) + } + + result := c.callTool("linetta_create_checkpoint", map[string]any{"node_id": nodeID}) + if isToolError(result) { + t.Fatalf("create_checkpoint: %v", result) + } + var cp struct { + SnapshotID string `json:"snapshot_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &cp); err != nil { + t.Fatalf("decode checkpoint: %v", err) + } + if cp.SnapshotID == "" { + t.Fatal("a checkpoint must return a snapshot id even when the text is unchanged") + } + + if r := c.callTool("linetta_undo_last_change", map[string]any{}); !isToolError(r) { + t.Error("undo with no id must be refused") + } + if r := c.callTool("linetta_undo_last_change", map[string]any{ + "batch_id": "a", "snapshot_id": "b", + }); !isToolError(r) { + t.Error("undo with both ids must be refused") + } + if r := c.callTool("linetta_undo_last_change", map[string]any{ + "snapshot_id": "no-such-snapshot", + }); !isToolError(r) { + t.Error("undo with an unknown snapshot must be refused") + } +} diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go index d1edce9d..0f49fa00 100644 --- a/engine/internal/engineapp/mcp_disabled.go +++ b/engine/internal/engineapp/mcp_disabled.go @@ -11,12 +11,15 @@ import ( "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" ) // Mobile builds cannot host a local server, so MCP is compiled out entirely — @@ -40,6 +43,12 @@ type mcpToolRepos struct { plot *plot.Builder manuscript *manuscript.Searcher context *storycontext.ContextBuilder + snapshots *snapshot.Repo + story *storyops.Service + msEdit *manuscriptedit.Service + enqueue func(nodeID string) + notify func(method string, params any) + clock func() int64 db *sql.DB } diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go index 20eaed43..8fc02807 100644 --- a/engine/internal/engineapp/mcp_enabled.go +++ b/engine/internal/engineapp/mcp_enabled.go @@ -12,6 +12,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/mcphost" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" @@ -19,7 +20,9 @@ import ( "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/rpc/handlers" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" ) // MCP ships on desktop and on the Mac App Store. It is deliberately NOT gated @@ -47,6 +50,12 @@ type mcpToolRepos struct { plot *plot.Builder manuscript *manuscript.Searcher context *storycontext.ContextBuilder + snapshots *snapshot.Repo + story *storyops.Service + msEdit *manuscriptedit.Service + enqueue func(nodeID string) + notify func(method string, params any) + clock func() int64 db *sql.DB } @@ -71,6 +80,14 @@ func setupMCP(deps mcpDeps) (*mcpController, func() error) { Context: deps.repos.context, Settings: deps.settingsStore, Activity: activity, + + Snapshots: deps.repos.snapshots, + Story: deps.repos.story, + ManuscriptEdit: deps.repos.msEdit, + Limiter: mcphost.NewLimiter(), + EnqueueSummary: deps.repos.enqueue, + Notify: deps.repos.notify, + Clock: deps.repos.clock, } host := mcphost.New(mcphost.Deps{ Settings: deps.settingsStore, diff --git a/engine/internal/engineapp/mcp_revise_test.go b/engine/internal/engineapp/mcp_revise_test.go new file mode 100644 index 00000000..de29f36e --- /dev/null +++ b/engine/internal/engineapp/mcp_revise_test.go @@ -0,0 +1,135 @@ +//go:build !mobile + +package engineapp + +import ( + "encoding/json" + "strings" + "testing" +) + +// seedScene writes prose through the MCP write path and returns the scene id. +func seedScene(t *testing.T, c *mcpClient, nodeID, text string) { + t.Helper() + _, v := readScene(t, c, nodeID) + if r := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": text, "expected_content_version": v, + }); isToolError(r) { + t.Fatalf("seed write: %v", r) + } +} + +// A rename is the case this tool exists for: change a name everywhere without +// resending whole scene bodies. +func TestMCPReviseSceneReplacesAcrossScenes(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + seedScene(t, c, nodeID, "서린은 골목에 서 있었다. 서린의 그림자는 없었다.") + + result := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "서린", "replace": "지한", + }) + if isToolError(result) { + t.Fatalf("revise_scene: %v", result) + } + var out struct { + Applied int `json:"applied"` + ChangedNodes []string `json:"changed_nodes"` + Matches []struct { + Occurrences int `json:"occurrences"` + } `json:"matches"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode revise: %v", err) + } + if out.Applied != 1 || len(out.ChangedNodes) != 1 { + t.Fatalf("result = %+v, want one scene revised", out) + } + if len(out.Matches) == 0 || out.Matches[0].Occurrences < 2 { + t.Errorf("both occurrences should be reported: %+v", out.Matches) + } + + body, _ := readScene(t, c, nodeID) + if strings.Contains(body, "서린") { + t.Fatalf("the old name survived: %q", body) + } + if !strings.Contains(body, "지한") { + t.Fatalf("the new name is missing: %q", body) + } +} + +// dry_run is how an agent checks the blast radius of a common phrase before +// committing to it. +func TestMCPReviseSceneDryRunChangesNothing(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + const original = "그는 문을 열었다. 문은 잠겨 있지 않았다." + seedScene(t, c, nodeID, original) + + result := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "문", "replace": "창", "dry_run": true, + }) + if isToolError(result) { + t.Fatalf("dry run: %v", result) + } + var out struct { + Applied int `json:"applied"` + DryRun bool `json:"dry_run"` + Matches []struct { + After string `json:"after"` + } `json:"matches"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode dry run: %v", err) + } + if !out.DryRun || out.Applied != 0 { + t.Fatalf("result = %+v, want a preview that applied nothing", out) + } + if len(out.Matches) == 0 || out.Matches[0].After == "" { + t.Error("a dry run must show what the text would become") + } + + body, _ := readScene(t, c, nodeID) + if body != original { + t.Fatalf("dry run changed the scene:\n got %q\nwant %q", body, original) + } +} + +func TestMCPReviseSceneReportsNoMatch(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + seedScene(t, c, nodeID, "짧은 본문.") + + result := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "존재하지않는문구", "replace": "무엇이든", + }) + if !isToolError(result) { + t.Fatal("a revision with no match must report an error") + } + if msg := toolErrorText(result); !strings.Contains(msg, "linetta_search_manuscript") { + t.Errorf("the message should point at the search tool: %s", msg) + } +} + +// The scene is snapshotted before a revision, so the writer can restore it. +func TestMCPReviseSceneSnapshots(t *testing.T) { + app, c, projectID, nodeID := startWritableMCP(t) + seedScene(t, c, nodeID, "고쳐질 문장이 여기 있다.") + + if r := c.callTool("linetta_revise_scene", map[string]any{ + "project_id": projectID, "find": "고쳐질", "replace": "고쳐진", + }); isToolError(r) { + t.Fatalf("revise: %v", r) + } + + raw, rpcErr := call(t, app, "snapshots.list_for_node", `{"node_id":"`+nodeID+`"}`) + if rpcErr != nil { + t.Fatalf("snapshots.list_for_node: %+v", rpcErr) + } + var entries []struct { + Reason string `json:"reason"` + } + if err := json.Unmarshal(raw, &entries); err != nil { + t.Fatalf("decode snapshots: %v", err) + } + if len(entries) < 2 { + t.Fatalf("expected a snapshot from the write and one from the revision, got %d", len(entries)) + } +} diff --git a/engine/internal/engineapp/mcp_write_test.go b/engine/internal/engineapp/mcp_write_test.go new file mode 100644 index 00000000..ae984117 --- /dev/null +++ b/engine/internal/engineapp/mcp_write_test.go @@ -0,0 +1,299 @@ +//go:build !mobile + +package engineapp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/mcphost" +) + +// startWritableMCP brings up a server in full mode with one seeded scene. +func startWritableMCP(t *testing.T) (*App, *mcpClient, string, string) { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = app.Close() }) + + port := freeTestPort(t) + patch := fmt.Sprintf( + `{"mcp_mode":"full","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, port) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + if _, rpcErr := call(t, app, "mcp.enable", ""); rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + d, err := mcphost.ReadDiscoveryFile(home) + if err != nil { + t.Fatalf("ReadDiscoveryFile: %v", err) + } + c := &mcpClient{t: t, url: fmt.Sprintf("http://127.0.0.1:%d/mcp", d.Port), token: d.Token} + c.initialize() + + created, rpcErr := call(t, app, "projects.create", + `{"title":"쓰기 테스트","genres":["fantasy"],"length_target":"short","default_pov":"first"}`) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var proj struct { + ID string `json:"id"` + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(created, &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + return app, c, proj.ID, *proj.LastOpenedNodeID +} + +func readScene(t *testing.T, c *mcpClient, nodeID string) (body string, version int) { + t.Helper() + result := c.callTool("linetta_read_scene", map[string]any{"node_id": nodeID}) + if isToolError(result) { + t.Fatalf("read_scene: %v", result) + } + var out struct { + Body string `json:"body"` + ContentVersion int `json:"content_version"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + return out.Body, out.ContentVersion +} + +// read_only must not merely refuse writes — the tools must be absent, so a +// misbehaving agent cannot call one at all. +func TestMCPReadOnlyHidesWriteTools(t *testing.T) { + _, c := startMCPServer(t) // read_only + names := strings.Join(c.toolNames(), ",") + for _, w := range mcphost.WriteToolNames { + if strings.Contains(names, w) { + t.Errorf("read_only exposed the write tool %q", w) + } + } + if len(c.toolNames()) != len(mcphost.ReadToolNames) { + t.Errorf("read_only tool count = %d, want %d", len(c.toolNames()), len(mcphost.ReadToolNames)) + } +} + +func TestMCPFullModeExposesWriteTools(t *testing.T) { + _, c, _, _ := startWritableMCP(t) + names := strings.Join(c.toolNames(), ",") + for _, w := range mcphost.WriteToolNames { + if !strings.Contains(names, w) { + t.Errorf("full mode is missing the write tool %q", w) + } + } + want := len(mcphost.ReadToolNames) + len(mcphost.WriteToolNames) + if got := len(c.toolNames()); got != want { + t.Errorf("full mode tool count = %d, want %d", got, want) + } +} + +// The happy path: read, write, and the prose is really there — plus a +// snapshot id the agent can revert with. +func TestMCPWriteSceneWritesAndSnapshots(t *testing.T) { + app, c, _, nodeID := startWritableMCP(t) + _, version := readScene(t, c, nodeID) + + const prose = "비가 그친 골목에서 그는 그림자를 잃었다.\n\n가로등은 켜져 있었다." + result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": prose, "expected_content_version": version, + }) + if isToolError(result) { + t.Fatalf("write_scene: %v", result) + } + var out struct { + ContentVersion int `json:"content_version"` + WordCount int `json:"word_count"` + SnapshotID string `json:"snapshot_id"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode write_scene: %v", err) + } + if out.ContentVersion <= version { + t.Errorf("content_version did not advance: %d -> %d", version, out.ContentVersion) + } + if out.WordCount == 0 { + t.Error("word count was not recomputed") + } + + body, _ := readScene(t, c, nodeID) + if !strings.Contains(body, "그림자를 잃었다") { + t.Fatalf("prose not stored: %q", body) + } + + // The snapshot is the revert path for prose; undo_last_change's batch id + // restores the outline and leaves bodies alone. + raw, rpcErr := call(t, app, "snapshots.list_for_node", fmt.Sprintf(`{"node_id":%q}`, nodeID)) + if rpcErr != nil { + t.Fatalf("snapshots.list_for_node: %+v", rpcErr) + } + if !strings.Contains(string(raw), "companion-before") { + t.Errorf("no pre-write snapshot recorded: %s", raw) + } +} + +// The whole point of expected_content_version: a write against stale state is +// refused instead of silently overwriting the writer's own edits. +func TestMCPWriteSceneRefusesStaleVersion(t *testing.T) { + app, c, _, nodeID := startWritableMCP(t) + _, stale := readScene(t, c, nodeID) + + // The writer edits in the app, so the agent's version is now behind. + if _, rpcErr := call(t, app, "nodes.update_content", + fmt.Sprintf(`{"id":%q,"doc":"{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"작가가 직접 쓴 문장\"}]}]}"}`, nodeID)); rpcErr != nil { + t.Fatalf("nodes.update_content: %+v", rpcErr) + } + + result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "에이전트가 덮어쓰려는 문장", "expected_content_version": stale, + }) + if !isToolError(result) { + t.Fatal("a stale write must be refused") + } + if msg := toolErrorText(result); !strings.Contains(msg, "read") { + t.Errorf("the conflict message should tell the agent to re-read: %s", msg) + } + body, _ := readScene(t, c, nodeID) + if !strings.Contains(body, "작가가 직접 쓴 문장") { + t.Fatalf("the writer's text was lost: %q", body) + } +} + +func TestMCPWriteSceneRejectsMissingVersionAndContainers(t *testing.T) { + app, c, projectID, nodeID := startWritableMCP(t) + + if result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "버전 없이", + }); !isToolError(result) { + t.Error("a write without expected_content_version must be refused") + } + + // A container has no body; writing to one is a mistake worth naming. + raw, rpcErr := call(t, app, "nodes.create_child", + fmt.Sprintf(`{"parent_id":%q,"kind":"container","label":"1부"}`, nodeID)) + if rpcErr != nil { + // The seeded node is a leaf, so create a container at the root instead. + raw, rpcErr = call(t, app, "nodes.create_sibling", + fmt.Sprintf(`{"sibling_id":%q,"kind":"container","label":"1부"}`, nodeID)) + if rpcErr != nil { + t.Skipf("could not create a container to test with: %+v", rpcErr) + } + } + var container struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &container); err != nil || container.ID == "" { + t.Skip("could not decode the container node") + } + _ = projectID + if result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": container.ID, "text": "본문", "expected_content_version": 1, + }); !isToolError(result) { + t.Error("writing a body to a container must be refused") + } +} + +// write_summary is what fills the empty summary sections the brief reports. +func TestMCPWriteSummaryForScene(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + _, version := readScene(t, c, nodeID) + writeResult := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "그는 골목에서 그림자를 잃었다.", "expected_content_version": version, + }) + if isToolError(writeResult) { + t.Fatalf("write_scene: %v", writeResult) + } + _, fresh := readScene(t, c, nodeID) + + const summary = "서린이 골목에서 자신의 그림자가 사라진 것을 처음 자각한다." + result := c.callTool("linetta_write_summary", map[string]any{ + "node_id": nodeID, "summary": summary, "expected_content_version": fresh, + }) + if isToolError(result) { + t.Fatalf("write_summary: %v", result) + } + + // A fresh summary must read back as fresh, not stale. + scene := c.callTool("linetta_read_scene", map[string]any{"node_id": nodeID}) + var out struct { + Summary string `json:"summary"` + SummaryIsStale bool `json:"summary_is_stale"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, scene)), &out); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + if out.Summary != summary { + t.Errorf("summary = %q, want %q", out.Summary, summary) + } + if out.SummaryIsStale { + t.Error("a summary written against the current version must not read as stale") + } +} + +// A summary of text that has since changed would make the brief lie, so a +// stale version is refused. +func TestMCPWriteSummaryRefusesStaleVersion(t *testing.T) { + _, c, _, nodeID := startWritableMCP(t) + _, stale := readScene(t, c, nodeID) + if result := c.callTool("linetta_write_scene", map[string]any{ + "node_id": nodeID, "text": "새 본문", "expected_content_version": stale, + }); isToolError(result) { + t.Fatalf("write_scene: %v", result) + } + + result := c.callTool("linetta_write_summary", map[string]any{ + "node_id": nodeID, "summary": "낡은 본문에 대한 요약", "expected_content_version": stale, + }) + if !isToolError(result) { + t.Fatal("a summary written against stale text must be refused") + } +} + +func TestMCPWriteSummarySynopsisAndArgumentChecks(t *testing.T) { + _, c, projectID, nodeID := startWritableMCP(t) + + const synopsis = "존재가 지워진 남자가 자신을 지운 조직을 추적한다." + if result := c.callTool("linetta_write_summary", map[string]any{ + "project_id": projectID, "summary": synopsis, + }); isToolError(result) { + t.Fatalf("synopsis write: %v", result) + } + works := c.callTool("linetta_list_works", map[string]any{}) + if !strings.Contains(structuredJSON(t, works), "자신을 지운 조직") { + t.Errorf("synopsis not stored: %s", structuredJSON(t, works)) + } + + if result := c.callTool("linetta_write_summary", map[string]any{"summary": "대상 없음"}); !isToolError(result) { + t.Error("a summary with no target must be refused") + } + if result := c.callTool("linetta_write_summary", map[string]any{ + "node_id": nodeID, "project_id": projectID, "summary": "둘 다", + }); !isToolError(result) { + t.Error("passing both node_id and project_id must be refused") + } +} + +// toolErrorText returns the human-readable text of a tool error, which is what +// the agent actually reads (structuredContent holds the zero value on errors). +func toolErrorText(result map[string]any) string { + content, _ := result["content"].([]any) + parts := []string{} + for _, raw := range content { + block, _ := raw.(map[string]any) + if text, ok := block["text"].(string); ok { + parts = append(parts, text) + } + } + return strings.Join(parts, " ") +} diff --git a/engine/internal/mcphost/limits.go b/engine/internal/mcphost/limits.go new file mode 100644 index 00000000..5c4077dc --- /dev/null +++ b/engine/internal/mcphost/limits.go @@ -0,0 +1,80 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// callsPerMinute caps how fast one server instance will serve tool calls. A +// runaway agent loop should hit a wall the writer can see in the activity log, +// not rewrite forty scenes. The limit is deliberately generous for a human- +// paced session and only bites on a loop. +const callsPerMinute = 120 + +// limiter is a simple token bucket refilled continuously. One bucket covers +// reads and writes alike: an agent stuck in a read loop is a problem too, and +// two buckets would be two things to reason about. +type limiter struct { + mu sync.Mutex + tokens float64 + capacity float64 + perSec float64 + last time.Time + now func() time.Time +} + +func newLimiter(perMinute int) *limiter { + return &limiter{ + tokens: float64(perMinute), + capacity: float64(perMinute), + perSec: float64(perMinute) / 60, + now: time.Now, + } +} + +// allow reports whether a call may proceed, consuming one token if so. +func (l *limiter) allow() bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + if l.last.IsZero() { + l.last = now + } + l.tokens += now.Sub(l.last).Seconds() * l.perSec + if l.tokens > l.capacity { + l.tokens = l.capacity + } + l.last = now + if l.tokens < 1 { + return false + } + l.tokens-- + return true +} + +// limited wraps a typed tool handler with the rate limit. Applied at +// registration next to the activity decorator, so no tool can be added without +// one — the same "cannot forget" property. +func limited[In, Out any](l *limiter, h mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + if l == nil { + return h + } + return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { + if !l.allow() { + var zero Out + return toolErr( + "too many Linetta tool calls in a short window (limit %d per minute). "+ + "Slow down, or ask the writer to review what you have changed so far.", + callsPerMinute), zero, nil + } + return h(ctx, req, in) + } +} + +// NewLimiter returns the shared rate limiter for one engine tool layer. +func NewLimiter() *limiter { return newLimiter(callsPerMinute) } diff --git a/engine/internal/mcphost/limits_test.go b/engine/internal/mcphost/limits_test.go new file mode 100644 index 00000000..f9fb0293 --- /dev/null +++ b/engine/internal/mcphost/limits_test.go @@ -0,0 +1,52 @@ +//go:build !mobile + +package mcphost + +import ( + "testing" + "time" +) + +// A runaway loop must hit a wall; a human-paced session must not. +func TestLimiterStopsABurstAndRefills(t *testing.T) { + now := time.Unix(0, 0) + l := newLimiter(60) + l.now = func() time.Time { return now } + + for i := 0; i < 60; i++ { + if !l.allow() { + t.Fatalf("call %d was refused while the bucket should still be full", i+1) + } + } + if l.allow() { + t.Fatal("the 61st call in the same instant should be refused") + } + + // One second of refill buys exactly one more call at 60/min. + now = now.Add(time.Second) + if !l.allow() { + t.Fatal("the bucket should refill over time") + } + if l.allow() { + t.Fatal("refill must not hand out more than it earned") + } +} + +// The bucket must not accumulate an unbounded burst while nobody is calling. +func TestLimiterCapsRefill(t *testing.T) { + now := time.Unix(0, 0) + l := newLimiter(10) + l.now = func() time.Time { return now } + l.allow() + + now = now.Add(time.Hour) + granted := 0 + for i := 0; i < 100; i++ { + if l.allow() { + granted++ + } + } + if granted != 10 { + t.Fatalf("after a long idle the bucket granted %d calls, want the %d-call capacity", granted, 10) + } +} diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go index e675ffa8..e469b8f2 100644 --- a/engine/internal/mcphost/tools.go +++ b/engine/internal/mcphost/tools.go @@ -6,18 +6,22 @@ import ( "context" "fmt" "strings" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/mention" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/storycontext" + "github.com/devlikebear/linetta/engine/internal/storyops" ) // ToolDeps carries everything the tool layer reads from. Every field is a repo @@ -33,6 +37,43 @@ type ToolDeps struct { Context *storycontext.ContextBuilder Settings *settings.Store Activity *ActivityRepo + + // Write-side collaborators. Snapshots make every body change revertible; + // EnqueueSummary keeps agent prose in the summarizer's queue; Notify tells + // the running UI that something outside it changed the manuscript. + Snapshots *snapshot.Repo + Story *storyops.Service + ManuscriptEdit *manuscriptedit.Service + Limiter *limiter + EnqueueSummary func(nodeID string) + Notify func(method string, params any) + Clock func() int64 +} + +// now returns the wall clock the tools stamp writes with. +func (d ToolDeps) now() int64 { + if d.Clock != nil { + return d.Clock() + } + return time.Now().UnixMilli() +} + +// ChangedPayload is the body of an "mcp.changed" notification: what an external +// agent just altered, so the UI can refetch instead of showing stale text. +type ChangedPayload struct { + ProjectID string `json:"project_id"` + Tool string `json:"tool"` + NodeIDs []string `json:"node_ids,omitempty"` + BatchID string `json:"batch_id,omitempty"` +} + +func (d ToolDeps) notifyChanged(projectID, tool string, nodeIDs []string, batchID string) { + if d.Notify == nil { + return + } + d.Notify("mcp.changed", ChangedPayload{ + ProjectID: projectID, Tool: tool, NodeIDs: nodeIDs, BatchID: batchID, + }) } // Register installs the tool set for a mode. Read tools are always present; @@ -45,7 +86,9 @@ type ToolDeps struct { // running server never serves a stale tool set. func (d ToolDeps) Register(s *mcp.Server, mode string) { d.registerReadTools(s) - _ = mode // write tools land in Phase 3 + if mode == settings.MCPModeFull { + d.registerWriteTools(s) + } } // scopedInput is implemented by tool inputs that name a work and/or a target, @@ -58,6 +101,7 @@ type scopedInput interface { // in the activity log the writer can inspect. Wrapping at registration time // means no tool can forget to report itself. func record[In, Out any](d ToolDeps, tool string, h mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + h = limited(d.Limiter, h) return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { res, out, err := h(ctx, req, in) diff --git a/engine/internal/mcphost/tools_batch.go b/engine/internal/mcphost/tools_batch.go new file mode 100644 index 00000000..1d502e60 --- /dev/null +++ b/engine/internal/mcphost/tools_batch.go @@ -0,0 +1,246 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/storyops" +) + +// ---------- linetta_apply_story_ops ---------- + +type applyStoryOpsInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work to change"` + NodeID string `json:"node_id,omitempty" jsonschema:"the scene ops without an explicit target apply to"` + Summary string `json:"summary" jsonschema:"one line describing the change, shown to the writer"` + Ops []storyops.Op `json:"ops" jsonschema:"the mutations to apply as one batch"` +} + +func (in applyStoryOpsInput) scope() (string, string) { return in.ProjectID, in.NodeID } + +type applyStoryOpsOutput struct { + Applied int `json:"applied"` + Created map[string]string `json:"created,omitempty"` + Failures []opFailure `json:"failures,omitempty"` + RolledBack bool `json:"rolled_back,omitempty"` + UndoBatchID string `json:"undo_batch_id,omitempty"` + ChangedNodes []string `json:"changed_nodes,omitempty"` +} + +type opFailure struct { + Index int `json:"index"` + Op string `json:"op,omitempty"` + Error string `json:"error"` +} + +// ---------- linetta_create_checkpoint ---------- + +type createCheckpointInput struct { + NodeID string `json:"node_id" jsonschema:"scene to checkpoint"` +} + +func (in createCheckpointInput) scope() (string, string) { return "", in.NodeID } + +type createCheckpointOutput struct { + SnapshotID string `json:"snapshot_id"` + NodeID string `json:"node_id"` + Created bool `json:"created"` +} + +// ---------- linetta_undo_last_change ---------- + +type undoInput struct { + // Exactly one. A batch id undoes a structural change from + // linetta_apply_story_ops; a snapshot id restores a scene's prose. + BatchID string `json:"batch_id,omitempty" jsonschema:"undo_batch_id from linetta_apply_story_ops"` + SnapshotID string `json:"snapshot_id,omitempty" jsonschema:"snapshot_id from linetta_write_scene or linetta_create_checkpoint"` +} + +func (in undoInput) scope() (string, string) { return "", in.SnapshotID } + +type undoOutput struct { + Reverted string `json:"reverted"` // outline | scene + NodeID string `json:"node_id,omitempty"` +} + +func (d ToolDeps) registerBatchTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_apply_story_ops", + Description: "Apply a batch of story changes: outline nodes, storylines and beats, characters, " + + "places, relationships, Fact Book cards, and memories. Structural batches are all-or-nothing — " + + "if one op fails the outline is put back — and a clean run returns undo_batch_id. " + + "Scene prose is NOT written here; use linetta_write_scene, which checks the version first.", + }, record(d, "linetta_apply_story_ops", d.applyStoryOps)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_create_checkpoint", + Description: "Save a restore point for a scene before a large rewrite. Returns a snapshot_id " + + "linetta_undo_last_change can restore.", + }, record(d, "linetta_create_checkpoint", d.createCheckpoint)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_undo_last_change", + Description: "Undo a change you just made. Pass batch_id to revert a structural batch from " + + "linetta_apply_story_ops, or snapshot_id to restore a scene's previous text. These are " + + "different paths: undoing a batch restores the outline and leaves scene bodies alone.", + }, record(d, "linetta_undo_last_change", d.undoLastChange)) +} + +func (d ToolDeps) applyStoryOps(ctx context.Context, _ *mcp.CallToolRequest, in applyStoryOpsInput) (*mcp.CallToolResult, applyStoryOpsOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, applyStoryOpsOutput{}, nil + } + if len(in.Ops) == 0 { + return toolErr("ops is required"), applyStoryOpsOutput{}, nil + } + // One door per mutation type. The applier's set_scene_text overwrites + // unconditionally, so letting it through here would route around + // linetta_write_scene's version check entirely. + for i, op := range in.Ops { + if op.Type == "set_scene_text" { + return toolErr( + "ops[%d] set_scene_text is not accepted here; use linetta_write_scene, which requires the "+ + "content_version so the writer's own edits cannot be overwritten", i), + applyStoryOpsOutput{}, nil + } + } + nodeID := strings.TrimSpace(in.NodeID) + if nodeID != "" { + if _, errResult := d.requireNode(ctx, nodeID); errResult != nil { + return errResult, applyStoryOpsOutput{}, nil + } + } + if d.Story == nil { + return toolErr("story operations are unavailable in this build"), applyStoryOpsOutput{}, nil + } + + result := d.Story.ApplyOps(ctx, p.ID, nodeID, storyops.Proposal{ + Summary: strings.TrimSpace(in.Summary), Ops: in.Ops, + }, d.now) + + out := applyStoryOpsOutput{ + Applied: result.Applied, + Created: result.Created, + RolledBack: result.RolledBack, + UndoBatchID: result.UndoBatchID, + } + for _, ch := range result.ChangedNodes { + out.ChangedNodes = append(out.ChangedNodes, ch.NodeID) + } + for _, f := range result.Failures { + out.Failures = append(out.Failures, opFailure{Index: f.Index, Op: f.Op, Error: f.Error}) + } + if result.IsError() { + // Reported as a tool error so the agent notices, with the structured + // detail kept so it can see which op failed and why. + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: applyFailureText(result)}}, + }, out, nil + } + if result.Applied > 0 { + d.notifyChanged(p.ID, "linetta_apply_story_ops", out.ChangedNodes, result.UndoBatchID) + } + return nil, out, nil +} + +func applyFailureText(r storyops.ApplyOpsResult) string { + var b strings.Builder + if r.RolledBack { + b.WriteString("the batch failed and the outline was put back; nothing was applied. ") + } + for _, f := range r.Failures { + if f.Index >= 0 { + fmt.Fprintf(&b, "ops[%d] %s: %s. ", f.Index, f.Op, f.Error) + continue + } + fmt.Fprintf(&b, "%s. ", f.Error) + } + return strings.TrimSpace(b.String()) +} + +func (d ToolDeps) createCheckpoint(ctx context.Context, _ *mcp.CallToolRequest, in createCheckpointInput) (*mcp.CallToolResult, createCheckpointOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, createCheckpointOutput{}, nil + } + if d.Snapshots == nil { + return toolErr("version history is unavailable in this build"), createCheckpointOutput{}, nil + } + doc := "" + if n.ContentDoc != nil { + doc = *n.ContentDoc + } + snap, created, err := d.Snapshots.CreateIfChanged(ctx, n.ID, doc, snapshot.ReasonManual, d.now()) + if err != nil { + return toolErr("could not save a checkpoint: %v", err), createCheckpointOutput{}, nil + } + if !created { + // Nothing changed since the last snapshot, so the newest one already is + // this checkpoint. Hand back its id rather than a confusing empty value. + if latest, err := d.Snapshots.LatestForNode(ctx, n.ID); err == nil { + return nil, createCheckpointOutput{SnapshotID: latest.ID, NodeID: n.ID, Created: false}, nil + } + } + return nil, createCheckpointOutput{SnapshotID: snap.ID, NodeID: n.ID, Created: created}, nil +} + +func (d ToolDeps) undoLastChange(ctx context.Context, _ *mcp.CallToolRequest, in undoInput) (*mcp.CallToolResult, undoOutput, error) { + batchID := strings.TrimSpace(in.BatchID) + snapshotID := strings.TrimSpace(in.SnapshotID) + switch { + case batchID == "" && snapshotID == "": + return toolErr("pass batch_id to undo a structural batch, or snapshot_id to restore a scene's text"), + undoOutput{}, nil + case batchID != "" && snapshotID != "": + return toolErr("pass either batch_id or snapshot_id, not both"), undoOutput{}, nil + case batchID != "": + if d.Story == nil { + return toolErr("story operations are unavailable in this build"), undoOutput{}, nil + } + if err := d.Story.UndoApply(ctx, batchID, d.now); err != nil { + if errors.Is(err, storyops.ErrUndoBatchNotFound) { + return toolErr("undo is no longer available for that change"), undoOutput{}, nil + } + return toolErr("could not undo the change: %v", err), undoOutput{}, nil + } + d.notifyChanged("", "linetta_undo_last_change", nil, batchID) + return nil, undoOutput{Reverted: "outline"}, nil + } + + if d.Snapshots == nil { + return toolErr("version history is unavailable in this build"), undoOutput{}, nil + } + snap, err := d.Snapshots.GetByID(ctx, snapshotID) + if err != nil { + return toolErr("snapshot %q not found", snapshotID), undoOutput{}, nil + } + n, errResult := d.requireNode(ctx, snap.NodeID) + if errResult != nil { + return errResult, undoOutput{}, nil + } + // Snapshot the current text first, so restoring is itself revertible. + curDoc := "" + if n.ContentDoc != nil { + curDoc = *n.ContentDoc + } + if _, _, err := d.Snapshots.CreateIfChanged(ctx, n.ID, curDoc, snapshot.ReasonManual, d.now()); err != nil { + return toolErr("could not snapshot before restoring: %v", err), undoOutput{}, nil + } + if err := d.Nodes.UpdateContent(ctx, n.ID, snap.ContentDoc, d.now()); err != nil { + return toolErr("could not restore the scene: %v", err), undoOutput{}, nil + } + if d.EnqueueSummary != nil { + d.EnqueueSummary(n.ID) + } + d.notifyChanged(n.ProjectID, "linetta_undo_last_change", []string{n.ID}, "") + return nil, undoOutput{Reverted: "scene", NodeID: n.ID}, nil +} diff --git a/engine/internal/mcphost/tools_revise.go b/engine/internal/mcphost/tools_revise.go new file mode 100644 index 00000000..1791a675 --- /dev/null +++ b/engine/internal/mcphost/tools_revise.go @@ -0,0 +1,135 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/manuscriptedit" +) + +// ---------- linetta_revise_scene ---------- + +type reviseSceneInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work"` + Find string `json:"find" jsonschema:"the exact text to replace"` + Replace string `json:"replace" jsonschema:"what to put in its place"` + // NodeIDs narrows the edit to specific scenes. Omit to sweep the work — + // useful for a rename, dangerous for a common phrase, so the tool reports + // what it touched. + NodeIDs []string `json:"node_ids,omitempty" jsonschema:"scenes to edit; omit to search the whole work"` + MatchCase bool `json:"match_case,omitempty"` + WholeWord bool `json:"whole_word,omitempty"` + // DryRun returns the matches without changing anything. + DryRun bool `json:"dry_run,omitempty" jsonschema:"preview the matches without applying them"` +} + +func (in reviseSceneInput) scope() (string, string) { return in.ProjectID, "" } + +type reviseMatch struct { + NodeID string `json:"node_id"` + Label string `json:"label,omitempty"` + Occurrences int `json:"occurrences"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` +} + +type reviseSceneOutput struct { + Applied int `json:"applied"` + DryRun bool `json:"dry_run"` + Matches []reviseMatch `json:"matches"` + ChangedNodes []string `json:"changed_nodes,omitempty"` + Failures []string `json:"failures,omitempty"` +} + +func (d ToolDeps) registerReviseTool(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_revise_scene", + Description: "Replace exact text across one or more scenes — a renamed character, a corrected " + + "term, a reworded line — without resending whole scene bodies. Every touched scene is " + + "snapshotted first. Pass dry_run to see what would change before committing; omit node_ids " + + "only when you mean to sweep the entire work.", + }, record(d, "linetta_revise_scene", d.reviseScene)) +} + +func (d ToolDeps) reviseScene(ctx context.Context, _ *mcp.CallToolRequest, in reviseSceneInput) (*mcp.CallToolResult, reviseSceneOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, reviseSceneOutput{}, nil + } + find := strings.TrimSpace(in.Find) + if find == "" { + return toolErr("find is required"), reviseSceneOutput{}, nil + } + if strings.TrimSpace(in.Replace) == "" { + return toolErr("replace is required; to delete text, replace it with the surrounding wording you want"), + reviseSceneOutput{}, nil + } + if d.Manuscript == nil || d.ManuscriptEdit == nil { + return toolErr("manuscript editing is unavailable in this build"), reviseSceneOutput{}, nil + } + // Every named scene must belong to an allowed work, or a restricted server + // could be steered into another work by id. + for _, nodeID := range in.NodeIDs { + if _, errResult := d.requireNode(ctx, nodeID); errResult != nil { + return errResult, reviseSceneOutput{}, nil + } + } + + plan, err := d.ManuscriptEdit.PlanReplace(ctx, manuscriptedit.ReplacePlanRequest{ + ProjectID: p.ID, + Query: find, + Replacement: in.Replace, + NodeIDs: in.NodeIDs, + MatchCase: in.MatchCase, + WholeWord: in.WholeWord, + }) + if err != nil { + return toolErr("could not plan the revision: %v", err), reviseSceneOutput{}, nil + } + if len(plan.Candidates) == 0 { + return toolErr("no scene contains %q; check the exact wording with linetta_search_manuscript", find), + reviseSceneOutput{}, nil + } + + out := reviseSceneOutput{DryRun: in.DryRun} + candidateIDs := make([]string, 0, len(plan.Candidates)) + for _, c := range plan.Candidates { + out.Matches = append(out.Matches, reviseMatch{ + NodeID: c.NodeID, + Label: c.Breadcrumb, + Occurrences: c.Occurrences, + Before: c.Before, + After: c.After, + }) + candidateIDs = append(candidateIDs, c.ID) + } + if in.DryRun { + return nil, out, nil + } + + result, err := d.ManuscriptEdit.ApplyReplace(ctx, plan, candidateIDs, d.now()) + if err != nil { + return toolErr("could not apply the revision: %v", err), reviseSceneOutput{}, nil + } + out.Applied = result.Applied + out.ChangedNodes = result.ChangedNodeIDs + for _, f := range result.Failures { + out.Failures = append(out.Failures, f.Message) + } + for _, nodeID := range result.ChangedNodeIDs { + if d.EnqueueSummary != nil { + d.EnqueueSummary(nodeID) + } + } + if result.Applied > 0 { + d.notifyChanged(p.ID, "linetta_revise_scene", result.ChangedNodeIDs, "") + } + if result.Applied == 0 && len(result.Failures) > 0 { + return toolErr("nothing was revised: %s", strings.Join(out.Failures, "; ")), out, nil + } + return nil, out, nil +} diff --git a/engine/internal/mcphost/tools_write.go b/engine/internal/mcphost/tools_write.go new file mode 100644 index 00000000..65b76e4f --- /dev/null +++ b/engine/internal/mcphost/tools_write.go @@ -0,0 +1,234 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "errors" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/snapshot" + "github.com/devlikebear/linetta/engine/internal/storyops" +) + +// WriteToolNames lists the write tools, in registration order. They are +// registered only in settings.MCPModeFull, so read_only does not merely refuse +// writes — the tools are absent from tools/list. +var WriteToolNames = []string{ + "linetta_write_scene", + "linetta_write_summary", + "linetta_revise_scene", + "linetta_apply_story_ops", + "linetta_create_checkpoint", + "linetta_undo_last_change", +} + +// maxSceneRunes caps one scene body. A runaway agent should hit a wall with a +// clear message rather than commit a megabyte to the manuscript. +const maxSceneRunes = 60000 + +// ---------- linetta_write_scene ---------- + +type writeSceneInput struct { + NodeID string `json:"node_id" jsonschema:"id of the scene to write"` + Text string `json:"text" jsonschema:"the full scene body as plain prose; blank lines separate paragraphs"` + // ExpectedContentVersion is the content_version from linetta_read_scene. + // Required: it is what stops an agent from overwriting edits the writer + // made after the agent last read the scene. + // A pointer, not an int: a scene that has never been written has version 0, + // so "absent" and "zero" must stay distinguishable or the first draft can + // never be written. + ExpectedContentVersion *int `json:"expected_content_version" jsonschema:"the content_version returned by linetta_read_scene"` +} + +func (in writeSceneInput) scope() (string, string) { return "", in.NodeID } + +type writeSceneOutput struct { + NodeID string `json:"node_id"` + ContentVersion int `json:"content_version"` + WordCount int `json:"word_count"` + // SnapshotID is the pre-write version. Reverting prose goes through this, + // not through linetta_undo_last_change's batch id: undoing a structural + // batch restores the outline and leaves scene bodies alone. + SnapshotID string `json:"snapshot_id,omitempty"` +} + +// ---------- linetta_write_summary ---------- + +type writeSummaryInput struct { + // Exactly one target. A scene or container summary feeds the story brief; + // the synopsis is the work-level blurb. + NodeID string `json:"node_id,omitempty" jsonschema:"scene or container to summarize"` + ProjectID string `json:"project_id,omitempty" jsonschema:"work whose synopsis to write; omit when node_id is set"` + Summary string `json:"summary" jsonschema:"3-5 sentences preserving characters, places, and key events"` + // ExpectedContentVersion is required for scenes only — containers and the + // synopsis have no version tracking their children's edits. + ExpectedContentVersion *int `json:"expected_content_version,omitempty" jsonschema:"for a scene, the content_version from linetta_read_scene"` +} + +func (in writeSummaryInput) scope() (string, string) { return in.ProjectID, in.NodeID } + +type writeSummaryOutput struct { + Target string `json:"target"` // scene | container | synopsis + NodeID string `json:"node_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Summary string `json:"summary"` +} + +// registerWriteTools installs the mutating tools. Only called for +// settings.MCPModeFull. +func (d ToolDeps) registerWriteTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_write_scene", + Description: "Replace a scene's body with new prose. Call linetta_read_scene first and pass the " + + "content_version it returned: if the writer edited the scene since then the write is refused, " + + "so their work is never silently overwritten. The previous text is snapshotted first and the " + + "returned snapshot_id restores it.", + }, record(d, "linetta_write_scene", d.writeScene)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_write_summary", + Description: "Write the summary Linetta shows for a scene or chapter, or the work's synopsis. " + + "Summaries feed linetta_get_story_context, so writing one after drafting keeps later briefs " + + "accurate — when a brief reports an empty summary section, this is the tool that fills it. " + + "A scene summary needs the content_version from linetta_read_scene; chapters and the synopsis " + + "do not.", + }, record(d, "linetta_write_summary", d.writeSummary)) + + d.registerReviseTool(s) + d.registerBatchTools(s) +} + +func (d ToolDeps) writeScene(ctx context.Context, _ *mcp.CallToolRequest, in writeSceneInput) (*mcp.CallToolResult, writeSceneOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, writeSceneOutput{}, nil + } + if n.Kind != node.KindLeaf { + return toolErr("node %q is a container (%s), not a scene; only scenes hold body text", n.ID, n.Label), + writeSceneOutput{}, nil + } + if in.ExpectedContentVersion == nil { + return toolErr("expected_content_version is required; call linetta_read_scene first and pass the value it returns"), + writeSceneOutput{}, nil + } + expected := *in.ExpectedContentVersion + if expected < 0 { + return toolErr("expected_content_version must not be negative"), writeSceneOutput{}, nil + } + if count := len([]rune(in.Text)); count > maxSceneRunes { + return toolErr("scene text is %d characters; the limit is %d — split it across scenes", count, maxSceneRunes), + writeSceneOutput{}, nil + } + + // Snapshot before touching anything, so the previous text survives even if + // the write itself fails partway. + snapshotID := "" + if d.Snapshots != nil { + beforeDoc := "" + if n.ContentDoc != nil { + beforeDoc = *n.ContentDoc + } + snap, created, err := d.Snapshots.CreateIfChanged(ctx, n.ID, beforeDoc, snapshot.ReasonCompanionBefore, d.now()) + if err != nil { + return toolErr("could not snapshot the current text: %v", err), writeSceneOutput{}, nil + } + if created { + snapshotID = snap.ID + } + } + + doc, err := storyops.PlainTextToTiptapDoc(in.Text) + if err != nil { + return toolErr("could not convert the text: %v", err), writeSceneOutput{}, nil + } + if err := d.Nodes.UpdateContentIfVersion(ctx, n.ID, doc, expected, d.now()); err != nil { + if errors.Is(err, node.ErrContentConflict) { + return toolErr( + "the scene changed since you read it (you passed version %d). Call linetta_read_scene again, "+ + "merge your changes into the current text, and retry with the fresh content_version.", + expected), writeSceneOutput{}, nil + } + return toolErr("could not write the scene: %v", err), writeSceneOutput{}, nil + } + + after, err := d.Nodes.Get(ctx, n.ID) + if err != nil { + return toolErr("wrote the scene but could not read it back: %v", err), writeSceneOutput{}, nil + } + // The summarizer keeps the story brief honest; without this an agent's + // prose would never even get the short-scene plaintext summary. + if d.EnqueueSummary != nil { + d.EnqueueSummary(n.ID) + } + d.notifyChanged(n.ProjectID, "linetta_write_scene", []string{n.ID}, "") + + return nil, writeSceneOutput{ + NodeID: after.ID, + ContentVersion: after.ContentVersion, + WordCount: after.WordCount, + SnapshotID: snapshotID, + }, nil +} + +func (d ToolDeps) writeSummary(ctx context.Context, _ *mcp.CallToolRequest, in writeSummaryInput) (*mcp.CallToolResult, writeSummaryOutput, error) { + summary := strings.TrimSpace(in.Summary) + if summary == "" { + return toolErr("summary is required"), writeSummaryOutput{}, nil + } + nodeID := strings.TrimSpace(in.NodeID) + projectID := strings.TrimSpace(in.ProjectID) + if nodeID == "" && projectID == "" { + return toolErr("pass node_id to summarize a scene or chapter, or project_id to write the work's synopsis"), + writeSummaryOutput{}, nil + } + if nodeID != "" && projectID != "" { + return toolErr("pass either node_id or project_id, not both"), writeSummaryOutput{}, nil + } + + if nodeID == "" { + p, errResult := d.requireProject(ctx, projectID) + if errResult != nil { + return errResult, writeSummaryOutput{}, nil + } + if _, err := d.Projects.Update(ctx, d.now(), project.UpdateInput{ID: p.ID, Synopsis: &summary}); err != nil { + return toolErr("could not write the synopsis: %v", err), writeSummaryOutput{}, nil + } + d.notifyChanged(p.ID, "linetta_write_summary", nil, "") + return nil, writeSummaryOutput{Target: "synopsis", ProjectID: p.ID, Summary: summary}, nil + } + + n, errResult := d.requireNode(ctx, nodeID) + if errResult != nil { + return errResult, writeSummaryOutput{}, nil + } + target := "container" + forVersion := n.ContentVersion + if n.Kind == node.KindLeaf { + target = "scene" + // Scenes carry a version that tracks their own edits, so a summary + // written against stale text must be refused — otherwise the brief + // would report a fresh summary of prose that has since changed. + if in.ExpectedContentVersion == nil { + return toolErr("expected_content_version is required for a scene summary; call linetta_read_scene first"), + writeSummaryOutput{}, nil + } + if *in.ExpectedContentVersion != n.ContentVersion { + return toolErr( + "the scene changed since you read it (you passed version %d, current is %d). "+ + "Re-read the scene and summarize the current text.", + *in.ExpectedContentVersion, n.ContentVersion), writeSummaryOutput{}, nil + } + forVersion = *in.ExpectedContentVersion + } + + if err := d.Nodes.SetSummary(ctx, n.ID, summary, forVersion); err != nil { + return toolErr("could not write the summary: %v", err), writeSummaryOutput{}, nil + } + d.notifyChanged(n.ProjectID, "linetta_write_summary", []string{n.ID}, "") + return nil, writeSummaryOutput{Target: target, NodeID: n.ID, Summary: summary}, nil +}