diff --git a/docs/API/Agent.md b/docs/API/Agent.md index 29952524..a18013a2 100644 --- a/docs/API/Agent.md +++ b/docs/API/Agent.md @@ -1,8 +1,8 @@ # `window.__TAURITAVERN__.api.agent` — Agent API -本文档是 Agent Host ABI 草案。它描述前端/扩展可见的稳定入口,不等同于 Rust 内部 service/repository。 +本文档是 Agent Host ABI 当前参考。它描述前端/扩展可见的稳定入口,不等同于 Rust 内部 service/repository。 -状态:当前已实现 canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、workspace 读改工具循环与前端 dryRun adapter。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval/listRuns 等未来草案。 +状态:当前已实现 canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill tools、workspace 读改工具循环与前端 dryRun adapter。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval/listRuns 等未来设计。 `provider_state` 是 Rust 后端内部 continuation contract,不是 Host ABI。前端/扩展不应读写 `_tauritavern_provider_state`;需要诊断时通过 run events、`modelResponsePath` 与 LLM API log 观察。 @@ -136,7 +136,7 @@ Public Host ABI 可以允许调用方省略 `stableChatId`,但 `api.agent.star - 如果调用方希望 `worldinfo_read_activated` 返回非错误结果,必须在 prompt snapshot 中提供本次 run 的 `worldInfoActivation`。 - 当前拒绝已有 `tools`、`tool_choice`、`role: "tool"` 或已有 `tool_calls` 的外部 tool turns。 - 当前拒绝 `stream: true` 与 `autoCommit: true`。 -- `workspaceMode` / `resumeRunId` 当前只是后续阶段字段,不应作为当前行为依赖。 +- `workspaceMode` / `resumeRunId` 当前只是后续字段,不应作为当前行为依赖。 - 参数无效必须 reject,不静默回退 Legacy Generate。 ## 5. subscribe @@ -375,7 +375,7 @@ commit.cursor_integrity commit.save_failed ``` -## 15. Rust Command 草案 +## 15. Rust Command ```text start_agent_run(dto) @@ -388,7 +388,7 @@ finalize_agent_run_commit(dto) Command 层必须是薄封装。Agent loop 不写在 command 内。 -后续草案命令: +后续命令: ```text approve_agent_tool_call(dto) @@ -414,20 +414,22 @@ Agent Mode on: ## 17. 当前工具与手动验证 -当前开放八个内建工具: +当前开放十个内建工具: | Canonical name | Model-facing alias | 说明 | | --- | --- | --- | | `chat.search` | `chat_search` | 搜索当前 run 绑定的聊天。只有 `query` 必填;可选 `limit`、`role`、`start_message`、`end_message`、`scan_limit`。返回 message index、snippet 与 ref。 | | `chat.read_messages` | `chat_read_messages` | 按 0-based message index 读取当前聊天消息;每项可选 `start_char`、`max_chars` 读取长消息片段。 | | `worldinfo.read_activated` | `worldinfo_read_activated` | 读取本次 run 的最终激活世界书条目;模型可读文本只包含条目名、世界书名、条目内容。 | +| `skill.list` | `skill_list` | 列出已安装 Skill 的索引摘要;当前尚未接入 profile visible/deny policy。 | +| `skill.read` | `skill_read` | 读取已安装 Skill 内的 UTF-8 文本文件;默认 `SKILL.md`,支持 `path` 与 `max_chars`。 | | `workspace.list_files` | `workspace_list_files` | 列出模型可见 workspace 文件;`path` 省略、空字符串、`.`、`./` 表示 workspace root | | `workspace.read_file` | `workspace_read_file` | 读取 UTF-8 文本文件并返回行号;完整读取记录 read-state | | `workspace.write_file` | `workspace_write_file` | 写 UTF-8 文本到 manifest 可写 roots,当前为 `output/`、`scratch/`、`plan/`、`summaries/`、`persist/` | | `workspace.apply_patch` | `workspace_apply_patch` | 单文件 `old_string` / `new_string` 精确替换,要求已完整读取或由本 run 创建/修改 | | `workspace.finish` | `workspace_finish` | 结束工具循环,默认 final artifact 为 `output/main.md` | -当前不存在 `skill.list`、`skill.read`、MCP、shell 或 extension bridge 工具。 +当前不存在 MCP、shell 或 extension bridge 工具。 模型可修正的工具错误会作为 `is_error = true` tool result 回填下一轮。宿主级 IO、journal、checkpoint、序列化、取消和模型响应结构错误仍然让 run failed。 @@ -446,4 +448,4 @@ const run = await agent.startRunFromLegacyGenerate({ const stop = agent.subscribe(run.runId, event => console.log(event)); ``` -更完整的多阶段工具循环 smoke 见 `docs/CurrentState/AgentFramework.md`。 +更完整的多轮工具循环 smoke 见 `docs/CurrentState/AgentFramework.md`。 diff --git a/docs/API/README.md b/docs/API/README.md index 79ef421b..704eea2e 100644 --- a/docs/API/README.md +++ b/docs/API/README.md @@ -32,8 +32,11 @@ const api = host?.api; - 提供 Extension KV JSON + Blob 存储,支持多 table。 - `api.agent`(已落地 canonical model IR、provider_state continuation、上下文只读工具与 workspace 读改工具循环) - 面向 Agent Run / timeline / workspace / checkpoint / commit。 - - 当前提供启动 run、订阅/读取事件、取消、读取 workspace 文件、prepare/finalize/commit;模型侧内建 chat search/read、world info read 与 workspace list/read/write/patch/finish 工具由 Rust runtime 注册。 - - approval、listRuns、readDiff、rollback 仍是后续阶段;当前入口显式 throw。 + - 当前提供启动 run、订阅/读取事件、取消、读取 workspace 文件、prepare/finalize/commit;模型侧内建 chat search/read、world info read、skill list/read 与 workspace list/read/write/patch/finish 工具由 Rust runtime 注册。 + - approval、listRuns、readDiff、rollback 仍是后续工作;当前入口显式 throw。 +- `api.skill`(已落地) + - 面向本地 Agent Skill 管理。 + - 当前提供 list、previewImport、installImport、readFile、export/exportSkill;Agent run 内只能通过 `skill.list` / `skill.read` 工具消费已安装 Skill。 - `api.mcp`(规划中) - 面向 MCP server/tool/resource/prompt 的独立平台能力。 - Agent 可以消费 MCP,但 MCP 不依附 Agent Mode。 @@ -47,7 +50,8 @@ const api = host?.api; | [Dev.md](Dev.md) | `api.dev` 完整参考 | | [WorldInfo.md](WorldInfo.md) | `api.worldInfo` 完整参考 | | [ExtensionStore.md](Extension.md) | `api.extension.store` 完整参考 | -| [Agent.md](Agent.md) | `api.agent` 草案(Agent Run / workspace / timeline) | +| [Agent.md](Agent.md) | `api.agent` 当前参考(Agent Run / workspace / timeline) | +| [Skill.md](Skill.md) | `api.skill` 完整参考(Skill 管理、导入导出、读取) | | [MCP.md](MCP.md) | `api.mcp` 草案(MCP server/tool/resource/prompt) | | [Migration.md](Migration.md) | 从 SillyTavern 扩展迁移到 TauriTavern 的适配指南 | @@ -55,5 +59,6 @@ const api = host?.api; - API 类型定义见 `src/types.d.ts` - 宿主契约与稳定性边界见 `docs/FrontendHostContract.md` -- Agent 已落地当前 Host ABI、canonical model IR、provider_state continuation、上下文只读工具与 workspace 读改工具;真实边界见 `docs/API/Agent.md`、`docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md` +- Agent 已落地当前 Host ABI、canonical model IR、provider_state continuation、上下文只读工具、Skill tools 与 workspace 读改工具;真实边界见 `docs/API/Agent.md`、`docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md` +- Skill 管理 API 真实边界见 `docs/API/Skill.md` 与 `docs/Agent/Skill.md` - MCP 仍处于规划阶段;实现前请以 `docs/API/MCP.md` 与 `docs/FrontendHostContract.md` 的草案约束为准 diff --git a/docs/API/Skill.md b/docs/API/Skill.md new file mode 100644 index 00000000..ff47b6fb --- /dev/null +++ b/docs/API/Skill.md @@ -0,0 +1,102 @@ +# `window.__TAURITAVERN__.api.skill` — Skill API + +Skill API 用于管理本地 Agent Skill。它不是 Agent run API;Agent 只是 Skill 的消费者之一。 + +## 入口 + +```js +await (window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__); + +const skill = window.__TAURITAVERN__.api.skill; +``` + +## 方法 + +```ts +type TauriTavernSkillApi = { + list(): Promise; + previewImport(input: TauriTavernSkillImportInput): Promise; + installImport(request: { + input: TauriTavernSkillImportInput; + conflictStrategy?: 'skip' | 'replace'; + }): Promise; + readFile(options: { + name: string; + path: string; + maxChars?: number; + }): Promise; + export(options: { name: string }): Promise; + exportSkill(options: { name: string }): Promise; +}; +``` + +## 导入输入 + +```ts +type TauriTavernSkillImportInput = + | { + kind: 'inlineFiles'; + files: Array<{ + path: string; + encoding?: 'utf8' | 'utf-8' | 'base64'; + content: string; + mediaType?: string; + sizeBytes?: number; + sha256?: string; + }>; + source?: unknown; + } + | { + kind: 'directory'; + path: string; + source?: unknown; + } + | { + kind: 'archiveFile'; + path: string; + source?: unknown; + }; +``` + +`source` 用于记录来源引用。Preset / Character embedded import 会传入稳定来源 ID,以便删除 Preset / Character 时清理仅由该来源引用的 Skill。 + +## 冲突语义 + +`previewImport()` 会返回: + +```ts +type conflict.kind = 'new' | 'same' | 'different'; +``` + +- `new`:同名 Skill 不存在。 +- `same`:同名且内容 hash 相同。 +- `different`:同名但内容 hash 不同,安装时必须传 `conflictStrategy`。 + +`installImport()` 的结果: + +```ts +type action = 'installed' | 'replaced' | 'already_installed' | 'skipped'; +``` + +不同 hash 冲突没有显式 `skip` / `replace` 时会 reject,不会自动改名。 + +## 读取与导出 + +`readFile()`: + +- 只能读取已安装 Skill 内的 UTF-8 文本文件。 +- `path` 必须是 Skill 相对路径。 +- `maxChars` 省略时默认 20000,后端最大 80000。 +- 二进制文件、非法路径、symlink escape、缺失文件都会 reject。 + +`export()` / `exportSkill()`: + +- 返回 base64 编码的 `.ttskill` zip。 +- `.ttskill` 只包含 Skill 文件本身;不会写入会改变内容 hash 的导出诊断文件。 + +## 兼容边界 + +- Skill 管理不是 SillyTavern 上游 API。 +- Skill import/export 不触发上游 `GENERATION_*`、`TOOL_CALLS_*` 或 regex 事件。 +- Agent Mode off 时,Legacy Generate 不读取 Skill。 +- 模型不能通过 `api.skill` 安装或替换 Skill;当前 Skill 安装只由用户 UI / Host ABI 显式触发。 diff --git a/docs/Agent/McpSkill.md b/docs/Agent/McpSkill.md index f6d8b059..cd1ee777 100644 --- a/docs/Agent/McpSkill.md +++ b/docs/Agent/McpSkill.md @@ -6,9 +6,9 @@ - MCP 是独立平台集成能力,不是 Agent Runtime 本体。 - Skill 是渐进披露的文本/资源包,不是自动吞入 prompt 的大文件。 -- Agent 可以消费 MCP 和 Skill,但必须经过 ToolRegistry、ContextFrame、Policy 与 Journal。 +- Agent 可以消费 MCP 和 Skill,但必须经过 ToolRegistry、Policy 与 Journal;不得由 prompt 直接打开旁路。 -当前状态(2026-05-02):尚未实现 `window.__TAURITAVERN__.api.mcp`,也未把 MCP/Skill 接入 Agent tool registry。当前 Agent registry 已有内建工具:`chat.search`、`chat.read_messages`、`worldinfo.read_activated`、`workspace.list_files`、`workspace.read_file`、`workspace.write_file`、`workspace.apply_patch`、`workspace.finish`。 +当前状态(2026-05-02):尚未实现 `window.__TAURITAVERN__.api.mcp`,MCP 也未接入 Agent tool registry。Skill 已落地 `window.__TAURITAVERN__.api.skill`,并已通过 `skill.list` / `skill.read` 接入 Agent tool registry。当前 Skill 细节以 `docs/Agent/Skill.md` 与 `docs/API/Skill.md` 为准。 ## 1. MCP 边界 @@ -120,20 +120,17 @@ skills/ assets/ ``` -`SKILL.md` frontmatter 示例: +当前 `SKILL.md` frontmatter 最小示例: ```yaml --- name: long-form-romance description: 长篇恋爱剧情写作技巧 -allowed-tools: - - workspace.read_file - - workspace.apply_patch -when-to-use: 当需要扩写情感铺垫和细腻心理描写时 -context: lazy --- ``` +工具权限、MCP server、预算与可见性不由 Skill frontmatter 直接授予;这些能力必须进入 profile/policy/approval 体系。TauriTavern 专属 metadata 可放在可选 `agents/tauritavern.json`,schema 无效时应 fail-fast。 + ## 8. Skill 读取策略 Agent 默认只看到 skill 索引: @@ -141,17 +138,15 @@ Agent 默认只看到 skill 索引: ```text name description -when-to-use -allowed-tools ``` 读取全文必须通过: ```text -skill.read(name, section?, budget?) +skill.read(name, path?, max_chars?) ``` -`skill.read` 是 tool call,必须写 journal。Preset 可以声明某些 skill 自动进入 context,但仍受 budget 管控。 +`skill.read` 是 tool call,必须写 journal。当前默认读取 `SKILL.md`,只支持 UTF-8 文本文件;profile/preset/character 的 visible、deny 与 read budget policy 仍是后续工作。 ## 9. Skill 来源 @@ -167,38 +162,44 @@ Skill 可以来自: ## 10. Skill 与 Workspace -Skill 可以表现为 workspace resource: +当前 Skill 不挂进 Agent workspace 根,而是通过 `skill.read` 作为 tool result 回填模型。未来如果需要统一资源视图,可以把 Skill 表现为 read-only virtual resource: ```text skills//SKILL.md skills//examples/foo.md ``` -但它默认是 read-only virtual resource。Agent 不能修改原始 skill;如果需要摘录或改写,应写入 `scratch/` 或 `summaries/`。 +它必须保持 read-only。Agent 不能修改原始 skill;如果需要摘录或改写,应写入 `scratch/`、`summaries/` 或 `output/`。 ## 11. Agent Context -Skill 进入 prompt 的路径: +Skill 进入模型上下文的当前路径: ```text -skill.list / preset auto include +skill.list / skill.read -> SkillService - -> WorkspaceResource or PromptComponent::Skill - -> ContextAssemblyService - -> ModelRequest + -> Agent tool result + -> ModelRequest next turn ``` 不要把所有 Skill 全文塞进 system prompt。 -## 12. 后续最小实现 +## 12. 当前已落地与后续 -Skill 最小实现: +Skill 已落地: -- `SkillRepository` 列出本地 skill。 +- `SkillRepository` / `FileSkillRepository` / `SkillService`。 +- `api.skill` 管理入口。 +- `.ttskill` 导入导出。 +- Preset / Character embedded skill 导入确认与 source ref 清理。 - `skill.list`。 - `skill.read`。 -- profile/preset 控制可见 skill。 -- skill content 受 budget 限制。 - tool result 写 journal。 +Skill 后续只补: + +- profile/preset/character 控制可见 skill。 +- skill content 受 read budget 限制。 +- recommended skill 与 embedded skill 在 profile resolver 中合流。 + MCP 最小实现应晚于内置工具系统稳定之后。 diff --git a/docs/Agent/ProfilesAndPreset.md b/docs/Agent/ProfilesAndPreset.md index c33392dc..70b66317 100644 --- a/docs/Agent/ProfilesAndPreset.md +++ b/docs/Agent/ProfilesAndPreset.md @@ -411,7 +411,9 @@ profile_switch_denied "workspace.finish", "chat.search", "chat.read_messages", - "worldinfo.read_activated" + "worldinfo.read_activated", + "skill.list", + "skill.read" ], "deny": ["*"] }, @@ -423,4 +425,4 @@ profile_switch_denied } ``` -这个 profile 足以支撑当前上下文只读工具与 workspace 读改工具循环,同时给后续 Skill、profile routing 与 MCP 留出自然扩展点。 +这个 profile 足以支撑当前上下文只读工具、Skill 读取与 workspace 读改工具循环。后续 profile routing 应在此基础上收窄 Skill 可见性、deny policy 与 read budget,而不是新增平行工具入口。 diff --git a/docs/Agent/README.md b/docs/Agent/README.md index 3a6e1b10..60cc1350 100644 --- a/docs/Agent/README.md +++ b/docs/Agent/README.md @@ -16,10 +16,11 @@ | [ToolSystem.md](ToolSystem.md) | ToolSpec、ToolResult、Tool Registry、Policy、审批与 Legacy ToolManager 边界 | | [LlmGateway.md](LlmGateway.md) | provider-agnostic LLM gateway 与现有 `ChatCompletionService` 的复用边界 | | [McpSkill.md](McpSkill.md) | MCP 独立集成、Skill 渐进披露、安全边界 | +| [Skill.md](Skill.md) | 当前 Skill 格式、存储、导入导出、Agent tool 与安全边界 | | [TestingStrategy.md](TestingStrategy.md) | Domain/Application/Frontend/Security/Performance 测试矩阵 | ## 进度跟踪 实时开发进度不写在本目录;请更新 `docs/CurrentState/AgentFramework.md`。 -截至 2026-05-02,canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、workspace 读改工具循环与前端 dryRun adapter 已落地。当前真实能力边界以 `docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md` 为准;本目录中的 profile、MCP、timeline、diff/rollback 等内容仍是后续设计。 +截至 2026-05-02,canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill 管理与读取、workspace 读改工具循环、前端 dryRun adapter 已落地。当前真实能力边界以 `docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md` 为准;本目录中的 profile、MCP、timeline、diff/rollback 等内容仍是后续设计。 diff --git a/docs/Agent/Skill.md b/docs/Agent/Skill.md new file mode 100644 index 00000000..9fd7833b --- /dev/null +++ b/docs/Agent/Skill.md @@ -0,0 +1,131 @@ +# TauriTavern Agent Skill + +本文档记录当前已落地的 Agent Skill 能力。长期开发以本文、`docs/CurrentState/AgentFramework.md`、`docs/Agent/ToolSystem.md` 与 `docs/API/Skill.md` 为准。 + +## 定义 + +Skill 是 Agent 按需读取的本地知识包,不是可执行插件,也不是默认注入 prompt 的大段文本。 + +一个 Skill 是一个目录: + +```text +my-skill/ + SKILL.md + references/ + examples/ + assets/ + scripts/ + agents/tauritavern.json +``` + +当前要求: + +- `SKILL.md` 必须存在,并以 YAML frontmatter 开头。 +- frontmatter 必须包含 `name` 与 `description`。 +- `name` 使用小写 ASCII、数字、`-`、`_`,最长 128。 +- `agents/tauritavern.json` 可选;一旦存在,schema 无效就 fail-fast。 +- `scripts/` 会随 Skill 导入、导出,并在预览中提示风险;当前不会执行。 + +## 当前实现 + +已落地: + +- Domain:`SkillIndexEntry`、`SkillImportInput`、`SkillImportPreview`、`SkillInstallRequest`、`SkillReadResult` 等模型。 +- Repository:`SkillRepository` trait 与文件实现 `FileSkillRepository`。 +- Service:`SkillService`。 +- Host ABI:`window.__TAURITAVERN__.api.skill`。 +- Agent tools:`skill.list` / `skill.read`,模型侧 alias 为 `skill_list` / `skill_read`。 +- Preset / Character embedded skill 扫描与导入确认 UI。 +- Preset / Character 删除时,删除仅由该来源引用的已安装 Skill。 + +本地存储: + +```text +data_root/_tauritavern/skills/ + installed// + index/skills.json + .staging/ +``` + +`FileSkillRepository` 只负责文件系统、zip、staging、索引与原子安装;运行时可见性、profile policy、journal 由 Agent tool/runtime 层负责。 + +## 导入导出 + +支持输入: + +- `inlineFiles`:Preset / 角色卡嵌入 Skill 的文件列表。 +- `directory`:本地 Skill 目录。 +- `archiveFile`:`.ttskill` zip 包。 + +导入流程: + +```text +materialize input into .staging + -> validate package + -> compute files/hash/warnings + -> compare installed index + -> install / replace / skip + -> update index +``` + +冲突策略: + +- 同名不存在:安装。 +- 同名且 hash 相同:视为已安装,并合并 source refs。 +- 同名但 hash 不同:必须显式 `skip` 或 `replace`。 + +导出: + +- `api.skill.export({ name })` 返回 `{ fileName, contentBase64, sha256 }`。 +- `.ttskill` 内只包含 Skill 文件本身,不写入会改变内容 hash 的诊断 sidecar。 + +## Agent 读取 + +`skill.list`: + +- 只读。 +- 返回已安装 Skill 的索引摘要。 +- 当前尚未接入 profile visible/deny policy,因此列出全局已安装 Skill。 + +`skill.read`: + +- 只读。 +- 参数:`name`、可选 `path`、可选 `max_chars`。 +- `path` 默认 `SKILL.md`。 +- 只能读取 UTF-8 文本文件;二进制文件返回可恢复 tool error。 +- `max_chars` 默认 20000,最大 80000。 +- 结果写入 Agent journal / tool result,并作为后续模型上下文的一部分回填。 + +Skill 文件对 Agent 是只读 virtual resource。Agent 不能修改 installed Skill;需要摘录、总结或改写时写入 workspace 的 `scratch/`、`summaries/` 或 `output/`。 + +## 安全边界 + +导入、安装、导出与 repository 层必须 fail-fast: + +- `SKILL.md` 缺失、frontmatter 无效、`name` / `description` 缺失。 +- path traversal、绝对路径、Windows drive prefix、NUL、symlink escape。 +- zip entry 超限、压缩比超限、总大小超限。 +- `agents/tauritavern.json` schema 无效。 +- 同名冲突但没有用户决策。 +- index 缺失但 installed 目录已存在。 + +Agent tool 层的模型可修正读取错误,例如缺失文件、二进制文件、非法 path 或超出 read budget,应返回 recoverable tool error;repository 内部 IO、index 损坏和安装一致性错误仍 fail-fast。 + +当前限制: + +- 不执行 Skill 自带脚本。 +- 不让 Skill 自动安装 MCP server。 +- 不让 Skill 授予工具权限。 +- 不支持 marketplace、自动更新、多版本并存或依赖解析。 +- 不支持模型在 run 内安装/替换 Skill。 + +## 后续开发 + +下一步只做必要能力: + +- profile/preset/character 的最小 visible / deny / read budget policy。 +- `skill.list` 按 visible policy 收窄结果。 +- `skill.read` 按 policy 与 budget 拒绝不可见或过量读取。 +- 明确 recommended skill 与 embedded skill 在 profile resolver 中的合流规则。 + +不要把 Skill 扩展成脚本执行、权限授予或 MCP 配置入口;这些能力必须走独立的 tool/policy/approval 体系。 diff --git a/docs/Agent/SkillImportExport.md b/docs/Agent/SkillImportExport.md new file mode 100644 index 00000000..089e924e --- /dev/null +++ b/docs/Agent/SkillImportExport.md @@ -0,0 +1,1127 @@ +# TauriTavern Agent Skill Import and Export + +本文档记录 TauriTavern 中 Skill 导入导出格式、存储布局,以及 Preset / 角色卡嵌入 Skill 的推荐格式。它是后续实现的设计参考,不表示当前能力已经落地。 + +当前状态(2026-05-02): + +- Agent runtime 已落地 workspace、journal、checkpoint、commit bridge、canonical model IR 与内建 workspace/chat/worldinfo 工具。 +- `skill.list` / `skill.read` 尚未实现。 +- `SkillRepository` 尚未实现。 +- Preset / 角色卡内嵌 Skill 的导入确认 UI 尚未实现。 + +相关契约: + +- `docs/AgentArchitecture.md` +- `docs/AgentContract.md` +- `docs/Agent/McpSkill.md` +- `docs/Agent/ProfilesAndPreset.md` +- `docs/Agent/Workspace.md` +- `docs/CurrentState/AgentFramework.md` + +## 1. 问题定义 + +TauriTavern 的 Agent Skill 不是“另一段 prompt”,也不是“可执行插件”。它的第一性定义应该是: + +> 一个可被 Agent 按需学习和引用的本地知识包。它教会 Agent 如何以可重复的方式完成某类任务,并可携带示例、模板、参考文件和少量宿主可理解的元数据。 + +它要解决的问题是: + +1. 写作、角色扮演、剧情规划、风格控制等领域存在大量可复用方法论。 +2. 这些方法论不应该每次都被用户手工复制到 system prompt。 +3. 它们也不应该默认全文塞进上下文,造成 token 浪费和 prompt 污染。 +4. Skill 可能来自 Preset 作者、角色卡作者、扩展作者、Agent Profile 作者、用户自己和未来 marketplace,必须具备交换格式。 +5. Skill 可能只有一个 `SKILL.md`,也可能携带多级目录、示例、资源和模板。 +6. 导入 Skill 需要用户明确知情,尤其是来自角色卡、Preset 或扩展的外部内容。 + +因此,Skill 格式必须同时满足: + +- 简洁:普通作者可以手写。 +- 灵活:支持目录和多文件。 +- 可审计:导入前可以列出名称、描述、文件、大小、来源和风险。 +- 可迁移:可随 TauriTavern 数据目录备份/恢复。 +- 可兼容:尽量靠近已有 Agent Skill 生态,而不是发明孤岛格式。 +- 可治理:不能绕过 Agent Profile、Tool Policy、Visible Resource Policy 或用户 deny。 + +## 2. 非目标 + +第一期不解决这些问题: + +- 不执行 Skill 自带脚本。 +- 不让 Skill 自动安装 MCP server。 +- 不让 Skill 直接授予工具权限。 +- 不实现远端 URL 自动拉取和自动更新。 +- 不设计完整 marketplace、签名、信任链和版本解析。 +- 不把 Skill 做成 SillyTavern 兼容的上游格式。 +- 不让 Legacy Generate 在 Agent Mode off 时读取或使用 Skill。 + +这些能力都可以未来扩展,但第一期必须先把格式、存储和导入语义做干净。 + +## 3. 外部生态对齐 + +OpenAI Codex Skills、Agent Skills 规范和 Claude Agent Skills 在核心模型上高度一致: + +- 一个 Skill 是一个目录。 +- 根目录必须有 `SKILL.md`。 +- `SKILL.md` 使用 YAML frontmatter 提供 `name` 与 `description` 等索引信息。 +- 除 `SKILL.md` 外,可以有额外文件、资源、脚本或参考资料。 +- Agent 默认不应把所有 Skill 全文读入上下文,而应先看到索引,再按需读取。 + +TauriTavern 应采用这个共同子集作为基础,而不是把 Skill 设计成纯 JSON。 + +推荐原则: + +- `SKILL.md` 是跨生态核心。 +- TauriTavern 专属元数据放 sidecar 文件,不污染通用 frontmatter。 +- 导入导出保持普通目录结构,可以被人类直接查看和编辑。 +- `.ttskill` 只是目录包 zip,不是 opaque binary。 + +## 4. Skill 本体格式 + +### 4.1 推荐目录结构 + +最小 Skill: + +```text +long-form-romance/ + SKILL.md +``` + +带参考文件的 Skill: + +```text +long-form-romance/ + SKILL.md + references/ + pacing.md + emotional-beats.md + examples/ + short-scene.md + revision-example.md + assets/ + outline-template.json + agents/ + tauritavern.json +``` + +目录语义: + +| 路径 | 语义 | +| --- | --- | +| `SKILL.md` | 必需。Skill 的主说明与索引 frontmatter | +| `references/` | 可选。长参考、规则、写作指南、风格表 | +| `examples/` | 可选。输入输出示例、修改前后对照 | +| `assets/` | 可选。模板、图片、JSON 资源 | +| `scripts/` | 可选但第一期不执行。未来可由工具策略显式控制 | +| `agents/tauritavern.json` | 可选。TauriTavern 专属元数据 | + +第一期实现中,`scripts/` 可以被导入和导出,但 runtime 不执行,也不暴露为工具。导入 UI 应显示“包含脚本文件;当前版本仅存储,不执行”。 + +### 4.2 `SKILL.md` frontmatter + +推荐遵循开放 Agent Skills 的核心字段: + +```yaml +--- +name: long-form-romance +description: Use when writing long-form romantic roleplay scenes that need emotional pacing, continuity, and subtle internal monologue. +license: CC-BY-4.0 +metadata: + author: example-author + version: "1.0.0" + tags: + - writing + - romance +--- +``` + +必需字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `name` | string | Skill 稳定 ID。建议 ASCII kebab-case | +| `description` | string | 何时使用该 Skill。必须足够具体,供 `skill.list` 暴露 | + +可选字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `license` | string | 许可证声明 | +| `metadata.author` | string | 作者 | +| `metadata.version` | string | 作者声明的版本 | +| `metadata.tags` | string[] | 标签 | + +不建议放在 `SKILL.md` 顶层的字段: + +| 字段 | 原因 | +| --- | --- | +| `allowed-tools` | 容易被误解为授权。TauriTavern 中工具权限必须由 profile/plan/user/platform policy resolve | +| `mcpServers` | Skill/Preset/角色卡不得直接写 MCP stdio command | +| `autoRun` | Skill 不应自动执行行为 | +| `systemPromptPriority` | ContextFrame 和 profile policy 才决定 prompt 位置与预算 | + +### 4.3 TauriTavern sidecar + +TauriTavern 专属信息放在: + +```text +agents/tauritavern.json +``` + +建议 schema: + +```json +{ + "version": 1, + "displayName": "Long-form Romance", + "sourceKind": "user", + "allowImplicitInvocation": true, + "recommendedTools": [ + "skill.read", + "workspace.read_file", + "workspace.apply_patch" + ], + "recommendedContext": { + "defaultBudgetTokens": 2000, + "preferredMode": "lazy" + }, + "tags": ["writing", "romance"], + "notes": "recommendedTools is advisory; runtime policy still decides." +} +``` + +字段语义: + +| 字段 | 类型 | 语义 | +| --- | --- | --- | +| `version` | number | sidecar schema 版本。第一版为 `1` | +| `displayName` | string | UI 展示名;不参与唯一性 | +| `sourceKind` | string | `user` / `preset` / `character` / `extension` / `marketplace` / `unknown` | +| `allowImplicitInvocation` | boolean | 是否允许 profile/router 在匹配场景时自动显示给 Agent | +| `recommendedTools` | string[] | 作者建议工具;不授予权限 | +| `recommendedContext.defaultBudgetTokens` | number | 建议读取预算 | +| `recommendedContext.preferredMode` | string | `lazy` / `index_only` / `auto_include`;最终仍由 policy 裁决 | +| `tags` | string[] | UI 标签 | +| `notes` | string | 给用户或开发者看的说明 | + +`agents/tauritavern.json` 无效时,导入应 fail-fast,而不是静默忽略。原因是该文件一旦存在,就代表作者希望表达 TauriTavern 专属语义;静默忽略会让用户误以为策略生效。 + +## 5. Skill ID 与命名 + +Skill 的稳定身份以 `SKILL.md` frontmatter 的 `name` 为准。 + +建议约束: + +- 非空。 +- 长度不超过 128。 +- 建议使用 ASCII 小写、数字、`-`、`_`。 +- 第一版可以对非 ASCII name 直接拒绝,或允许但落盘目录使用 hash。为了简单和可交换,推荐第一版要求 ASCII。 +- 不允许 `/`、`\`、`.`、`..`、NUL、Windows drive prefix。 + +物理目录名建议为: + +```text +/ +``` + +如果未来允许非 ASCII 或更宽松 name,可以改为: + +```text +--/ +``` + +但第一期不建议上来就做复杂映射。Skill 引用需要稳定,自动改名会增加维护成本。 + +## 6. 独立导入导出格式 + +### 6.1 支持的输入 + +第一期建议支持: + +```text +my-skill/ + SKILL.md +``` + +以及: + +```text +my-skill.ttskill +``` + +`.ttskill` 本质是 zip。它不是新的二进制协议。 + +zip 内部允许两种 layout: + +```text +SKILL.md +references/... +assets/... +``` + +或: + +```text +my-skill/ + SKILL.md + references/... + assets/... +``` + +扫描规则: + +1. 解压前先扫描 entry。 +2. 忽略 `__MACOSX` 与资源 fork。 +3. 找到所有候选 `SKILL.md`。 +4. 若没有候选,失败。 +5. 若多个同级候选导致歧义,失败。 +6. 选择唯一 skill root 后,其他识别到的独立 skill root 不自动导入,除非未来支持 multi-skill bundle。 + +第一期不建议支持一个 `.ttskill` 包含多个 skill。多 Skill bundle 会立即引入批量冲突、部分成功、回滚和 UI 复杂度,ROI 不高。 + +### 6.2 导入校验 + +导入必须在 staging 中完成: + +```text +data_root/_tauritavern/skills/.staging// +``` + +校验项: + +- `SKILL.md` 必须存在。 +- frontmatter 必须能解析。 +- `name` 与 `description` 必须存在且类型正确。 +- `name` 必须通过 Skill ID 校验。 +- 所有 entry path 必须是相对路径。 +- 禁止 `..`、绝对路径、Windows drive prefix、NUL。 +- 禁止 symlink。 +- 限制 entry 数量。 +- 限制单文件大小。 +- 限制总解压大小。 +- 限制压缩比,防 zip bomb。 +- 可选拒绝隐藏文件,或至少拒绝 `.git/`、`.ssh/`、`.env`。 + +推荐第一期限制: + +```text +max files: 1000 +max single file: 16 MiB +max total uncompressed: 256 MiB +max SKILL.md: 1 MiB +``` + +这些值比完整数据归档小得多,因为单个 Skill 不应承担数据备份职责。 + +### 6.3 冲突策略 + +以 `name` 判断冲突。 + +推荐策略: + +| 情况 | 行为 | +| --- | --- | +| 同名不存在 | 安装 | +| 同名存在且内容 hash 相同 | 视为已安装,可提示无需导入 | +| 同名存在且内容 hash 不同 | 弹窗让用户选择 `跳过` / `替换` | + +不建议自动改名为 `name-2`。原因: + +- `name` 是 Agent 可见引用。 +- Preset / 角色卡可能通过 `name` 推荐 Skill。 +- 自动改名会破坏作者意图,且难以解释为什么 Agent 找不到原名。 + +第一期不做多版本并存。多版本并存需要 resolution policy、profile pin、UI 选择和清理策略,复杂度明显高于收益。 + +### 6.4 原子写入 + +安装流程: + +```text +scan archive + -> extract to staging + -> validate staging + -> compute manifest/hash + -> conflict decision + -> rename existing to backup? 或直接替换 + -> atomic rename staging to installed/ + -> write index +``` + +若目标文件系统不支持跨目录 rename,应复用现有 temp+rename/copy fallback 基础设施,而不是写第二套复制逻辑。 + +失败必须清理 staging,并返回明确错误。 + +### 6.5 导出格式 + +导出单个 Skill: + +```text +long-form-romance.ttskill +``` + +内部推荐: + +```text +SKILL.md +references/ +assets/ +agents/tauritavern.json +``` + +不要把外层目录名作为语义。接收方应以 `SKILL.md` 的 `name` 为准。 + +导出时可以附带一个非必需清单: + +```text +agents/tauritavern-export.json +``` + +示例: + +```json +{ + "version": 1, + "exportedAt": "2026-05-02T00:00:00Z", + "exportedBy": "TauriTavern", + "format": "ttskill", + "skillName": "long-form-romance" +} +``` + +该文件只用于诊断,不参与运行时语义。 + +## 7. 本地存储布局 + +推荐存储在 data root 下: + +```text +data_root/ + _tauritavern/ + skills/ + installed/ + long-form-romance/ + SKILL.md + references/ + assets/ + agents/ + tauritavern.json + index/ + skills.json + .staging/ +``` + +理由: + +- `_tauritavern` 是 TauriTavern 自有运行和扩展数据空间。 +- 不污染 SillyTavern 的 `default-user` 布局。 +- 现有数据归档导出整个 `data_root`,会自然携带 `_tauritavern/skills`。 +- 现有导入 layout 已把 `_tauritavern` 识别为 data-root 特征。 +- 与 `_tauritavern/agent-workspaces`、`extension-store`、`prompt-cache` 的职责一致。 + +不建议放在: + +| 位置 | 问题 | +| --- | --- | +| `default-user/skills` | 容易误认为上游 SillyTavern 用户数据;未来上游同步可能冲突 | +| `default-user/extensions` | Skill 不是前端扩展 | +| `agent-workspaces/chats/.../resources/skills` | 这是对话级资源,不适合作为全局安装库 | +| app bundle 内 | 用户导入内容不应写应用资源目录 | + +## 8. Repository 与 Domain 边界 + +建议新增 domain trait: + +```rust +#[async_trait] +pub trait SkillRepository: Send + Sync { + async fn list_skills(&self) -> Result, DomainError>; + async fn get_skill(&self, name: &str) -> Result; + async fn read_skill_file(&self, name: &str, path: &str) -> Result; + async fn preview_import(&self, input: SkillImportInput) -> Result; + async fn install_import(&self, decision: SkillImportDecision) -> Result; + async fn export_skill(&self, name: &str, output: SkillExportTarget) -> Result; +} +``` + +Domain model 只表达逻辑语义: + +```rust +SkillIndexEntry { + name, + description, + display_name, + source_kind, + tags, + installed_hash, +} + +SkillPackage { + name, + description, + root_ref, + files, + tauritavern_metadata, +} + +SkillFileRef { + path, + kind, + media_type, + size_bytes, + hash, +} +``` + +Infrastructure 实现文件系统、zip、staging 和原子替换。 + +Presentation command 只做: + +- DTO 反序列化。 +- 获取 service。 +- 调用 service。 +- 错误映射。 + +不要把 zip 解压或目录扫描写进 Tauri command。 + +## 9. Agent Runtime 消费方式 + +Skill 进入 Agent 的路径应保持与 `docs/Agent/McpSkill.md` 一致: + +```text +skill.list / profile visible skill policy + -> SkillService + -> PromptComponent::SkillIndex 或 WorkspaceResource + -> ContextFrame + -> AgentModelRequest +``` + +读取全文: + +```text +model calls skill.read + -> ToolRegistry policy check + -> SkillService.read + -> journal: tool_call_requested / tool_call_completed + -> ToolResult with content or resource refs +``` + +重要约束: + +- Agent 默认只看到 Skill 索引,不看到所有全文。 +- `skill.read` 必须写 journal。 +- `skill.read` 受 budget 限制。 +- `skill.read` 只能读 visible skill。 +- Skill 文件作为 read-only virtual resource 暴露。 +- Agent 不能修改已安装 Skill。 +- 如果需要摘录、摘要、改写,必须写 `scratch/`、`summaries/` 或 `output/`。 + +模型可见路径示例: + +```text +skills/long-form-romance/SKILL.md +skills/long-form-romance/references/pacing.md +``` + +这些是逻辑路径,不是系统路径。读取仍经 SkillRepository / WorkspaceResource 层,不允许工具直接拼文件系统路径。 + +## 10. Preset 嵌入格式 + +上游 SillyTavern preset 是宽松 JSON。TauriTavern 当前 Rust 后端也把 preset `data` 作为 `serde_json::Value` 保存,并按上游目录映射落盘。因此,Preset 内嵌 Skill 应放在扩展字段,而不是改变 preset 主体 schema。 + +推荐路径: + +```text +preset.extensions.tauritavern.skills +``` + +示例: + +```json +{ + "name": "Narrative Writer", + "temperature": 0.8, + "extensions": { + "regex_scripts": [], + "tauritavern": { + "skills": { + "version": 1, + "items": [ + { + "bundleFormat": "inline-files-v1", + "source": { + "kind": "preset", + "label": "Narrative Writer" + }, + "files": [ + { + "path": "SKILL.md", + "encoding": "utf8", + "content": "---\nname: long-form-romance\ndescription: Use when writing long romantic scenes with emotional continuity.\n---\n\n# Long-form Romance\n\n..." + }, + { + "path": "references/pacing.md", + "encoding": "utf8", + "content": "# Pacing\n\n..." + } + ] + } + ], + "recommended": [ + { + "name": "long-form-romance", + "autoVisible": true + } + ] + } + } + } +} +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `version` | 嵌入 schema 版本。第一版为 `1` | +| `items` | 内嵌 Skill 包列表 | +| `items[].bundleFormat` | 第一版只支持 `inline-files-v1` | +| `items[].source` | 供导入 UI 展示来源 | +| `items[].files` | 文件列表 | +| `recommended` | Preset 对已安装 Skill 的推荐引用,不代表已安装 | + +`inline-files-v1` 文件字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `path` | string | Skill 包内相对路径 | +| `encoding` | string | `utf8` 或 `base64` | +| `content` | string | 文件内容 | +| `mediaType` | string | 可选,主要用于二进制资源 | +| `sizeBytes` | number | 可选,用于预览和校验 | +| `sha256` | string | 可选,用于完整性提示 | + +为什么不用 base64 zip: + +- JSON 中 inline files 更容易审计和 diff。 +- 用户导入前可以逐文件预览。 +- 前端和后端都能直接做路径级校验。 +- 写作类 Skill 主要是文本,base64 zip 会降低可维护性。 + +什么时候可以用 base64: + +- 单个二进制资源,例如图片。 +- 未来为了兼容外部 `.ttskill` 直接嵌入时,可新增 `bundleFormat: "base64-zip-v1"`,但不作为第一期默认。 + +## 11. 角色卡嵌入格式 + +角色卡应优先使用 Tavern Card V2 的 `data.extensions`。 + +推荐路径: + +```text +character.data.extensions.tauritavern.skills +``` + +示例: + +```json +{ + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "Example Character", + "description": "...", + "extensions": { + "regex_scripts": [], + "tauritavern": { + "skills": { + "version": 1, + "items": [ + { + "bundleFormat": "inline-files-v1", + "source": { + "kind": "character", + "label": "Example Character" + }, + "files": [ + { + "path": "SKILL.md", + "encoding": "utf8", + "content": "---\nname: example-character-voice\ndescription: Use when preserving Example Character's speech cadence and recurring motifs.\n---\n\n# Character Voice\n\n..." + } + ] + } + ], + "recommended": [ + { + "name": "example-character-voice", + "autoVisible": true + } + ] + } + } + } + } +} +``` + +原因: + +- 上游角色卡生态已经把 `data.extensions` 作为扩展兼容面。 +- 当前 TauriTavern Rust `CharacterExtensions` 使用 flatten 保留未知扩展字段。 +- 导入 normalize 过程中 top-level unknown 字段更容易丢失。 +- `regex_scripts` 也在 `data.extensions` 下,这是已有兼容模式。 + +PNG 写入仍遵守上游行为: + +- `tEXt` chunk keyword `chara` 保存 base64 UTF-8 JSON。 +- 可同时写 `ccv3`。 +- 读取优先 `ccv3`,再 fallback `chara`。 + +Skill 嵌入不应该放在: + +| 路径 | 问题 | +| --- | --- | +| top-level `skills` | 可能被导入转换丢弃,也不是 Tavern Card 兼容面 | +| `data.skills` | 不属于上游约定,且 Rust model 不保证保留 | +| `data.character_book` | 世界书语义,不应用来塞 Skill | +| `creator_notes` | 会污染角色作者说明,也无法携带多文件 | + +## 12. 嵌入 Skill 与推荐 Skill 的区别 + +必须区分两种概念: + +```text +embedded skill + 包含完整文件内容,可以导入安装。 + +recommended skill + 只引用 name,表示作者建议使用;不包含内容。 +``` + +Preset / 角色卡可以同时包含: + +```json +{ + "items": [ + { "bundleFormat": "inline-files-v1", "files": [] } + ], + "recommended": [ + { "name": "long-form-romance", "autoVisible": true } + ] +} +``` + +导入行为: + +- `items` 触发“是否导入 Skill”弹窗。 +- `recommended` 不安装任何东西,只在已安装时可作为 profile/context policy 的候选。 +- 未安装的 recommended skill 可以在 UI 中显示为“推荐但未安装”。 + +这样可以避免角色卡作者用一个轻量引用强迫用户安装未知内容。 + +## 13. Preset / 角色卡导入确认流程 + +目标体验类似 embedded regex: + +> 用户导入 Preset / 角色卡后,若发现内嵌 Skill,弹窗询问是否导入。 + +但 Skill 与 regex 的差异是: + +- regex 是当前对象内的脚本启用许可。 +- Skill 是安装到 TauriTavern 全局 Skill 库的资源包。 +- Skill 可能包含多文件、二进制资源、脚本文件和潜在大内容。 + +推荐流程: + +```text +Preset / character imported + -> scan extensions.tauritavern.skills + -> build import preview + -> show popup + -> user selects items and conflict decisions + -> backend validates staging + -> install selected skills + -> show result +``` + +弹窗应展示: + +- 来源:Preset 名称 / 角色名 / 文件名。 +- Skill `name`。 +- `description`。 +- 文件数。 +- 总大小。 +- 是否包含 `scripts/`。 +- 是否包含二进制资源。 +- 冲突状态:新安装 / 已安装相同 / 同名不同。 +- 目标安装位置。 + +弹窗按钮建议: + +- `导入选中 Skill` +- `暂不导入` +- `查看详情` + +冲突项需要单独选择: + +- `跳过` +- `替换已安装版本` + +不建议默认“全部替换”。 + +### 13.1 一次性提醒 key + +可以借鉴 regex: + +```text +AlertSkillPreset___ +AlertSkillCharacter__ +``` + +加入 `` 是为了避免同一个 Preset / 角色卡更新了内嵌 Skill 后,用户永远不再看到提醒。 + +如果用户拒绝导入: + +- 不删除嵌入数据。 +- 记录提醒 key。 +- 未来用户可在 Skill 管理界面重新查看“来自当前 Preset/角色卡的可导入 Skill”。 + +### 13.2 事件语义 + +不要伪装成 SillyTavern 上游事件。 + +可以在 TauriTavern 前端 bridge 内监听现有事件: + +- `PRESET_CHANGED` +- `CHAT_CHANGED` +- Preset import 成功路径 +- Character import 成功路径 + +然后启动 TauriTavern 自有 Skill preview/import 流程。 + +Agent run/timeline 事件仍属于 `api.agent`,Skill 安装不是 Agent run event,除非它发生在某次 Agent run 内的受控工具调用中。第一期建议 Skill 安装只由用户 UI 触发,不由模型触发。 + +## 14. Profile / Preset / Character 的 Skill Policy + +Preset 和角色卡可以声明: + +```json +{ + "recommended": [ + { + "name": "long-form-romance", + "autoVisible": true, + "priority": 50 + } + ] +} +``` + +但这不等于: + +- 自动导入。 +- 自动全文进入 prompt。 +- 自动授予工具。 +- 自动覆盖用户设置。 + +最终 runtime resolution 应遵守: + +```text +Built-in defaults + < Preset agent schema + < Character agent schema + < User profile override + < Per-run override +``` + +以及: + +- user deny 最高。 +- deny 优先 allow。 +- 未显式 visible 的 skill 默认不可见。 +- required skill 缺失时,如果 profile 标记为 required,应 fail-fast;如果只是 recommended,应记录 warning 或 UI 提示。 + +建议在 Agent Profile 中表达: + +```json +{ + "skills": { + "visible": ["long-form-romance", "example-character-voice"], + "autoInclude": [], + "deny": ["unsafe-skill"], + "readBudgetTokens": 3000 + } +} +``` + +第一期可以只支持: + +- `visible` +- `deny` +- `readBudgetTokens` + +不要过早实现复杂 routing。 + +## 15. Security 与 Fail-fast + +Skill 是本地知识包,但来源可能不可信。导入时应按“安装外部内容”处理。 + +必须 fail-fast: + +- `SKILL.md` 缺失。 +- frontmatter 无法解析。 +- `name` / `description` 缺失。 +- path traversal。 +- symlink。 +- zip entry 超限。 +- 解压后发现文件逃逸 staging。 +- `agents/tauritavern.json` 存在但 schema 无效。 +- 同名冲突但没有用户 decision。 +- 原子安装失败。 + +可作为 warning: + +- 包含 `scripts/`,但当前不执行。 +- 包含未知目录。 +- 缺少 license。 +- recommended skill 未安装。 +- sidecar 中存在当前版本不支持的字段。 + +禁止: + +- Skill 自动执行脚本。 +- Skill 自动注册 MCP command。 +- Skill 自动扩大工具权限。 +- Skill 自动读取用户 secrets。 +- Skill 被模型修改后直接覆盖安装库。 +- 导入失败时“跳过坏文件继续安装”。 + +## 16. 性能与索引 + +`skill.list` 应非常轻量。不要每次运行扫描和读取所有文件全文。 + +建议维护: + +```text +data_root/_tauritavern/skills/index/skills.json +``` + +示例: + +```json +{ + "version": 1, + "updatedAt": "2026-05-02T00:00:00Z", + "skills": [ + { + "name": "long-form-romance", + "description": "Use when writing long romantic scenes with emotional continuity.", + "displayName": "Long-form Romance", + "root": "installed/long-form-romance", + "hash": "sha256:...", + "sourceKind": "user", + "tags": ["writing", "romance"], + "fileCount": 4, + "totalSizeBytes": 12000 + } + ] +} +``` + +索引损坏时建议 fail-fast 于 Skill 管理操作,但 Agent run 可以返回明确错误“Skill index invalid”。不要静默重建后继续,因为重建可能掩盖磁盘损坏或部分安装失败。 + +可以提供用户显式“重建 Skill 索引”操作。 + +## 17. 测试策略 + +Domain tests: + +- `SKILL.md` frontmatter 解析。 +- name 校验。 +- sidecar schema 解析。 +- embedded schema 解析。 +- recommended vs embedded 区分。 + +Infrastructure tests: + +- `.ttskill` root layout。 +- `.ttskill` single-folder layout。 +- 无 `SKILL.md` 失败。 +- 多 `SKILL.md` 歧义失败。 +- path traversal 失败。 +- symlink 失败。 +- zip bomb/entry limit 失败。 +- 同名同 hash。 +- 同名不同 hash 要求 decision。 +- staging cleanup。 +- atomic replace。 + +Application tests: + +- preview import 不写 installed。 +- install decision 写 installed。 +- `skill.list` 只返回索引。 +- `skill.read` 受 visible policy 限制。 +- `skill.read` 受 budget 限制。 +- tool call 写 journal。 + +Frontend tests: + +- Preset import 后发现 embedded skill。 +- Character import 后发现 embedded skill。 +- 用户拒绝后记录 alert key。 +- embedded hash 改变后再次提醒。 +- 冲突替换需要明确选择。 +- 包含 scripts 时 UI 显示风险提示。 + +Regression tests: + +- Agent Mode off 不改变 Legacy Generate。 +- Preset 保存仍保持上游字段。 +- 角色卡导入导出保留 `data.extensions.tauritavern`。 +- regex embedded prompt 语义不变。 +- world/lorebook embedded prompt 语义不变。 + +## 18. 推荐实施顺序 + +### 18.1 格式与 parser + +先实现纯解析: + +- `SkillManifest`。 +- `TauriTavernSkillMetadata`。 +- `EmbeddedSkillBundle`。 +- `SkillImportPreview`。 + +不接 UI,不接 Agent。 + +### 18.2 FileSkillRepository + +新增: + +```text +src-tauri/src/domain/repositories/skill_repository.rs +src-tauri/src/infrastructure/repositories/file_skill_repository/ +``` + +实现: + +- list installed。 +- preview `.ttskill`。 +- install from staging。 +- export `.ttskill`。 + +### 18.3 Skill 管理 API + +新增 Tauri commands 或 Host ABI: + +```ts +api.skill.previewImport(...) +api.skill.installImport(...) +api.skill.list() +api.skill.export(...) +``` + +是否挂在 `window.__TAURITAVERN__.api.skill`,还是 `api.agent.skill`,需要结合 Host ABI 风格决定。倾向 `api.skill`,因为 Skill 管理不是只属于 Agent run;Agent 只是消费者之一。 + +### 18.4 Preset / Character embedded scan + +在前端导入成功路径扫描: + +- `preset.extensions.tauritavern.skills` +- `character.data.extensions.tauritavern.skills` + +调用 preview/import UI。 + +### 18.5 Agent tool 接入 + +实现: + +- `skill.list` +- `skill.read` + +并把结果纳入 journal 和 ContextFrame。 + +### 18.6 Profile policy + +加入最小 policy: + +- visible skills。 +- denied skills。 +- read budget。 + +### 18.7 后续增强 + +- marketplace。 +- 签名。 +- 多版本并存。 +- Skill update。 +- base64 zip embedded bundle。 +- Skill dependencies。 + +这些都不应阻塞第一期。 + +## 19. 主要取舍 + +### 19.1 为什么采用目录包 + +优点: + +- 对齐 OpenAI Codex Skills、Agent Skills 和 Claude Skills。 +- 普通作者可以手写。 +- 多文件自然表达。 +- 易审计、易 diff、易导入导出。 + +缺点: + +- 需要 zip/staging/path 校验。 +- 需要处理目录冲突和索引。 + +判断:收益明显大于复杂度,因为 Skill 天然不是单 JSON 对象。 + +### 19.2 为什么不用纯 JSON + +纯 JSON 的优点是便于嵌入 Preset/角色卡。 + +但缺点更大: + +- Markdown 指令会被 JSON escaping 污染。 +- 多文件和二进制资源表达别扭。 +- 难以与外部 Agent Skill 生态互通。 +- 作者手写和版本管理体验差。 + +因此,独立格式应以目录为准;嵌入格式只是把目录文件列表内联进 JSON。 + +### 19.3 为什么不自动改名解决冲突 + +自动改名看似友好,但会破坏 Skill 的核心身份。 + +如果角色卡推荐 `example-character-voice`,用户导入后变成 `example-character-voice-2`,Profile 和 Agent 都难以解释该如何匹配。 + +所以第一期只允许: + +- 跳过。 +- 替换。 +- 已安装相同内容则忽略。 + +### 19.4 为什么 Skill 权限只是建议 + +Skill 来自外部作者。若它能声明 `allowed-tools` 并直接生效,就等于角色卡/Preset 能扩大运行时能力,违反 AgentContract。 + +正确模型是: + +```text +Skill recommendedTools + -> profile resolver input + -> user/platform/tool policy + -> final visible tools +``` + +## 20. 参考链接 + +- OpenAI Codex Skills: `https://developers.openai.com/codex/skills` +- OpenAI Codex Customization: `https://developers.openai.com/codex/concepts/customization` +- Agent Skills Specification: `https://agentskills.io/specification` +- Agent Skills Client Implementation: `https://agentskills.io/client-implementation/adding-skills-support` +- Claude Agent Skills Overview: `https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview` diff --git a/docs/Agent/TestingStrategy.md b/docs/Agent/TestingStrategy.md index a8b2a468..836d6ac2 100644 --- a/docs/Agent/TestingStrategy.md +++ b/docs/Agent/TestingStrategy.md @@ -1,6 +1,6 @@ # TauriTavern Agent Testing Strategy -本文档定义 Agent 系统的测试策略。Agent 涉及生成、文件、工具、外部协议、保存与兼容事件,测试必须从第一阶段就建立。 +本文档定义 Agent 系统的测试策略。Agent 涉及生成、文件、工具、外部协议、保存与兼容事件,测试必须持续作为开发入口的一部分。 ## 1. 测试目标 @@ -74,7 +74,9 @@ workspace repository rejects symlink escape workspace repository handles unicode relative paths file sizes and retention MCP config allowlist -SkillRepository list/read +SkillRepository preview/install/read/export/source refs +Skill archive roundtrip hash +Skill repository rejects symlink escape ``` 文件测试应使用临时目录,并覆盖 macOS/Linux/Windows path 差异。 @@ -109,10 +111,11 @@ recent workspace write/patch tool result hydration ```text window.__TAURITAVERN__.api.agent exists after ready +window.__TAURITAVERN__.api.skill exists after ready window.__TAURITAVERN__.api.mcp exists after ready when MCP Host ABI lands subscribe returns idempotent unsubscribe Agent API uses safeInvoke, not raw command dependency in public caller -types.d.ts includes agent types; mcp types land with MCP Host ABI +types.d.ts includes agent and skill types; mcp types land with MCP Host ABI ``` Legacy 回归: diff --git a/docs/Agent/ToolSystem.md b/docs/Agent/ToolSystem.md index ba0f580f..1f6a0f39 100644 --- a/docs/Agent/ToolSystem.md +++ b/docs/Agent/ToolSystem.md @@ -158,13 +158,15 @@ Policy violation 必须写 journal 并 fail-fast,除非这是模型可恢复 ### 7.0 当前实现 -截至 2026-05-02,当前 registry 开放 chat / world info / workspace 三类八个内建工具。Registry 只产 canonical `AgentToolSpec`,provider-facing schema/alias 由 gateway 边界转换: +截至 2026-05-02,当前 registry 开放 chat / world info / skill / workspace 四类十个内建工具。Registry 只产 canonical `AgentToolSpec`,provider-facing schema/alias 由 gateway 边界转换: | Canonical name | Model-facing alias | Side effect | 状态 | | --- | --- | --- | --- | | `chat.search` | `chat_search` | 只读搜索当前聊天,返回 message index、snippet 与 ref | 已落地 | | `chat.read_messages` | `chat_read_messages` | 只读按 message index 读取当前聊天消息,可读取字符范围 | 已落地 | | `worldinfo.read_activated` | `worldinfo_read_activated` | 只读读取本轮 run 捕获的最终激活世界书条目 | 已落地 | +| `skill.list` | `skill_list` | 只读列出已安装 Skill 索引摘要 | 已落地 | +| `skill.read` | `skill_read` | 只读读取已安装 Skill 内的 UTF-8 文本文件 | 已落地 | | `workspace.list_files` | `workspace_list_files` | 只读列出模型可见 workspace 文件 | 已落地 | | `workspace.read_file` | `workspace_read_file` | 只读读取 UTF-8 文本,完整读取记录 read-state | 已落地 | | `workspace.write_file` | `workspace_write_file` | 写 run workspace 文件,成功后 checkpoint | 已落地 | @@ -187,17 +189,14 @@ persist/ `workspace.apply_patch` 使用 Claude Code 风格的 `old_string` / `new_string` 单文件精确替换。未完整读取、版本变化、匹配 0 次或多次会作为 recoverable tool error 返回模型。模型传入的非法 path、空 path、不可见/不可写 path 也作为可恢复工具错误回填;repository 内部 escape/symlink/IO、journal、checkpoint、序列化、取消和模型响应结构错误仍 fail-fast。 -当前没有 `skill.list`、`skill.read`、MCP、shell、extension bridge 或审批工具。 +当前没有 MCP、shell、extension bridge、profile routing 或审批工具。 ### 7.1 后续内置工具候选 -后续优先补齐 Skill-like 资源与策略工具: +后续优先补齐策略与 checkpoint 工具: ```text workspace.create_checkpoint - -skill.list -skill.read ``` ### 7.2 Workspace Tools @@ -266,13 +265,17 @@ skill.read `skill.list` - Read-only。 -- 返回当前 profile 可见 skill 的摘要。 +- 返回已安装 Skill 的索引摘要。 +- 当前尚未接入 profile visible/deny policy,因此结果是全局安装索引;profile policy 落地后必须在 tool dispatcher 层收窄可见范围。 +- 不读取 `SKILL.md` 全文。 `skill.read` - Read-only。 -- 支持 section/budget。 -- 结果可进入 ContextFrame。 +- 输入为 `name`、可选 `path`、可选 `max_chars`。 +- `path` 默认 `SKILL.md`,必须是 Skill 内相对路径。 +- 只能读取 UTF-8 文本;二进制、缺失文件、非法路径、symlink escape、不可见 Skill 或超预算读取都是 recoverable tool error,除非 repository 内部 IO / index 损坏等宿主级问题需要 fail-fast。 +- 结果进入 journal / tool result / 下一轮 model request。Skill 原始文件保持 read-only;模型需要摘录或改写时应写入 `scratch/`、`summaries/` 或 `output/`。 ### 7.5 WorldInfo Tools @@ -427,11 +430,11 @@ result returns to backend journal - Rust-owned builtin registry。 - canonical tool specs 与 provider-safe model alias。 - Agent runtime 使用 canonical `ToolCall` / `ToolResult` part,provider 边界负责格式转换。 -- chat search/read_messages、worldinfo read_activated、workspace list/read/write/apply_patch/finish。 +- chat search/read_messages、worldinfo read_activated、skill list/read、workspace list/read/write/apply_patch/finish。 - tool arguments / tool results resource refs。 - recoverable tool error 回填模型。 - 前 5 轮 workspace write/patch 成功结果会 hydrate 完整文件内容到下一轮模型上下文。 - workspace mutation checkpoint。 - journal events。 -下一步新增 `skill.list`、`skill.read`、MCP 或 extension bridge 工具时,应复用这一套 registry/dispatcher/result/error 语义,而不是新建旁路。 +下一步新增 MCP 或 extension bridge 工具时,应复用这一套 registry/dispatcher/result/error 语义,而不是新建旁路。Skill policy 扩展也必须留在现有 `skill.list` / `skill.read` dispatcher 与 profile resolver 之间,不能新建第二套 Skill 读取入口。 diff --git a/docs/AgentArchitecture.md b/docs/AgentArchitecture.md index 26c24ed2..17682154 100644 --- a/docs/AgentArchitecture.md +++ b/docs/AgentArchitecture.md @@ -7,16 +7,18 @@ 1. `docs/CurrentState/AgentFramework.md`:当前真实实现状态与手动 smoke。 2. `docs/AgentArchitecture.md`:系统边界、分层、数据流。 3. `docs/AgentContract.md`:不可破坏的不变量与 fail-fast 约束。 -4. `docs/AgentImplementPlan.md`:分阶段实施计划与验收标准。 +4. `docs/AgentImplementPlan.md`:当前实施基线、后续顺序与验收命令。 5. `docs/Agent/Workspace.md`:Workspace、Artifact、Checkpoint 的存储语义。 6. `docs/Agent/RunEventJournal.md`:Run Event、状态机、恢复/取消语义。 7. `docs/Agent/ProfilesAndPreset.md`:Preset、Agent Profile、Plan Policy。 8. `docs/Agent/ToolSystem.md`:Tool Registry、Tool Result、权限与审批。 9. `docs/Agent/LlmGateway.md`:provider-agnostic LLM gateway 与现有 ChatCompletionService 的复用边界。 -10. `docs/Agent/McpSkill.md`:MCP 与 Skill 的边界。 -11. `docs/API/Agent.md`:前端 Host ABI 与 Tauri command 草案。 -12. `docs/API/MCP.md`:非 Agent 模式下的 MCP Host ABI 草案。 -13. `docs/Agent/TestingStrategy.md`:测试矩阵与回归守护。 +10. `docs/Agent/Skill.md`:当前 Skill 格式、存储、导入导出、Agent tool 与安全边界。 +11. `docs/Agent/McpSkill.md`:MCP 与 Skill 的边界。 +12. `docs/API/Agent.md`:前端 Agent Host ABI。 +13. `docs/API/Skill.md`:前端 Skill 管理 Host ABI。 +14. `docs/API/MCP.md`:非 Agent 模式下的 MCP Host ABI 草案。 +15. `docs/Agent/TestingStrategy.md`:测试矩阵与回归守护。 ## 1. 核心定义 @@ -64,7 +66,7 @@ Agent 系统必须同时满足五个目标: ## 4. 非目标 -第一阶段不追求这些事项: +当前不追求这些事项: - 不重写完整 SillyTavern PromptManager。 - 不把所有历史消息复制到 workspace。 @@ -145,7 +147,7 @@ LLM Gateway / provider adapter - Public Host ABI 入口为 `api.agent.startRunFromLegacyGenerate()` 与 `api.agent.startRunWithPromptSnapshot()`,没有 `startRun()` alias。 - `startRunFromLegacyGenerate()` 是当前推荐的兼容桥;它捕获 Legacy prompt 语义与本轮最终 `worldInfoActivation`,同时禁用 Legacy ToolManager tools。 - `startRunWithPromptSnapshot()` 是低层测试/集成入口;调用方必须提供不含 external tools/tool turns 的 chat completion payload。 -- 后端当前开放 `chat.search`、`chat.read_messages`、`worldinfo.read_activated`、`workspace.list_files`、`workspace.read_file`、`workspace.write_file`、`workspace.apply_patch`、`workspace.finish` 八个内建工具,对模型暴露为 provider-safe alias。 +- 后端当前开放 `chat.search`、`chat.read_messages`、`worldinfo.read_activated`、`skill.list`、`skill.read`、`workspace.list_files`、`workspace.read_file`、`workspace.write_file`、`workspace.apply_patch`、`workspace.finish` 十个内建工具,对模型暴露为 provider-safe alias。 - Agent runtime 当前使用 `AgentModelRequest` / `AgentModelResponse` / `AgentModelContentPart` 作为内部模型语义,不再直接读写 OpenAI-compatible raw JSON。 - `AgentModelGateway` 仍复用 `ChatCompletionService::generate_exchange_with_cancel()`,在 canonical IR 与现有 provider payload pipeline 之间转换。 - Claude / Gemini / OpenAI Responses / Gemini Interactions 的 native metadata 以 opaque `Native` part 保存和回放;tool call id 缺失会 fail-fast。 @@ -156,7 +158,7 @@ LLM Gateway / provider adapter - 当前模型可见 / 可写 workspace 根由 run manifest roots 驱动,默认包含 `output/`、`scratch/`、`plan/`、`summaries/`、`persist/`;`persist/` 是 chat workspace 级持久 root 的 run projection,只有 `finalizeCommit()` 成功后才 promote 回稳定 chat workspace;`input/`、`tool-args/`、`tool-results/`、`model-responses/`、`checkpoints/` 与 `events.jsonl` 不作为模型工具资源暴露。 - 工具循环最多 80 轮,必须以 `workspace.finish` 结束;模型直接输出文本会 fail-fast。 - 模型可修正的工具错误以 `is_error = true` tool result 回填下一轮;宿主级 IO、journal、checkpoint、序列化、取消和模型响应结构错误仍 fail-fast。 -- `skill.list`、`skill.read`、`readDiff`、`rollback`、`resume-run`、tool approval、profile routing、MCP、timeline UI、streaming Agent loop、主发送按钮 Agent toggle 仍未实现。 +- Skill profile policy、readDiff、rollback、resume-run、tool approval、profile routing、MCP、timeline UI、streaming Agent loop、主发送按钮 Agent toggle 仍未实现。 ### 5.2 Run 与 Workspace 身份 diff --git a/docs/AgentImplementPlan.md b/docs/AgentImplementPlan.md index 0be22e2c..e2083f7e 100644 --- a/docs/AgentImplementPlan.md +++ b/docs/AgentImplementPlan.md @@ -1,6 +1,6 @@ # TauriTavern Agent Implementation Plan -本文档记录当前可继续开发的实施基线与后续顺序。旧阶段性施工计划已经收敛为当前架构、测试与契约;后续开发不应再从旧阶段文档倒推当前行为。 +本文档记录当前可继续开发的实施基线与后续顺序。历史施工计划已经收敛为当前架构、测试与契约;后续开发不应再从旧计划倒推当前行为。 当前事实以 `docs/CurrentState/AgentFramework.md` 为准,架构边界以 `docs/AgentArchitecture.md` 与 `docs/AgentContract.md` 为准。 @@ -16,8 +16,9 @@ - Tool loop 由 Rust runtime 独占推进,不递归调用前端 `Generate()`。 - Agent runtime 已使用 canonical model IR,不再把 OpenAI-compatible raw JSON 当作运行时事实。 - `provider_state` 已用于 run-scoped continuation。OpenAI Responses 通过它驱动 persistent WebSocket、incremental input 与 `previous_response_id`。 +- Agent Skill repository/service、导入导出、embedded skill 导入确认、`api.skill`、`skill.list` / `skill.read` 已落地。 -旧阶段只保留为这些不变量: +历史计划只保留为这些不变量: - Agent Mode off 时,上游 SillyTavern `Generate()`、ToolManager、事件顺序与 chat 保存语义不变。 - `stableChatId` 是长期聊天身份;`workspaceId` 由 `kind + stableChatId` 派生;`runId` 表示单次执行。 @@ -128,6 +129,8 @@ Tool registry 只产 canonical `AgentToolSpec`,不再暴露 OpenAI-shaped `ope | `chat.search` | `chat_search` | read-only | | `chat.read_messages` | `chat_read_messages` | read-only | | `worldinfo.read_activated` | `worldinfo_read_activated` | read-only | +| `skill.list` | `skill_list` | read-only | +| `skill.read` | `skill_read` | read-only | | `workspace.list_files` | `workspace_list_files` | read-only | | `workspace.read_file` | `workspace_read_file` | read-only | | `workspace.write_file` | `workspace_write_file` | mutating | @@ -136,7 +139,7 @@ Tool registry 只产 canonical `AgentToolSpec`,不再暴露 OpenAI-shaped `ope 当前尚未落地: -- `skill.list` / `skill.read` +- Skill profile visible / deny / read budget policy - MCP 工具 - shell 工具 - 外部 extension tools @@ -174,7 +177,7 @@ initialize_run 写 manifest / prompt snapshot / workspace root ↓ prepare_agent_tool_request 生成 AgentModelRequest ↓ -model -> read-only context tools / workspace tools -> model -> ... -> workspace.finish +model -> read-only context tools / skill tools / workspace tools -> model -> ... -> workspace.finish ↓ 工具调用参数与结果写入 workspace refs ↓ @@ -224,15 +227,17 @@ prepareCommit / saveReply / finalizeCommit - profile 显式声明 ContextFrame 资源预算。 - Plan node 若锁定 profile,runtime 必须拒绝模型自行切换。 -### 8.3 剩余只读上下文资源 +### 8.3 Skill Policy 与创作者资源合流 -目标:在不膨胀 prompt snapshot 的前提下,把创作者资源变成按需读取的工具/virtual resource。 +目标:在不膨胀 prompt snapshot 的前提下,把已安装 Skill 与创作者资源变成可审计、可预算的按需读取资源。 -优先级: +内容: -- `skill.list` / `skill.read` -- preset / character author resources 的统一 Skill-like 入口 -- 可审计的 context budget 与 resource refs +- profile / preset / character 声明 visible skills、deny skills 与 read budget。 +- `skill.list` 根据 policy 只返回当前 run 可见 Skill。 +- `skill.read` 对不可见 Skill、超预算读取和非法资源返回 recoverable tool error。 +- preset / character author resources 复用 Skill-like 索引与 source ref 语义,不另建平行资源系统。 +- journal 记录 Skill read 的 tool args、resource refs 与失败原因。 ### 8.4 Timeline UI 与人工控制 @@ -276,6 +281,8 @@ Agent 相关 Rust 变更至少运行: cargo fmt --manifest-path src-tauri/Cargo.toml cargo check --manifest-path src-tauri/Cargo.toml cargo test --manifest-path src-tauri/Cargo.toml agent --lib +cargo test --manifest-path src-tauri/Cargo.toml skill --lib +cargo test --manifest-path src-tauri/Cargo.toml agent_tools --lib git diff --check ``` @@ -304,6 +311,7 @@ pnpm run check:contracts - LLM gateway:`docs/Agent/LlmGateway.md` - 工具语义:`docs/Agent/ToolSystem.md` - workspace 语义:`docs/Agent/Workspace.md` +- Skill 语义:`docs/Agent/Skill.md` - 事件语义:`docs/Agent/RunEventJournal.md` - 测试矩阵:`docs/Agent/TestingStrategy.md` -- Host ABI:`docs/API/Agent.md`、`docs/FrontendHostContract.md`、`src/types.d.ts` +- Host ABI:`docs/API/Agent.md`、`docs/API/Skill.md`、`docs/FrontendHostContract.md`、`src/types.d.ts` diff --git a/docs/CurrentState/AgentFramework.md b/docs/CurrentState/AgentFramework.md index 6da0493b..e26c5510 100644 --- a/docs/CurrentState/AgentFramework.md +++ b/docs/CurrentState/AgentFramework.md @@ -2,7 +2,7 @@ 本文档是 Agent 框架的当前事实入口。后续开发先读本文,再读 `docs/AgentArchitecture.md`、`docs/AgentContract.md`、`docs/AgentImplementPlan.md` 与 `docs/Agent/` 下的专题文档。 -旧阶段性施工说明已经吸收为当前基线,不再作为开发入口;需要历史背景时只看 git history。 +历史施工说明已经吸收为当前基线,不再作为开发入口;需要历史背景时只看 git history。 ## 当前基线 @@ -15,6 +15,7 @@ - Agent runtime 已不再把 OpenAI-compatible raw JSON 当作内部事实;运行时使用 canonical `AgentModelRequest` / `AgentModelResponse` / `AgentModelMessage` / `AgentModelContentPart`。 - `AgentModelGateway` 在 Agent canonical IR 与现有 `ChatCompletionGenerateRequestDto` 之间转换;provider-native metadata 作为 opaque `Native` part 保留。 - `provider_state` 已是 run-scoped continuation contract;OpenAI Responses 使用它驱动 persistent WebSocket、incremental input 与 `previous_response_id`。 +- Agent Skill 管理、导入导出、embedded skill 提示导入、`skill.list` / `skill.read` 已落地。 - 当前工具循环是非 streaming;provider stream 仍不是 Agent timeline event。 - Legacy Generate 尚未默认切到 Agent;Agent Mode off 时上游 SillyTavern 生成、事件和保存语义不变。 @@ -34,6 +35,19 @@ api.agent.commit(input) api.agent.finalizeCommit(input) ``` +Skill 管理 API 已落地: + +```ts +api.skill.list() +api.skill.previewImport(input) +api.skill.installImport(request) +api.skill.readFile(input) +api.skill.export(input) +api.skill.exportSkill(input) +``` + +`api.skill` 是用户/UI/扩展侧的 Skill 管理入口;Agent run 内只通过 `skill.list` / `skill.read` 工具消费已安装 Skill。 + 明确不存在公共 `api.agent.startRun()` alias。启动入口必须表达 prompt 来源: - `startRunFromLegacyGenerate()`:调用 Legacy `Generate(..., dryRun = true)`,捕获 `GENERATE_AFTER_DATA` 中的 `generate_data` 与本轮最终 `worldInfoActivation`。 @@ -56,13 +70,15 @@ Tool registry 只产 canonical `AgentToolSpec`。Provider-facing alias 由 gatew | `chat.search` | `chat_search` | read-only | 搜索当前 run 绑定的聊天。只有 `query` 必填;可选 `limit`、`role`、`start_message`、`end_message`、`scan_limit`。 | | `chat.read_messages` | `chat_read_messages` | read-only | 按 0-based message index 读取当前聊天消息;每项可选 `start_char`、`max_chars`。JSONL header 不计入 index。 | | `worldinfo.read_activated` | `worldinfo_read_activated` | read-only | 读取本次 Agent run 捕获的最终激活世界书条目,不读取全局 last activation。 | +| `skill.list` | `skill_list` | read-only | 列出当前已安装 Skill 的索引摘要。profile visible/deny policy 尚未接入,因此目前是全局安装索引。 | +| `skill.read` | `skill_read` | read-only | 读取已安装 Skill 内的 UTF-8 文本文件;默认 `SKILL.md`,支持 `path` 与 `max_chars`。 | | `workspace.list_files` | `workspace_list_files` | read-only | 列出模型可见 workspace 文件。`path` 省略、空字符串、`.`、`./` 表示 workspace root。 | | `workspace.read_file` | `workspace_read_file` | read-only | 读取 UTF-8 文本文件并返回行号;完整读取会记录 read-state。 | | `workspace.write_file` | `workspace_write_file` | mutating | 写完整 UTF-8 文件;成功后记录 read-state 并创建 checkpoint。 | | `workspace.apply_patch` | `workspace_apply_patch` | mutating | 单文件 `old_string` / `new_string` 精确替换;要求已完整读取或由本 run 创建/修改。 | | `workspace.finish` | `workspace_finish` | control | 结束工具循环;默认 final artifact 是 `output/main.md`。 | -当前没有 `skill.list`、`skill.read`、MCP 工具、shell 工具、外部 extension tools、tool approval 或 profile routing。 +当前没有 MCP 工具、shell 工具、外部 extension tools、tool approval 或 profile routing。 ## Model Gateway 当前事实 @@ -271,6 +287,14 @@ _tauritavern/agent-workspaces/ / checkpoint.json +_tauritavern/skills/ + installed/ + / + SKILL.md + + index/ + skills.json + .staging/ ``` Workspace path 必须是相对路径。绝对路径、Windows drive prefix、NUL、`..` 会被拒绝。工具参数层可修正的问题返回 recoverable tool error;repository 内部 IO、journal、checkpoint、chat JSONL 损坏、序列化、取消和模型响应结构错误是 fatal runtime error。 @@ -298,13 +322,11 @@ const stop = agent.subscribe(run.runId, event => console.log(event)); 最近一次 Rust 侧验证基线: -- `cargo check` -- `cargo test agent_model_gateway` -- `cargo test agent_loop` -- `cargo test openai_responses_payload` -- `cargo test claude_native_content_blocks_are_replayed` -- `cargo test normalize_` -- `cargo test`:470 passed +- `cargo fmt --manifest-path src-tauri/Cargo.toml` +- `cargo check --manifest-path src-tauri/Cargo.toml` +- `cargo test --manifest-path src-tauri/Cargo.toml skill --lib`:9 passed +- `cargo test --manifest-path src-tauri/Cargo.toml agent_tools --lib`:5 passed +- `cargo test --manifest-path src-tauri/Cargo.toml agent --lib`:31 passed - `git diff --check` 前端 ABI 本次未修改,未重新运行前端检查。 @@ -317,7 +339,7 @@ const stop = agent.subscribe(run.runId, event => console.log(event)); - 将 `PromptSnapshot` 过渡输入逐步替换为 `GenerationIntent + ContextFrame`。 - 把 `AgentModelGateway` 进一步拆成明确的 provider adapter 模块,减少单文件体积。 - 完成 profile policy:allowed tools、tool budget、tool call mode、provider switch policy。 -- 实现 `skill.list` / `skill.read`。 +- 将 Skill 可见性、deny policy 与 read budget 接入 profile / preset / character resolver。 - 实现 readDiff、rollback、listRuns、resume-run、autoCommit/streaming 的明确策略。 ## 每次 Agent 相关变更必须更新 @@ -329,9 +351,10 @@ const stop = agent.subscribe(run.runId, event => console.log(event)); - `docs/AgentImplementPlan.md` - `docs/Agent/LlmGateway.md` - `docs/Agent/ToolSystem.md` +- `docs/Agent/Skill.md` - `docs/Agent/RunEventJournal.md` - `docs/Agent/TestingStrategy.md` -- 涉及 Host ABI 时同步 `docs/API/Agent.md`、`docs/FrontendHostContract.md`、`src/types.d.ts` +- 涉及 Host ABI 时同步 `docs/API/Agent.md`、`docs/API/Skill.md`、`docs/FrontendHostContract.md`、`src/types.d.ts` ## 守护契约 diff --git a/docs/FrontendHostContract.md b/docs/FrontendHostContract.md index de674816..ceb1b8f1 100644 --- a/docs/FrontendHostContract.md +++ b/docs/FrontendHostContract.md @@ -151,19 +151,26 @@ - `openEntry()` 必须复用上游 World Info 模块自身的导航能力;宿主 ABI 层不得直接依赖 `#WorldInfo`、`#world_editor_select`、`[uid=\"...\"]` 等 DOM 细节。 - `api.agent`:TauriTavern Agent Run API。用于启动 Agent Run、订阅 run event、取消、审批工具、读取 workspace 文件/diff、rollback/commit。 - - 详细草案见:`docs/API/Agent.md`。 + - 详细参考见:`docs/API/Agent.md`。 - 当前已落地 Host ABI:`startRunFromLegacyGenerate()`、`startRunWithPromptSnapshot()`、`cancel()`、`readEvents()`、`readWorkspaceFile()`、`subscribe()`、`prepareCommit()`、`finalizeCommit()`、`commit()`。 - `startRunFromLegacyGenerate()` 是当前兼容入口:使用 Legacy dryRun 生成 `promptSnapshot`,再进入 Rust-owned Agent loop。 - `startRunWithPromptSnapshot()` 必须在调用 backend 前解析 `stableChatId`;`workspaceId` 由 `kind + stableChatId` 派生,`runId` 仍表示单次执行。 - 不存在公共 `startRun()` alias;启动入口必须通过名称表达来源和职责。 - Legacy `Generate(..., dryRun = true)` 不返回 payload;Agent adapter 必须通过 `GENERATE_AFTER_DATA` 事件捕获 `generate_data`。 - - 当前模型可见工具为 `workspace_list_files`、`workspace_read_file`、`workspace_write_file`、`workspace_apply_patch`、`workspace_finish`;工具注册由 Rust runtime 独占,前端 Legacy ToolManager tools 必须禁用。 + - 当前模型可见工具为 `chat_search`、`chat_read_messages`、`worldinfo_read_activated`、`skill_list`、`skill_read`、`workspace_list_files`、`workspace_read_file`、`workspace_write_file`、`workspace_apply_patch`、`workspace_finish`;工具注册由 Rust runtime 独占,前端 Legacy ToolManager tools 必须禁用。 - 当前显式拒绝 `stream: true`、`autoCommit: true`、external tools、external tool choice 和已有 tool turns。 - 可恢复工具错误会写入 Agent journal 并回填下一轮模型;宿主级错误仍让 run failed。 - Agent event 属于 Agent Run journal/timeline 投影,不得伪装成上游 SillyTavern `GENERATION_*` / `TOOL_CALLS_*` 事件。 - `subscribe()` 当前是 polling wrapper,必须返回幂等 `unsubscribe`;底层 Tauri 事件名与 Rust command 名属于 Internal,不是第三方 Public Contract。 - Agent Mode off 时,Legacy `Generate()`、`ToolManager`、`api.chat` 行为必须不变。 +- `api.skill`:TauriTavern Agent Skill 管理 API。用于列出、预览导入、安装、读取和导出本地 Skill。 + - 详细参考见:`docs/API/Skill.md` 与 `docs/Agent/Skill.md`。 + - 当前已落地 Host ABI:`list()`、`previewImport()`、`installImport()`、`readFile()`、`export()`、`exportSkill()`。 + - `api.skill` 是 UI / 扩展侧管理入口,不是 Agent run 内的工具入口;模型只能通过 Rust runtime 注册的 `skill.list` / `skill.read` 消费已安装 Skill。 + - Preset / Character embedded skill 导入必须经过用户确认;同名不同 hash 必须显式 skip 或 replace,不自动改名。 + - Skill import/export 不触发上游 SillyTavern `GENERATION_*`、`TOOL_CALLS_*` 或 regex 事件。 + - `api.mcp`(规划中):MCP Server/Tool/Resource/Prompt 的独立平台 API。Agent Mode 可以消费 MCP,但 MCP 不依附 Agent Mode。 - 详细草案见:`docs/API/MCP.md`。 - MCP stdio command/config 不得由 Agent/Preset/角色卡/世界书直接写入;危险工具调用必须经过 capability policy 与审批。 diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 8fe5b561..419befea 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -18,6 +18,7 @@ use crate::application::services::preset_service::PresetService; use crate::application::services::quick_reply_service::QuickReplyService; use crate::application::services::secret_service::SecretService; use crate::application::services::settings_service::SettingsService; +use crate::application::services::skill_service::SkillService; use crate::application::services::stable_diffusion_service::StableDiffusionService; use crate::application::services::theme_service::ThemeService; use crate::application::services::tokenization_service::TokenizationService; @@ -42,6 +43,7 @@ pub struct AppState { pub settings_service: Arc, pub user_directory_service: Arc, pub secret_service: Arc, + pub skill_service: Arc, pub content_service: Arc, pub extension_service: Arc, pub extension_store_service: Arc, @@ -89,6 +91,7 @@ impl AppState { settings_service: services.settings_service, user_directory_service: services.user_directory_service, secret_service: services.secret_service, + skill_service: services.skill_service, content_service: services.content_service, extension_service: services.extension_service, extension_store_service: services.extension_store_service, diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 5396a00f..eefb70ee 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -21,6 +21,7 @@ use crate::application::services::preset_service::PresetService; use crate::application::services::quick_reply_service::QuickReplyService; use crate::application::services::secret_service::SecretService; use crate::application::services::settings_service::SettingsService; +use crate::application::services::skill_service::SkillService; use crate::application::services::stable_diffusion_service::StableDiffusionService; use crate::application::services::theme_service::ThemeService; use crate::application::services::tokenization_service::TokenizationService; @@ -49,6 +50,7 @@ use crate::domain::repositories::prompt_cache_repository::PromptCacheRepository; use crate::domain::repositories::quick_reply_repository::QuickReplyRepository; use crate::domain::repositories::secret_repository::SecretRepository; use crate::domain::repositories::settings_repository::SettingsRepository; +use crate::domain::repositories::skill_repository::SkillRepository; use crate::domain::repositories::stable_diffusion_repository::StableDiffusionRepository; use crate::domain::repositories::theme_repository::ThemeRepository; use crate::domain::repositories::tokenizer_repository::TokenizerRepository; @@ -84,6 +86,7 @@ use crate::infrastructure::repositories::file_prompt_cache_repository::FilePromp use crate::infrastructure::repositories::file_quick_reply_repository::FileQuickReplyRepository; use crate::infrastructure::repositories::file_secret_repository::FileSecretRepository; use crate::infrastructure::repositories::file_settings_repository::FileSettingsRepository; +use crate::infrastructure::repositories::file_skill_repository::FileSkillRepository; use crate::infrastructure::repositories::file_theme_repository::FileThemeRepository; use crate::infrastructure::repositories::file_user_directory_repository::FileUserDirectoryRepository; use crate::infrastructure::repositories::file_user_repository::FileUserRepository; @@ -97,6 +100,7 @@ pub(super) struct AppServices { pub settings_service: Arc, pub user_directory_service: Arc, pub secret_service: Arc, + pub skill_service: Arc, pub content_service: Arc, pub extension_service: Arc, pub extension_store_service: Arc, @@ -128,6 +132,7 @@ struct AppRepositories { prompt_cache_repository: Arc, user_directory_repository: Arc, secret_repository: Arc, + skill_repository: Arc, content_repository: Arc, extension_repository: Arc, extension_store_repository: Arc, @@ -201,6 +206,7 @@ pub(super) async fn build_services( let quick_reply_service = Arc::new(QuickReplyService::new( repositories.quick_reply_repository.clone(), )); + let skill_service = Arc::new(SkillService::new(repositories.skill_repository.clone())); let chat_completion_service = Arc::new(ChatCompletionService::new( repositories.chat_completion_repository, repositories.secret_repository.clone(), @@ -214,6 +220,7 @@ pub(super) async fn build_services( repositories.checkpoint_repository.clone(), repositories.chat_repository.clone(), repositories.group_chat_repository.clone(), + skill_service.clone(), Arc::new(ChatCompletionAgentModelGateway::new( chat_completion_service.clone(), )), @@ -280,6 +287,7 @@ pub(super) async fn build_services( settings_service, user_directory_service, secret_service, + skill_service, content_service, extension_service, extension_store_service, @@ -345,6 +353,9 @@ fn build_repositories( let secret_repository: Arc = Arc::new(FileSecretRepository::new( default_user_dir.join("secrets.json"), )); + let skill_repository: Arc = Arc::new(FileSkillRepository::new( + data_root.join("_tauritavern").join("skills"), + )); let content_repository: Arc = Arc::new(FileContentRepository::new( app_handle.clone(), @@ -435,6 +446,7 @@ fn build_repositories( prompt_cache_repository, user_directory_repository, secret_repository, + skill_repository, content_repository, extension_repository, extension_store_repository, diff --git a/src-tauri/src/application/services/agent_runtime_service.rs b/src-tauri/src/application/services/agent_runtime_service.rs index da383afd..97192ca9 100644 --- a/src-tauri/src/application/services/agent_runtime_service.rs +++ b/src-tauri/src/application/services/agent_runtime_service.rs @@ -5,6 +5,7 @@ use tokio::sync::{RwLock, watch}; use crate::application::services::agent_model_gateway::AgentModelGateway; use crate::application::services::agent_tools::{AgentToolDispatcher, BuiltinAgentToolRegistry}; +use crate::application::services::skill_service::SkillService; use crate::domain::repositories::agent_run_repository::AgentRunRepository; use crate::domain::repositories::chat_repository::ChatRepository; use crate::domain::repositories::checkpoint_repository::CheckpointRepository; @@ -46,6 +47,7 @@ impl AgentRuntimeService { checkpoint_repository: Arc, chat_repository: Arc, group_chat_repository: Arc, + skill_service: Arc, model_gateway: Arc, ) -> Self { let tool_registry = BuiltinAgentToolRegistry::phase2c(); @@ -54,6 +56,7 @@ impl AgentRuntimeService { chat_repository, group_chat_repository, workspace_repository.clone(), + skill_service, ); Self { run_repository, diff --git a/src-tauri/src/application/services/agent_runtime_service/prompt_snapshot.rs b/src-tauri/src/application/services/agent_runtime_service/prompt_snapshot.rs index df4ba8f9..d8d1fad0 100644 --- a/src-tauri/src/application/services/agent_runtime_service/prompt_snapshot.rs +++ b/src-tauri/src/application/services/agent_runtime_service/prompt_snapshot.rs @@ -48,6 +48,8 @@ pub(super) fn prepare_agent_tool_request( "Use chat_search to find relevant prior messages when you need more context. Only query is required.", "Use chat_read_messages with message indexes from chat_search, or exact indexes you already know. For long messages, read smaller ranges with start_char and max_chars.", "Use worldinfo_read_activated when active lore for this run matters.", + "Use skill_list to discover installed Agent Skills when reusable writing, editing, planning, style, or character guidance may help.", + "Use skill_read to read SKILL.md first, then read referenced Skill files only when needed.", "Use workspace_list_files to inspect visible workspace files.", "Use workspace_read_file before modifying an existing file. Read output has line numbers; never include line number prefixes in old_string or new_string.", "Use workspace_apply_patch for precise edits to existing files. The old_string must match exactly and uniquely unless replace_all is true.", diff --git a/src-tauri/src/application/services/agent_runtime_service/tests.rs b/src-tauri/src/application/services/agent_runtime_service/tests.rs index 10de85c1..0ae1d835 100644 --- a/src-tauri/src/application/services/agent_runtime_service/tests.rs +++ b/src-tauri/src/application/services/agent_runtime_service/tests.rs @@ -21,6 +21,7 @@ use crate::application::services::agent_model_gateway::{ use crate::application::services::agent_tools::{ AgentToolDispatcher, AgentToolEffect, AgentToolSession, }; +use crate::application::services::skill_service::SkillService; use crate::domain::models::agent::{ AgentChatRef, AgentModelContentPart, AgentModelRequest, AgentModelRole, AgentRun, AgentRunEventLevel, AgentRunStatus, AgentToolCall, WorkspacePath, @@ -32,6 +33,7 @@ use crate::domain::repositories::chat_repository::ChatRepository; use crate::domain::repositories::workspace_repository::WorkspaceRepository; use crate::infrastructure::repositories::file_agent_repository::FileAgentRepository; use crate::infrastructure::repositories::file_chat_repository::FileChatRepository; +use crate::infrastructure::repositories::file_skill_repository::FileSkillRepository; #[test] fn workspace_id_uses_stable_chat_id_not_character_chat_file_name() { @@ -115,6 +117,7 @@ async fn agent_loop_writes_artifact_and_reaches_awaiting_commit() { repository.clone(), test_chat_repository(&root), test_chat_repository(&root), + test_skill_service(&root), model_gateway, ); let request = ChatCompletionGenerateRequestDto { @@ -297,6 +300,7 @@ async fn agent_loop_reads_and_patches_workspace_artifact() { repository.clone(), test_chat_repository(&root), test_chat_repository(&root), + test_skill_service(&root), model_gateway, ); let request = ChatCompletionGenerateRequestDto { @@ -420,6 +424,7 @@ async fn finalize_commit_promotes_persistent_workspace_projection() { repository.clone(), test_chat_repository(&root), test_chat_repository(&root), + test_skill_service(&root), model_gateway, ); let request = ChatCompletionGenerateRequestDto { @@ -581,6 +586,7 @@ async fn agent_loop_returns_recoverable_tool_errors_to_model() { repository.clone(), test_chat_repository(&root), test_chat_repository(&root), + test_skill_service(&root), model_gateway, ); let request = ChatCompletionGenerateRequestDto { @@ -680,6 +686,7 @@ async fn workspace_patch_requires_full_read_state() { chat_repository.clone(), chat_repository, repository.clone(), + test_skill_service(&root), ); let mut session = AgentToolSession::default(); let patch_call = AgentToolCall { @@ -799,6 +806,7 @@ async fn dispatcher_searches_and_reads_current_chat_messages() { chat_repository.clone(), chat_repository, repository.clone(), + test_skill_service(&root), ); let mut session = AgentToolSession::default(); let search_call = AgentToolCall { @@ -890,6 +898,7 @@ async fn dispatcher_reads_worldinfo_activation_from_run_snapshot() { chat_repository.clone(), chat_repository, repository.clone(), + test_skill_service(&root), ); let mut session = AgentToolSession::default(); let call = AgentToolCall { @@ -925,6 +934,12 @@ fn test_chat_repository(root: &Path) -> Arc { )) } +fn test_skill_service(root: &Path) -> Arc { + Arc::new(SkillService::new(Arc::new(FileSkillRepository::new( + root.join("skills"), + )))) +} + async fn save_character_payload( repository: &FileChatRepository, root: &Path, diff --git a/src-tauri/src/application/services/agent_tools.rs b/src-tauri/src/application/services/agent_tools.rs index b6a1a8bc..2ad4530d 100644 --- a/src-tauri/src/application/services/agent_tools.rs +++ b/src-tauri/src/application/services/agent_tools.rs @@ -3,6 +3,7 @@ mod common; mod dispatcher; mod registry; mod session; +mod skill; mod workspace; mod world_info; diff --git a/src-tauri/src/application/services/agent_tools/dispatcher.rs b/src-tauri/src/application/services/agent_tools/dispatcher.rs index a377755f..e05d7f7f 100644 --- a/src-tauri/src/application/services/agent_tools/dispatcher.rs +++ b/src-tauri/src/application/services/agent_tools/dispatcher.rs @@ -5,9 +5,11 @@ use serde_json::json; use super::chat; use super::session::AgentToolSession; +use super::skill; use super::workspace; use super::world_info; use crate::application::errors::ApplicationError; +use crate::application::services::skill_service::SkillService; use crate::domain::models::agent::{AgentToolCall, AgentToolResult, WorkspacePath}; use crate::domain::repositories::agent_run_repository::AgentRunRepository; use crate::domain::repositories::chat_repository::ChatRepository; @@ -42,6 +44,7 @@ pub struct AgentToolDispatcher { chat_repository: Arc, group_chat_repository: Arc, workspace_repository: Arc, + skill_service: Arc, } impl AgentToolDispatcher { @@ -50,12 +53,14 @@ impl AgentToolDispatcher { chat_repository: Arc, group_chat_repository: Arc, workspace_repository: Arc, + skill_service: Arc, ) -> Self { Self { run_repository, chat_repository, group_chat_repository, workspace_repository, + skill_service, } } @@ -90,6 +95,8 @@ impl AgentToolDispatcher { world_info::WORLDINFO_READ_ACTIVATED => { world_info::read_activated(self.workspace_repository.as_ref(), run_id, call).await? } + skill::SKILL_LIST => skill::list(self.skill_service.as_ref(), call).await?, + skill::SKILL_READ => skill::read(self.skill_service.as_ref(), call).await?, workspace::WORKSPACE_LIST_FILES => { workspace::list_files(self.workspace_repository.as_ref(), run_id, call).await? } diff --git a/src-tauri/src/application/services/agent_tools/registry.rs b/src-tauri/src/application/services/agent_tools/registry.rs index 66c23174..80d6bdd7 100644 --- a/src-tauri/src/application/services/agent_tools/registry.rs +++ b/src-tauri/src/application/services/agent_tools/registry.rs @@ -1,4 +1,5 @@ use super::chat::{chat_read_messages_spec, chat_search_spec}; +use super::skill::{skill_list_spec, skill_read_spec}; use super::workspace::{ workspace_apply_patch_spec, workspace_finish_spec, workspace_list_files_spec, workspace_read_file_spec, workspace_write_file_spec, @@ -18,6 +19,8 @@ impl BuiltinAgentToolRegistry { chat_search_spec(), chat_read_messages_spec(), worldinfo_read_activated_spec(), + skill_list_spec(), + skill_read_spec(), workspace_list_files_spec(), workspace_read_file_spec(), workspace_write_file_spec(), @@ -43,6 +46,13 @@ mod tests { let tools = registry.specs(); assert_eq!(tools[0].model_name, "chat_search"); + assert_eq!( + tools + .iter() + .find(|spec| spec.model_name == "skill_read") + .map(|spec| spec.name.as_str()), + Some("skill.read") + ); assert_eq!( tools .iter() diff --git a/src-tauri/src/application/services/agent_tools/skill/list.rs b/src-tauri/src/application/services/agent_tools/skill/list.rs new file mode 100644 index 00000000..8b82800e --- /dev/null +++ b/src-tauri/src/application/services/agent_tools/skill/list.rs @@ -0,0 +1,51 @@ +use serde_json::json; + +use super::super::dispatcher::AgentToolEffect; +use crate::application::errors::ApplicationError; +use crate::application::services::skill_service::SkillService; +use crate::domain::models::agent::{AgentToolCall, AgentToolResult}; + +pub(in crate::application::services::agent_tools) async fn list( + skill_service: &SkillService, + call: &AgentToolCall, +) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> { + let skills = skill_service.list_skills().await?; + let content = if skills.is_empty() { + "No Agent Skills are installed.".to_string() + } else { + skills + .iter() + .map(|skill| { + let display = skill + .display_name + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or(skill.name.as_str()); + format!( + "- {}: {}{}", + skill.name, + skill.description, + if display == skill.name { + String::new() + } else { + format!(" (display name: {display})") + } + ) + }) + .collect::>() + .join("\n") + }; + + Ok(( + AgentToolResult { + call_id: call.id.clone(), + name: call.name.clone(), + content, + structured: json!({ "skills": skills }), + is_error: false, + error_code: None, + resource_refs: Vec::new(), + }, + AgentToolEffect::None, + )) +} diff --git a/src-tauri/src/application/services/agent_tools/skill/mod.rs b/src-tauri/src/application/services/agent_tools/skill/mod.rs new file mode 100644 index 00000000..a7b43971 --- /dev/null +++ b/src-tauri/src/application/services/agent_tools/skill/mod.rs @@ -0,0 +1,10 @@ +mod list; +mod read; +mod specs; + +pub(super) use self::list::list; +pub(super) use self::read::read; +pub(super) use self::specs::{skill_list_spec, skill_read_spec}; + +pub(super) const SKILL_LIST: &str = "skill.list"; +pub(super) const SKILL_READ: &str = "skill.read"; diff --git a/src-tauri/src/application/services/agent_tools/skill/read.rs b/src-tauri/src/application/services/agent_tools/skill/read.rs new file mode 100644 index 00000000..9938c7ab --- /dev/null +++ b/src-tauri/src/application/services/agent_tools/skill/read.rs @@ -0,0 +1,92 @@ +use serde_json::json; + +use super::super::common::{ + object_args, optional_usize_arg, required_trimmed_string_arg, tool_error, +}; +use super::super::dispatcher::AgentToolEffect; +use crate::application::errors::ApplicationError; +use crate::application::services::skill_service::SkillService; +use crate::domain::models::agent::{AgentToolCall, AgentToolResult}; + +pub(in crate::application::services::agent_tools) async fn read( + skill_service: &SkillService, + call: &AgentToolCall, +) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> { + let Some(args) = object_args(call) else { + return Ok(( + tool_error( + call, + "tool.invalid_arguments", + "arguments must be an object", + ), + AgentToolEffect::None, + )); + }; + let Some(name) = required_trimmed_string_arg(args, "name") else { + return Ok(( + tool_error(call, "tool.invalid_arguments", "name is required"), + AgentToolEffect::None, + )); + }; + let path = args + .get("path") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("SKILL.md"); + let max_chars = match optional_usize_arg(args, "max_chars") { + Ok(value) => value, + Err(message) => { + return Ok(( + tool_error(call, "tool.invalid_arguments", &message), + AgentToolEffect::None, + )); + } + }; + + let read = match skill_service.read_skill_file(name, path, max_chars).await { + Ok(read) => read, + Err(ApplicationError::ValidationError(message)) => { + return Ok(( + tool_error(call, "skill.invalid_request", &message), + AgentToolEffect::None, + )); + } + Err(ApplicationError::NotFound(message)) => { + return Ok(( + tool_error(call, "skill.not_found", &message), + AgentToolEffect::None, + )); + } + Err(error) => return Err(error), + }; + + let content = format!( + "{} chars from {}, sha256 {}{}\n{}", + read.chars, + read.resource_ref, + read.sha256, + if read.truncated { " (truncated)" } else { "" }, + read.content + ); + Ok(( + AgentToolResult { + call_id: call.id.clone(), + name: call.name.clone(), + content, + structured: json!({ + "name": read.name, + "path": read.path, + "bytes": read.bytes, + "sha256": read.sha256, + "chars": read.chars, + "truncated": read.truncated, + "resourceRef": read.resource_ref, + }), + is_error: false, + error_code: None, + resource_refs: vec![read.resource_ref], + }, + AgentToolEffect::None, + )) +} diff --git a/src-tauri/src/application/services/agent_tools/skill/specs.rs b/src-tauri/src/application/services/agent_tools/skill/specs.rs new file mode 100644 index 00000000..29b7e6b8 --- /dev/null +++ b/src-tauri/src/application/services/agent_tools/skill/specs.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +use super::{SKILL_LIST, SKILL_READ}; +use crate::domain::models::agent::AgentToolSpec; + +const MODEL_SKILL_LIST: &str = "skill_list"; +const MODEL_SKILL_READ: &str = "skill_read"; + +pub(in crate::application::services::agent_tools) fn skill_list_spec() -> AgentToolSpec { + AgentToolSpec { + name: SKILL_LIST.to_string(), + model_name: MODEL_SKILL_LIST.to_string(), + title: "Skill List".to_string(), + description: "List installed Agent Skills by name and description. Use this before skill_read when reusable writing, editing, planning, or character guidance may help.".to_string(), + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": {} + }), + output_schema: None, + annotations: json!({ "readOnly": true, "sourceKind": "skill" }), + source: "builtin".to_string(), + } +} + +pub(in crate::application::services::agent_tools) fn skill_read_spec() -> AgentToolSpec { + AgentToolSpec { + name: SKILL_READ.to_string(), + model_name: MODEL_SKILL_READ.to_string(), + title: "Skill Read".to_string(), + description: "Read a UTF-8 file from an installed Agent Skill. Start with SKILL.md, then follow referenced paths such as references/*.md or examples/*.md as needed.".to_string(), + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Installed Skill name from skill_list." + }, + "path": { + "type": "string", + "description": "Skill package relative file path. Defaults to SKILL.md." + }, + "max_chars": { + "type": "integer", + "description": "Maximum characters to read. Defaults to 20000; maximum is 80000." + } + }, + "required": ["name"] + }), + output_schema: None, + annotations: json!({ "readOnly": true, "sourceKind": "skill" }), + source: "builtin".to_string(), + } +} diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index 2fedc79d..42bdfd01 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -17,6 +17,7 @@ pub mod preset_service; pub mod quick_reply_service; pub mod secret_service; pub mod settings_service; +pub mod skill_service; pub mod stable_diffusion_service; pub mod theme_service; pub mod tokenization_service; diff --git a/src-tauri/src/application/services/skill_service.rs b/src-tauri/src/application/services/skill_service.rs new file mode 100644 index 00000000..f413c5c6 --- /dev/null +++ b/src-tauri/src/application/services/skill_service.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use crate::application::errors::ApplicationError; +use crate::domain::models::skill::{ + SkillExportResult, SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest, + SkillInstallResult, SkillReadResult, +}; +use crate::domain::repositories::skill_repository::SkillRepository; + +pub struct SkillService { + repository: Arc, +} + +impl SkillService { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + pub async fn list_skills(&self) -> Result, ApplicationError> { + Ok(self.repository.list_skills().await?) + } + + pub async fn preview_import( + &self, + input: SkillImportInput, + ) -> Result { + Ok(self.repository.preview_import(input).await?) + } + + pub async fn install_import( + &self, + request: SkillInstallRequest, + ) -> Result { + Ok(self.repository.install_import(request).await?) + } + + pub async fn read_skill_file( + &self, + name: &str, + path: &str, + max_chars: Option, + ) -> Result { + Ok(self + .repository + .read_skill_file(name, path, max_chars) + .await?) + } + + pub async fn export_skill(&self, name: &str) -> Result { + Ok(self.repository.export_skill(name).await?) + } + + pub async fn delete_skills_for_source( + &self, + source_kind: &str, + source_id: &str, + ) -> Result, ApplicationError> { + Ok(self + .repository + .delete_skills_for_source(source_kind, source_id) + .await?) + } +} diff --git a/src-tauri/src/domain/models/mod.rs b/src-tauri/src/domain/models/mod.rs index cbe14179..33e5fd36 100644 --- a/src-tauri/src/domain/models/mod.rs +++ b/src-tauri/src/domain/models/mod.rs @@ -11,6 +11,7 @@ pub mod preset; pub mod quick_reply; pub mod secret; pub mod settings; +pub mod skill; pub mod theme; pub mod tt_sync; pub mod update; diff --git a/src-tauri/src/domain/models/skill.rs b/src-tauri/src/domain/models/skill.rs new file mode 100644 index 00000000..40fc6eaa --- /dev/null +++ b/src-tauri/src/domain/models/skill.rs @@ -0,0 +1,177 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillIndexEntry { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default)] + pub tags: Vec, + pub installed_hash: String, + pub file_count: usize, + pub total_bytes: u64, + pub has_scripts: bool, + pub has_binary: bool, + pub installed_at: DateTime, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillSourceRef { + pub kind: String, + pub id: String, + pub label: String, + pub installed_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillFileRef { + pub path: String, + pub kind: SkillFileKind, + pub media_type: String, + pub size_bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SkillFileKind { + Text, + Binary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SkillImportPreview { + pub skill: SkillIndexEntry, + pub files: Vec, + pub conflict: SkillImportConflict, + #[serde(default)] + pub warnings: Vec, + #[serde(default)] + pub source: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillImportConflict { + pub kind: SkillImportConflictKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installed_hash: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SkillImportConflictKind { + New, + Same, + Different, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SkillInstallConflictStrategy { + Skip, + Replace, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SkillInstallRequest { + pub input: SkillImportInput, + #[serde(default)] + pub conflict_strategy: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SkillInstallResult { + pub name: String, + pub action: SkillInstallAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SkillInstallAction { + Installed, + Replaced, + AlreadyInstalled, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum SkillImportInput { + InlineFiles { + files: Vec, + #[serde(default)] + source: Value, + }, + Directory { + path: String, + #[serde(default)] + source: Value, + }, + ArchiveFile { + path: String, + #[serde(default)] + source: Value, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SkillInlineFile { + pub path: String, + #[serde(default = "default_inline_encoding")] + pub encoding: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillReadResult { + pub name: String, + pub path: String, + pub content: String, + pub chars: usize, + pub bytes: u64, + pub sha256: String, + pub truncated: bool, + pub resource_ref: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillExportResult { + pub file_name: String, + pub bytes: Vec, + pub sha256: String, +} + +fn default_inline_encoding() -> String { + "utf8".to_string() +} diff --git a/src-tauri/src/domain/repositories/mod.rs b/src-tauri/src/domain/repositories/mod.rs index ae5d2428..14bbaaf8 100644 --- a/src-tauri/src/domain/repositories/mod.rs +++ b/src-tauri/src/domain/repositories/mod.rs @@ -17,6 +17,7 @@ pub mod prompt_cache_repository; pub mod quick_reply_repository; pub mod secret_repository; pub mod settings_repository; +pub mod skill_repository; pub mod stable_diffusion_repository; pub mod theme_repository; pub mod tokenizer_repository; diff --git a/src-tauri/src/domain/repositories/skill_repository.rs b/src-tauri/src/domain/repositories/skill_repository.rs new file mode 100644 index 00000000..7bf4ee1e --- /dev/null +++ b/src-tauri/src/domain/repositories/skill_repository.rs @@ -0,0 +1,37 @@ +use async_trait::async_trait; + +use crate::domain::errors::DomainError; +use crate::domain::models::skill::{ + SkillExportResult, SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest, + SkillInstallResult, SkillReadResult, +}; + +#[async_trait] +pub trait SkillRepository: Send + Sync { + async fn list_skills(&self) -> Result, DomainError>; + + async fn preview_import( + &self, + input: SkillImportInput, + ) -> Result; + + async fn install_import( + &self, + request: SkillInstallRequest, + ) -> Result; + + async fn read_skill_file( + &self, + name: &str, + path: &str, + max_chars: Option, + ) -> Result; + + async fn export_skill(&self, name: &str) -> Result; + + async fn delete_skills_for_source( + &self, + source_kind: &str, + source_id: &str, + ) -> Result, DomainError>; +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/archive.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/archive.rs new file mode 100644 index 00000000..3132e2d1 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/archive.rs @@ -0,0 +1,162 @@ +use std::fs; +use std::io::{Cursor, Read, Write}; +use std::path::Path; + +use zip::ZipWriter; +use zip::read::ZipFile; + +use super::package::collect_skill_files; +use super::paths::normalize_skill_path; +use super::{MAX_FILES, MAX_SINGLE_FILE_BYTES, MAX_TOTAL_BYTES, MAX_ZIP_COMPRESSION_RATIO}; +use crate::domain::errors::DomainError; +use crate::infrastructure::zipkit; + +pub(super) fn extract_archive(archive_path: &Path, destination: &Path) -> Result<(), DomainError> { + let file = fs::File::open(archive_path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to open Skill archive '{}': {}", + archive_path.display(), + error + )) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + DomainError::InvalidData(format!( + "Failed to read Skill archive '{}': {}", + archive_path.display(), + error + )) + })?; + if archive.len() > MAX_FILES { + return Err(DomainError::InvalidData(format!( + "Skill archive must contain <= {MAX_FILES} entries" + ))); + } + + let mut total_bytes = 0u64; + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|error| { + DomainError::InvalidData(format!("Failed to read Skill archive entry: {error}")) + })?; + if zip_entry_is_symlink(&entry) { + return Err(DomainError::InvalidData(format!( + "Skill archive entry cannot be a symlink: {}", + entry.name() + ))); + } + if entry.size() > MAX_SINGLE_FILE_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill archive entry '{}' exceeds {} bytes", + entry.name(), + MAX_SINGLE_FILE_BYTES + ))); + } + if entry.compressed_size() > 0 + && entry.size() / entry.compressed_size() > MAX_ZIP_COMPRESSION_RATIO + { + return Err(DomainError::InvalidData(format!( + "Skill archive entry '{}' has an excessive compression ratio", + entry.name() + ))); + } + total_bytes = total_bytes + .checked_add(entry.size()) + .ok_or_else(|| DomainError::InvalidData("Skill archive is too large".to_string()))?; + if total_bytes > MAX_TOTAL_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill archive exceeds {} bytes", + MAX_TOTAL_BYTES + ))); + } + + let (entry_path, display_name) = zipkit::enclosed_zip_entry_path_with_name(&entry)?; + let raw_path = entry_path.to_string_lossy().to_string(); + if is_ignored_archive_path(&raw_path) { + continue; + } + let path = normalize_skill_path(&raw_path)?; + if entry.is_dir() { + fs::create_dir_all(destination.join(path)).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill archive directory '{}': {}", + display_name, error + )) + })?; + continue; + } + + let target = destination.join(path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill archive parent '{}': {}", + parent.display(), + error + )) + })?; + } + let mut output = fs::File::create(&target).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill archive file '{}': {}", + target.display(), + error + )) + })?; + std::io::copy(&mut entry, &mut output).map_err(|error| { + DomainError::InternalError(format!( + "Failed to extract Skill archive file '{}': {}", + target.display(), + error + )) + })?; + } + Ok(()) +} + +pub(super) fn export_skill_dir(root: &Path) -> Result, DomainError> { + let files = collect_skill_files(root)?; + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + for file in &files { + let path = root.join(&file.path); + writer + .start_file(file.path.as_str(), zipkit::export_file_options(&path)) + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to add Skill export file '{}': {}", + file.path, error + )) + })?; + let bytes = fs::read(&path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill export file '{}': {}", + path.display(), + error + )) + })?; + writer.write_all(&bytes).map_err(|error| { + DomainError::InternalError(format!( + "Failed to write Skill export file '{}': {}", + file.path, error + )) + })?; + } + + let cursor = writer.finish().map_err(|error| { + DomainError::InternalError(format!("Failed to finish Skill export archive: {error}")) + })?; + Ok(cursor.into_inner()) +} + +fn zip_entry_is_symlink(entry: &ZipFile<'_, R>) -> bool { + entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) +} + +fn is_ignored_archive_path(path: &str) -> bool { + path.split('/').any(|segment| segment == "__MACOSX") + || path.ends_with(".DS_Store") + || path + .split('/') + .next_back() + .is_some_and(|name| name.starts_with("._")) +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/fs_ops.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/fs_ops.rs new file mode 100644 index 00000000..bd4948c3 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/fs_ops.rs @@ -0,0 +1,163 @@ +use std::fs; +use std::path::Path; + +use uuid::Uuid; + +use super::MAX_SINGLE_FILE_BYTES; +use super::paths::normalize_skill_path; +use crate::domain::errors::DomainError; + +pub(super) fn copy_dir_contents(source: &Path, destination: &Path) -> Result<(), DomainError> { + fs::create_dir_all(destination).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill package directory '{}': {}", + destination.display(), + error + )) + })?; + + for entry in fs::read_dir(source).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill package directory '{}': {}", + source.display(), + error + )) + })? { + let entry = entry.map_err(|error| { + DomainError::InternalError(format!("Failed to read Skill package entry: {error}")) + })?; + let source_path = entry.path(); + let metadata = fs::symlink_metadata(&source_path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill package entry metadata '{}': {}", + source_path.display(), + error + )) + })?; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData(format!( + "Skill package cannot contain symlink: {}", + source_path.display() + ))); + } + let relative = source_path.strip_prefix(source).map_err(|error| { + DomainError::InternalError(format!("Failed to compute Skill relative path: {error}")) + })?; + let normalized = normalize_skill_path(&relative.to_string_lossy())?; + let target_path = destination.join(normalized); + if metadata.is_dir() { + copy_dir_contents(&source_path, &target_path)?; + } else if metadata.is_file() { + if metadata.len() > MAX_SINGLE_FILE_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill file '{}' exceeds {} bytes", + source_path.display(), + MAX_SINGLE_FILE_BYTES + ))); + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill package parent '{}': {}", + parent.display(), + error + )) + })?; + } + fs::copy(&source_path, &target_path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to copy Skill package file '{}' -> '{}': {}", + source_path.display(), + target_path.display(), + error + )) + })?; + } + } + Ok(()) +} + +pub(super) fn activate_package_dir(source: &Path, target: &Path) -> Result<(), DomainError> { + let backup = target.with_file_name(format!( + ".backup-{}-{}", + target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("skill"), + Uuid::new_v4().simple() + )); + let had_target = target.exists(); + if had_target { + fs::rename(target, &backup).map_err(|error| { + DomainError::InternalError(format!( + "Failed to move existing Skill '{}' to backup '{}': {}", + target.display(), + backup.display(), + error + )) + })?; + } + + let result = fs::rename(source, target).or_else(|rename_error| { + copy_dir_contents(source, target)?; + fs::remove_dir_all(source).map_err(|remove_error| { + DomainError::InternalError(format!( + "Failed to remove Skill staging directory after copy fallback. Rename error: {}; remove error: {}", + rename_error, remove_error + )) + }) + }); + + match result { + Ok(()) => { + if had_target { + cleanup_dir(&backup); + } + Ok(()) + } + Err(error) => { + if had_target { + let _ = fs::rename(&backup, target); + } + Err(error) + } + } +} + +pub(super) fn delete_installed_skill_dir(path: &Path, name: &str) -> Result<(), DomainError> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!("Skill directory not found: {name}")) + } else { + DomainError::InternalError(format!( + "Failed to read Skill directory metadata '{}': {}", + path.display(), + error + )) + } + })?; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData(format!( + "Skill directory cannot be a symlink: {name}" + ))); + } + if !metadata.is_dir() { + return Err(DomainError::InvalidData(format!( + "Skill installed path is not a directory: {name}" + ))); + } + + fs::remove_dir_all(path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to delete Skill directory '{}': {}", + path.display(), + error + )) + }) +} + +pub(super) fn cleanup_dir(path: &Path) { + if path.exists() { + let _ = fs::remove_dir_all(path); + } +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/index.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/index.rs new file mode 100644 index 00000000..a6d1cf57 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/index.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use tokio::fs as tokio_fs; + +use super::paths::validate_skill_name; +use super::{FileSkillRepository, INDEX_VERSION}; +use crate::domain::errors::DomainError; +use crate::domain::models::skill::SkillIndexEntry; +use crate::infrastructure::persistence::file_system::{ + replace_file_with_fallback, unique_temp_path, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SkillIndexFile { + pub(super) version: u32, + pub(super) skills: Vec, +} + +impl FileSkillRepository { + pub(super) async fn load_index(&self) -> Result { + self.ensure_layout().await?; + let path = self.index_path(); + let text = match tokio_fs::read_to_string(&path).await { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if self.has_installed_skill_directories().await? { + return Err(DomainError::InvalidData( + "Skill index is missing while installed skills exist".to_string(), + )); + } + let index = SkillIndexFile { + version: INDEX_VERSION, + skills: Vec::new(), + }; + self.save_index(&index).await?; + return Ok(index); + } + Err(error) => { + return Err(DomainError::InternalError(format!( + "Failed to read Skill index '{}': {}", + path.display(), + error + ))); + } + }; + + let index: SkillIndexFile = serde_json::from_str(&text).map_err(|error| { + DomainError::InvalidData(format!( + "Invalid Skill index '{}': {}", + path.display(), + error + )) + })?; + if index.version != INDEX_VERSION { + return Err(DomainError::InvalidData(format!( + "Unsupported Skill index version {}", + index.version + ))); + } + for skill in &index.skills { + validate_skill_name(&skill.name)?; + } + Ok(index) + } + + pub(super) async fn save_index(&self, index: &SkillIndexFile) -> Result<(), DomainError> { + self.ensure_layout().await?; + let path = self.index_path(); + let text = serde_json::to_string_pretty(index).map_err(|error| { + DomainError::InvalidData(format!("Failed to serialize Skill index: {error}")) + })?; + let temp = unique_temp_path(&path, "skills.json"); + tokio_fs::write(&temp, text).await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to write temporary Skill index '{}': {}", + temp.display(), + error + )) + })?; + replace_file_with_fallback(&temp, &path).await?; + Ok(()) + } + + async fn has_installed_skill_directories(&self) -> Result { + let mut entries = match tokio_fs::read_dir(self.installed_root()).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(DomainError::InternalError(format!( + "Failed to read Skill installed directory: {error}" + ))); + } + }; + while let Some(entry) = entries.next_entry().await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill installed directory entry: {error}" + )) + })? { + let metadata = entry.metadata().await.map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill installed entry metadata: {error}" + )) + })?; + if metadata.is_dir() { + return Ok(true); + } + } + Ok(false) + } +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/install.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/install.rs new file mode 100644 index 00000000..4cad1ccc --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/install.rs @@ -0,0 +1,122 @@ +use super::FileSkillRepository; +use super::fs_ops::{activate_package_dir, cleanup_dir}; +use super::materialize::PreparedImport; +use super::package::{ValidatedSkill, validate_skill_root}; +use super::source_refs::merge_source_refs; +use crate::domain::errors::DomainError; +use crate::domain::models::skill::{ + SkillImportConflict, SkillImportConflictKind, SkillInstallAction, SkillInstallConflictStrategy, + SkillInstallResult, +}; + +impl FileSkillRepository { + pub(super) async fn preview_prepared( + &self, + prepared: &PreparedImport, + ) -> Result { + let mut validated = validate_skill_root(&prepared.package_root, prepared.source.clone())?; + let index = self.load_index().await?; + let installed = index + .skills + .iter() + .find(|skill| skill.name == validated.entry.name); + validated.preview.conflict = match installed { + None => SkillImportConflict { + kind: SkillImportConflictKind::New, + installed_hash: None, + }, + Some(entry) if entry.installed_hash == validated.entry.installed_hash => { + SkillImportConflict { + kind: SkillImportConflictKind::Same, + installed_hash: Some(entry.installed_hash.clone()), + } + } + Some(entry) => SkillImportConflict { + kind: SkillImportConflictKind::Different, + installed_hash: Some(entry.installed_hash.clone()), + }, + }; + Ok(validated) + } + + pub(super) async fn install_validated( + &self, + prepared: PreparedImport, + validated: ValidatedSkill, + strategy: Option, + ) -> Result { + let mut index = self.load_index().await?; + let existing_position = index + .skills + .iter() + .position(|skill| skill.name == validated.entry.name); + + match validated.preview.conflict.kind { + SkillImportConflictKind::Same => { + let skill = match existing_position { + Some(position) => { + merge_source_refs( + &mut index.skills[position].source_refs, + validated.entry.source_refs, + ); + index.skills[position].source_refs.sort_by(|left, right| { + left.kind.cmp(&right.kind).then(left.id.cmp(&right.id)) + }); + self.save_index(&index).await?; + Some(index.skills[position].clone()) + } + None => None, + }; + cleanup_dir(&prepared.cleanup_root); + return Ok(SkillInstallResult { + name: validated.entry.name, + action: SkillInstallAction::AlreadyInstalled, + skill, + }); + } + SkillImportConflictKind::Different => match strategy { + Some(SkillInstallConflictStrategy::Skip) => { + cleanup_dir(&prepared.cleanup_root); + return Ok(SkillInstallResult { + name: validated.entry.name, + action: SkillInstallAction::Skipped, + skill: existing_position.map(|position| index.skills[position].clone()), + }); + } + Some(SkillInstallConflictStrategy::Replace) => {} + None => { + cleanup_dir(&prepared.cleanup_root); + return Err(DomainError::InvalidData(format!( + "Skill '{}' already exists with different content; conflict_strategy is required", + validated.entry.name + ))); + } + }, + SkillImportConflictKind::New => {} + } + + let target = self.installed_root().join(&validated.entry.name); + let replaced = target.exists(); + activate_package_dir(&prepared.package_root, &target)?; + cleanup_dir(&prepared.cleanup_root); + + match existing_position { + Some(position) => index.skills[position] = validated.entry.clone(), + None => index.skills.push(validated.entry.clone()), + } + index + .skills + .sort_by(|left, right| left.name.cmp(&right.name)); + self.save_index(&index).await?; + + Ok(SkillInstallResult { + name: validated.entry.name.clone(), + action: if replaced { + SkillInstallAction::Replaced + } else { + SkillInstallAction::Installed + }, + skill: Some(validated.entry), + }) + } +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/layout.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/layout.rs new file mode 100644 index 00000000..614734db --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/layout.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +use tokio::fs as tokio_fs; + +use super::FileSkillRepository; +use crate::domain::errors::DomainError; + +impl FileSkillRepository { + pub(super) fn installed_root(&self) -> PathBuf { + self.root.join("installed") + } + + pub(super) fn staging_root(&self) -> PathBuf { + self.root.join(".staging") + } + + pub(super) fn index_dir(&self) -> PathBuf { + self.root.join("index") + } + + pub(super) fn index_path(&self) -> PathBuf { + self.index_dir().join("skills.json") + } + + pub(super) async fn ensure_layout(&self) -> Result<(), DomainError> { + tokio_fs::create_dir_all(self.installed_root()) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill installed directory: {error}" + )) + })?; + tokio_fs::create_dir_all(self.staging_root()) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill staging directory: {error}" + )) + })?; + tokio_fs::create_dir_all(self.index_dir()) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill index directory: {error}" + )) + })?; + Ok(()) + } +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/manifest.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/manifest.rs new file mode 100644 index 00000000..96778eca --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/manifest.rs @@ -0,0 +1,207 @@ +use std::fs; +use std::path::Path; + +use serde::Deserialize; + +use super::{MAX_SKILL_MD_BYTES, SIDECAR_VERSION}; +use crate::domain::errors::DomainError; + +#[derive(Debug, Default)] +pub(super) struct SkillFrontmatter { + pub(super) name: String, + pub(super) description: String, + pub(super) license: Option, + pub(super) author: Option, + pub(super) version: Option, + pub(super) tags: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct TauriTavernSidecar { + pub(super) version: u32, + #[serde(default)] + pub(super) display_name: Option, + #[serde(default)] + pub(super) source_kind: Option, + #[serde(default)] + pub(super) allow_implicit_invocation: Option, + #[serde(default)] + pub(super) recommended_tools: Vec, + #[serde(default)] + pub(super) recommended_context: Option, + #[serde(default)] + pub(super) tags: Vec, + #[serde(default)] + pub(super) notes: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct TauriTavernRecommendedContext { + #[serde(default)] + pub(super) default_budget_tokens: Option, + #[serde(default)] + pub(super) preferred_mode: Option, +} + +pub(super) fn read_skill_frontmatter(root: &Path) -> Result { + let skill_md_path = root.join("SKILL.md"); + let skill_md_metadata = fs::symlink_metadata(&skill_md_path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::InvalidData("Skill package must contain SKILL.md".to_string()) + } else { + DomainError::InternalError(format!( + "Failed to read SKILL.md metadata '{}': {}", + skill_md_path.display(), + error + )) + } + })?; + if skill_md_metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData( + "SKILL.md cannot be a symlink".to_string(), + )); + } + if !skill_md_metadata.is_file() { + return Err(DomainError::InvalidData( + "SKILL.md must be a file".to_string(), + )); + } + if skill_md_metadata.len() > MAX_SKILL_MD_BYTES { + return Err(DomainError::InvalidData(format!( + "SKILL.md must be <= {MAX_SKILL_MD_BYTES} bytes" + ))); + } + + let skill_md = fs::read_to_string(&skill_md_path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read SKILL.md '{}': {}", + skill_md_path.display(), + error + )) + })?; + parse_skill_frontmatter(&skill_md) +} + +pub(super) fn parse_skill_frontmatter(text: &str) -> Result { + let normalized = text.replace("\r\n", "\n"); + let Some(rest) = normalized.strip_prefix("---\n") else { + return Err(DomainError::InvalidData( + "SKILL.md must start with YAML frontmatter".to_string(), + )); + }; + let Some(end) = rest.find("\n---") else { + return Err(DomainError::InvalidData( + "SKILL.md frontmatter is not closed".to_string(), + )); + }; + let yaml = &rest[..end]; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|error| { + DomainError::InvalidData(format!("Invalid SKILL.md frontmatter: {error}")) + })?; + let name = yaml_string(&value, "name").ok_or_else(|| { + DomainError::InvalidData("SKILL.md frontmatter must include name".to_string()) + })?; + let description = yaml_string(&value, "description").ok_or_else(|| { + DomainError::InvalidData("SKILL.md frontmatter must include description".to_string()) + })?; + if description.trim().is_empty() { + return Err(DomainError::InvalidData( + "SKILL.md description cannot be empty".to_string(), + )); + } + + let metadata = yaml_mapping_child(&value, "metadata"); + Ok(SkillFrontmatter { + name, + description, + license: yaml_string(&value, "license"), + author: metadata.and_then(|value| yaml_string(value, "author")), + version: metadata.and_then(|value| yaml_string(value, "version")), + tags: metadata + .map(|value| yaml_string_array(value, "tags")) + .unwrap_or_default(), + }) +} + +pub(super) fn read_sidecar(root: &Path) -> Result, DomainError> { + let path = root.join("agents").join("tauritavern.json"); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(DomainError::InternalError(format!( + "Failed to read Skill sidecar metadata '{}': {}", + path.display(), + error + ))); + } + }; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData( + "agents/tauritavern.json cannot be a symlink".to_string(), + )); + } + let text = fs::read_to_string(&path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill sidecar '{}': {}", + path.display(), + error + )) + })?; + let sidecar: TauriTavernSidecar = serde_json::from_str(&text).map_err(|error| { + DomainError::InvalidData(format!("Invalid agents/tauritavern.json: {error}")) + })?; + if sidecar.version != SIDECAR_VERSION { + return Err(DomainError::InvalidData(format!( + "Unsupported agents/tauritavern.json version {}", + sidecar.version + ))); + } + let _ = sidecar.allow_implicit_invocation; + let _ = &sidecar.recommended_tools; + if let Some(context) = &sidecar.recommended_context { + let _ = context.default_budget_tokens; + let _ = &context.preferred_mode; + } + let _ = &sidecar.notes; + Ok(Some(sidecar)) +} + +fn yaml_string(value: &serde_yaml::Value, key: &str) -> Option { + let map = value.as_mapping()?; + let raw = map.get(serde_yaml::Value::String(key.to_string()))?; + raw.as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn yaml_string_array(value: &serde_yaml::Value, key: &str) -> Vec { + let Some(map) = value.as_mapping() else { + return Vec::new(); + }; + let Some(raw) = map.get(serde_yaml::Value::String(key.to_string())) else { + return Vec::new(); + }; + let Some(items) = raw.as_sequence() else { + return Vec::new(); + }; + items + .iter() + .filter_map(serde_yaml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn yaml_mapping_child<'a>( + value: &'a serde_yaml::Value, + key: &str, +) -> Option<&'a serde_yaml::Value> { + value + .as_mapping()? + .get(serde_yaml::Value::String(key.to_string())) +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/materialize.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/materialize.rs new file mode 100644 index 00000000..a6094cd9 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/materialize.rs @@ -0,0 +1,249 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde_json::Value; +use uuid::Uuid; + +use super::archive::extract_archive; +use super::fs_ops::{cleanup_dir, copy_dir_contents}; +use super::package::sha256_hex; +use super::paths::normalize_skill_path; +use super::{FileSkillRepository, MAX_FILES, MAX_SINGLE_FILE_BYTES, MAX_TOTAL_BYTES}; +use crate::domain::errors::DomainError; +use crate::domain::models::skill::{SkillImportInput, SkillInlineFile}; + +pub(super) struct PreparedImport { + pub(super) cleanup_root: PathBuf, + pub(super) package_root: PathBuf, + pub(super) source: Value, +} + +impl FileSkillRepository { + pub(super) async fn materialize_input( + &self, + input: &SkillImportInput, + ) -> Result { + self.ensure_layout().await?; + let staging_dir = self + .staging_root() + .join(format!("import-{}", Uuid::new_v4().simple())); + tokio::fs::create_dir_all(&staging_dir) + .await + .map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill staging directory '{}': {}", + staging_dir.display(), + error + )) + })?; + + let result = match input { + SkillImportInput::InlineFiles { files, source } => { + let package_root = staging_dir.join("package"); + fs::create_dir_all(&package_root).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill inline package root '{}': {}", + package_root.display(), + error + )) + })?; + write_inline_files(files, &package_root)?; + Ok(PreparedImport { + cleanup_root: staging_dir.clone(), + package_root, + source: source.clone(), + }) + } + SkillImportInput::Directory { path, source } => { + let source_root = PathBuf::from(path); + let selected_root = select_skill_root(&source_root)?; + let package_root = staging_dir.join("package"); + copy_dir_contents(&selected_root, &package_root)?; + Ok(PreparedImport { + cleanup_root: staging_dir.clone(), + package_root, + source: source.clone(), + }) + } + SkillImportInput::ArchiveFile { path, source } => { + let archive_root = staging_dir.join("archive"); + fs::create_dir_all(&archive_root).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill archive extraction root '{}': {}", + archive_root.display(), + error + )) + })?; + extract_archive(Path::new(path), &archive_root)?; + let selected_root = select_skill_root(&archive_root)?; + let package_root = staging_dir.join("package"); + copy_dir_contents(&selected_root, &package_root)?; + Ok(PreparedImport { + cleanup_root: staging_dir.clone(), + package_root, + source: source.clone(), + }) + } + }; + + if result.is_err() { + cleanup_dir(&staging_dir); + } + result + } +} + +fn write_inline_files(files: &[SkillInlineFile], root: &Path) -> Result<(), DomainError> { + if files.is_empty() { + return Err(DomainError::InvalidData( + "Skill inline import must include at least one file".to_string(), + )); + } + if files.len() > MAX_FILES { + return Err(DomainError::InvalidData(format!( + "Skill inline import must include <= {MAX_FILES} files" + ))); + } + let mut total_bytes = 0u64; + for file in files { + let path = normalize_skill_path(&file.path)?; + let bytes = match file.encoding.trim().to_ascii_lowercase().as_str() { + "utf8" | "utf-8" => file.content.as_bytes().to_vec(), + "base64" => BASE64_STANDARD + .decode(file.content.as_bytes()) + .map_err(|error| { + DomainError::InvalidData(format!( + "Invalid base64 content for '{}': {error}", + path + )) + })?, + encoding => { + return Err(DomainError::InvalidData(format!( + "Unsupported Skill inline encoding '{encoding}' for '{path}'" + ))); + } + }; + if let Some(expected_size) = file.size_bytes { + if expected_size != bytes.len() as u64 { + return Err(DomainError::InvalidData(format!( + "Inline Skill file '{}' size mismatch: expected {}, got {}", + path, + expected_size, + bytes.len() + ))); + } + } + if let Some(expected_hash) = file.sha256.as_deref() { + if expected_hash.trim().to_ascii_lowercase() != sha256_hex(&bytes) { + return Err(DomainError::InvalidData(format!( + "Inline Skill file '{}' sha256 mismatch", + path + ))); + } + } + if bytes.len() as u64 > MAX_SINGLE_FILE_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill file '{}' exceeds {} bytes", + path, MAX_SINGLE_FILE_BYTES + ))); + } + total_bytes = total_bytes + .checked_add(bytes.len() as u64) + .ok_or_else(|| DomainError::InvalidData("Skill package is too large".to_string()))?; + if total_bytes > MAX_TOTAL_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill package exceeds {} bytes", + MAX_TOTAL_BYTES + ))); + } + + let target = root.join(&path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| { + DomainError::InternalError(format!( + "Failed to create Skill inline parent '{}': {}", + parent.display(), + error + )) + })?; + } + fs::write(&target, bytes).map_err(|error| { + DomainError::InternalError(format!( + "Failed to write Skill inline file '{}': {}", + target.display(), + error + )) + })?; + } + Ok(()) +} + +fn select_skill_root(root: &Path) -> Result { + let metadata = fs::symlink_metadata(root).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill import root '{}': {}", + root.display(), + error + )) + })?; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData( + "Skill import root cannot be a symlink".to_string(), + )); + } + if !metadata.is_dir() { + return Err(DomainError::InvalidData(format!( + "Skill import root is not a directory: {}", + root.display() + ))); + } + let mut candidates = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill import root '{}': {}", + root.display(), + error + )) + })? { + let entry = entry.map_err(|error| { + DomainError::InternalError(format!("Failed to read Skill import entry: {error}")) + })?; + let metadata = fs::symlink_metadata(entry.path()).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill import entry metadata '{}': {}", + entry.path().display(), + error + )) + })?; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData( + "Skill import package cannot contain symlink roots".to_string(), + )); + } + if metadata.is_dir() && entry.path().join("SKILL.md").is_file() { + candidates.push(entry.path()); + } + } + + if root.join("SKILL.md").is_file() { + if !candidates.is_empty() { + return Err(DomainError::InvalidData( + "Skill package contains multiple candidate SKILL.md roots".to_string(), + )); + } + return Ok(root.to_path_buf()); + } + + match candidates.len() { + 1 => Ok(candidates.remove(0)), + 0 => Err(DomainError::InvalidData( + "Skill package must contain exactly one SKILL.md at root or one top-level folder" + .to_string(), + )), + _ => Err(DomainError::InvalidData( + "Skill package contains multiple candidate SKILL.md roots".to_string(), + )), + } +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/mod.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/mod.rs new file mode 100644 index 00000000..6994d3c0 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/mod.rs @@ -0,0 +1,122 @@ +mod archive; +mod fs_ops; +mod index; +mod install; +mod layout; +mod manifest; +mod materialize; +mod package; +mod paths; +mod read; +mod source_refs; + +#[cfg(test)] +mod tests; + +use std::path::PathBuf; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use crate::domain::errors::DomainError; +use crate::domain::models::skill::{ + SkillExportResult, SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest, + SkillInstallResult, SkillReadResult, +}; +use crate::domain::repositories::skill_repository::SkillRepository; + +const INDEX_VERSION: u32 = 1; +const SIDECAR_VERSION: u32 = 1; +const MAX_FILES: usize = 1000; +const MAX_SINGLE_FILE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024; +const MAX_SKILL_MD_BYTES: u64 = 1024 * 1024; +const MAX_ZIP_COMPRESSION_RATIO: u64 = 100; +const DEFAULT_READ_MAX_CHARS: usize = 20_000; +const MAX_READ_CHARS: usize = 80_000; + +pub struct FileSkillRepository { + pub(super) root: PathBuf, + mutation_lock: Mutex<()>, +} + +impl FileSkillRepository { + pub fn new(root: PathBuf) -> Self { + Self { + root, + mutation_lock: Mutex::new(()), + } + } +} + +#[async_trait] +impl SkillRepository for FileSkillRepository { + async fn list_skills(&self) -> Result, DomainError> { + Ok(self.load_index().await?.skills) + } + + async fn preview_import( + &self, + input: SkillImportInput, + ) -> Result { + let prepared = self.materialize_input(&input).await?; + let result = self + .preview_prepared(&prepared) + .await + .map(|validated| validated.preview); + fs_ops::cleanup_dir(&prepared.cleanup_root); + result + } + + async fn install_import( + &self, + request: SkillInstallRequest, + ) -> Result { + let _guard = self.mutation_lock.lock().await; + let prepared = self.materialize_input(&request.input).await?; + let validated = match self.preview_prepared(&prepared).await { + Ok(validated) => validated, + Err(error) => { + fs_ops::cleanup_dir(&prepared.cleanup_root); + return Err(error); + } + }; + self.install_validated(prepared, validated, request.conflict_strategy) + .await + } + + async fn read_skill_file( + &self, + name: &str, + path: &str, + max_chars: Option, + ) -> Result { + read::read_skill_file(self, name, path, max_chars).await + } + + async fn export_skill(&self, name: &str) -> Result { + let name = paths::validate_skill_name(name)?; + let index = self.load_index().await?; + if !index.skills.iter().any(|skill| skill.name == name) { + return Err(DomainError::NotFound(format!("Skill not found: {name}"))); + } + + let root = self.installed_root().join(&name); + let bytes = archive::export_skill_dir(&root)?; + let sha256 = package::sha256_hex(&bytes); + Ok(SkillExportResult { + file_name: format!("{name}.ttskill"), + bytes, + sha256, + }) + } + + async fn delete_skills_for_source( + &self, + source_kind: &str, + source_id: &str, + ) -> Result, DomainError> { + let _guard = self.mutation_lock.lock().await; + source_refs::delete_skills_for_source(self, source_kind, source_id).await + } +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/package.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/package.rs new file mode 100644 index 00000000..fc8b0ed5 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/package.rs @@ -0,0 +1,229 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use chrono::Utc; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::manifest::{read_sidecar, read_skill_frontmatter}; +use super::paths::{normalize_optional_string, normalize_skill_path, validate_skill_name}; +use super::source_refs::skill_source_ref_from_import_source; +use super::{MAX_FILES, MAX_SINGLE_FILE_BYTES, MAX_TOTAL_BYTES}; +use crate::domain::errors::DomainError; +use crate::domain::models::skill::{ + SkillFileKind, SkillFileRef, SkillImportConflict, SkillImportConflictKind, SkillImportPreview, + SkillIndexEntry, +}; + +pub(super) struct ValidatedSkill { + pub(super) entry: SkillIndexEntry, + pub(super) preview: SkillImportPreview, +} + +pub(super) fn validate_skill_root( + root: &Path, + source: Value, +) -> Result { + let frontmatter = read_skill_frontmatter(root)?; + let name = validate_skill_name(&frontmatter.name)?; + + let sidecar = read_sidecar(root)?; + let files = collect_skill_files(root)?; + let file_count = files.len(); + let total_bytes = files.iter().map(|file| file.size_bytes).sum(); + let has_scripts = files + .iter() + .any(|file| file.path == "scripts" || file.path.starts_with("scripts/")); + let has_binary = files.iter().any(|file| file.kind == SkillFileKind::Binary); + let installed_hash = directory_hash(root, &files)?; + let mut tags = BTreeSet::new(); + tags.extend(frontmatter.tags.iter().cloned()); + if let Some(sidecar) = &sidecar { + tags.extend( + sidecar + .tags + .iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()), + ); + } + + let mut warnings = Vec::new(); + if has_scripts { + warnings.push( + "Skill contains scripts/ files; TauriTavern stores them but does not execute them." + .to_string(), + ); + } + if has_binary { + warnings.push( + "Skill contains binary files; Agent skill.read can only read UTF-8 text files." + .to_string(), + ); + } + if frontmatter.license.is_none() { + warnings.push("Skill has no license field.".to_string()); + } + + let entry = SkillIndexEntry { + name, + description: frontmatter.description, + display_name: sidecar + .as_ref() + .and_then(|sidecar| normalize_optional_string(sidecar.display_name.as_deref())), + source_kind: sidecar + .as_ref() + .and_then(|sidecar| normalize_optional_string(sidecar.source_kind.as_deref())), + license: frontmatter.license, + author: frontmatter.author, + version: frontmatter.version, + tags: tags.into_iter().collect(), + installed_hash, + file_count, + total_bytes, + has_scripts, + has_binary, + installed_at: Utc::now(), + source_refs: Vec::new(), + }; + let mut entry = entry; + if let Some(source_ref) = skill_source_ref_from_import_source(&source, &entry.installed_hash)? { + entry.source_refs.push(source_ref); + } + + Ok(ValidatedSkill { + preview: SkillImportPreview { + skill: entry.clone(), + files, + conflict: SkillImportConflict { + kind: SkillImportConflictKind::New, + installed_hash: None, + }, + warnings, + source, + }, + entry, + }) +} + +pub(super) fn collect_skill_files(root: &Path) -> Result, DomainError> { + let mut files = Vec::new(); + collect_skill_files_inner(root, root, &mut files)?; + files.sort_by(|left, right| left.path.cmp(&right.path)); + if files.is_empty() { + return Err(DomainError::InvalidData( + "Skill package cannot be empty".to_string(), + )); + } + if files.len() > MAX_FILES { + return Err(DomainError::InvalidData(format!( + "Skill package must contain <= {MAX_FILES} files" + ))); + } + let total = files.iter().map(|file| file.size_bytes).sum::(); + if total > MAX_TOTAL_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill package exceeds {} bytes", + MAX_TOTAL_BYTES + ))); + } + Ok(files) +} + +fn collect_skill_files_inner( + root: &Path, + current: &Path, + files: &mut Vec, +) -> Result<(), DomainError> { + for entry in fs::read_dir(current).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill directory '{}': {}", + current.display(), + error + )) + })? { + let entry = entry.map_err(|error| { + DomainError::InternalError(format!("Failed to read Skill directory entry: {error}")) + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill file metadata '{}': {}", + path.display(), + error + )) + })?; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData(format!( + "Skill package cannot contain symlink: {}", + path.display() + ))); + } + if metadata.is_dir() { + collect_skill_files_inner(root, &path, files)?; + continue; + } + if !metadata.is_file() { + continue; + } + if metadata.len() > MAX_SINGLE_FILE_BYTES { + return Err(DomainError::InvalidData(format!( + "Skill file '{}' exceeds {} bytes", + path.display(), + MAX_SINGLE_FILE_BYTES + ))); + } + let relative = path.strip_prefix(root).map_err(|error| { + DomainError::InternalError(format!("Failed to compute Skill file path: {error}")) + })?; + let normalized = normalize_skill_path(&relative.to_string_lossy())?; + let bytes = fs::read(&path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill file '{}': {}", + path.display(), + error + )) + })?; + let kind = if std::str::from_utf8(&bytes).is_ok() { + SkillFileKind::Text + } else { + SkillFileKind::Binary + }; + let media_type = mime_guess::from_path(&normalized) + .first_or_octet_stream() + .essence_str() + .to_string(); + files.push(SkillFileRef { + path: normalized, + kind, + media_type, + size_bytes: metadata.len(), + sha256: sha256_hex(&bytes), + }); + } + Ok(()) +} + +fn directory_hash(root: &Path, files: &[SkillFileRef]) -> Result { + let mut hasher = Sha256::new(); + for file in files { + hasher.update(file.path.as_bytes()); + hasher.update([0]); + let bytes = fs::read(root.join(&file.path)).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill file '{}' for hashing: {}", + file.path, error + )) + })?; + hasher.update(bytes); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +pub(super) fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/paths.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/paths.rs new file mode 100644 index 00000000..4aaa27c7 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/paths.rs @@ -0,0 +1,99 @@ +use crate::domain::errors::DomainError; + +pub(super) fn validate_skill_name(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(DomainError::InvalidData( + "Skill name cannot be empty".to_string(), + )); + } + if value.len() > 128 { + return Err(DomainError::InvalidData( + "Skill name must be <= 128 characters".to_string(), + )); + } + if matches!(value, "." | "..") { + return Err(DomainError::InvalidData( + "Skill name cannot be '.' or '..'".to_string(), + )); + } + if !value + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')) + { + return Err(DomainError::InvalidData( + "Skill name must use lowercase ASCII letters, digits, '-' or '_'".to_string(), + )); + } + Ok(value.to_string()) +} + +pub(super) fn normalize_skill_path(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(DomainError::InvalidData( + "Skill file path cannot be empty".to_string(), + )); + } + if value.contains('\0') { + return Err(DomainError::InvalidData( + "Skill file path cannot contain NUL".to_string(), + )); + } + if value.starts_with('/') || value.starts_with('\\') { + return Err(DomainError::InvalidData( + "Skill file path must be relative".to_string(), + )); + } + if value.len() >= 2 && value.as_bytes()[1] == b':' && value.as_bytes()[0].is_ascii_alphabetic() + { + return Err(DomainError::InvalidData( + "Skill file path cannot use a Windows drive prefix".to_string(), + )); + } + + let normalized = value.replace('\\', "/"); + let mut parts = Vec::new(); + for segment in normalized.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return Err(DomainError::InvalidData( + "Skill file path cannot contain ..".to_string(), + )); + } + if matches!(segment, ".git" | ".ssh" | ".env") { + return Err(DomainError::InvalidData(format!( + "Skill file path contains forbidden segment: {segment}" + ))); + } + parts.push(segment); + } + if parts.is_empty() { + return Err(DomainError::InvalidData( + "Skill file path cannot be empty".to_string(), + )); + } + Ok(parts.join("/")) +} + +pub(super) fn normalize_source_string(raw: &str, label: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(DomainError::InvalidData(format!("{label} cannot be empty"))); + } + if value.contains('\0') { + return Err(DomainError::InvalidData(format!( + "{label} cannot contain NUL" + ))); + } + Ok(value.to_string()) +} + +pub(super) fn normalize_optional_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/read.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/read.rs new file mode 100644 index 00000000..ca65601a --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/read.rs @@ -0,0 +1,130 @@ +use std::fs; + +use super::paths::{normalize_skill_path, validate_skill_name}; +use super::{DEFAULT_READ_MAX_CHARS, FileSkillRepository, MAX_READ_CHARS}; +use crate::domain::errors::DomainError; +use crate::domain::models::skill::SkillReadResult; + +pub(super) async fn read_skill_file( + repository: &FileSkillRepository, + name: &str, + path: &str, + max_chars: Option, +) -> Result { + let name = validate_skill_name(name)?; + let path = normalize_skill_path(path)?; + let index = repository.load_index().await?; + if !index.skills.iter().any(|skill| skill.name == name) { + return Err(DomainError::NotFound(format!("Skill not found: {name}"))); + } + + let skill_root = repository.installed_root().join(&name); + let root_metadata = fs::symlink_metadata(&skill_root).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!("Skill directory not found: {name}")) + } else { + DomainError::InternalError(format!( + "Failed to read Skill directory metadata '{}': {}", + skill_root.display(), + error + )) + } + })?; + if root_metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData(format!( + "Skill directory cannot be a symlink: {name}" + ))); + } + if !root_metadata.is_dir() { + return Err(DomainError::InvalidData(format!( + "Skill installed path is not a directory: {name}" + ))); + } + + let full_path = skill_root.join(&path); + let metadata = fs::symlink_metadata(&full_path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!("Skill file not found: skills/{name}/{path}")) + } else { + DomainError::InternalError(format!( + "Failed to read Skill file metadata '{}': {}", + full_path.display(), + error + )) + } + })?; + if metadata.file_type().is_symlink() { + return Err(DomainError::InvalidData(format!( + "Skill file cannot be a symlink: skills/{name}/{path}" + ))); + } + if !metadata.is_file() { + return Err(DomainError::InvalidData(format!( + "Skill path is not a file: skills/{name}/{path}" + ))); + } + + let canonical_root = fs::canonicalize(&skill_root).map_err(|error| { + DomainError::InternalError(format!( + "Failed to resolve Skill directory '{}': {}", + skill_root.display(), + error + )) + })?; + let canonical_file = fs::canonicalize(&full_path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to resolve Skill file '{}': {}", + full_path.display(), + error + )) + })?; + if !canonical_file.starts_with(&canonical_root) { + return Err(DomainError::InvalidData(format!( + "Skill file escapes installed directory: skills/{name}/{path}" + ))); + } + + let bytes = fs::read(&full_path).map_err(|error| { + DomainError::InternalError(format!( + "Failed to read Skill file '{}': {}", + full_path.display(), + error + )) + })?; + let text = std::str::from_utf8(&bytes).map_err(|_| { + DomainError::InvalidData(format!( + "Skill file is not UTF-8 text: skills/{name}/{path}" + )) + })?; + let requested_chars = max_chars.unwrap_or(DEFAULT_READ_MAX_CHARS); + if requested_chars == 0 { + return Err(DomainError::InvalidData( + "max_chars must be greater than 0".to_string(), + )); + } + if requested_chars > MAX_READ_CHARS { + return Err(DomainError::InvalidData(format!( + "max_chars must be <= {MAX_READ_CHARS}" + ))); + } + + let total_chars = text.chars().count(); + let truncated = total_chars > requested_chars; + let content = if truncated { + text.chars().take(requested_chars).collect() + } else { + text.to_string() + }; + let sha256 = super::package::sha256_hex(&bytes); + + Ok(SkillReadResult { + name: name.clone(), + path: path.clone(), + content, + chars: total_chars.min(requested_chars), + bytes: bytes.len() as u64, + sha256, + truncated, + resource_ref: format!("skills/{name}/{path}"), + }) +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/source_refs.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/source_refs.rs new file mode 100644 index 00000000..121a05b5 --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/source_refs.rs @@ -0,0 +1,107 @@ +use serde_json::Value; + +use super::FileSkillRepository; +use super::fs_ops::delete_installed_skill_dir; +use super::paths::normalize_source_string; +use crate::domain::errors::DomainError; +use crate::domain::models::skill::SkillSourceRef; + +pub(super) fn skill_source_ref_from_import_source( + source: &Value, + installed_hash: &str, +) -> Result, DomainError> { + let Some(kind) = source + .get("kind") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + + let id = source + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let Some(id) = id else { + if matches!(kind, "preset" | "character") { + return Err(DomainError::InvalidData(format!( + "Skill import source.id is required for source kind '{kind}'" + ))); + } + return Ok(None); + }; + + let kind = normalize_source_string(kind, "source kind")?; + let id = normalize_source_string(id, "source id")?; + let label = source + .get("label") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| id.clone()); + + Ok(Some(SkillSourceRef { + kind, + id, + label, + installed_hash: installed_hash.to_string(), + })) +} + +pub(super) fn merge_source_refs( + target: &mut Vec, + source_refs: Vec, +) { + for source_ref in source_refs { + target.retain(|existing| existing.kind != source_ref.kind || existing.id != source_ref.id); + target.push(source_ref); + } +} + +pub(super) async fn delete_skills_for_source( + repository: &FileSkillRepository, + source_kind: &str, + source_id: &str, +) -> Result, DomainError> { + let source_kind = normalize_source_string(source_kind, "source kind")?; + let source_id = normalize_source_string(source_id, "source id")?; + let mut index = repository.load_index().await?; + let mut next_skills = Vec::with_capacity(index.skills.len()); + let mut deleted = Vec::new(); + let mut changed = false; + + for mut skill in index.skills { + let original_len = skill.source_refs.len(); + skill + .source_refs + .retain(|source| source.kind != source_kind || source.id != source_id); + + if skill.source_refs.len() == original_len { + next_skills.push(skill); + continue; + } + + changed = true; + if skill.source_refs.is_empty() { + delete_installed_skill_dir( + &repository.installed_root().join(&skill.name), + &skill.name, + )?; + deleted.push(skill.name); + } else { + next_skills.push(skill); + } + } + + if changed { + next_skills.sort_by(|left, right| left.name.cmp(&right.name)); + index.skills = next_skills; + repository.save_index(&index).await?; + } + + Ok(deleted) +} diff --git a/src-tauri/src/infrastructure/repositories/file_skill_repository/tests.rs b/src-tauri/src/infrastructure/repositories/file_skill_repository/tests.rs new file mode 100644 index 00000000..a98ee40c --- /dev/null +++ b/src-tauri/src/infrastructure/repositories/file_skill_repository/tests.rs @@ -0,0 +1,364 @@ +use std::path::PathBuf; + +use serde_json::{Value, json}; +use tokio::fs as tokio_fs; +use uuid::Uuid; + +use super::*; +use crate::domain::models::skill::{ + SkillImportConflictKind, SkillImportInput, SkillInlineFile, SkillInstallAction, + SkillInstallConflictStrategy, SkillInstallRequest, +}; +use crate::domain::repositories::skill_repository::SkillRepository; + +fn temp_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "tauritavern-skill-{label}-{}", + Uuid::new_v4().simple() + )) +} + +fn inline_skill(name: &str, extra_files: Vec<(&str, &str)>) -> SkillImportInput { + inline_skill_with_source(name, extra_files, json!({"kind": "test"})) +} + +fn inline_skill_with_source( + name: &str, + extra_files: Vec<(&str, &str)>, + source: Value, +) -> SkillImportInput { + let mut files = vec![SkillInlineFile { + path: "SKILL.md".to_string(), + encoding: "utf8".to_string(), + content: format!( + "---\nname: {name}\ndescription: Use for testing Skill imports.\nmetadata:\n tags:\n - tests\n---\n\n# Test\n" + ), + media_type: None, + size_bytes: None, + sha256: None, + }]; + files.extend( + extra_files + .into_iter() + .map(|(path, content)| SkillInlineFile { + path: path.to_string(), + encoding: "utf8".to_string(), + content: content.to_string(), + media_type: None, + size_bytes: None, + sha256: None, + }), + ); + SkillImportInput::InlineFiles { files, source } +} + +#[tokio::test] +async fn installs_inline_skill_and_reads_file() { + let root = temp_root("install"); + let repository = FileSkillRepository::new(root.clone()); + let result = repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "hello")]), + conflict_strategy: None, + }) + .await + .expect("install skill"); + + assert_eq!(result.action, SkillInstallAction::Installed); + let listed = repository.list_skills().await.expect("list skills"); + assert_eq!(listed[0].name, "test-skill"); + assert_eq!(listed[0].tags, vec!["tests"]); + + let read = repository + .read_skill_file("test-skill", "references/a.md", None) + .await + .expect("read skill file"); + assert_eq!(read.content, "hello"); + assert_eq!(read.resource_ref, "skills/test-skill/references/a.md"); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn requires_explicit_replace_on_conflict() { + let root = temp_root("conflict"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "one")]), + conflict_strategy: None, + }) + .await + .expect("install initial"); + + let error = repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "two")]), + conflict_strategy: None, + }) + .await + .expect_err("conflict should fail"); + assert!(error.to_string().contains("conflict_strategy is required")); + + let replaced = repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "two")]), + conflict_strategy: Some(SkillInstallConflictStrategy::Replace), + }) + .await + .expect("replace skill"); + assert_eq!(replaced.action, SkillInstallAction::Replaced); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn deletes_skill_when_last_linked_source_is_deleted() { + let root = temp_root("delete-source-last"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill_with_source( + "test-skill", + vec![], + json!({"kind":"preset","id":"preset:openai:One","label":"One"}), + ), + conflict_strategy: None, + }) + .await + .expect("install skill"); + + let deleted = repository + .delete_skills_for_source("preset", "preset:openai:One") + .await + .expect("delete linked skills"); + assert_eq!(deleted, vec!["test-skill"]); + assert!(repository.list_skills().await.expect("list").is_empty()); + assert!(!root.join("installed").join("test-skill").exists()); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn keeps_skill_until_all_linked_sources_are_deleted() { + let root = temp_root("delete-source-shared"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill_with_source( + "test-skill", + vec![], + json!({"kind":"preset","id":"preset:openai:One","label":"One"}), + ), + conflict_strategy: None, + }) + .await + .expect("install first source"); + repository + .install_import(SkillInstallRequest { + input: inline_skill_with_source( + "test-skill", + vec![], + json!({"kind":"character","id":"character:Aurelia","label":"Aurelia"}), + ), + conflict_strategy: None, + }) + .await + .expect("link same skill to second source"); + + let deleted = repository + .delete_skills_for_source("preset", "preset:openai:One") + .await + .expect("delete first source"); + assert!(deleted.is_empty()); + let listed = repository.list_skills().await.expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].source_refs.len(), 1); + assert_eq!(listed[0].source_refs[0].id, "character:Aurelia"); + + let deleted = repository + .delete_skills_for_source("character", "character:Aurelia") + .await + .expect("delete second source"); + assert_eq!(deleted, vec!["test-skill"]); + assert!(repository.list_skills().await.expect("list").is_empty()); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn replacing_skill_drops_previous_source_links() { + let root = temp_root("replace-source-links"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill_with_source( + "test-skill", + vec![("references/a.md", "one")], + json!({"kind":"preset","id":"preset:openai:One","label":"One"}), + ), + conflict_strategy: None, + }) + .await + .expect("install first source"); + repository + .install_import(SkillInstallRequest { + input: inline_skill_with_source( + "test-skill", + vec![("references/a.md", "two")], + json!({"kind":"character","id":"character:Aurelia","label":"Aurelia"}), + ), + conflict_strategy: Some(SkillInstallConflictStrategy::Replace), + }) + .await + .expect("replace skill"); + + let deleted = repository + .delete_skills_for_source("preset", "preset:openai:One") + .await + .expect("delete old source"); + assert!(deleted.is_empty()); + let listed = repository.list_skills().await.expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].source_refs.len(), 1); + assert_eq!(listed[0].source_refs[0].id, "character:Aurelia"); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn rejects_invalid_sidecar_schema() { + let root = temp_root("sidecar"); + let repository = FileSkillRepository::new(root.clone()); + let error = repository + .preview_import(inline_skill( + "test-skill", + vec![( + "agents/tauritavern.json", + r#"{"version":1,"unexpected":true}"#, + )], + )) + .await + .expect_err("invalid sidecar should fail"); + assert!( + error + .to_string() + .contains("Invalid agents/tauritavern.json") + ); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn exported_skill_archive_can_be_reimported() { + let root = temp_root("export"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "hello")]), + conflict_strategy: None, + }) + .await + .expect("install skill"); + + let exported = repository + .export_skill("test-skill") + .await + .expect("export skill"); + let archive_path = root.join("test-skill.ttskill"); + tokio_fs::write(&archive_path, exported.bytes) + .await + .expect("write archive"); + + let second_root = temp_root("reimport"); + let second_repository = FileSkillRepository::new(second_root.clone()); + let preview = second_repository + .preview_import(SkillImportInput::ArchiveFile { + path: archive_path.to_string_lossy().to_string(), + source: json!({"kind": "test"}), + }) + .await + .expect("preview exported archive"); + assert_eq!(preview.skill.name, "test-skill"); + assert_eq!(preview.conflict.kind, SkillImportConflictKind::New); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); + tokio_fs::remove_dir_all(second_root) + .await + .expect("cleanup"); +} + +#[tokio::test] +async fn exported_skill_roundtrip_preserves_hash() { + let root = temp_root("export-same"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "hello")]), + conflict_strategy: None, + }) + .await + .expect("install skill"); + + let installed_hash = repository.list_skills().await.expect("list skills")[0] + .installed_hash + .clone(); + let exported = repository + .export_skill("test-skill") + .await + .expect("export skill"); + let archive_path = root.join("test-skill.ttskill"); + tokio_fs::write(&archive_path, exported.bytes) + .await + .expect("write archive"); + + let preview = repository + .preview_import(SkillImportInput::ArchiveFile { + path: archive_path.to_string_lossy().to_string(), + source: json!({"kind": "test"}), + }) + .await + .expect("preview exported archive"); + + assert_eq!(preview.conflict.kind, SkillImportConflictKind::Same); + assert_eq!(preview.skill.installed_hash, installed_hash); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[cfg(unix)] +#[tokio::test] +async fn read_rejects_symlink_escape_inside_installed_skill() { + use std::os::unix::fs::symlink; + + let root = temp_root("symlink-escape"); + let repository = FileSkillRepository::new(root.clone()); + repository + .install_import(SkillInstallRequest { + input: inline_skill("test-skill", vec![("references/a.md", "hello")]), + conflict_strategy: None, + }) + .await + .expect("install skill"); + + let skill_root = root.join("installed").join("test-skill"); + let outside = root.join("outside"); + tokio_fs::create_dir_all(&outside) + .await + .expect("create outside dir"); + tokio_fs::write(outside.join("a.md"), "outside") + .await + .expect("write outside file"); + tokio_fs::remove_dir_all(skill_root.join("references")) + .await + .expect("remove references dir"); + symlink(&outside, skill_root.join("references")).expect("create symlink"); + + let error = repository + .read_skill_file("test-skill", "references/a.md", None) + .await + .expect_err("symlink escape should fail"); + assert!(error.to_string().contains("escapes installed directory")); + + tokio_fs::remove_dir_all(root).await.expect("cleanup"); +} diff --git a/src-tauri/src/infrastructure/repositories/mod.rs b/src-tauri/src/infrastructure/repositories/mod.rs index 11b04d28..f8002310 100644 --- a/src-tauri/src/infrastructure/repositories/mod.rs +++ b/src-tauri/src/infrastructure/repositories/mod.rs @@ -13,6 +13,7 @@ pub mod file_prompt_cache_repository; pub mod file_quick_reply_repository; pub mod file_secret_repository; pub mod file_settings_repository; +pub mod file_skill_repository; pub mod file_theme_repository; pub mod file_user_directory_repository; pub mod file_user_repository; diff --git a/src-tauri/src/presentation/commands/character_commands.rs b/src-tauri/src/presentation/commands/character_commands.rs index 02e94a4c..e0714717 100644 --- a/src-tauri/src/presentation/commands/character_commands.rs +++ b/src-tauri/src/presentation/commands/character_commands.rs @@ -12,6 +12,8 @@ use crate::application::dto::character_dto::{ use crate::presentation::commands::helpers::{log_command, map_command_error}; use crate::presentation::errors::CommandError; +const SKILL_SOURCE_KIND_CHARACTER: &str = "character"; + #[tauri::command] pub async fn get_all_characters( shallow: bool, @@ -126,11 +128,25 @@ pub async fn delete_character( ) -> Result<(), CommandError> { log_command(format!("delete_character {}", dto.name)); + let name = dto.name.clone(); app_state .character_service .delete_character(dto) .await - .map_err(map_command_error("Failed to delete character")) + .map_err(map_command_error("Failed to delete character"))?; + + app_state + .skill_service + .delete_skills_for_source( + SKILL_SOURCE_KIND_CHARACTER, + &character_skill_source_id(&name), + ) + .await + .map_err(map_command_error( + "Failed to delete Agent Skills linked to character", + ))?; + + Ok(()) } #[tauri::command] @@ -238,3 +254,7 @@ pub async fn clear_character_cache( .await .map_err(map_command_error("Failed to clear character cache")) } + +fn character_skill_source_id(name: &str) -> String { + format!("character:{}", name.trim()) +} diff --git a/src-tauri/src/presentation/commands/mod.rs b/src-tauri/src/presentation/commands/mod.rs index 069de463..2660e498 100644 --- a/src-tauri/src/presentation/commands/mod.rs +++ b/src-tauri/src/presentation/commands/mod.rs @@ -29,6 +29,7 @@ pub mod registry; pub mod runtime_paths_commands; pub mod secret_commands; pub mod settings_commands; +pub mod skill_commands; pub mod stable_diffusion_commands; pub mod theme_commands; pub mod tokenizer_commands; diff --git a/src-tauri/src/presentation/commands/preset_commands.rs b/src-tauri/src/presentation/commands/preset_commands.rs index 9f3be55a..bf9938cd 100644 --- a/src-tauri/src/presentation/commands/preset_commands.rs +++ b/src-tauri/src/presentation/commands/preset_commands.rs @@ -11,6 +11,8 @@ use crate::domain::models::preset::PresetType; use crate::infrastructure::logging::logger; use crate::presentation::errors::CommandError; +const SKILL_SOURCE_KIND_PRESET: &str = "preset"; + /// Save a preset #[tauri::command] pub async fn save_preset( @@ -95,6 +97,27 @@ pub async fn delete_preset( CommandError::from(e) })?; + let source_id = preset_skill_source_id(preset_type.to_api_id(), &dto.name); + let deleted_skills = app_state + .skill_service + .delete_skills_for_source(SKILL_SOURCE_KIND_PRESET, &source_id) + .await + .map_err(|e| { + logger::error(&format!( + "Failed to delete Agent Skills linked to preset '{}': {}", + dto.name, e + )); + CommandError::from(e) + })?; + if !deleted_skills.is_empty() { + logger::info(&format!( + "Deleted {} Agent Skill(s) linked to preset '{}': {}", + deleted_skills.len(), + dto.name, + deleted_skills.join(", ") + )); + } + logger::info(&format!("Preset deleted successfully: {}", dto.name)); Ok(()) } @@ -217,6 +240,18 @@ pub async fn delete_openai_preset( .await { Ok(()) => { + let source_id = preset_skill_source_id(PresetType::OpenAI.to_api_id(), &dto.name); + if let Err(e) = app_state + .skill_service + .delete_skills_for_source(SKILL_SOURCE_KIND_PRESET, &source_id) + .await + { + logger::error(&format!( + "Failed to delete Agent Skills linked to OpenAI preset '{}': {}", + dto.name, e + )); + return Ok(DeleteOpenAIPresetResponseDto::error()); + } logger::info(&format!("OpenAI preset deleted successfully: {}", dto.name)); Ok(DeleteOpenAIPresetResponseDto::success()) } @@ -227,6 +262,10 @@ pub async fn delete_openai_preset( } } +fn preset_skill_source_id(api_id: &str, name: &str) -> String { + format!("preset:{}:{}", api_id.trim(), name.trim()) +} + /// List presets of a specific type #[tauri::command] pub async fn list_presets( diff --git a/src-tauri/src/presentation/commands/registry.rs b/src-tauri/src/presentation/commands/registry.rs index bca89cf5..a4b8d70c 100644 --- a/src-tauri/src/presentation/commands/registry.rs +++ b/src-tauri/src/presentation/commands/registry.rs @@ -241,6 +241,12 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen super::agent_commands::read_agent_workspace_file, super::agent_commands::prepare_agent_run_commit, super::agent_commands::finalize_agent_run_commit, + // Agent Skill commands + super::skill_commands::list_skills, + super::skill_commands::preview_skill_import, + super::skill_commands::install_skill_import, + super::skill_commands::read_skill_file, + super::skill_commands::export_skill, // Chat completion commands super::chat_completion_commands::get_chat_completions_status, super::chat_completion_commands::generate_chat_completion, diff --git a/src-tauri/src/presentation/commands/skill_commands.rs b/src-tauri/src/presentation/commands/skill_commands.rs new file mode 100644 index 00000000..c317651d --- /dev/null +++ b/src-tauri/src/presentation/commands/skill_commands.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Serialize; +use tauri::State; + +use crate::app::AppState; +use crate::domain::models::skill::{ + SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest, SkillInstallResult, + SkillReadResult, +}; +use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::errors::CommandError; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillExportPayload { + pub file_name: String, + pub content_base64: String, + pub sha256: String, +} + +#[tauri::command] +pub async fn list_skills( + app_state: State<'_, Arc>, +) -> Result, CommandError> { + log_command("list_skills"); + + app_state + .skill_service + .list_skills() + .await + .map_err(map_command_error("Failed to list Agent Skills")) +} + +#[tauri::command] +pub async fn preview_skill_import( + input: SkillImportInput, + app_state: State<'_, Arc>, +) -> Result { + log_command("preview_skill_import"); + + app_state + .skill_service + .preview_import(input) + .await + .map_err(map_command_error("Failed to preview Agent Skill import")) +} + +#[tauri::command] +pub async fn install_skill_import( + request: SkillInstallRequest, + app_state: State<'_, Arc>, +) -> Result { + log_command("install_skill_import"); + + app_state + .skill_service + .install_import(request) + .await + .map_err(map_command_error("Failed to install Agent Skill")) +} + +#[tauri::command] +pub async fn read_skill_file( + name: String, + path: String, + max_chars: Option, + app_state: State<'_, Arc>, +) -> Result { + log_command(format!("read_skill_file {}/{}", name, path)); + + app_state + .skill_service + .read_skill_file(&name, &path, max_chars) + .await + .map_err(map_command_error("Failed to read Agent Skill file")) +} + +#[tauri::command] +pub async fn export_skill( + name: String, + app_state: State<'_, Arc>, +) -> Result { + log_command(format!("export_skill {}", name)); + + let exported = app_state + .skill_service + .export_skill(&name) + .await + .map_err(map_command_error("Failed to export Agent Skill"))?; + + Ok(SkillExportPayload { + file_name: exported.file_name, + content_base64: BASE64_STANDARD.encode(exported.bytes), + sha256: exported.sha256, + }) +} diff --git a/src/script.js b/src/script.js index cc6687b7..2cbc05cd 100644 --- a/src/script.js +++ b/src/script.js @@ -11125,6 +11125,48 @@ function selectImportedChar(charId) { select_rm_info('char_import_no_toast', charId, oldSelectedChar); } +/** + * @param {{ avatarFileName: string; label: string }} options + */ +async function maybePromptForImportedCharacterSkills({ avatarFileName, label }) { + const hostAbi = window.__TAURITAVERN__; + if (!hostAbi) { + return; + } + + try { + if (!hostAbi.api?.skill) { + throw new Error('TauriTavern Agent Skill API is not available'); + } + + const { maybePromptForCharacterEmbeddedSkills } = await import('./scripts/tauri/agent-skills/embedded-import.js'); + await maybePromptForCharacterEmbeddedSkills({ + avatarFileName, + label, + loadCharacter: async () => { + const response = await fetch('/api/characters/get', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ avatar_url: avatarFileName }), + cache: 'no-cache', + }); + + if (!response.ok) { + const details = String(await response.text()).trim(); + const reason = details || response.statusText || `HTTP ${response.status}`; + throw new Error(`Failed to read imported character for Agent Skill scan: ${reason}`); + } + + return response.json(); + }, + }); + } catch (error) { + console.error('Failed to start Agent Skill import prompt for character', error); + const message = error instanceof Error ? error.message : String(error || t`Unknown error`); + toastr.error(message, t`Agent Skill import failed`); + } +} + /** * Imports a character from a file. * @param {File} file File to import @@ -11206,6 +11248,10 @@ async function importCharacter(file, { preserveFileName = '', importTags = false await importCharactersTags([avatarFileName]); selectImportedChar(data.file_name); } + await maybePromptForImportedCharacterSkills({ + avatarFileName, + label: String(data.file_name).replace('.png', ''), + }); return avatarFileName; } } catch (error) { @@ -11518,6 +11564,8 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) worldsToDelete.add(primaryWorldName); } + const { clearCharacterSkillImportReminders } = await import('./scripts/tauri/agent-skills/reminders.js'); + clearCharacterSkillImportReminders(character.avatar); accountStorage.removeItem(`AlertWI_${character.avatar}`); accountStorage.removeItem(`AlertRegex_${character.avatar}`); accountStorage.removeItem(`mediaWarningShown:${character.avatar}`); diff --git a/src/scripts/openai.js b/src/scripts/openai.js index 2aff6b2d..3fed4d4e 100644 --- a/src/scripts/openai.js +++ b/src/scripts/openai.js @@ -4761,6 +4761,15 @@ async function onPresetImportFileChange(e) { option.innerText = data.name; $('#settings_preset_openai').append(option).trigger('change'); } + + try { + const { maybePromptForPresetEmbeddedSkills } = await import('./tauri/agent-skills/embedded-import.js'); + await maybePromptForPresetEmbeddedSkills({ apiId: 'openai', name: data.name, preset: presetBody }); + } catch (error) { + console.error('Failed to start Agent Skill import prompt for OpenAI preset', error); + const message = error instanceof Error ? error.message : String(error || t`Unknown error`); + toastr.error(message, t`Agent Skill import failed`); + } } async function onExportPresetClick() { @@ -4889,6 +4898,8 @@ async function onDeletePresetClick() { toastr.warning(t`Preset was not deleted from server`); } else { toastr.success(t`Preset deleted`); + const { clearPresetSkillImportReminders } = await import('./tauri/agent-skills/reminders.js'); + clearPresetSkillImportReminders('openai', nameToDelete); await eventSource.emit(event_types.PRESET_DELETED, { apiId: 'openai', name: nameToDelete }); } diff --git a/src/scripts/preset-manager.js b/src/scripts/preset-manager.js index 1293b2e7..fcc05e9d 100644 --- a/src/scripts/preset-manager.js +++ b/src/scripts/preset-manager.js @@ -1122,7 +1122,16 @@ export async function initPresetManager() { await presetManager.savePreset(name, data); const successToast = !presetManager.isAdvancedFormatting() ? t`Preset imported` : t`Template imported`; toastr.success(successToast); - e.target.value = null; + try { + const { maybePromptForPresetEmbeddedSkills } = await import('./tauri/agent-skills/embedded-import.js'); + await maybePromptForPresetEmbeddedSkills({ apiId, name, preset: data }); + } catch (error) { + console.error('Failed to start Agent Skill import prompt for preset', error); + const message = error instanceof Error ? error.message : String(error || t`Unknown error`); + toastr.error(message, t`Agent Skill import failed`); + } finally { + e.target.value = null; + } }); $(document).on('click', '[data-preset-manager-delete]', async function () { @@ -1146,6 +1155,8 @@ export async function initPresetManager() { if (result) { const successToast = !presetManager.isAdvancedFormatting() ? t`Preset deleted` : t`Template deleted`; toastr.success(successToast); + const { clearPresetSkillImportReminders } = await import('./tauri/agent-skills/reminders.js'); + clearPresetSkillImportReminders(apiId, name); await eventSource.emit(event_types.PRESET_DELETED, { apiId, name }); } else { const warningToast = !presetManager.isAdvancedFormatting() ? t`Preset was not deleted from server` : t`Template was not deleted from server`; diff --git a/src/scripts/tauri/agent-skills/embedded-import.js b/src/scripts/tauri/agent-skills/embedded-import.js new file mode 100644 index 00000000..ab9ca06a --- /dev/null +++ b/src/scripts/tauri/agent-skills/embedded-import.js @@ -0,0 +1,655 @@ +// @ts-check + +import { t } from '../../i18n.js'; +import { POPUP_RESULT, POPUP_TYPE, Popup } from '../../popup.js'; +import { SURFACE, applySurface } from '../../tauritavern/layout-kit.js'; +import { buildSkillImportReminderKey, hasSkillImportReminder, setSkillImportReminder } from './reminders.js'; + +const EMBEDDED_SKILLS_VERSION = 1; +const INLINE_FILES_BUNDLE_FORMAT = 'inline-files-v1'; + +/** + * @param {unknown} value + * @returns {Record} + */ +function requirePlainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Embedded Agent Skill payload must be an object'); + } + + return /** @type {Record} */ (value); +} + +/** + * @param {unknown} value + * @param {string} label + */ +function requireNonEmptyString(value, label) { + const resolved = String(value || '').trim(); + if (!resolved) { + throw new Error(`${label} is required`); + } + + return resolved; +} + +/** + * @param {unknown} value + */ +function normalizeEncoding(value) { + const encoding = String(value || 'utf8').trim().toLowerCase(); + if (!['utf8', 'utf-8', 'base64'].includes(encoding)) { + throw new Error(`Unsupported embedded Agent Skill file encoding: ${encoding}`); + } + + return encoding; +} + +/** + * @param {unknown} value + * @param {string} label + */ +function normalizeOptionalNonNegativeInteger(value, label) { + if (value === null || value === undefined) { + return undefined; + } + + const number = Number(value); + if (!Number.isInteger(number) || number < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + + return number; +} + +/** + * @param {unknown} value + * @param {{ kind: string; id: string; label: string }} fallbackSource + */ +function normalizeEmbeddedSource(value, fallbackSource) { + const source = value === null || value === undefined ? {} : requirePlainObject(value); + const kind = requireNonEmptyString(fallbackSource.kind || source.kind, 'source.kind'); + const id = requireNonEmptyString(fallbackSource.id || source.id, 'source.id'); + return { + ...source, + kind, + id, + label: String(source.label || fallbackSource.label || source.kind || fallbackSource.kind).trim(), + }; +} + +/** + * @param {unknown} value + */ +function normalizeEmbeddedFile(value) { + const file = requirePlainObject(value); + + /** @type {Record} */ + const output = { + path: requireNonEmptyString(file.path, 'skill file path'), + encoding: normalizeEncoding(file.encoding), + content: file.content, + }; + + if (typeof output.content !== 'string') { + throw new Error('skill file content must be a string'); + } + + const mediaType = String(file.mediaType || '').trim(); + if (mediaType) { + output.mediaType = mediaType; + } + + const sizeBytes = normalizeOptionalNonNegativeInteger(file.sizeBytes, 'sizeBytes'); + if (sizeBytes !== undefined) { + output.sizeBytes = sizeBytes; + } + + const sha256 = String(file.sha256 || '').trim(); + if (sha256) { + output.sha256 = sha256; + } + + return output; +} + +/** + * @param {unknown} value + * @param {number} index + * @param {{ kind: string; id: string; label: string }} fallbackSource + */ +function normalizeEmbeddedItem(value, index, fallbackSource) { + const item = requirePlainObject(value); + const bundleFormat = requireNonEmptyString(item.bundleFormat, `items[${index}].bundleFormat`); + if (bundleFormat !== INLINE_FILES_BUNDLE_FORMAT) { + throw new Error(`Unsupported embedded Agent Skill bundle format: ${bundleFormat}`); + } + + if (!Array.isArray(item.files) || item.files.length === 0) { + throw new Error(`Embedded Agent Skill item ${index + 1} requires at least one file`); + } + + const source = normalizeEmbeddedSource(item.source, fallbackSource); + return { + index, + input: { + kind: 'inlineFiles', + files: item.files.map(normalizeEmbeddedFile), + source, + }, + }; +} + +/** + * @param {unknown} embedded + * @param {{ kind: string; id: string; label: string }} fallbackSource + */ +function normalizeEmbeddedSkills(embedded, fallbackSource) { + if (embedded === null || embedded === undefined) { + return []; + } + + const payload = requirePlainObject(embedded); + if (Number(payload.version) !== EMBEDDED_SKILLS_VERSION) { + throw new Error(`Unsupported embedded Agent Skill schema version: ${payload.version}`); + } + + if (payload.items === null || payload.items === undefined) { + return []; + } + + if (!Array.isArray(payload.items)) { + throw new Error('Embedded Agent Skill items must be an array'); + } + + return payload.items.map((item, index) => normalizeEmbeddedItem(item, index, fallbackSource)); +} + +/** + * @param {any} preset + * @param {{ apiId: string; name: string }} source + */ +export function extractPresetEmbeddedSkills(preset, source) { + const label = String(source.name || '').trim() || t`Imported preset`; + return normalizeEmbeddedSkills(preset?.extensions?.tauritavern?.skills, { + kind: 'preset', + id: buildPresetSkillSourceId(source.apiId, label), + label, + }); +} + +/** + * @param {any} character + * @param {{ label: string; avatarFileName?: string; characterId?: string }} source + */ +export function extractCharacterEmbeddedSkills(character, source) { + const embedded = character?.data?.extensions?.tauritavern?.skills + ?? character?.extensions?.tauritavern?.skills; + const label = String(source.label || character?.name || character?.data?.name || '').trim() || t`Imported character`; + return normalizeEmbeddedSkills(embedded, { + kind: 'character', + id: buildCharacterSkillSourceId(source.avatarFileName || source.characterId || label), + label, + }); +} + +/** + * @param {unknown} apiId + * @param {unknown} name + */ +function buildPresetSkillSourceId(apiId, name) { + return `preset:${requireNonEmptyString(apiId, 'apiId')}:${requireNonEmptyString(name, 'preset name')}`; +} + +/** + * @param {unknown} value + */ +function buildCharacterSkillSourceId(value) { + const characterId = requireNonEmptyString(value, 'character id').replace(/\.png$/i, ''); + return `character:${characterId}`; +} + +function getSkillApi() { + const hostAbi = window.__TAURITAVERN__; + if (!hostAbi) { + return null; + } + + const skillApi = hostAbi.api?.skill; + if (!skillApi || typeof skillApi !== 'object') { + throw new Error('TauriTavern Agent Skill API is not available'); + } + + if (typeof skillApi.previewImport !== 'function' || typeof skillApi.installImport !== 'function') { + throw new Error('TauriTavern Agent Skill API is incomplete'); + } + + return skillApi; +} + +/** + * @param {unknown} value + * @returns {string} + */ +function stableStringify(value) { + if (value === null || typeof value !== 'object') { + const primitive = JSON.stringify(value); + return primitive === undefined ? 'undefined' : primitive; + } + + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + const object = /** @type {Record} */ (value); + return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(',')}}`; +} + +/** + * @param {string} text + */ +function fnv1aHex(text) { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +/** + * @param {number} bytes + */ +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + + const units = ['B', 'KiB', 'MiB', 'GiB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +/** + * @param {HTMLElement} parent + * @param {string} tagName + * @param {string} text + */ +function appendText(parent, tagName, text) { + const element = document.createElement(tagName); + element.textContent = text; + parent.append(element); + return element; +} + +/** + * @param {any} preview + */ +function getConflictLabel(preview) { + const kind = String(preview?.conflict?.kind || ''); + if (kind === 'new') { + return t`New`; + } + if (kind === 'same') { + return t`Already installed`; + } + if (kind === 'different') { + return t`Different installed version`; + } + + return kind || t`Unknown`; +} + +/** + * @param {{ preview: any; item: any }[]} previews + * @param {string} sourceLabel + */ +function buildPreviewPopupContent(previews, sourceLabel) { + const root = document.createElement('div'); + root.classList.add('tauritavern-agent-skill-import'); + + appendText(root, 'h3', t`Import embedded Agent Skills?`); + appendText(root, 'p', t`Source: ${sourceLabel}`); + + const list = document.createElement('div'); + list.style.display = 'flex'; + list.style.flexDirection = 'column'; + list.style.gap = '0.75rem'; + root.append(list); + + for (const entry of previews) { + const preview = entry.preview; + const skill = preview?.skill || {}; + const conflictKind = String(preview?.conflict?.kind || ''); + + const item = document.createElement('section'); + item.style.border = '1px solid var(--SmartThemeBorderColor)'; + item.style.borderRadius = '6px'; + item.style.padding = '0.75rem'; + item.style.display = 'flex'; + item.style.flexDirection = 'column'; + item.style.gap = '0.45rem'; + list.append(item); + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.gap = '0.6rem'; + header.style.alignItems = 'center'; + header.style.justifyContent = 'space-between'; + item.append(header); + + const title = appendText(header, 'strong', String(skill.displayName || skill.name || t`Unnamed Skill`)); + title.style.overflowWrap = 'anywhere'; + + const conflict = appendText(header, 'span', getConflictLabel(preview)); + conflict.style.fontSize = '0.9em'; + conflict.style.opacity = '0.8'; + + const description = appendText(item, 'div', String(skill.description || '')); + description.style.overflowWrap = 'anywhere'; + + const totalBytes = Number(skill.totalBytes || 0); + const metadata = [ + t`${Number(skill.fileCount || 0)} files`, + formatBytes(totalBytes), + ]; + if (skill.hasScripts) { + metadata.push(t`scripts stored only`); + } + if (skill.hasBinary) { + metadata.push(t`binary files`); + } + + const meta = appendText(item, 'div', metadata.join(' · ')); + meta.style.fontSize = '0.9em'; + meta.style.opacity = '0.8'; + + if (Array.isArray(preview.warnings) && preview.warnings.length > 0) { + const warnings = document.createElement('ul'); + warnings.style.margin = '0.25rem 0 0 1rem'; + for (const warning of preview.warnings) { + appendText(warnings, 'li', String(warning)); + } + item.append(warnings); + } + + const details = document.createElement('details'); + const summary = appendText(details, 'summary', t`Files`); + summary.style.cursor = 'pointer'; + const files = document.createElement('ul'); + files.style.margin = '0.25rem 0 0 1rem'; + for (const file of Array.isArray(preview.files) ? preview.files : []) { + appendText(files, 'li', `${String(file.path || '')} (${formatBytes(Number(file.sizeBytes || 0))})`); + } + details.append(files); + item.append(details); + + const action = document.createElement('div'); + action.style.display = 'flex'; + action.style.justifyContent = 'flex-end'; + action.style.alignItems = 'center'; + action.style.gap = '0.5rem'; + item.append(action); + + if (conflictKind === 'new') { + const label = document.createElement('label'); + label.classList.add('checkbox_label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = true; + checkbox.dataset.skillImportIndex = String(entry.item.index); + label.append(checkbox); + const text = document.createElement('span'); + text.textContent = t`Import`; + label.append(text); + action.append(label); + } else if (conflictKind === 'different') { + const select = document.createElement('select'); + select.classList.add('text_pole'); + select.dataset.skillConflictIndex = String(entry.item.index); + + const skip = document.createElement('option'); + skip.value = 'skip'; + skip.textContent = t`Skip`; + select.append(skip); + + const replace = document.createElement('option'); + replace.value = 'replace'; + replace.textContent = t`Replace`; + select.append(replace); + + action.append(select); + } else { + appendText(action, 'span', t`Link source`); + } + } + + return root; +} + +/** + * @param {HTMLElement} root + * @param {{ preview: any; item: any }[]} previews + */ +function collectImportDecisions(root, previews) { + const decisions = []; + for (const entry of previews) { + const conflictKind = String(entry.preview?.conflict?.kind || ''); + if (conflictKind === 'new') { + const checkbox = root.querySelector(`[data-skill-import-index="${entry.item.index}"]`); + if (checkbox instanceof HTMLInputElement && checkbox.checked) { + decisions.push({ item: entry.item }); + } + continue; + } + + if (conflictKind === 'different') { + const select = root.querySelector(`[data-skill-conflict-index="${entry.item.index}"]`); + if (select instanceof HTMLSelectElement && select.value === 'replace') { + decisions.push({ item: entry.item, conflictStrategy: 'replace' }); + } + continue; + } + + if (conflictKind === 'same') { + decisions.push({ item: entry.item }); + } + } + + return decisions; +} + +/** + * @param {{ preview: any; item: any }[]} previews + * @param {string} sourceLabel + */ +async function showImportPopup(previews, sourceLabel) { + const root = buildPreviewPopupContent(previews, sourceLabel); + const hasImportableSkill = hasImportablePreviews(previews); + + const popup = new Popup(root, hasImportableSkill ? POPUP_TYPE.CONFIRM : POPUP_TYPE.TEXT, '', { + okButton: hasImportableSkill ? t`Import or link selected Skills` : t`OK`, + cancelButton: hasImportableSkill ? t`Not now` : false, + wide: true, + allowVerticalScrolling: true, + leftAlign: true, + }); + popup.dlg.classList.add('tauritavern-agent-skill-import-popup'); + applySurface(popup.dlg, SURFACE.FullscreenWindow); + + const result = await popup.show(); + if (result !== POPUP_RESULT.AFFIRMATIVE) { + return null; + } + + if (!hasImportableSkill) { + return []; + } + + return collectImportDecisions(root, previews); +} + +/** + * @param {{ preview: any; item: any }[]} previews + */ +function hasImportablePreviews(previews) { + return previews.some(entry => { + const kind = String(entry.preview?.conflict?.kind || ''); + return kind === 'new' || kind === 'different' || kind === 'same'; + }); +} + +/** + * @returns {any} + */ +function getToastr() { + return /** @type {any} */ (toastr); +} + +/** + * @param {any[]} results + */ +function reportInstallResults(results) { + const installed = results.filter(result => result?.action === 'installed').length; + const replaced = results.filter(result => result?.action === 'replaced').length; + const unchanged = results.filter(result => result?.action === 'already_installed').length; + + const parts = []; + if (installed) { + parts.push(t`${installed} installed`); + } + if (replaced) { + parts.push(t`${replaced} replaced`); + } + if (unchanged) { + parts.push(t`${unchanged} unchanged`); + } + + getToastr().success(parts.join(', ') || t`Agent Skills imported`, t`Agent Skills`); +} + +/** + * @param {unknown} error + */ +function reportEmbeddedSkillError(error) { + console.error('Agent Skill embedded import failed', error); + const message = error instanceof Error ? error.message : String(error || t`Unknown error`); + getToastr().error(message, t`Agent Skill import failed`); +} + +/** + * @param {{ items: any[]; sourceLabel: string; storageKey: string }} options + */ +async function previewAndPromptEmbeddedSkills({ items, sourceLabel, storageKey }) { + const skillApi = getSkillApi(); + if (!skillApi || items.length === 0) { + return; + } + + if (hasSkillImportReminder(storageKey)) { + return; + } + + const previews = []; + for (const item of items) { + previews.push({ + item, + preview: await skillApi.previewImport(item.input), + }); + } + + const decisions = await showImportPopup(previews, sourceLabel); + if (decisions === null) { + setSkillImportReminder(storageKey); + return; + } + + if (decisions.length === 0) { + setSkillImportReminder(storageKey); + if (hasImportablePreviews(previews)) { + getToastr().info(t`No Agent Skills imported`); + } + return; + } + + const results = []; + for (const decision of decisions) { + const request = /** @type {any} */ ({ + input: decision.item.input, + }); + if (decision.conflictStrategy !== undefined) { + request.conflictStrategy = decision.conflictStrategy; + } + results.push(await skillApi.installImport(request)); + } + + reportInstallResults(results); + setSkillImportReminder(storageKey); +} + +/** + * @param {{ apiId: string; name: string; preset: any }} options + */ +export async function maybePromptForPresetEmbeddedSkills({ apiId, name, preset }) { + try { + if (!getSkillApi()) { + return; + } + + const embedded = preset?.extensions?.tauritavern?.skills; + const items = extractPresetEmbeddedSkills(preset, { apiId, name }); + if (items.length === 0) { + return; + } + + const hash = fnv1aHex(stableStringify(embedded)); + const storageKey = buildSkillImportReminderKey(['AlertSkillPreset', apiId, name, hash]); + await previewAndPromptEmbeddedSkills({ + items, + sourceLabel: String(name || t`Imported preset`), + storageKey, + }); + } catch (error) { + reportEmbeddedSkillError(error); + } +} + +/** + * @param {{ avatarFileName: string; label: string; loadCharacter: () => Promise }} options + */ +export async function maybePromptForCharacterEmbeddedSkills({ avatarFileName, label, loadCharacter }) { + try { + if (!getSkillApi()) { + return; + } + + if (typeof loadCharacter !== 'function') { + throw new Error('loadCharacter is required'); + } + + const character = await loadCharacter(); + const embedded = character?.data?.extensions?.tauritavern?.skills + ?? character?.extensions?.tauritavern?.skills; + const items = extractCharacterEmbeddedSkills(character, { label, avatarFileName }); + if (items.length === 0) { + return; + } + + const hash = fnv1aHex(stableStringify(embedded)); + const storageKey = buildSkillImportReminderKey(['AlertSkillCharacter', avatarFileName, hash]); + await previewAndPromptEmbeddedSkills({ + items, + sourceLabel: String(label || character?.name || character?.data?.name || t`Imported character`), + storageKey, + }); + } catch (error) { + reportEmbeddedSkillError(error); + } +} diff --git a/src/scripts/tauri/agent-skills/reminders.js b/src/scripts/tauri/agent-skills/reminders.js new file mode 100644 index 00000000..875ae2fd --- /dev/null +++ b/src/scripts/tauri/agent-skills/reminders.js @@ -0,0 +1,57 @@ +// @ts-check + +import { accountStorage } from '../../util/AccountStorage.js'; + +export const SKILL_IMPORT_REMINDER_VALUE = '1'; + +/** + * @param {unknown[]} parts + */ +export function buildSkillImportReminderKey(parts) { + const sanitizedParts = parts + .map(part => String(part || 'unknown').replace(/[^\w.-]+/g, '_').slice(0, 96)) + .filter(Boolean); + return sanitizedParts.join('_'); +} + +/** + * @param {string} key + */ +export function hasSkillImportReminder(key) { + return accountStorage.getItem(key) === SKILL_IMPORT_REMINDER_VALUE; +} + +/** + * @param {string} key + */ +export function setSkillImportReminder(key) { + accountStorage.setItem(key, SKILL_IMPORT_REMINDER_VALUE); +} + +/** + * @param {unknown[]} parts + */ +function clearSkillImportReminderPrefix(parts) { + const prefix = buildSkillImportReminderKey(parts); + const state = accountStorage.getState(); + for (const key of Object.keys(state)) { + if (key === prefix || key.startsWith(`${prefix}_`)) { + accountStorage.removeItem(key); + } + } +} + +/** + * @param {unknown} apiId + * @param {unknown} name + */ +export function clearPresetSkillImportReminders(apiId, name) { + clearSkillImportReminderPrefix(['AlertSkillPreset', apiId, name]); +} + +/** + * @param {unknown} avatarFileName + */ +export function clearCharacterSkillImportReminders(avatarFileName) { + clearSkillImportReminderPrefix(['AlertSkillCharacter', avatarFileName]); +} diff --git a/src/tauri/main/api/skill.js b/src/tauri/main/api/skill.js new file mode 100644 index 00000000..8992018b --- /dev/null +++ b/src/tauri/main/api/skill.js @@ -0,0 +1,229 @@ +// @ts-check + +/** + * @param {unknown} value + * @param {string} label + */ +function requireNonEmptyString(value, label) { + const resolved = String(value || '').trim(); + if (!resolved) { + throw new Error(`${label} is required`); + } + return resolved; +} + +/** + * @param {unknown} value + * @param {string} label + * @returns {Record} + */ +function requirePlainObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + + return /** @type {Record} */ (value); +} + +/** + * @param {unknown} value + * @returns {Record} + */ +function normalizeSource(value) { + if (value === null || value === undefined) { + return {}; + } + + return requirePlainObject(value, 'source'); +} + +/** + * @param {unknown} value + */ +function normalizeEncoding(value) { + const encoding = String(value || 'utf8').trim().toLowerCase(); + if (!['utf8', 'utf-8', 'base64'].includes(encoding)) { + throw new Error(`Unsupported skill file encoding: ${encoding}`); + } + + return encoding; +} + +/** + * @param {unknown} value + * @param {string} label + */ +function normalizeOptionalNonNegativeInteger(value, label) { + if (value === null || value === undefined) { + return undefined; + } + + const number = Number(value); + if (!Number.isInteger(number) || number < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + + return number; +} + +/** + * @param {unknown} value + */ +function normalizeSkillInlineFile(value) { + const file = requirePlainObject(value, 'skill inline file'); + /** @type {Record} */ + const output = { + path: requireNonEmptyString(file.path, 'skill file path'), + encoding: normalizeEncoding(file.encoding), + content: String(file.content ?? ''), + }; + + if (typeof file.content !== 'string') { + throw new Error('skill file content must be a string'); + } + + const mediaType = String(file.mediaType || '').trim(); + if (mediaType) { + output.mediaType = mediaType; + } + + const sizeBytes = normalizeOptionalNonNegativeInteger(file.sizeBytes, 'sizeBytes'); + if (sizeBytes !== undefined) { + output.sizeBytes = sizeBytes; + } + + const sha256 = String(file.sha256 || '').trim(); + if (sha256) { + output.sha256 = sha256; + } + + return output; +} + +/** + * @param {unknown} value + */ +function normalizeSkillImportInput(value) { + const input = requirePlainObject(value, 'skill import input'); + const kind = requireNonEmptyString(input.kind, 'skill import kind'); + + if (kind === 'inlineFiles') { + if (!Array.isArray(input.files) || input.files.length === 0) { + throw new Error('inlineFiles skill import requires at least one file'); + } + + return { + kind, + files: input.files.map(normalizeSkillInlineFile), + source: normalizeSource(input.source), + }; + } + + if (kind === 'directory' || kind === 'archiveFile') { + return { + kind, + path: requireNonEmptyString(input.path, 'skill import path'), + source: normalizeSource(input.source), + }; + } + + throw new Error(`Unsupported skill import kind: ${kind}`); +} + +/** + * @param {unknown} value + */ +function normalizeConflictStrategy(value) { + if (value === null || value === undefined) { + return undefined; + } + + const strategy = requireNonEmptyString(value, 'conflictStrategy'); + if (strategy !== 'skip' && strategy !== 'replace') { + throw new Error(`Unsupported skill conflict strategy: ${strategy}`); + } + + return strategy; +} + +/** + * @param {unknown} value + */ +function normalizeSkillInstallRequest(value) { + const request = requirePlainObject(value, 'skill install request'); + /** @type {Record} */ + const output = { + input: normalizeSkillImportInput(request.input), + }; + + const conflictStrategy = normalizeConflictStrategy(request.conflictStrategy); + if (conflictStrategy !== undefined) { + output.conflictStrategy = conflictStrategy; + } + + return output; +} + +/** + * @param {{ safeInvoke: (command: string, args?: any) => Promise }} deps + */ +function createSkillApi({ safeInvoke }) { + async function list() { + return safeInvoke('list_skills'); + } + + async function previewImport(input) { + return safeInvoke('preview_skill_import', { + input: normalizeSkillImportInput(input), + }); + } + + async function installImport(request) { + return safeInvoke('install_skill_import', { + request: normalizeSkillInstallRequest(request), + }); + } + + async function readFile(options) { + const name = requireNonEmptyString(options?.name, 'skill name'); + const path = requireNonEmptyString(options?.path, 'skill file path'); + const maxChars = normalizeOptionalNonNegativeInteger(options?.maxChars, 'maxChars'); + return safeInvoke('read_skill_file', { name, path, maxChars }); + } + + async function exportSkill(options) { + const name = requireNonEmptyString(options?.name, 'skill name'); + return safeInvoke('export_skill', { name }); + } + + return { + list, + previewImport, + installImport, + readFile, + export: exportSkill, + exportSkill, + }; +} + +/** + * @param {any} context + */ +export function installSkillApi(context) { + const hostWindow = /** @type {any} */ (window); + const hostAbi = hostWindow.__TAURITAVERN__; + if (!hostAbi || typeof hostAbi !== 'object') { + throw new Error('Host ABI __TAURITAVERN__ is missing'); + } + + const safeInvoke = context?.safeInvoke; + if (typeof safeInvoke !== 'function') { + throw new Error('Tauri main context safeInvoke is missing'); + } + + if (!hostAbi.api || typeof hostAbi.api !== 'object') { + hostAbi.api = {}; + } + + hostAbi.api.skill = createSkillApi({ safeInvoke }); +} diff --git a/src/tauri/main/bootstrap.js b/src/tauri/main/bootstrap.js index 90094742..d601f434 100644 --- a/src/tauri/main/bootstrap.js +++ b/src/tauri/main/bootstrap.js @@ -25,6 +25,7 @@ import { installAgentApi } from './api/agent.js'; import { installDevApi } from './api/dev.js'; import { installExtensionStoreApi } from './api/extension-store.js'; import { installLayoutApi } from './api/layout.js'; +import { installSkillApi } from './api/skill.js'; import { installWorldInfoApi } from './api/world-info.js'; import { initializeTauriIntegration } from './bootstrap/initialize-tauri-integration.js'; import { @@ -272,7 +273,7 @@ export function bootstrapTauriMain() { installNativeShareBridge(); const context = createTauriMainContext({ invoke, convertFileSrc }); - installHostAbi(context); installLayoutApi(context); installChatApi(context); installAgentApi(context); installDevApi(context); installExtensionStoreApi(context); installWorldInfoApi(); + installHostAbi(context); installLayoutApi(context); installChatApi(context); installAgentApi(context); installSkillApi(context); installDevApi(context); installExtensionStoreApi(context); installWorldInfoApi(); installMainApiOptionParking(); installWorldInfoGlobalSelectorSelect2Enforcer(); if (perfEnabled) { diff --git a/src/tauri/main/kernel/invokes/tauri-commands.js b/src/tauri/main/kernel/invokes/tauri-commands.js index 024e08c3..13e4fd4b 100644 --- a/src/tauri/main/kernel/invokes/tauri-commands.js +++ b/src/tauri/main/kernel/invokes/tauri-commands.js @@ -47,6 +47,7 @@ export {}; * | 'download_external_import_url' * | 'encode_openai_tokens' * | 'export_character_content' + * | 'export_skill' * | 'find_secret' * | 'generate_chat_completion' * | 'get_all_background_metadata' @@ -82,6 +83,7 @@ export {}; * | 'import_group_chat_payload' * | 'import_world_info' * | 'install_extension' + * | 'install_skill_import' * | 'ios_import_data_archive_from_picker' * | 'ios_share_file' * | 'ios_share_export_data_archive' @@ -90,11 +92,13 @@ export {}; * | 'list_chat_backups' * | 'list_recent_chat_summaries' * | 'list_recent_group_chat_summaries' + * | 'list_skills' * | 'list_user_image_folders' * | 'list_user_images' * | 'load_settings_snapshot' * | 'move_extension' * | 'read_secret_state' + * | 'read_skill_file' * | 'read_thumbnail_asset' * | 'read_user_avatar_asset' * | 'read_user_file_asset' @@ -107,6 +111,7 @@ export {}; * | 'restore_preset' * | 'restore_settings_snapshot' * | 'rotate_secret' + * | 'preview_skill_import' * | 'save_export_data_archive' * | 'save_preset' * | 'save_quick_reply_set' diff --git a/src/types.d.ts b/src/types.d.ts index 44d8773d..5cc5a025 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -172,6 +172,119 @@ type TauriTavernAgentApi = { rollback: () => never; }; +type TauriTavernSkillFileKind = 'text' | 'binary'; + +type TauriTavernSkillImportConflictKind = 'new' | 'same' | 'different'; + +type TauriTavernSkillInstallConflictStrategy = 'skip' | 'replace'; + +type TauriTavernSkillInstallAction = 'installed' | 'replaced' | 'already_installed' | 'skipped'; + +type TauriTavernSkillIndexEntry = { + name: string; + description: string; + displayName?: string; + sourceKind?: string; + license?: string; + author?: string; + version?: string; + tags: string[]; + installedHash: string; + fileCount: number; + totalBytes: number; + hasScripts: boolean; + hasBinary: boolean; + installedAt: string; + sourceRefs?: TauriTavernSkillSourceRef[]; +}; + +type TauriTavernSkillSourceRef = { + kind: string; + id: string; + label: string; + installedHash: string; +}; + +type TauriTavernSkillInlineFile = { + path: string; + encoding?: 'utf8' | 'utf-8' | 'base64'; + content: string; + mediaType?: string; + sizeBytes?: number; + sha256?: string; +}; + +type TauriTavernSkillImportInput = + | { + kind: 'inlineFiles'; + files: TauriTavernSkillInlineFile[]; + source?: any; + } + | { + kind: 'directory'; + path: string; + source?: any; + } + | { + kind: 'archiveFile'; + path: string; + source?: any; + }; + +type TauriTavernSkillFileRef = { + path: string; + kind: TauriTavernSkillFileKind; + mediaType: string; + sizeBytes: number; + sha256: string; +}; + +type TauriTavernSkillImportPreview = { + skill: TauriTavernSkillIndexEntry; + files: TauriTavernSkillFileRef[]; + conflict: { + kind: TauriTavernSkillImportConflictKind; + installedHash?: string; + }; + warnings: string[]; + source: any; +}; + +type TauriTavernSkillInstallResult = { + name: string; + action: TauriTavernSkillInstallAction; + skill?: TauriTavernSkillIndexEntry; +}; + +type TauriTavernSkillReadResult = { + name: string; + path: string; + content: string; + chars: number; + bytes: number; + sha256: string; + truncated: boolean; + resourceRef: string; +}; + +type TauriTavernSkillExportPayload = { + fileName: string; + contentBase64: string; + sha256: string; +}; + +type TauriTavernSkillApi = { + list: () => Promise; + previewImport: (input: TauriTavernSkillImportInput) => Promise; + installImport: (request: { + input: TauriTavernSkillImportInput; + conflictStrategy?: TauriTavernSkillInstallConflictStrategy; + }) => Promise; + readFile: (options: { name: string; path: string; maxChars?: number }) => Promise; + export: (options: { name: string }) => Promise; + exportSkill: (options: { name: string }) => Promise; +}; + type TauriTavernFrontendLogsApi = { list: (options?: { limit?: number }) => Promise; subscribe: ( @@ -284,6 +397,7 @@ type TauriTavernLayoutApi = { type TauriTavernHostApi = { chat?: TauriTavernChatApi; agent?: TauriTavernAgentApi; + skill?: TauriTavernSkillApi; layout?: TauriTavernLayoutApi; dev?: TauriTavernDevApi; worldInfo?: TauriTavernWorldInfoApi; diff --git a/tests/skill-api-contract.test.mjs b/tests/skill-api-contract.test.mjs new file mode 100644 index 00000000..354eeb2b --- /dev/null +++ b/tests/skill-api-contract.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function installHarness() { + const calls = []; + globalThis.window = { + __TAURITAVERN__: { api: {} }, + }; + + const { installSkillApi } = await import(pathToFileURL(path.join(REPO_ROOT, 'src/tauri/main/api/skill.js'))); + installSkillApi({ + safeInvoke: async (command, args) => { + calls.push({ command, args }); + return { command, args }; + }, + }); + + return { + calls, + skill: globalThis.window.__TAURITAVERN__.api.skill, + }; +} + +test('api.skill installs and forwards normalized import DTOs', async () => { + const { calls, skill } = await installHarness(); + + assert.ok(skill); + await skill.previewImport({ + kind: 'inlineFiles', + files: [ + { + path: 'SKILL.md', + content: '---\nname: test-skill\ndescription: Use in tests.\n---\n', + }, + ], + source: { kind: 'preset', label: 'Test preset' }, + }); + + assert.equal(calls[0].command, 'preview_skill_import'); + assert.deepEqual(calls[0].args.input, { + kind: 'inlineFiles', + files: [ + { + path: 'SKILL.md', + encoding: 'utf8', + content: '---\nname: test-skill\ndescription: Use in tests.\n---\n', + }, + ], + source: { kind: 'preset', label: 'Test preset' }, + }); +}); + +test('api.skill forwards install conflict strategy without implicit replace', async () => { + const { calls, skill } = await installHarness(); + const input = { + kind: 'inlineFiles', + files: [{ path: 'SKILL.md', content: '---\nname: test-skill\ndescription: Use in tests.\n---\n' }], + }; + + await skill.installImport({ input }); + await skill.installImport({ input, conflictStrategy: 'replace' }); + + assert.deepEqual(calls[0].args.request, { + input: { + kind: 'inlineFiles', + files: [{ path: 'SKILL.md', encoding: 'utf8', content: '---\nname: test-skill\ndescription: Use in tests.\n---\n' }], + source: {}, + }, + }); + assert.equal(calls[1].args.request.conflictStrategy, 'replace'); +}); + +test('api.skill fails fast on unsupported import shapes', async () => { + const { skill } = await installHarness(); + + await assert.rejects( + () => skill.previewImport({ kind: 'base64Zip', content: 'abc' }), + /Unsupported skill import kind/, + ); + await assert.rejects( + () => skill.previewImport({ kind: 'inlineFiles', files: [] }), + /requires at least one file/, + ); + await assert.rejects( + () => skill.installImport({ input: { kind: 'directory', path: '/tmp/skill' }, conflictStrategy: 'merge' }), + /Unsupported skill conflict strategy/, + ); +});