feat(agent): Add Agent Skill management and read tools

- Add file-backed Skill storage with import preview, install, export, source refs, and safe file reads
- Expose the Skill Host API and embedded Preset/Character Skill import prompts
- Register skill.list and skill.read in the Agent tool loop
- Cover archive roundtrips, source cleanup, symlink safety, and API contracts
- Refresh Agent docs around the current Skill capability boundary
This commit is contained in:
Darkatse 2026-05-02 22:10:49 -04:00
parent 20a42e722b
commit f990f778c9
61 changed files with 5548 additions and 88 deletions

View file

@ -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`。

View file

@ -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` 的草案约束为准

102
docs/API/Skill.md Normal file
View file

@ -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<TauriTavernSkillIndexEntry[]>;
previewImport(input: TauriTavernSkillImportInput): Promise<TauriTavernSkillImportPreview>;
installImport(request: {
input: TauriTavernSkillImportInput;
conflictStrategy?: 'skip' | 'replace';
}): Promise<TauriTavernSkillInstallResult>;
readFile(options: {
name: string;
path: string;
maxChars?: number;
}): Promise<TauriTavernSkillReadResult>;
export(options: { name: string }): Promise<TauriTavernSkillExportPayload>;
exportSkill(options: { name: string }): Promise<TauriTavernSkillExportPayload>;
};
```
## 导入输入
```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 显式触发。

View file

@ -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/<name>/SKILL.md
skills/<name>/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 最小实现应晚于内置工具系统稳定之后。

View file

@ -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,而不是新增平行工具入口。

View file

@ -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 等内容仍是后续设计。

131
docs/Agent/Skill.md Normal file
View file

@ -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/<skill-name>/
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 体系。

File diff suppressed because it is too large Load diff

View file

@ -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 回归:

View file

@ -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 读取入口。

View file

@ -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 身份

View file

@ -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`

View file

@ -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-id>/
checkpoint.json
<snapshotted files...>
_tauritavern/skills/
installed/
<skill-name>/
SKILL.md
<skill files...>
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`
## 守护契约

View file

@ -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 与审批。

View file

@ -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<SettingsService>,
pub user_directory_service: Arc<UserDirectoryService>,
pub secret_service: Arc<SecretService>,
pub skill_service: Arc<SkillService>,
pub content_service: Arc<ContentService>,
pub extension_service: Arc<ExtensionService>,
pub extension_store_service: Arc<ExtensionStoreService>,
@ -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,

View file

@ -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<SettingsService>,
pub user_directory_service: Arc<UserDirectoryService>,
pub secret_service: Arc<SecretService>,
pub skill_service: Arc<SkillService>,
pub content_service: Arc<ContentService>,
pub extension_service: Arc<ExtensionService>,
pub extension_store_service: Arc<ExtensionStoreService>,
@ -128,6 +132,7 @@ struct AppRepositories {
prompt_cache_repository: Arc<dyn PromptCacheRepository>,
user_directory_repository: Arc<dyn UserDirectoryRepository>,
secret_repository: Arc<dyn SecretRepository>,
skill_repository: Arc<dyn SkillRepository>,
content_repository: Arc<dyn ContentRepository>,
extension_repository: Arc<dyn ExtensionRepository>,
extension_store_repository: Arc<dyn ExtensionStoreRepository>,
@ -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<dyn SecretRepository> = Arc::new(FileSecretRepository::new(
default_user_dir.join("secrets.json"),
));
let skill_repository: Arc<dyn SkillRepository> = Arc::new(FileSkillRepository::new(
data_root.join("_tauritavern").join("skills"),
));
let content_repository: Arc<dyn ContentRepository> = 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,

View file

@ -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<dyn CheckpointRepository>,
chat_repository: Arc<dyn ChatRepository>,
group_chat_repository: Arc<dyn GroupChatRepository>,
skill_service: Arc<SkillService>,
model_gateway: Arc<dyn AgentModelGateway>,
) -> 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,

View file

@ -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.",

View file

@ -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<FileChatRepository> {
))
}
fn test_skill_service(root: &Path) -> Arc<SkillService> {
Arc::new(SkillService::new(Arc::new(FileSkillRepository::new(
root.join("skills"),
))))
}
async fn save_character_payload(
repository: &FileChatRepository,
root: &Path,

View file

@ -3,6 +3,7 @@ mod common;
mod dispatcher;
mod registry;
mod session;
mod skill;
mod workspace;
mod world_info;

View file

@ -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<dyn ChatRepository>,
group_chat_repository: Arc<dyn GroupChatRepository>,
workspace_repository: Arc<dyn WorkspaceRepository>,
skill_service: Arc<SkillService>,
}
impl AgentToolDispatcher {
@ -50,12 +53,14 @@ impl AgentToolDispatcher {
chat_repository: Arc<dyn ChatRepository>,
group_chat_repository: Arc<dyn GroupChatRepository>,
workspace_repository: Arc<dyn WorkspaceRepository>,
skill_service: Arc<SkillService>,
) -> 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?
}

View file

@ -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()

View file

@ -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::<Vec<_>>()
.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,
))
}

View file

@ -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";

View file

@ -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,
))
}

View file

@ -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(),
}
}

View file

@ -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;

View file

@ -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<dyn SkillRepository>,
}
impl SkillService {
pub fn new(repository: Arc<dyn SkillRepository>) -> Self {
Self { repository }
}
pub async fn list_skills(&self) -> Result<Vec<SkillIndexEntry>, ApplicationError> {
Ok(self.repository.list_skills().await?)
}
pub async fn preview_import(
&self,
input: SkillImportInput,
) -> Result<SkillImportPreview, ApplicationError> {
Ok(self.repository.preview_import(input).await?)
}
pub async fn install_import(
&self,
request: SkillInstallRequest,
) -> Result<SkillInstallResult, ApplicationError> {
Ok(self.repository.install_import(request).await?)
}
pub async fn read_skill_file(
&self,
name: &str,
path: &str,
max_chars: Option<usize>,
) -> Result<SkillReadResult, ApplicationError> {
Ok(self
.repository
.read_skill_file(name, path, max_chars)
.await?)
}
pub async fn export_skill(&self, name: &str) -> Result<SkillExportResult, ApplicationError> {
Ok(self.repository.export_skill(name).await?)
}
pub async fn delete_skills_for_source(
&self,
source_kind: &str,
source_id: &str,
) -> Result<Vec<String>, ApplicationError> {
Ok(self
.repository
.delete_skills_for_source(source_kind, source_id)
.await?)
}
}

View file

@ -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;

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub installed_hash: String,
pub file_count: usize,
pub total_bytes: u64,
pub has_scripts: bool,
pub has_binary: bool,
pub installed_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_refs: Vec<SkillSourceRef>,
}
#[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<SkillFileRef>,
pub conflict: SkillImportConflict,
#[serde(default)]
pub warnings: Vec<String>,
#[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<String>,
}
#[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<SkillInstallConflictStrategy>,
}
#[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<SkillIndexEntry>,
}
#[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<SkillInlineFile>,
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
}
#[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<u8>,
pub sha256: String,
}
fn default_inline_encoding() -> String {
"utf8".to_string()
}

View file

@ -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;

View file

@ -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<Vec<SkillIndexEntry>, DomainError>;
async fn preview_import(
&self,
input: SkillImportInput,
) -> Result<SkillImportPreview, DomainError>;
async fn install_import(
&self,
request: SkillInstallRequest,
) -> Result<SkillInstallResult, DomainError>;
async fn read_skill_file(
&self,
name: &str,
path: &str,
max_chars: Option<usize>,
) -> Result<SkillReadResult, DomainError>;
async fn export_skill(&self, name: &str) -> Result<SkillExportResult, DomainError>;
async fn delete_skills_for_source(
&self,
source_kind: &str,
source_id: &str,
) -> Result<Vec<String>, DomainError>;
}

View file

@ -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<Vec<u8>, 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<R: Read + ?Sized>(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("._"))
}

View file

@ -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);
}
}

View file

@ -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<SkillIndexEntry>,
}
impl FileSkillRepository {
pub(super) async fn load_index(&self) -> Result<SkillIndexFile, DomainError> {
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<bool, DomainError> {
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)
}
}

View file

@ -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<ValidatedSkill, DomainError> {
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<SkillInstallConflictStrategy>,
) -> Result<SkillInstallResult, DomainError> {
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),
})
}
}

View file

@ -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(())
}
}

View file

@ -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<String>,
pub(super) author: Option<String>,
pub(super) version: Option<String>,
pub(super) tags: Vec<String>,
}
#[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<String>,
#[serde(default)]
pub(super) source_kind: Option<String>,
#[serde(default)]
pub(super) allow_implicit_invocation: Option<bool>,
#[serde(default)]
pub(super) recommended_tools: Vec<String>,
#[serde(default)]
pub(super) recommended_context: Option<TauriTavernRecommendedContext>,
#[serde(default)]
pub(super) tags: Vec<String>,
#[serde(default)]
pub(super) notes: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct TauriTavernRecommendedContext {
#[serde(default)]
pub(super) default_budget_tokens: Option<u32>,
#[serde(default)]
pub(super) preferred_mode: Option<String>,
}
pub(super) fn read_skill_frontmatter(root: &Path) -> Result<SkillFrontmatter, DomainError> {
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<SkillFrontmatter, DomainError> {
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<Option<TauriTavernSidecar>, 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<String> {
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<String> {
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()))
}

View file

@ -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<PreparedImport, DomainError> {
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<PathBuf, DomainError> {
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(),
)),
}
}

View file

@ -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<Vec<SkillIndexEntry>, DomainError> {
Ok(self.load_index().await?.skills)
}
async fn preview_import(
&self,
input: SkillImportInput,
) -> Result<SkillImportPreview, DomainError> {
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<SkillInstallResult, DomainError> {
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<usize>,
) -> Result<SkillReadResult, DomainError> {
read::read_skill_file(self, name, path, max_chars).await
}
async fn export_skill(&self, name: &str) -> Result<SkillExportResult, DomainError> {
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<Vec<String>, DomainError> {
let _guard = self.mutation_lock.lock().await;
source_refs::delete_skills_for_source(self, source_kind, source_id).await
}
}

View file

@ -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<ValidatedSkill, DomainError> {
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<Vec<SkillFileRef>, 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::<u64>();
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<SkillFileRef>,
) -> 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<String, DomainError> {
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())
}

View file

@ -0,0 +1,99 @@
use crate::domain::errors::DomainError;
pub(super) fn validate_skill_name(raw: &str) -> Result<String, DomainError> {
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<String, DomainError> {
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<String, DomainError> {
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<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}

View file

@ -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<usize>,
) -> Result<SkillReadResult, DomainError> {
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}"),
})
}

View file

@ -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<Option<SkillSourceRef>, 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<SkillSourceRef>,
source_refs: Vec<SkillSourceRef>,
) {
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<Vec<String>, 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)
}

View file

@ -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");
}

View file

@ -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;

View file

@ -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())
}

View file

@ -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;

View file

@ -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(

View file

@ -241,6 +241,12 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> 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,

View file

@ -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<AppState>>,
) -> Result<Vec<SkillIndexEntry>, 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<AppState>>,
) -> Result<SkillImportPreview, CommandError> {
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<AppState>>,
) -> Result<SkillInstallResult, CommandError> {
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<usize>,
app_state: State<'_, Arc<AppState>>,
) -> Result<SkillReadResult, CommandError> {
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<AppState>>,
) -> Result<SkillExportPayload, CommandError> {
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,
})
}

View file

@ -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}`);

View file

@ -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 });
}

View file

@ -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`;

View file

@ -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<string, any>}
*/
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<string, any>} */ (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<string, any>} */
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<string, unknown>} */ (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<any> }} 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);
}
}

View file

@ -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]);
}

229
src/tauri/main/api/skill.js Normal file
View file

@ -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<string, any>}
*/
function requirePlainObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return /** @type {Record<string, any>} */ (value);
}
/**
* @param {unknown} value
* @returns {Record<string, any>}
*/
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<string, any>} */
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<string, any>} */
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<any> }} 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 });
}

View file

@ -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) {

View file

@ -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'

114
src/types.d.ts vendored
View file

@ -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<TauriTavernSkillIndexEntry[]>;
previewImport: (input: TauriTavernSkillImportInput) => Promise<TauriTavernSkillImportPreview>;
installImport: (request: {
input: TauriTavernSkillImportInput;
conflictStrategy?: TauriTavernSkillInstallConflictStrategy;
}) => Promise<TauriTavernSkillInstallResult>;
readFile: (options: { name: string; path: string; maxChars?: number }) => Promise<TauriTavernSkillReadResult>;
export: (options: { name: string }) => Promise<TauriTavernSkillExportPayload>;
exportSkill: (options: { name: string }) => Promise<TauriTavernSkillExportPayload>;
};
type TauriTavernFrontendLogsApi = {
list: (options?: { limit?: number }) => Promise<TauriTavernFrontendLogEntry[]>;
subscribe: (
@ -284,6 +397,7 @@ type TauriTavernLayoutApi = {
type TauriTavernHostApi = {
chat?: TauriTavernChatApi;
agent?: TauriTavernAgentApi;
skill?: TauriTavernSkillApi;
layout?: TauriTavernLayoutApi;
dev?: TauriTavernDevApi;
worldInfo?: TauriTavernWorldInfoApi;

View file

@ -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/,
);
});