diff --git a/README.md b/README.md index dffd478..8f924c5 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,49 @@ to an empty string opts out of SDK features permanently. * `RA3 Mod XML: Show cache report` * `RA3 Mod XML: Find unreferenced assets…` * `RA3 Mod XML: Find unreferenced assets of this type` +* `RA3 Mod XML: Enable AI Agent access…` +* `RA3 Mod XML: Install Agent Skill…` +* `RA3 Mod XML: Export AI Agent index snapshot` + +## AI Agent Access + +The extension can expose its semantic asset index to AI Agent clients through +a local, read-only MCP (Model Context Protocol) server. This is optional and +does not modify `PATH` or install global commands. + +Run: + +`RA3 Mod XML: Enable AI Agent access…` + +The command: + +1. Exports a stable index snapshot for the active project. +2. Creates a stable launcher under `~/.ra3modxml/`. +3. Starts a local read-only query server while VS Code is running. +4. Offers to: + * install the `ra3-mod-xml` Agent Skill to `~/.agents/skills/` (and + optionally to Claude Code or project-local skill directories), + * write the MCP client configuration for Claude Desktop or Cursor, + * copy a generic MCP configuration block. + +The MCP server prefers the live in-memory index while the extension is +running and falls back to the last exported snapshot when VS Code is closed. +The exposed tools include asset lookup, incoming semantic references, +**outgoing reference edges** (`get_asset_references`: which assets an asset +uses, through which element/attribute, and where in the XML), active-file +checks, `$DEFINE` lookup, and Include source resolution. + +`get_asset_references` follows `inheritFrom` ancestors and marks the +ancestor's entries with `definedIn`, so "this unit has no `WeaponSetUpdate`, +but the base unit it inherits from does" is answerable in a single call. It is +bounded by `depth` (default 1, max 3), `targetTypes` and `maxEdges`, and +reports truncation instead of silently dropping results. + +Discovery is per project (`~/.ra3modxml/endpoints/.json`), so several +VS Code windows can enable agent access at the same time without shadowing each +other, and a client can never be silently answered from a different project. + +See `docs/ai-agent-integration-plan.md` for the full design and progress. ## Requirements diff --git a/README.zh-CN.md b/README.zh-CN.md index ca0e149..4491820 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -152,6 +152,35 @@ * `RA3 Mod XML: Show cache report`(显示缓存报告) * `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…) * `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产) +* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…) +* `RA3 Mod XML: Install Agent Skill…`(安装 Agent Skill…) +* `RA3 Mod XML: Export AI Agent index snapshot`(导出 AI Agent 索引快照) + +## AI Agent 访问 + +扩展可以通过本地只读 MCP(Model Context Protocol)Server,把语义索引提供给 AI Agent 使用。该功能可选,不会修改 `PATH`,也不会注册全局命令。 + +运行: + +`RA3 Mod XML: Enable AI Agent access…` + +该命令会: + +1. 为当前项目导出稳定的索引快照; +2. 在 `~/.ra3modxml/` 下创建稳定 launcher; +3. 在 VS Code 运行期间启动本地只读查询服务; +4. 让用户选择: + * 安装 `ra3-mod-xml` Agent Skill 到 `~/.agents/skills/`(也可选择 Claude Code 或项目级目录); + * 写入 Claude Desktop / Cursor 的 MCP 配置; + * 复制通用 MCP 配置。 + +MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭后回退到最近一次导出的快照。暴露的工具包括资产定义查询、语义引用查询、**正向引用边**(`get_asset_references`:该资产用了哪些资产、通过哪个元素/属性、写在 XML 的哪一行)、文件是否有效 include、`$DEFINE` 查询以及 Include source 解析。 + +`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。 + +Endpoint 按项目存放(`~/.ra3modxml/endpoints/.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。 + +完整设计与进度见 `docs/ai-agent-integration-plan.md`。 ## 环境要求 diff --git a/docs/ai-agent-integration-plan.md b/docs/ai-agent-integration-plan.md new file mode 100644 index 0000000..8f7beb9 --- /dev/null +++ b/docs/ai-agent-integration-plan.md @@ -0,0 +1,773 @@ +# RA3 Mod XML AI Agent 接入计划与进度追踪 + +> 状态:实施中 +> 创建时间:2026-09-09 +> 目标:让 AI Agent / 其他工具能够方便、可靠地使用 RA3 Mod XML 扩展构建的语义索引。 + +--- + +## 进度追踪 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| Phase 0 | 计划与进度文件 | ✅ 完成 | +| Phase 1 | 外部快照格式与导出命令 | ✅ 完成 | +| Phase 2 | 查询核心与 CLI | ✅ 完成 | +| Phase 3 | MCP Server | ✅ 完成(基础 stdio MCP) | +| Phase 4 | MCP 配置助手 / 稳定 launcher | ✅ 完成(launcher + 启用命令 + Claude/Cursor 配置写入 + 复制配置) | +| Phase 5 | Agent Skill 安装器 | ✅ 完成(默认安装 + 多位置选择 + 安装记录 + 卸载/同步函数) | +| Phase 6 | Live 查询与快照合并策略 | ✅ 完成(本地只读 HTTP live server + endpoint 文件 + MCP 在线优先/离线兜底 + final 索引静默 5 秒自动快照) | +| Phase 7 | 测试、文档、发布 | 🟡 部分完成(README + 计划文档已更新;全部 31 个 test/*.test.mjs 逐个直接运行通过;已用直接 esbuild CLI 验证 dist 构建与 MCP smoke test;`node --test` 受沙箱限制,尚未 VSIX 打包/发布) | + +### 二期进度(2026-09-10) + +| 阶段 | 内容 | 状态 | +|---|---|---| +| 二期 A1 | live 响应携带 `projectDir`,MCP 侧校验 | ✅ 完成 | +| 二期 A2 | endpoint 按项目分文件(保留全局兜底) | ✅ 完成 | +| 二期 A3 | live server 支持 `?project=` 路由(不再只看活动编辑器) | ✅ 完成 | +| 二期 A4 | 关闭窗口只清理自己写的 endpoint | ✅ 完成 | +| 二期 A5 | `processId` 存活校验 | ✅ 完成 | +| 二期 A6 | live 失败负缓存 | ✅ 完成 | +| 二期 A7 | `snapshotBaseName` 改为 sha1 前缀 + 可读 slug | ✅ 完成 | +| 二期 B1 | Skill 适用范围段落(正/负信号 + `get_status` 探针) | ✅ 完成 | +| 二期 C1 | `get_asset_references`:有界、带 provenance 的正向引用查询 | ✅ 完成 | +| 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 | +| 二期 C3 | 二期测试与文档 | ✅ 完成(264/264 测试通过) | + +--- + +## 1. 背景与目标 + +当前 RA3 Mod XML 扩展已经能构建大型项目的语义索引,包括: + +- asset 定义:`type / id / file / line / origin / stream` +- `$DEFINE` +- `` / `xi:include` 关系 +- 语义引用和反向引用 +- manifest 资产 +- `.w3x` art 资产 + +这些能力目前主要服务于 VS Code 编辑器内部。 +本计划的目标是: + +1. 让 AI Agent、脚本和其他工具能够方便、可靠地使用该索引; +2. 不需要用户注册全局 CLI / 修改 PATH; +3. 安装/更新扩展后能通过一次性设置完成接入; +4. 在用户持续编辑代码时,索引查询仍然足够实时且不会造成磁盘写放大; +5. 为 AI Agent 提供工具 + 引导 Skill,让它知道何时、如何调用索引。 + +--- + +## 2. 核心设计决策 + +### 2.1 不采用“全局 CLI + PATH”作为主入口 + +CLI 可以作为高级/调试工具保留,但不是默认路径。 + +### 2.2 以 MCP Server 作为 AI Agent 主接口 + +MCP Server 使用本地 stdio 启动,通过绝对路径配置即可,不需要 PATH。 + +### 2.3 内存索引为主,磁盘快照为辅 + +- 实时查询尽量走 VS Code 扩展内存中的 `ModIndex`; +- 磁盘只保存低频、合并后的“当前稳定快照”作为离线兜底; +- 避免用户连续编辑保存时产生大量磁盘 IO。 + +### 2.4 Agent Skill 是可选增强层 + +MCP 注册解决“工具可用”,Skill 解决“Agent 知道怎么用好工具”。 +Skill 不作为启用 MCP 的强制条件。 + +### 2.5 所有查询结果都携带索引状态 + +外部接口必须返回:索引是否存在、是否正在构建、是否完整、是否 stale、数据版本/时间。 + +--- + +## 3. 目标架构 + +```text +VS Code 扩展 + │ + ├── 内存 ModIndex + │ │ + │ ├── 本地只读查询服务(可选,热数据) + │ └── 低频快照导出(冷数据) + │ + ├── MCP 配置助手 + │ └── 写入 AI 客户端配置 + 稳定 launcher + │ + └── Agent Skill 安装器 + └── 安装到 ~/.agents/skills 等位置 + +AI Agent / 外部工具 + │ + ├── MCP Server(推荐) + │ ├── 扩展在线 → 查询内存/本地服务 + │ └── 扩展离线 → 读取最近稳定快照 + │ + ├── CLI(可选) + └── HTTP/JSON-RPC(可选) +``` + +--- + +## 4. 组件设计 + +### 4.1 外部快照格式 + +定义一份稳定、公开的快照 schema,不直接暴露内部 workspaceStorage 缓存。 + +```json +{ + "schemaVersion": 1, + "project": "D:/Mods/CoronaMod/mods/mods/corona", + "generatedAt": "...", + "buildId": 128, + "phase": "art", + "complete": true, + "stale": false, + "stats": { + "assetCount": 38304, + "referenceCount": 97862, + "defineCount": 240, + "streamCount": 12 + }, + "assets": [ + { + "type": "GameObject", + "id": "...", + "file": "Data/...", + "line": 10, + "origin": "project", + "stream": "static", + "viaInstance": false + } + ], + "defines": [], + "references": [], + "includeGraph": [], + "sourceCandidates": [] +} +``` + +生成方式: + +- 从 `ModIndex` 导出; +- 使用数组而不是 JS Map; +- 原子写入:`temp + rename`; +- 同一项目只维护 `current` 快照,不保留每次历史版本; +- 可选维护 `previous` 用于构建期间回退。 + +### 4.2 MCP Server + +MCP Server 不直接持有完整索引,而是作为查询代理: + +1. 扩展在线时,转发到 VS Code 扩展的本地只读查询服务; +2. 扩展离线时,读取最近一次稳定快照; +3. 所有工具返回结果时附带索引状态。 + +建议 MCP 工具集: + +```text +get_status() +list_projects() +find_asset(id, type?) +find_references(id, type?) +list_assets_by_type(type, prefix?, limit?) +get_definition(type, id) +resolve_include(source) +is_file_active(path) +find_define(name) +get_usage_guide() +``` + +### 4.3 稳定 launcher 与 MCP 配置助手 + +扩展安装路径会随版本变化,所以不能直接写死在 MCP 配置里。 + +稳定入口: + +```text +~/.ra3modxml/ + ra3-mod-xml-mcp.cmd + projects.json + snapshots/ + current.json.gz + meta.json + skill-install.json +``` + +MCP 配置示例: + +```json +{ + "mcpServers": { + "ra3-mod-xml": { + "command": "C:\\Users\\lanyi\\.ra3modxml\\ra3-mod-xml-mcp.cmd", + "args": ["--project", "D:\\Mods\\CoronaMod\\mods\\mods\\corona"] + } + } +} +``` + +扩展每次激活/升级时刷新 launcher 内容,指向当前扩展安装目录。 +这样 MCP 配置只需要写一次,升级不断。 + +### 4.4 Agent Skill + +默认安装: + +```text +~/.agents/skills/ra3-mod-xml/ +├── SKILL.md +└── references/ + └── query-guide.md +``` + +Skill 内容原则: + +- 只描述功能本身; +- 说明“什么情况下该用这个 Skill”以及“怎么用”; +- 不引用各个工作区里的具体文档,例如 `docs/codebase-navigation-guide.md`,避免注意力噪音; +- 保持通用,不绑定某个 mod。 + +Skill 内容建议包括: + +- 索引覆盖范围和状态含义; +- MCP 工具用途; +- 推荐查询工作流: + - 从 ID 找定义 → `find_asset` + - 找所有引用 → `find_references` + - 确认文件是否 active → `is_file_active` + - 追继承链 → `find_references` + `find_asset` + - 找未引用资产 → `unreferenced` +- 使用规则: + - 优先查询索引,而不是全项目 `rg`; + - 查询尽量带 type,避免同名不同类型混淆; + - 不要把整个索引快照读进上下文; + - 如果索引状态不是 ready/stale,应查询状态或重试。 + +### 4.5 索引状态与一致性 + +定义索引状态: + +| 状态 | 含义 | +|---|---| +| `no_index` | 从未构建 | +| `building` | 正在构建/重建 | +| `ready_xml` | XML phase 已发布,art 未完成 | +| `ready` | 完整 final 索引 | +| `stale` | 构建期间有文件变化 | +| `error` | 构建失败但保留 last good | + +查询接口支持两种模式: + +- 默认 / eventual:有可用快照就返回,同时附带 `stale/incomplete` 标记; +- 严格 / consistent:调用方传 `wait_for_index=true` / `allow_stale=false`,等待新 final 索引或超时。 + +### 4.6 持久化策略 + +- 热数据:内存 `ModIndex`; +- 实时查询:本地查询服务 / MCP 转发; +- 冷数据:低频快照,例如: + - 用户手动导出; + - 索引稳定一段时间后自动写一次; + - 扩展退出前写一次(尽力而为); +- 不每次 rebuild 都写全量快照。 + +### 4.7 用户交互流程 + +安装/更新扩展后: + +1. 首次索引完成; +2. 如果尚未启用且未被用户忽略,弹出提示: + +```text +RA3 Mod XML 索引已就绪。 +是否让 AI Agent 使用索引查询能力? +``` + +3. 用户点击“启用”; +4. 扩展默认注册 MCP Server; +5. 同时询问/默认勾选: + +```text +[✓] 同时安装 RA3 Mod XML Agent Skill(推荐) +``` + +6. 写入 MCP 配置; +7. 安装 Skill 到 `~/.agents/skills/ra3-mod-xml/`; +8. 提供“安装到其他位置”或“手动操作指南”。 + +### 4.8 安全设计 + +- 本地查询服务只监听 `127.0.0.1`; +- 使用随机 token; +- MCP Server 默认只读,不提供文件修改/命令执行工具; +- 写 MCP 配置前必须用户同意; +- Skill 安装目录记录 managed marker,避免覆盖用户自定义内容。 + +--- + +## 5. 实施阶段 + +### Phase 1:外部快照格式与导出命令 + +- 新增 `src/agent/snapshot.ts` +- 新增命令:`ra3modxml.exportIndexSnapshot` +- 定义 `schemaVersion` +- 从 `ModIndex` 生成可序列化快照 +- 原子写入 `~/.ra3modxml/snapshots/current.json.gz` +- 输出状态元数据 + +### Phase 2:查询核心与 CLI + +- 复用纯 TS `indexer` 模块; +- 新增 `src/agent/query.ts`: + - `findAsset` + - `findReferences` + - `isFileActive` + - `listAssetsByType` + - `getStatus` +- 新增 CLI 入口(可选): + - 不注册 PATH; + - 通过绝对路径调用; + - 供脚本/调试使用。 + +### Phase 3:MCP Server + +- 新增 `src/agent/mcpServer.ts` +- 支持 stdio 协议; +- 启动时读取连接信息/快照; +- 提供前述 MCP 工具; +- 每个响应带索引状态。 + +### Phase 4:MCP 配置助手 + +- 新增 `src/agent/setup.ts` +- 创建稳定 launcher; +- 检测/写入常见客户端配置: + - Claude Desktop + - Cursor + - Codex + - 通用 JSON +- 提供“复制 MCP 配置”; +- 提供手动操作指南。 + +### Phase 5:Agent Skill 安装器 + +- 新增 `src/agent/skill.ts` +- 生成 `SKILL.md` + `references/query-guide.md` +- 默认安装到 `~/.agents/skills/ra3-mod-xml/` +- 可选安装到: + - `~/.claude/skills/ra3-mod-xml/` + - 项目 `.agents/skills/` + - 项目 `.claude/skills/` + - 自定义位置 +- 记录已安装位置,扩展升级时自动同步; +- 不覆盖用户自定义内容。 + +### Phase 6:Live 查询与快照合并 + +- 在扩展内启动本地只读查询服务; +- MCP Server 在线时优先转发到扩展; +- 离线时读取 `current.json.gz`; +- 实现快照 quiet-period 合并写入,避免频繁全量落盘; +- 实现状态机:`no_index / building / ready_xml / ready / stale / error`。 + +### Phase 7:测试、文档、发布 + +- 测试快照导出/导入一致性; +- 测试 MCP 工具查询; +- 测试 Skill 安装/更新/卸载; +- 测试持续编辑场景下的状态与 stale 行为; +- 更新 README / docs; +- 发布新版本。 + +--- + +## 6. 预估文件改动 + +### 新增文件 + +```text +src/agent/ + types.ts + snapshot.ts + query.ts + mcpServer.ts + setup.ts + skill.ts + localServer.ts + launcher.ts + +docs/ + ai-agent-integration-plan.md +``` + +### 修改文件 + +```text +package.json # commands / configuration +src/extension.ts # 注册命令、设置、初始化 +src/workspace.ts # 暴露 index status / snapshot 发布钩子 +src/features/agentSetup.ts # 用户引导 UI(或并入 agent/setup.ts) +``` + +--- + +## 7. 验收标准 + +1. 用户安装/更新扩展并完成一次索引后,可以看到“启用 AI Agent 访问”的引导。 +2. 用户点击启用后,不需要手动配置 PATH,不需要手动查找 workspaceStorage。 +3. 常见 AI 客户端能通过 MCP 调用索引工具。 +4. Agent 能正确回答: + - “某 asset 定义在哪个文件?” + - “谁引用了这个 asset?” + - “这个 XML 文件是否真的被 Include?” +5. 查询接口在无索引/索引更新中不会返回误导性空结果。 +6. 用户连续编辑保存时,不会因为频繁快照写入而产生明显卡顿或磁盘膨胀。 +7. Skill 安装后,Agent 知道优先使用 MCP 查询,而不是全文 grep。 +8. 扩展升级后,MCP 配置仍然有效,Skill 能自动同步到已安装位置。 + +--- +--- + +# 第二部分:二期计划(讨论结论 + 实施) + +> 讨论时间:2026-09-10 +> 主题:Skill 的项目识别边界、正向/间接引用查询的取舍、MCP 与 localhost 架构现状 + +--- + +## 8. 讨论结论一:谁来判定"这是不是一个红警 3 模组项目" + +### 问题 + +Skill 需要说明"何时应该被使用"和"适用范围"。这要求 Agent 能理解什么是 +"红警 3 模组项目里的 XML",同时避免把无关项目误判为 RA3 模组。 + +问题在于:这个判断应该由扩展/Skill 自己解释(甚至给出推断规则),还是应该 +由项目开发者在自己的项目文档 / `AGENTS.md` 里声明? + +### 结论:分层,且 Skill 不做复杂推断 + +| 层 | 职责 | 理由 | +|---|---|---| +| **Skill** | 说明能力覆盖范围 + 少量正/负信号 + 便宜的探测方式 | Skill 是用户全局的,会在**所有**对话里加载,必须简短且能自我限制 | +| **项目开发者**(`AGENTS.md` / README / 项目标记) | 声明"这个仓库是什么项目、要不要用这些工具" | 只有项目自己知道自己是什么,这是每个仓库的局部事实 | +| **MCP 工具自身** | 用响应证明自己是否可用 | `get_status` 是最便宜的判定,不应靠读文档猜 | + +### 为什么不把判断规则写进 Skill + +- Skill 在 `~/.agents/skills/` 下全局生效;如果写一堆判定规则,会在与 RA3 + 无关的项目里制造"要不要用这个工具"的注意力噪音。 +- 规则永远不完备。真实反例: + - 目录里有 `Schemas/xsd/CnC3Types.xsd` → 可能只是 SDK 本身,不是 mod; + - 只有一堆从 SDK 抄来的 `.xsd` → 是"RA3 相关",但没有 mod 数据; + - `CnC3Types.xsd` 里的 `CnC3` 指 **C&C3**(命令与征服 3 / 凯恩之怒), + 所以"存在 CnC3Types.xsd" **不等于** "是 RA3 模组"; + - 有 `Data/Mod.xml` / `*.babproj` / `Data/additionalmaps/mapmetadata_*.xml` + → 基本可确认是 **SAGE / BinaryAssetBuilder** 系项目(RA3 只是其中之一)。 +- 一旦误判,Agent 会拿 RA3 的索引数据去回答另一个项目的问题,比不使用更糟。 + +### 落地方案 + +**Skill 侧**(`src/agent/skill.ts` 的 `SKILL_MD`)新增 applicability 段落: + +- 正向信号(任一命中即可尝试): + `Data/Mod.xml`;`Data/additionalmaps/mapmetadata_*.xml`;`*.babproj`; + XML 根为 ``;使用 ``。 +- 明确否定:无关仓库、仅含拷贝 `.xsd` 的项目、通用 XML 配置、构建脚本、 + 非 SAGE 游戏项目。 +- 便宜的探针:不确定时先调 `get_status`;它返回索引归属的 `projectDir`。 + 如果与当前工作区根不一致,或状态是 `no_index`,就**停止使用索引工具**, + 改为直接读文件。 + +这样即使误触发,成本也只是一次 `get_status`,不会污染后续推理。 + +**项目开发者侧**:在项目根 `AGENTS.md` 写一句即可,例如: + +```markdown +## RA3 Mod tooling +This repository is a Red Alert 3 mod (SAGE XML). When locating asset +definitions or references, prefer the `ra3-mod-xml` MCP tools +(`find_asset`, `find_references`, `is_file_active`) over full-text search. +``` + +扩展**不自动**往用户仓库写标记文件(如 `.agents/ra3-mod-xml-project.json`) +作为二期内容;若将来提供,必须显式确认。 + +--- + +## 9. 讨论结论二:要不要提供"某个 Asset 直接/间接引用的所有 Asset" + +### 场景 + +用户要求 Agent"查阅或修改雅典娜炮的武器"。Agent 需要: + +1. 找到雅典娜炮的 GameObject; +2. 读源码,发现 `` / `` 等子元素, + 在这些子元素里找到武器引用; +3. 若 `` 引用了临时物体,还要去读临时物体的 GameObject, + 在它的 `FireWeaponUpdate` 里找武器; +4. 若雅典娜炮只有 `FireWeaponUpdate`、`WeaponSetUpdate` 在 + `inheritFrom` 的 `BaseCannon` 上,还要读 `BaseCannon` 并综合判断。 + +评估问题: + +- 是否有必要提供"某个 Asset 直接或间接引用的所有 Asset"? +- 只给精炼 asset 列表,Agent 不知道是哪个子元素导致的引用,有用吗? +- 若同时提供上下文,磁盘/内存/性能允许吗?内容长度会不会反而比直接读源码更贵? +- 相对直接读源码未必有显著提升,反而增加注意力噪音? +- 间接引用会不会多项式/指数爆炸? + +### 先指出当前实现的真实缺口 + +现在的索引只有**反向**引用(谁引用了我),没有**正向**查询(我引用了谁)。 +`find_references(AthenaCannon)` 返回的是"谁引用了雅典娜炮",而不是"雅典娜炮 +用了哪些武器"。所以上述 Agent 工作流**目前完全无法用工具完成**,只能读源码。 + +因此问题不是"要不要锦上添花",而是这里确实缺一个基础能力。 + +### 结论:不提供"所有可达 Asset 的扁平闭包",改提供"有界 + 带 provenance 的边列表" + +**不采用扁平静态闭包的理由:** + +1. **丢失 provenance,Agent 仍然要读源码。** 到底是 `FireWeaponUpdate` 的武器、 + `WeaponSetUpdate` 的武器、`CreateObjectDie` 临时物体的武器,还是 + `inheritFrom` 继承来的武器?这决定了 Agent 改哪个文件、加不加 + `xai:joinAction`。丢掉上下文,列表基本没用。 +2. **尺寸会失控。** 一个 GameObject 的可达闭包可能是 + `Weapon → Projectile → Warhead → DamageNugget → FX → Particle → Texture`、 + `Model → Mesh → Material → Texture`、`AttributeModifier`、`Upgrade`、`Command`、 + `Button`……Corona 规模下单个单位几百到上千节点完全可能。200 条边就是几万 + token,**比直接读 3 个小 XML 文件贵得多**。 +3. **注意力噪音。** 列表里 80% 是与当前问题无关的 Texture / FX 时,推理质量下降。 +4. **"间接"的诱惑会持续膨胀。** 一旦提供无界闭包,Agent 会习惯性调用它, + 然后被淹没。 + +**但以下特性确实值得工具化:** + +- 多跳遍历是**纯机械**的,正是工具该做的事; +- `inheritFrom` 链 + `xai:joinAction` 语义很微妙,Agent 手推容易错; +- 每跳都 read 一个文件,Corona 下成本和 token 都很高; +- 工具可以做到**确定性、不遗漏**(按 XSD 判定"哪些属性是引用", + 而不是靠 Agent 逐个注意)。 + +**设计要点(二期实现遵循):** + +- 返回**边列表**而不是节点集合。每条边携带: + - `from`(effective asset)、`to`(解析出的定义位置)、 + - `via.kind`(`attribute` / `content` / `inheritFrom`)、 + - `via.element`、`via.parent`、`via.attribute`, + - `source.file` / `source.line`(XML 实际所在位置), + - `definedIn`(当该边来自 `inheritFrom` 祖先的 XML 时,标明真正写它的资产)。 +- **默认 `depth: 1`,硬上限 3。** Agent 拿到直接引用后自己决定是否对某个具体 + 节点递归,比一次性 dump 闭包更省 token、更有针对性。 +- **`targetTypes` 过滤**是抑制噪音最有效的手段。雅典娜炮场景就是 + `targetTypes: ["WeaponTemplate"]`(可选再带 `GameObject` 给 `CreateObjectDie`), + 返回可能只有 3–10 条边。 +- **`inheritFrom` 不消耗 depth**:继承是资产自身定义的一部分,不是运行时引用。 + 因此在同一 depth 内递归遍历继承链(链长上限 8),并把 `definedIn` 标出来。 +- **截断时返回 `omittedByTargetType` 摘要**,让 Agent 知道被砍掉了什么形状的数据, + 可以精确下钻,而不是无脑重试或放弃。 +- **不提供"合并后的有效值"**:`joinAction` 的 Replace/Remove/merge 语义算错的风险 + 太高,一期不做。 + +**关于爆炸和成本:** + +- 算法上不是指数:带 visited-set 的 BFS 是 `O(V+E)`。真正的风险是**语义宽度**, + 不是复杂度,因此用 `depth` + `targetTypes` + `maxEdges` 三重限制。 +- **只做 live 查询**(VS Code 打开时可用)。要拿元素上下文,最干净的做法是按需 + 解析定义所在文件的 DOM,而不是往 records 缓存里塞元素名/属性名 —— Corona 有 + ~98k 条引用记录,再塞 5 个字符串字段会让常驻内存明显膨胀,为一个默认 `depth=1` + 的功能付出这个成本不划算。 +- 离线快照路径明确返回"需要 live index",而不是静默返回空结果。 + +--- + +## 10. 讨论结论三:MCP / localhost 的实际架构与多窗口缺陷 + +### 当前架构(事实) + +```text +AI 客户端 + │ stdio (JSON-RPC, MCP) + ▼ +dist/agent/mcpServer.js ← 由 AI 客户端启动,参数 --project + │ HTTP (127.0.0.1:临时端口, Bearer token) + ▼ +VS Code 扩展进程内的 localServer ← 仅在"启用 AI Agent 访问"后启动 + │ + ├─ 在线 → ws.activeIndex() 实时内存索引 + └─ 离线 → ~/.ra3modxml/snapshots/project-.json.gz +``` + +- **AI 客户端不需要知道端口。** MCP Server 自己读 + `~/.ra3modxml/endpoint.json` 拿 `url` + `token` 再 `fetch`。localhost 层是 + 纯实现细节。 +- **端口**:`server.listen(port ?? 0, "127.0.0.1")`,即由操作系统分配临时端口, + 不会冲突;当前未传 `port`,所以永远是临时端口。 +- **鉴权**:`Authorization: Bearer `,token 为启动时随机生成;绑定 + `127.0.0.1`,局域网访问不到。 +- **降级**:live 查询失败(endpoint 不存在 / 连接被拒 / 超时 1.5s)时回退到快照。 + +### 多项目 / 多 VSCode 窗口的真实缺陷 + +`endpoint.json` 是**全局唯一**的一个文件,所有 VSCode 窗口都往它写入: + +1. **最后启用的窗口覆盖前面所有窗口。** 两个窗口都启用了 Agent 访问时, + 只有后启动的那个能被 MCP Server 找到。 +2. **可能串项目(严重)。** 假设窗口 B 覆盖了 endpoint,而某个 MCP Server 是用 + `--project 窗口A` 启动的,它的 `tryLiveQuery` 会打到**窗口 B** 的 server, + 拿到窗口 B 的索引数据,而返回值里没有任何东西能暴露这个不一致。 + 这比 `no_index` 危险得多 —— 会静默给出错误答案。 +3. **关闭任意窗口都会 `clearEndpoint()`**,把还开着的其他窗口的 live 通道一起断掉。 +4. **同一窗口内多项目也不稳。** live server 用 + `getIndex: () => ws.activeIndex()`,而 `activeIndex()` 跟着**当前活动编辑器**走。 + 用户切到另一个项目的文件,live 查询就会静默换项目;而 `endpoint.json` 里的 + `projectDir` 只在启动时写一次,之后不更新。 +5. `processId` 虽然写进了 endpoint,但**从未被校验**;VSCode 崩溃后残留的 endpoint + 只能靠连接失败兜底。 +6. 离线快照路径是**正确**的 —— `snapshotPathForProject()` 按项目路径做 hash, + 多项目互不干扰。问题只在 live 层。 + +### 修复方案(二期 A,按性价比排序) + +1. **live 响应必须带 `projectDir`,MCP 侧必须校验它和 `--project` 一致**, + 不一致就直接走快照。这是最小的正确性补丁,最先做。 +2. **endpoint 按项目存**:`~/.ra3modxml/endpoints/.json`, + MCP Server 按 `--project` 找对应的那个;同时保留 `endpoint.json` 作为 + "最近"的兼容入口。 +3. **live server 支持 `?project=` 路由**,而不是只认 `activeIndex()`。 + 扩展侧加 `indexForProject(projectDir)`,一个窗口的一个 server 就能服务它 + 所有已索引项目,也就不怕用户切编辑器。 +4. **关闭时只清自己的那一条**,不无条件删全局 endpoint。 +5. **校验 `processId` 是否存活**,不存活就当 endpoint 失效。 +6. `tryLiveQuery` 失败后做几秒的"live 不可用"负缓存,避免每次调用都试一次。 +7. `snapshotBaseName` 换成 sha1 前缀 + 可读 slug(与 `diskCacheKey` 一致的做法), + 降低碰撞概率。 + +--- + +## 11. 二期实施清单 + +### 二期 A:多窗口 / 多项目正确性 + +- `src/agent/snapshot.ts`:新增 `projectHash()`;`snapshotBaseName()` 改为 + `-`。 +- `src/agent/endpoint.ts`:新增 + `endpointPathForProject()` / `writeEndpointForProject()` / + `readEndpointForProject()` / `clearEndpointForProject()` / `isProcessAlive()`。 +- `src/workspace.ts`:新增 `indexForProject(projectDir)`。 +- `src/agent/liveQuery.ts`:`liveStatus()` 在没有索引时回落到请求的 `projectDir`。 +- `src/agent/localServer.ts`:`getIndex(projectDir?)` 路由、异步 handler、 + `/projects` 端点、所有响应携带 `index.projectDir`。 +- `src/agent/mcpServer.ts`:按项目读 endpoint、校验 `projectDir` 与 `processId`、 + `?project=` 透传、live 失败负缓存、响应 `projectDir` 二次校验。 +- `src/extension.ts`:启动时按项目写 endpoint、关闭时只清自己写过的那些。 + +### 二期 B:Skill 适用范围 + +- `src/agent/skill.ts`:`SKILL_MD` 增加 applicability 段落(正向信号、明确否定、 + `get_status` 探针);保持"不引用工作区文档"的原则。 + +### 二期 C:`get_asset_references` + +- `src/agent/forwardRefs.ts`(新增):DOM-based、有界、带 provenance 的正向引用遍历。 +- `src/agent/localServer.ts`:新增 `GET /get_asset_references`。 +- `src/agent/mcpServer.ts`:新增 `get_asset_references` 工具(live-only, + 离线时返回明确的"需要 live index"说明)。 +- `src/extension.ts`:为 live server 提供 `loadFile()`(读文本 + 解析 + LineMap)。 + +### 二期 D:测试 + +- `test/agentEndpoint.test.mjs`:按项目 endpoint、进程存活、清理隔离。 +- `test/agentLocalServer.test.mjs`:`?project=` 路由、`projectDir` 校验、 + `/get_asset_references`。 +- `test/agentForwardRefs.test.mjs`:直接引用、继承链、`CreateObjectDie` 内容引用、 + `targetTypes` 过滤、`maxEdges` 截断、`depth` 上限。 +- `test/agentSkill.test.mjs`:Skill 文本包含 applicability 段落且不含工作区文档引用。 +- `test/agentMcpRouting.test.mjs`:live URL 固定 `project`、跨项目响应拒绝。 + +--- + +## 12. 二期实施结果 + +全部完成,`npx tsc --noEmit` 通过,`test/*.test.mjs` 共 **264/264** 通过。 + +### 新增 / 修改文件 + +```text +新增: + src/agent/forwardRefs.ts 有界、带 provenance 的正向引用遍历 + test/agentForwardRefs.test.mjs + test/agentMcpRouting.test.mjs + +修改: + src/agent/endpoint.ts 按项目 endpoint + isProcessAlive() + src/agent/snapshot.ts projectHash() / projectSlug() / snapshotBaseName() + src/agent/liveQuery.ts liveStatus() 回落请求的 projectDir + src/agent/localServer.ts 异步 handler、?project= 路由、/projects、/get_asset_references + src/agent/mcpServer.ts 按项目 endpoint、projectDir 校验、负缓存、get_asset_references + src/agent/skill.ts applicability 段落 + get_asset_references 用法 + src/workspace.ts 新增 indexForProject() + src/extension.ts 按项目写/清 endpoint、loadFile、endpoint 刷新 + test/agentEndpoint.test.mjs + test/agentLocalServer.test.mjs + test/agentSkill.test.mjs +``` + +### 关键行为 + +**多窗口 / 多项目** + +- `~/.ra3modxml/endpoints/-.json` 一项目一文件;MCP Server 先读自己项目的 + 文件,读不到才退回全局 `endpoint.json`,且仅在 `projectDir` 匹配时使用。 +- 每个 live 请求带 `?project=`;server 用 `ModWorkspace.indexForProject()` 路由, + 不再依赖"当前活动编辑器"。 +- MCP 侧对响应里的 `index.projectDir` 做二次校验,不匹配就拒答并提示重新启用, + 而不是给一个看起来合理但属于别的项目的答案。 +- 关闭窗口只删除本窗口写过的 endpoint 文件。 +- `processId` 不存活时 endpoint 视为失效。 +- live 连接失败后有 5 秒负缓存,避免每次工具调用都探一次。 + +**Skill** + +- 新增 `When this skill applies` 段落:正向信号(`Data/Mod.xml`、 + `mapmetadata_*.xml`、`*.babproj`、``、 + `uri:ea.com:eala:asset`)、明确否定规则(无关仓库、仅含拷贝 `.xsd` 的项目)、 + 以及 `CnC3Types.xsd` 属于 C&C3 而非 RA3 的反例。 +- 规定用 `get_status` 做便宜探针:状态为 `no_index` 或 `projectDir` 不匹配时 + 停止使用索引工具,改为直接读文件。 +- 仍然不引用任何工作区文档(有测试断言)。 + +**`get_asset_references`(正向引用)** + +- 返回**边**:`from` / `to` / `via{kind,element,parent,attribute}` / + `source{file,line,character}` / `definedIn` / `value`。 +- `depth` 默认 1、上限 3;`inheritFrom` 不消耗 depth,祖先 XML 在同层遍历, + 用 `definedIn` 标出真正写这条边的资产(解决"雅典娜炮自己没有 WeaponSetUpdate, + 但 BaseCannon 有"的场景)。 +- `targetTypes` 按 XSD 可赋值性过滤,但 `inheritFrom` 边始终保留,因为 + 它解释了其余边写在哪里。 +- `maxEdges` 截断时返回 `truncated` + `omittedByTargetType`(只统计因上限被丢弃的边, + 已被 `targetTypes` 过滤的不计入)。 +- 只走 live 路径;不可用时返回明确的错误说明,而不是空结果。 + +### 已知限制 + +- `get_asset_references` 不计算 `xai:joinAction`(`Replace`/`Remove`)合并后的 + 有效值,需要调用方自行确认。 +- 只有 live 路径支持正向引用;离线快照路径明确返回"需要 live index"。 +- `snapshotBaseName` 改为 `-`,旧的 `project-.json.gz` + 快照文件不会被自动清理(该功能尚未发布,无兼容负担)。 +- 本沙箱禁止 spawn 子进程,因此 `node --test` 与 `npm run build` 无法直接运行; + 验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、 + 用管道驱动 `dist/agent/mcpServer.js` 做端到端 smoke test。 + + diff --git a/esbuild.mjs b/esbuild.mjs index 103bb2c..7f31adc 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -3,9 +3,14 @@ import * as esbuild from "esbuild"; const watch = process.argv.includes("--watch"); const ctx = await esbuild.context({ - entryPoints: ["./src/extension.ts"], + entryPoints: [ + "./src/extension.ts", + "./src/agent/mcpServer.ts", + "./src/agent/cli.ts", + ], bundle: true, - outfile: "dist/extension.js", + outdir: "dist", + outbase: "src", external: ["vscode"], format: "cjs", platform: "node", diff --git a/package.json b/package.json index 0b76692..9304049 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,18 @@ "command": "ra3modxml.showCacheReport", "title": "%ra3modxml.command.showCacheReport.title%" }, + { + "command": "ra3modxml.enableAgentAccess", + "title": "%ra3modxml.command.enableAgentAccess.title%" + }, + { + "command": "ra3modxml.installAgentSkill", + "title": "%ra3modxml.command.installAgentSkill.title%" + }, + { + "command": "ra3modxml.exportIndexSnapshot", + "title": "%ra3modxml.command.exportIndexSnapshot.title%" + }, { "command": "ra3modxml.findUnreferencedAssets", "title": "%ra3modxml.command.findUnreferencedAssets.title%" diff --git a/package.nls.json b/package.nls.json index 6683230..8beaedd 100644 --- a/package.nls.json +++ b/package.nls.json @@ -13,6 +13,9 @@ "ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild", "ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…", "ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report", + "ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: Enable AI Agent access…", + "ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: Install Agent Skill…", + "ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot", "ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…", "ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type" } diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 8b2edf8..32ca193 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -13,6 +13,9 @@ "ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建", "ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…", "ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告", + "ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: 启用 AI Agent 访问…", + "ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: 安装 Agent Skill…", + "ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照", "ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…", "ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产" } diff --git a/src/agent/cli.ts b/src/agent/cli.ts new file mode 100644 index 0000000..2bc63ee --- /dev/null +++ b/src/agent/cli.ts @@ -0,0 +1,159 @@ +/** + * Minimal CLI for querying an exported RA3 Mod XML agent snapshot. + * + * This is intentionally not installed into PATH. It is meant for scripts, + * debugging, and as a reference for MCP tool implementations. + * + * Usage examples: + * node out/agent/cli.js --project D:/Mods/Example status + * node out/agent/cli.js --snapshot /path/to/snapshot.json.gz find ExampleUnit + * node out/agent/cli.js --project D:/Mods/Example refs ExampleUnit GameObject + */ + +import { readSnapshotFile, snapshotPathForProject } from "./snapshot"; +import { + findAssets, + findDefine, + findReferenceGroups, + isFileActive, + listAssetsByType, + resolveIncludeSource, + statusFromSnapshot, +} from "./query"; + +interface CliOptions { + snapshotPath: string | null; + projectDir: string | null; + command: string; + args: string[]; +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + snapshotPath: null, + projectDir: null, + command: "status", + args: [], + }; + const positional: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--snapshot" || arg === "-s") { + options.snapshotPath = argv[++i] ?? null; + } else if (arg === "--project" || arg === "-p") { + options.projectDir = argv[++i] ?? null; + } else if (arg === "--help" || arg === "-h") { + options.command = "help"; + } else if (arg.startsWith("-")) { + // ignore unknown flags + } else { + positional.push(arg); + } + } + if (positional.length > 0) { + options.command = positional[0]; + options.args = positional.slice(1); + } + return options; +} + +function printHelp(): void { + console.log(`RA3 Mod XML agent snapshot CLI + +Usage: + node out/agent/cli.js --project [args...] + node out/agent/cli.js --snapshot [args...] + +Commands: + status + find [type] + refs [type] + list [prefix] + active + define + resolve + help +`); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.command === "help") { + printHelp(); + return; + } + const snapshotPath = + options.snapshotPath ?? + (options.projectDir ? snapshotPathForProject(options.projectDir) : null); + if (!snapshotPath) { + console.error("No --project or --snapshot provided."); + process.exitCode = 2; + return; + } + const snapshot = await readSnapshotFile(snapshotPath); + if (!snapshot) { + console.error(`Snapshot not found or unreadable: ${snapshotPath}`); + process.exitCode = 3; + return; + } + + const [a, b] = options.args; + switch (options.command) { + case "status": + console.log(JSON.stringify(statusFromSnapshot(snapshot), null, 2)); + break; + case "find": + if (!a) { + console.error("find requires an id."); + process.exitCode = 2; + return; + } + console.log(JSON.stringify(findAssets(snapshot, a, b), null, 2)); + break; + case "refs": + if (!a) { + console.error("refs requires an id."); + process.exitCode = 2; + return; + } + console.log(JSON.stringify(findReferenceGroups(snapshot, a, b), null, 2)); + break; + case "list": + if (!a) { + console.error("list requires a type."); + process.exitCode = 2; + return; + } + console.log(JSON.stringify(listAssetsByType(snapshot, a, b ?? ""), null, 2)); + break; + case "active": + if (!a) { + console.error("active requires a file path."); + process.exitCode = 2; + return; + } + console.log(JSON.stringify({ active: isFileActive(snapshot, a) }, null, 2)); + break; + case "define": + if (!a) { + console.error("define requires a name."); + process.exitCode = 2; + return; + } + console.log(JSON.stringify(findDefine(snapshot, a), null, 2)); + break; + case "resolve": + if (!a) { + console.error("resolve requires a source."); + process.exitCode = 2; + return; + } + console.log(JSON.stringify(resolveIncludeSource(snapshot, a), null, 2)); + break; + default: + console.error(`Unknown command: ${options.command}`); + process.exitCode = 2; + } +} + +void main(); diff --git a/src/agent/endpoint.ts b/src/agent/endpoint.ts new file mode 100644 index 0000000..4bc7dab --- /dev/null +++ b/src/agent/endpoint.ts @@ -0,0 +1,153 @@ +/** + * Read/write helpers for the local endpoint files used by MCP/CLI tools to + * find a live RA3 Mod XML query server. + * + * Discovery is **per project** (`endpoints/-.json`) because a + * single global `endpoint.json` breaks as soon as more than one VS Code + * window (or more than one project) has AI Agent access enabled: the last + * window to start would overwrite the file, and a client started for project + * A could silently receive project B's index. + * + * A global `endpoint.json` is still written as a legacy/"most recent" pointer + * so tooling that does not know the project can find something, but readers + * that do know the project must prefer the per-project file. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { defaultAgentHome, snapshotBaseName } from "./snapshot"; + +export interface AgentEndpoint { + /** Base URL of the local server, e.g. http://127.0.0.1:54321 */ + url: string; + token: string; + /** Absolute project root this endpoint serves (may serve several). */ + projectDir?: string; + /** Every project root the server can answer for. */ + projects?: string[]; + /** PID of the VS Code extension host, used to detect stale files. */ + processId?: number; + /** ISO timestamp of the last write, useful for diagnostics. */ + updatedAt?: string; +} + +/** Path to the legacy global endpoint file. */ +export function endpointPath(agentHome = defaultAgentHome()): string { + return join(agentHome, "endpoint.json"); +} + +/** Directory holding one endpoint file per project. */ +export function endpointDir(agentHome = defaultAgentHome()): string { + return join(agentHome, "endpoints"); +} + +/** Path to the per-project endpoint file. */ +export function endpointPathForProject( + projectDir: string, + agentHome = defaultAgentHome(), +): string { + return join(endpointDir(agentHome), `${snapshotBaseName(projectDir)}.json`); +} + +async function writeJson(file: string, value: unknown): Promise { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +async function readJson(file: string): Promise { + try { + return JSON.parse(await readFile(file, "utf8")) as T; + } catch { + return null; + } +} + +/** Writes the legacy global endpoint file. */ +export async function writeEndpoint( + endpoint: AgentEndpoint, + agentHome = defaultAgentHome(), +): Promise { + const file = endpointPath(agentHome); + await writeJson(file, endpoint); + return file; +} + +/** Writes one project's endpoint file (does not touch the global pointer). */ +export async function writeEndpointForProject( + projectDir: string, + endpoint: AgentEndpoint, + agentHome = defaultAgentHome(), +): Promise { + const file = endpointPathForProject(projectDir, agentHome); + await writeJson(file, { ...endpoint, projectDir: resolve(projectDir) }); + return file; +} + +/** Reads the legacy global endpoint file. */ +export async function readEndpoint( + agentHome = defaultAgentHome(), +): Promise { + const parsed = await readJson(endpointPath(agentHome)); + if (!parsed?.url || !parsed.token) return null; + return parsed; +} + +/** + * Reads one project's endpoint. Returns null when the file is missing or the + * recorded project does not match the requested one (defence in depth: the + * file name already encodes the project, but a stale/hand-edited file must + * never be able to redirect a client to another project). + */ +export async function readEndpointForProject( + projectDir: string, + agentHome = defaultAgentHome(), +): Promise { + const parsed = await readJson( + endpointPathForProject(projectDir, agentHome), + ); + if (!parsed?.url || !parsed.token) return null; + if (parsed.projectDir && !sameProject(parsed.projectDir, projectDir)) { + return null; + } + return parsed; +} + +/** Removes the legacy global endpoint file. */ +export async function clearEndpoint( + agentHome = defaultAgentHome(), +): Promise { + await rm(endpointPath(agentHome), { force: true }); +} + +/** Removes one project's endpoint file. */ +export async function clearEndpointForProject( + projectDir: string, + agentHome = defaultAgentHome(), +): Promise { + await rm(endpointPathForProject(projectDir, agentHome), { force: true }); +} + +/** True when both paths resolve to the same project root. */ +export function sameProject(a: string, b: string): boolean { + return resolve(a).toLowerCase() === resolve(b).toLowerCase(); +} + +/** + * True when a recorded extension-host PID is still running. + * + * `process.kill(pid, 0)` only probes existence: EPERM means the process + * exists but we may not signal it, which still counts as alive. Unknown PIDs + * are treated as alive so that a missing/older endpoint file does not disable + * the live path. + */ +export function isProcessAlive(pid: number | undefined | null): boolean { + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return true; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} diff --git a/src/agent/forwardRefs.ts b/src/agent/forwardRefs.ts new file mode 100644 index 0000000..5d8a18a --- /dev/null +++ b/src/agent/forwardRefs.ts @@ -0,0 +1,503 @@ +/** + * Bounded, provenance-carrying forward-reference (outgoing edge) queries. + * + * The reverse index answers "who references this asset". This module answers + * the opposite, which the index does not store: "which assets does this asset + * reference, and through which XML element/attribute?". + * + * Design decisions (see docs/ai-agent-integration-plan.md §9): + * + * - It returns **edges**, not a flat node set. Each edge carries the element + * name, parent element name and attribute that produced it, plus the exact + * file/line of the XML text. A node list without that context forces the + * agent back into reading source, which is exactly what this should avoid. + * - It is **bounded three ways**: `depth` (1 default, 3 max), `targetTypes` + * (assignability filter) and `maxEdges`. Truncation is reported together + * with a per-type summary of what was dropped, so the caller can narrow the + * query instead of silently receiving a partial answer. + * - `inheritFrom` does **not** consume depth. Inheritance is part of an + * asset's own effective definition, so the ancestor's XML is walked at the + * same level and tagged with `definedIn`. This is what makes "AthenaCannon + * has no WeaponSetUpdate, but BaseCannon does" answerable in one call. + * - It is DOM-based and therefore **live-only**: resolving element context + * needs the parse tree, and storing element names on all ~98k reference + * records would measurably inflate the persistent cache. + * - It deliberately does **not** compute merged/inherited effective values: + * `xai:joinAction` Replace/Remove semantics are too easy to get wrong. + * + * Pure TypeScript: no VS Code dependency. + */ + +import type { AssetDef, ModIndex } from "../indexer/types"; +import { localName } from "../indexer/xpointer"; +import { + isReferenceAttributeOfType, + isReferenceContentType, + resolveContentReferenceTargets, + resolveReferenceTargetsForType, +} from "../indexer/refs"; +import { + LineMap, + parseXml, + type XmlDocument, + type XmlElement, +} from "../language/xmlParser"; +import { resolveElementType } from "../language/typeContext"; +import { isAssignableTo } from "../model/schemaModel"; + +export interface ForwardRefVia { + kind: "attribute" | "content" | "inheritFrom"; + /** Element carrying the reference (e.g. `Weapon`). */ + element: string; + /** Parent element giving the element its context (e.g. `WeaponSlotHardpoint`). */ + parent: string | null; + /** Attribute name for `attribute` kind; null for `content`/`inheritFrom`. */ + attribute: string | null; +} + +/** A resolved definition location. */ +export interface ForwardRefTarget { + type: string; + id: string; + file: string; + line: number; +} + +export interface ForwardRefEdge { + /** 1-based hop number from the queried asset. */ + depth: number; + /** The asset the edge is attributed to (the queried asset for inherited XML). */ + from: { type: string; id: string }; + /** Resolved definition, or null for an unresolved reference. */ + to: ForwardRefTarget | null; + via: ForwardRefVia; + /** + * Present only when the XML text lives in an `inheritFrom` ancestor rather + * than in `from` itself. + */ + definedIn?: { type: string; id: string }; + /** Exact source position of the reference value. */ + source: { file: string; line: number; character: number }; + /** Raw value, present for unresolved references and `inheritFrom` edges. */ + value?: string; +} + +export interface ForwardRefNode extends ForwardRefTarget { + /** Shallowest depth at which this node was reached. */ + depth: number; +} + +export interface ForwardRefOptions { + /** Levels of assets to expand. 1 = only the queried asset (default). Max 3. */ + depth?: number; + /** Only keep edges/nodes whose target is assignable to one of these types. */ + targetTypes?: string[]; + /** Hard cap on returned edges (default 200). */ + maxEdges?: number; + /** Include edges whose reference value could not be resolved. */ + includeUnresolved?: boolean; + /** Walk `inheritFrom` ancestors (default true). */ + includeInheritance?: boolean; +} + +export interface ForwardRefResult { + roots: AssetDef[]; + edges: ForwardRefEdge[]; + nodes: ForwardRefNode[]; + truncated: boolean; + /** Target types dropped because `maxEdges` was reached. */ + omittedByTargetType: Record; + /** Non-fatal warnings (missing files, missing definitions, capped chains). */ + warnings: string[]; +} + +/** A parsed XML file plus the raw text, as needed by the DOM walk. */ +export interface LoadedXmlFile { + parse: XmlDocument; + lineMap: LineMap; + text: string; +} + +export type XmlFileLoader = (file: string) => Promise; + +const DEFAULT_MAX_EDGES = 200; +const MAX_DEPTH_LIMIT = 3; +/** + * Inheritance is walked at the same depth, so the chain length is capped + * separately to keep pathological hierarchies from expanding without bound. + */ +const MAX_INHERIT_CHAIN = 8; + +/** Parses a file's text into the shape this module needs. */ +export function parseLoadedXml(text: string): LoadedXmlFile { + return { parse: parseXml(text), lineMap: new LineMap(text), text }; +} + +/** + * Resolves the definitions to start from, then walks their outgoing + * references. Returns an empty result (with a warning) when nothing matches. + */ +export async function collectAssetReferences( + index: ModIndex, + id: string, + type: string | null, + loadFile: XmlFileLoader, + options: ForwardRefOptions = {}, +): Promise { + const wanted = id.toLowerCase(); + const wantedType = type?.toLowerCase() ?? null; + const roots = (index.assetsById.get(wanted) ?? []).filter( + (d) => !wantedType || d.type.toLowerCase() === wantedType, + ); + if (!roots.length) { + return { + roots: [], + edges: [], + nodes: [], + truncated: false, + omittedByTargetType: {}, + warnings: [ + `No definition found for id "${id}"${type ? ` of type "${type}"` : ""}.`, + ], + }; + } + const result = await collectForwardReferences(index, roots, loadFile, options); + return { ...result, roots }; +} + +/** + * Walks the outgoing references of `roots` and returns bounded edges. + */ +export async function collectForwardReferences( + index: ModIndex, + roots: readonly AssetDef[], + loadFile: XmlFileLoader, + options: ForwardRefOptions = {}, +): Promise { + const depthLimit = clampDepth(options.depth); + const maxEdges = Math.max(1, Math.floor(options.maxEdges ?? DEFAULT_MAX_EDGES)); + const targetTypes = (options.targetTypes ?? []).filter(Boolean); + const includeUnresolved = options.includeUnresolved ?? false; + const includeInheritance = options.includeInheritance ?? true; + + const edges: ForwardRefEdge[] = []; + const nodes = new Map(); + const omitted = new Map(); + const warnings: string[] = []; + let truncated = false; + let edgeCount = 0; + + const fileCache = new Map(); + const load = async (file: string): Promise => { + const key = file.toLowerCase(); + if (!fileCache.has(key)) { + try { + fileCache.set(key, await loadFile(file)); + } catch { + fileCache.set(key, null); + } + } + return fileCache.get(key) ?? null; + }; + + interface WorkItem { + /** Asset whose XML is walked. */ + def: AssetDef; + /** 0-based expansion level. */ + level: number; + /** Asset the edges are attributed to (the queried asset). */ + effective: { type: string; id: string }; + /** Chain from `effective` (exclusive) down to `def` (inclusive). */ + trail: { type: string; id: string }[]; + } + + const queue: WorkItem[] = roots.map((def) => ({ + def, + level: 0, + effective: { type: def.type, id: def.id }, + trail: [], + })); + const visited = new Set(); + + const matchesTargetType = (typeName: string): boolean => + targetTypes.length === 0 || + targetTypes.some((wanted) => isAssignableTo(typeName, wanted)); + + const addEdge = ( + level: number, + effective: WorkItem["effective"], + trail: WorkItem["trail"], + via: ForwardRefVia, + target: ForwardRefTarget | null, + source: ForwardRefEdge["source"], + value?: string, + ): void => { + // `targetTypes` drops edges the caller did not ask for. `inheritFrom` + // edges always survive: they explain where the remaining edges came from + // (an inherited weapon lives in the ancestor's file, and editing the + // derived asset would be wrong). This filter is applied before the edge + // cap so `omittedByTargetType` only reports cap-driven truncation. + if (target && via.kind !== "inheritFrom" && !matchesTargetType(target.type)) { + return; + } + if (edgeCount >= maxEdges) { + truncated = true; + const bucket = target?.type ?? "#unresolved"; + omitted.set(bucket, (omitted.get(bucket) ?? 0) + 1); + return; + } + edgeCount++; + const edge: ForwardRefEdge = { + depth: level + 1, + from: { type: effective.type, id: effective.id }, + to: target, + via, + source, + }; + if (trail.length) { + const owner = trail[trail.length - 1]; + edge.definedIn = { type: owner.type, id: owner.id }; + } + if (value !== undefined) edge.value = value; + edges.push(edge); + + if (target) { + const key = nodeKey(target); + const existing = nodes.get(key); + if (!existing || level + 1 < existing.depth) { + nodes.set(key, { ...target, depth: level + 1 }); + } + } + }; + + while (queue.length) { + const item = queue.shift()!; + const { def, level, effective, trail } = item; + const visitKey = [ + effective.type, + effective.id.toLowerCase(), + def.type, + def.id.toLowerCase(), + def.file.toLowerCase(), + def.line, + ].join("\u0000"); + if (visited.has(visitKey)) continue; + visited.add(visitKey); + + const loaded = await load(def.file); + if (!loaded) { + warnings.push(`Could not read ${def.file} (definition of ${def.type}:${def.id}).`); + continue; + } + const ownerEl = findDefinitionElement(loaded, def); + if (!ownerEl) { + warnings.push( + `Could not locate <${def.type} id="${def.id}"> inside ${def.file}; the index line may be stale.`, + ); + continue; + } + + const sourceAt = (offset: number): ForwardRefEdge["source"] => { + const pos = loaded.lineMap.positionAt(offset); + return { file: def.file, line: pos.line + 1, character: pos.character }; + }; + + for (const el of subtreeInDocumentOrder(ownerEl)) { + const elType = resolveElementType(el); + const elLocal = localName(el.name); + const parentLocal = el.parent ? localName(el.parent.name) : null; + + for (const attr of el.attrs) { + if (!attr.hasValue) continue; + const nameLower = attr.name.toLowerCase(); + // inheritFrom belongs to the asset element itself and is handled + // separately below so it can also expand the ancestor chain. + if (nameLower === "inheritfrom") continue; + const value = attr.value; + if (!value || value.startsWith("$") || value.startsWith("=")) continue; + if (!isReferenceAttributeOfType(elType, attr.name)) continue; + + const via: ForwardRefVia = { + kind: "attribute", + element: elLocal, + parent: parentLocal, + attribute: attr.name, + }; + const source = sourceAt(attr.valueStart); + const targets = resolveReferenceTargetsForType(index, elType, attr.name, value); + if (!targets.length) { + if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value); + continue; + } + for (const target of targets) { + const to = targetOf(target.def); + addEdge(level, effective, trail, via, to, source); + if (level + 1 < depthLimit) { + queue.push({ + def: target.def, + level: level + 1, + effective: { type: target.def.type, id: target.def.id }, + trail: [], + }); + } + } + } + + // Simple-content references (e.g. temp_id). + if (elType && isReferenceContentType(elType) && !el.selfClosing && el.closeTagStart >= 0) { + const raw = loaded.text.slice(el.startTagEnd, el.closeTagStart); + const value = raw.trim(); + if ( + !value || + value.startsWith("$") || + value.startsWith("=") || + value.includes("<") + ) { + continue; + } + const start = el.startTagEnd + raw.indexOf(value); + const via: ForwardRefVia = { + kind: "content", + element: elLocal, + parent: parentLocal, + attribute: null, + }; + const source = sourceAt(start); + const targets = resolveContentReferenceTargets(index, elType, value); + if (!targets.length) { + if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value); + continue; + } + for (const target of targets) { + const to = targetOf(target.def); + addEdge(level, effective, trail, via, to, source); + if (level + 1 < depthLimit) { + queue.push({ + def: target.def, + level: level + 1, + effective: { type: target.def.type, id: target.def.id }, + trail: [], + }); + } + } + } + } + + // inheritFrom on the definition element: walk the ancestor at the SAME + // level so its XML contributes to the queried asset's effective definition. + if (includeInheritance) { + const inheritAttr = ownerEl.attrs.find( + (a) => a.name.toLowerCase() === "inheritfrom", + ); + const value = inheritAttr?.value; + if (inheritAttr?.hasValue && value && !value.startsWith("$") && !value.startsWith("=")) { + const via: ForwardRefVia = { + kind: "inheritFrom", + element: localName(ownerEl.name), + parent: ownerEl.parent ? localName(ownerEl.parent.name) : null, + attribute: inheritAttr.name, + }; + const source = sourceAt(inheritAttr.valueStart); + const targets = resolveReferenceTargetsForType( + index, + def.type, + "inheritFrom", + value, + ); + if (!targets.length) { + if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value); + } else { + for (const target of targets) { + const to = targetOf(target.def); + addEdge(level, effective, trail, via, to, source, value); + // The trail records the asset whose XML we are about to walk, so + // edges found there report the correct `definedIn`. + const nextTrail = [ + ...trail, + { type: target.def.type, id: target.def.id }, + ]; + if (nextTrail.length < MAX_INHERIT_CHAIN) { + queue.push({ def: target.def, level, effective, trail: nextTrail }); + } else { + warnings.push( + `Inheritance chain for ${effective.type}:${effective.id} exceeded ${MAX_INHERIT_CHAIN} levels; deeper ancestors were not walked.`, + ); + } + } + } + } + } + } + + return { + roots: [...roots], + edges, + nodes: [...nodes.values()].sort( + (a, b) => a.depth - b.depth || a.type.localeCompare(b.type) || a.id.localeCompare(b.id), + ), + truncated, + omittedByTargetType: Object.fromEntries(omitted), + warnings, + }; +} + +function clampDepth(depth: number | undefined): number { + if (depth == null || !Number.isFinite(depth)) return 1; + return Math.max(1, Math.min(MAX_DEPTH_LIMIT, Math.floor(depth))); +} + +function targetOf(def: AssetDef): ForwardRefTarget { + return { type: def.type, id: def.id, file: def.file, line: def.line }; +} + +function nodeKey(target: ForwardRefTarget): string { + return `${target.type}\u0000${target.id.toLowerCase()}\u0000${target.file.toLowerCase()}\u0000${target.line}`; +} + +/** + * Finds the element defining `def` inside a parsed file. + * + * The index stores a line number for the `id` attribute, so an exact line + * match is the strongest signal; top-level placement is the next best. This + * matters when a nested element reuses the same id. + */ +export function findDefinitionElement( + loaded: LoadedXmlFile, + def: Pick, +): XmlElement | null { + const wanted = def.id.toLowerCase(); + const root = loaded.parse.root; + let best: XmlElement | null = null; + let bestScore = -1; + for (const el of loaded.parse.elements) { + const idAttr = el.attrs.find((a) => a.name.toLowerCase() === "id"); + if (!idAttr?.hasValue) continue; + if (idAttr.value.toLowerCase() !== wanted) continue; + let score = 0; + const isTopLevel = el.parent === root || (root == null && el.parent == null); + if (isTopLevel) score += 2; + if (def.line > 0) { + const line = loaded.lineMap.positionAt(idAttr.valueStart).line + 1; + if (line === def.line) score += 4; + } + if (score > bestScore) { + bestScore = score; + best = el; + } + } + return best; +} + +/** Depth-first, document-order element list for a subtree (no recursion). */ +export function subtreeInDocumentOrder(root: XmlElement): XmlElement[] { + const out: XmlElement[] = []; + const stack: XmlElement[] = [root]; + while (stack.length) { + const el = stack.pop()!; + out.push(el); + for (let i = el.children.length - 1; i >= 0; i--) { + stack.push(el.children[i]); + } + } + return out; +} diff --git a/src/agent/liveQuery.ts b/src/agent/liveQuery.ts new file mode 100644 index 0000000..2b1107d --- /dev/null +++ b/src/agent/liveQuery.ts @@ -0,0 +1,147 @@ +/** + * Query helpers that operate directly on the live in-memory ModIndex. + * + * These are useful for local HTTP/live services where converting the full + * index to an external snapshot on every query would be wasteful. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { resolve } from "node:path"; +import type { AssetDef, DefineDef, ModIndex, ReferenceSite } from "../indexer/types"; +import { statusFromIndex } from "./snapshot"; +import type { AgentIndexStatus, AgentReferenceGroup } from "./types"; + +function normalizePath(p: string): string { + return resolve(p).replace(/\\/g, "/").toLowerCase(); +} + +/** Finds asset definitions by id in the live index. */ +export function findAssetsLive( + index: ModIndex, + id: string, + type?: string | null, +): AssetDef[] { + const wanted = id.toLowerCase(); + const wantedType = type?.toLowerCase(); + const candidates = index.assetsById.get(wanted) ?? []; + if (!wantedType) return candidates; + return candidates.filter((a) => a.type.toLowerCase() === wantedType); +} + +/** Lists assets of one type in the live index. */ +export function listAssetsByTypeLive( + index: ModIndex, + type: string, + idPrefix = "", + limit?: number, +): AssetDef[] { + const wantedType = type.toLowerCase(); + const wantedPrefix = idPrefix.toLowerCase(); + const byType = index.assets.get(type); + if (!byType) return []; + const out: AssetDef[] = []; + for (const [id, defs] of byType) { + if (!id.startsWith(wantedPrefix)) continue; + for (const def of defs) { + if (def.type.toLowerCase() !== wantedType) continue; + out.push(def); + if (limit != null && out.length >= limit) return out; + } + } + return out; +} + +/** + * Converts the reverse reference map into groups for one asset id. + * Reference map keys are `type\0id\0file\0line`. + */ +export function findReferenceGroupsLive( + index: ModIndex, + id: string, + type?: string | null, +): AgentReferenceGroup[] { + const wanted = id.toLowerCase(); + const wantedType = type?.toLowerCase(); + const groups: AgentReferenceGroup[] = []; + for (const [key, sites] of index.references) { + const parts = key.split("\u0000"); + if (parts.length !== 4) continue; + const [typeName, defId, file, lineText] = parts; + if (defId.toLowerCase() !== wanted) continue; + if (wantedType && typeName.toLowerCase() !== wantedType) continue; + groups.push({ + type: typeName, + id: defId, + file, + line: Number(lineText) || 0, + sites, + }); + } + return groups; +} + +/** Flattens live reference groups into sites. */ +export function findReferenceSitesLive( + index: ModIndex, + id: string, + type?: string | null, +): ReferenceSite[] { + return findReferenceGroupsLive(index, id, type).flatMap((g) => g.sites); +} + +/** Returns streams containing a file in the live index. */ +export function streamsForFileLive( + index: ModIndex, + file: string, +): ModIndex["streams"] { + const key = normalizePath(file); + return index.streams.filter((s) => + [...s.files].some((candidate) => normalizePath(candidate) === key), + ); +} + +/** True when a file belongs to a live stream. */ +export function isFileActiveLive(index: ModIndex, file: string): boolean { + return streamsForFileLive(index, file).length > 0; +} + +/** Finds defines by name in the live index. */ +export function findDefineLive( + index: ModIndex, + name: string, +): DefineDef[] { + const wanted = name.toLowerCase(); + return index.defines.get(wanted) ?? []; +} + +/** Resolves Include source using the live source-candidate list. */ +export function resolveIncludeLive( + index: ModIndex, + source: string, +): { source: string; path: string } | null { + const wanted = source.toLowerCase(); + const hit = index.sourceCandidates.find( + (c) => c.source.toLowerCase() === wanted, + ); + return hit ? { source: hit.source, path: hit.path } : null; +} + +/** + * Returns a status object for the live index. + * + * `requestedProjectDir` is only echoed when no index is available, so callers + * can still verify which server they reached. It is never used to fake a + * `projectDir` when an index exists — the index is the source of truth. + */ +export function liveStatus( + index: ModIndex | null | undefined, + requestedProjectDir?: string, +): AgentIndexStatus { + return statusFromIndex(index, requestedProjectDir); +} + +/** Every project root this index belongs to (for `/projects` listing). */ +export function knownProjectDirs(index: ModIndex | null | undefined): string[] { + return index ? [index.projectDir] : []; +} diff --git a/src/agent/localServer.ts b/src/agent/localServer.ts new file mode 100644 index 0000000..4b99530 --- /dev/null +++ b/src/agent/localServer.ts @@ -0,0 +1,268 @@ +/** + * Local read-only HTTP server for live RA3 Mod XML index queries. + * + * The server runs inside the VS Code extension host when the user has enabled + * AI Agent access. It listens only on 127.0.0.1 and requires a bearer token so + * unrelated local processes cannot query it by accident. + * + * Requests may carry `?project=` to select which project's index answers + * the query. When omitted, the server falls back to the active project. Every + * response echoes `index.projectDir` so the caller can verify it reached the + * server/project it asked for — see docs/ai-agent-integration-plan.md §10 for + * why that check is required in multi-window setups. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { ModIndex } from "../indexer/types"; +import { + collectAssetReferences, + type ForwardRefOptions, + type XmlFileLoader, +} from "./forwardRefs"; +import { + findAssetsLive, + findDefineLive, + findReferenceGroupsLive, + isFileActiveLive, + listAssetsByTypeLive, + liveStatus, + resolveIncludeLive, +} from "./liveQuery"; + +const USAGE_GUIDE = `RA3 Mod XML live index query API + +Endpoints (all require Authorization: Bearer ): + GET /status + GET /projects + GET /find_asset?id=...&type=... + GET /find_references?id=...&type=... + GET /get_asset_references?id=...&type=...&depth=1&targetTypes=A,B&maxEdges=200 + GET /list_assets?type=...&prefix=...&limit=... + GET /is_file_active?path=... + GET /find_define?name=... + GET /resolve_include?source=... + GET /get_usage_guide + +All endpoints accept an optional ?project= selector. Responses +echo index.projectDir; treat the result as belonging to a different project +when it does not match what you asked for. +`; + +export interface LocalServerHandle { + port: number; + token: string; + close(): Promise; +} + +export interface LocalServerOptions { + /** Returns the current in-memory index for a project (null when unknown). */ + getIndex: (projectDir?: string) => ModIndex | null; + /** Every project root the live workspace currently knows about. */ + listProjects?: () => string[]; + /** + * Reads + parses one XML file. Required by /get_asset_references, which + * needs element context that the index does not store. + */ + loadFile?: XmlFileLoader; + token?: string; + /** Defaults to an OS-assigned port on 127.0.0.1. */ + port?: number; +} + +function sendJson(res: ServerResponse, status: number, value: unknown): void { + const body = JSON.stringify(value); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + }); + res.end(body); +} + +function sendText(res: ServerResponse, status: number, text: string): void { + res.writeHead(status, { + "content-type": "text/plain; charset=utf-8", + "content-length": Buffer.byteLength(text), + }); + res.end(text); +} + +function isAuthorized(req: IncomingMessage, token: string): boolean { + const header = req.headers.authorization ?? ""; + return header === `Bearer ${token}`; +} + +/** Parses a comma-separated `targetTypes` parameter. */ +function parseList(raw: string | null): string[] { + if (!raw) return []; + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseNumber(raw: string | null): number | undefined { + if (raw == null || raw === "") return undefined; + const value = Number(raw); + return Number.isFinite(value) ? value : undefined; +} + +function forwardRefOptionsFrom(q: URLSearchParams): ForwardRefOptions { + return { + depth: parseNumber(q.get("depth")), + targetTypes: parseList(q.get("targetTypes")), + maxEdges: parseNumber(q.get("maxEdges")), + includeUnresolved: q.get("includeUnresolved") === "true", + }; +} + +async function handle( + options: LocalServerOptions, + token: string, + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!isAuthorized(req, token)) { + sendJson(res, 401, { error: "Unauthorized" }); + return; + } + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const q = url.searchParams; + const projectDir = q.get("project") ?? undefined; + const index = options.getIndex(projectDir); + // When an index exists its own projectDir is authoritative; the selector is + // only echoed for no-index responses so callers can still verify the server. + const status = liveStatus(index, projectDir); + + switch (url.pathname) { + case "/status": + sendJson(res, 200, status); + return; + + case "/projects": + sendJson(res, 200, { + index: status, + data: options.listProjects?.() ?? [], + }); + return; + + case "/find_asset": + sendJson(res, 200, { + index: status, + data: index ? findAssetsLive(index, q.get("id") ?? "", q.get("type")) : [], + }); + return; + + case "/find_references": + sendJson(res, 200, { + index: status, + data: index + ? findReferenceGroupsLive(index, q.get("id") ?? "", q.get("type")) + : [], + }); + return; + + case "/list_assets": { + const limit = parseNumber(q.get("limit")); + sendJson(res, 200, { + index: status, + data: index + ? listAssetsByTypeLive(index, q.get("type") ?? "", q.get("prefix") ?? "", limit) + : [], + }); + return; + } + + case "/is_file_active": + sendJson(res, 200, { + index: status, + data: { active: index ? isFileActiveLive(index, q.get("path") ?? "") : false }, + }); + return; + + case "/find_define": + sendJson(res, 200, { + index: status, + data: index + ? findDefineLive(index, (q.get("name") ?? "").replace(/^\$/, "")) + : [], + }); + return; + + case "/resolve_include": + sendJson(res, 200, { + index: status, + data: index ? resolveIncludeLive(index, q.get("source") ?? "") : null, + }); + return; + + case "/get_asset_references": { + if (!index) { + sendJson(res, 200, { + index: status, + data: null, + error: + "get_asset_references requires a live index (VS Code must be open with the project indexed).", + }); + return; + } + if (!options.loadFile) { + sendJson(res, 200, { + index: status, + data: null, + error: "The live server was started without XML file access.", + }); + return; + } + const result = await collectAssetReferences( + index, + q.get("id") ?? "", + q.get("type"), + options.loadFile, + forwardRefOptionsFrom(q), + ); + sendJson(res, 200, { index: status, data: result }); + return; + } + + case "/get_usage_guide": + sendText(res, 200, USAGE_GUIDE); + return; + + default: + sendJson(res, 404, { error: `Not found: ${url.pathname}` }); + } +} + +/** Starts a local HTTP server; resolves once it is listening. */ +export async function startLocalServer( + options: LocalServerOptions, +): Promise { + const token = options.token ?? randomToken(); + const server = createServer((req, res) => { + void handle(options, token, req, res).catch((err) => { + sendJson(res, 500, { + error: err instanceof Error ? err.message : String(err), + }); + }); + }); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(options.port ?? 0, "127.0.0.1", () => resolveListen()); + }); + const address = server.address() as AddressInfo; + return { + port: address.port, + token, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }; +} + +function randomToken(): string { + return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} diff --git a/src/agent/mcpServer.ts b/src/agent/mcpServer.ts new file mode 100644 index 0000000..e6db7e6 --- /dev/null +++ b/src/agent/mcpServer.ts @@ -0,0 +1,499 @@ +/** + * Minimal MCP (Model Context Protocol) stdio server exposing the RA3 Mod XML + * agent snapshot query API. + * + * This is intentionally dependency-free. It speaks the JSON-RPC-over-stdio + * subset used by MCP clients: + * + * initialize + * notifications/initialized + * ping + * tools/list + * tools/call + * + * Usage: + * node out/agent/mcpServer.js --project D:/Mods/Example + * node out/agent/mcpServer.js --snapshot /path/to/snapshot.json.gz + */ + +import { createInterface } from "node:readline"; +import { + isProcessAlive, + readEndpoint, + readEndpointForProject, +} from "./endpoint"; +import { readSnapshotFile, snapshotPathForProject } from "./snapshot"; +import { + findAssets, + findDefine, + findReferenceGroups, + isFileActive, + listAssetsByType, + resolveIncludeSource, + statusFromSnapshot, +} from "./query"; +import type { AgentIndexSnapshot } from "./types"; + +interface McpTool { + name: string; + description: string; + inputSchema: Record; + handler: (args: Record, snapshot: AgentIndexSnapshot | null) => unknown; +} + +const USAGE_GUIDE = `RA3 Mod XML index query tools + +This MCP server exposes the semantic index built by the RA3 Mod XML VS Code extension. + +Use these tools instead of full-text grepping the XML tree when you need exact facts: +- find_asset(id, type?) -> definition sites (file/line/origin/stream) +- find_references(id, type?) -> semantic reference sites +- get_asset_references(id, type?, depth?, targetTypes?) -> outgoing reference EDGES with element context +- list_assets_by_type(type, prefix?, limit?) -> assets of a type +- is_file_active(path) -> whether a file is part of an indexed include stream +- find_define(name) -> $DEFINE definitions +- resolve_include(source) -> candidate source file +- get_status() -> current index state + +Tips: +- Asset ids are case-insensitive. +- Prefer passing type when the same id exists for multiple asset types. +- Always check the returned index state; if it is stale/incomplete, treat results as provisional. +- Use get_asset_references to follow "which weapon/model/upgrade does this asset use" chains. + It returns edges annotated with the element name, parent element and attribute that produced + them, plus the exact XML file/line, so you do not have to read source to find the link. + Start with depth 1 (the default) and pass targetTypes (e.g. ["WeaponTemplate"]) to cut noise. + Edges with a "definedIn" field come from an inheritFrom ancestor's XML. + When "truncated" is true, read "omittedByTargetType" and narrow the query instead of retrying. +- Do not attempt to read the entire snapshot file; query narrowly.`; + +const TOOLS: McpTool[] = [ + { + name: "get_status", + description: "Returns the current index state and basic statistics.", + inputSchema: { type: "object", properties: {} }, + handler: (_args, snapshot) => statusFromSnapshot(snapshot), + }, + { + name: "find_asset", + description: "Finds asset definitions by id, optionally filtered by asset type.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Asset id to find" }, + type: { type: "string", description: "Optional asset type filter" }, + }, + required: ["id"], + }, + handler: (args, snapshot) => { + const id = String(args.id ?? ""); + if (!snapshot) return statusFromSnapshot(snapshot); + return { + index: statusFromSnapshot(snapshot), + data: findAssets(snapshot, id, args.type ? String(args.type) : null), + }; + }, + }, + { + name: "find_references", + description: "Finds semantic reference sites pointing to an asset id, optionally filtered by type.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Asset id whose references to find" }, + type: { type: "string", description: "Optional asset type filter" }, + }, + required: ["id"], + }, + handler: (args, snapshot) => { + const id = String(args.id ?? ""); + if (!snapshot) return statusFromSnapshot(snapshot); + return { + index: statusFromSnapshot(snapshot), + data: findReferenceGroups(snapshot, id, args.type ? String(args.type) : null), + }; + }, + }, + { + name: "list_assets_by_type", + description: "Lists asset definitions of one type, optionally filtered by id prefix.", + inputSchema: { + type: "object", + properties: { + type: { type: "string", description: "Asset type" }, + prefix: { type: "string", description: "Optional id prefix" }, + limit: { type: "number", description: "Maximum number of results" }, + }, + required: ["type"], + }, + handler: (args, snapshot) => { + const type = String(args.type ?? ""); + if (!snapshot) return statusFromSnapshot(snapshot); + const limit = typeof args.limit === "number" ? args.limit : undefined; + return { + index: statusFromSnapshot(snapshot), + data: listAssetsByType(snapshot, type, args.prefix ? String(args.prefix) : "", limit), + }; + }, + }, + { + name: "is_file_active", + description: "Returns whether a file belongs to an indexed include stream (i.e. is not a dead file).", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path" }, + }, + required: ["path"], + }, + handler: (args, snapshot) => { + const path = String(args.path ?? ""); + if (!snapshot) return statusFromSnapshot(snapshot); + return { + index: statusFromSnapshot(snapshot), + data: { active: isFileActive(snapshot, path) }, + }; + }, + }, + { + name: "find_define", + description: "Finds $DEFINE constants by name.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Define name (with or without leading $)" }, + }, + required: ["name"], + }, + handler: (args, snapshot) => { + const name = String(args.name ?? "").replace(/^\$/, ""); + if (!snapshot) return statusFromSnapshot(snapshot); + return { + index: statusFromSnapshot(snapshot), + data: findDefine(snapshot, name), + }; + }, + }, + { + name: "resolve_include", + description: "Resolves an Include source string from the snapshot's candidate list.", + inputSchema: { + type: "object", + properties: { + source: { type: "string", description: "Include source, e.g. DATA:Units/Example.xml" }, + }, + required: ["source"], + }, + handler: (args, snapshot) => { + const source = String(args.source ?? ""); + if (!snapshot) return statusFromSnapshot(snapshot); + return { + index: statusFromSnapshot(snapshot), + data: resolveIncludeSource(snapshot, source), + }; + }, + }, + { + name: "get_asset_references", + description: + "Returns the outgoing references (edges) of an asset: which assets it references, through which element/attribute, and at which file/line. Also follows inheritFrom ancestors (marked with definedIn). Live index required.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Asset id whose outgoing references to return" }, + type: { type: "string", description: "Optional asset type filter" }, + depth: { + type: "number", + description: + "Levels of assets to expand: 1 (default) = the asset itself, including inherited XML; max 3.", + }, + targetTypes: { + type: "array", + items: { type: "string" }, + description: + "Only keep edges whose target is assignable to one of these types, e.g. [\"WeaponTemplate\"].", + }, + maxEdges: { type: "number", description: "Hard cap on returned edges (default 200)." }, + includeUnresolved: { + type: "boolean", + description: "Also return edges whose reference value could not be resolved.", + }, + }, + required: ["id"], + }, + // Live-only: element context is not stored in the on-disk snapshot. + handler: () => ({ + index: { state: "no_index" }, + error: + "get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.", + }), + }, + { + name: "get_usage_guide", + description: "Returns guidance for using the RA3 Mod XML index tools.", + inputSchema: { type: "object", properties: {} }, + handler: () => ({ text: USAGE_GUIDE }), + }, +]; + +/** Tools that can only be answered by the live extension server. */ +const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]); + +export function liveUrlForTool( + endpointUrl: string, + projectDir: string | null, + toolName: string, + args: Record, +): string | null { + const base = endpointUrl.replace(/\/$/, ""); + const q = new URLSearchParams(); + // Always pin the requested project. Without this the server would silently + // answer from whatever project its active editor points at. + if (projectDir) q.set("project", projectDir); + switch (toolName) { case "get_status": + return q.toString() ? `${base}/status?${q}` : `${base}/status`; + case "find_asset": + q.set("id", String(args.id ?? "")); + if (args.type != null) q.set("type", String(args.type)); + return `${base}/find_asset?${q}`; + case "find_references": + q.set("id", String(args.id ?? "")); + if (args.type != null) q.set("type", String(args.type)); + return `${base}/find_references?${q}`; + case "get_asset_references": + q.set("id", String(args.id ?? "")); + if (args.type != null) q.set("type", String(args.type)); + if (args.depth != null) q.set("depth", String(args.depth)); + if (Array.isArray(args.targetTypes)) { + q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(",")); + } + if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges)); + if (args.includeUnresolved != null) { + q.set("includeUnresolved", String(args.includeUnresolved)); + } + return `${base}/get_asset_references?${q}`; + case "list_assets_by_type": + q.set("type", String(args.type ?? "")); + if (args.prefix != null) q.set("prefix", String(args.prefix)); + if (args.limit != null) q.set("limit", String(args.limit)); + return `${base}/list_assets?${q}`; + case "is_file_active": + q.set("path", String(args.path ?? "")); + return `${base}/is_file_active?${q}`; + case "find_define": + q.set("name", String(args.name ?? "")); + return `${base}/find_define?${q}`; + case "resolve_include": + q.set("source", String(args.source ?? "")); + return `${base}/resolve_include?${q}`; + default: + return null; + } +} + +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); +} + +/** + * Rejects a live response that belongs to a different project than the one + * this MCP client was started for. This is the guard against the + * multi-window cross-talk described in docs/ai-agent-integration-plan.md §10: + * returning another project's data silently would be worse than returning + * nothing, because the agent cannot tell the difference. + */ +export function responseProjectMismatch( + payload: unknown, + projectDir: string | null, +): boolean { + if (!projectDir) return false; + const index = (payload as { index?: { projectDir?: string } } | null)?.index; + const reported = index?.projectDir; + if (!reported) return false; + return normalizePath(reported) !== normalizePath(projectDir); +} + +/** Cooldown after a failed live attempt, to avoid a probe per tool call. */ +const LIVE_RETRY_COOLDOWN_MS = 5000; +let liveUnavailableUntil = 0; + +interface LiveResult { + payload: unknown; + /** True when the live server answered but for a different project. */ + mismatched: boolean; +} + +/** Tries the live extension server; returns null when unavailable. */ +async function tryLiveQuery( + toolName: string, + args: Record, + projectDir: string | null, + agentHome?: string, +): Promise { + if (Date.now() < liveUnavailableUntil) return null; + // Prefer the per-project endpoint so two open windows cannot shadow each + // other; fall back to the legacy global file only when it matches. + let endpoint = projectDir + ? await readEndpointForProject(projectDir, agentHome) + : null; + if (!endpoint) { + const fallback = await readEndpoint(agentHome); + if ( + fallback && + (!projectDir || + !fallback.projectDir || + normalizePath(fallback.projectDir) === normalizePath(projectDir)) + ) { + endpoint = fallback; + } + } + if (!endpoint) return null; + // A crashed VS Code can leave the file behind; a dead PID means stale. + if (!isProcessAlive(endpoint.processId)) return null; + + const url = liveUrlForTool(endpoint.url, projectDir, toolName, args); + if (!url) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + try { + const res = await fetch(url, { + headers: { authorization: `Bearer ${endpoint.token}` }, + signal: controller.signal, + }); + if (!res.ok) { + if (res.status === 401 || res.status === 404) return null; + return null; + } + const payload: unknown = await res.json(); + return { payload, mismatched: responseProjectMismatch(payload, projectDir) }; + } catch { + liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS; + return null; + } finally { + clearTimeout(timeout); + } +} + +function sendMessage(message: unknown): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function resultFor(id: unknown, result: unknown): unknown { + return { jsonrpc: "2.0", id, result }; +} + +function errorFor(id: unknown, code: number, message: string): unknown { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +async function handleRequest( + message: Record, + snapshot: AgentIndexSnapshot | null, + projectDir: string | null, + agentHome?: string, +): Promise { + const method = String(message.method ?? ""); + const id = message.id; + const params = (message.params ?? {}) as Record; + + switch (method) { + case "initialize": + return resultFor(id, { + protocolVersion: params.protocolVersion ?? "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "ra3-mod-xml", version: "0.1.0" }, + }); + case "ping": + return resultFor(id, {}); + case "tools/list": + return resultFor(id, { + tools: TOOLS.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }); + case "tools/call": { + const toolName = String(params.name ?? ""); + const tool = TOOLS.find((t) => t.name === toolName); + if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`); + const args = (params.arguments ?? {}) as Record; + const live = await tryLiveQuery(toolName, args, projectDir, agentHome); + + if (live?.mismatched) { + // The server answered for another project. Refuse it: a plausible + // wrong answer is worse than an explicit failure. + liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS; + return textResult(id, { + index: { state: "error", projectDir: projectDir ?? undefined }, + error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`, + }); + } + + if (LIVE_ONLY_TOOLS.has(toolName)) { + // Report a clear reason instead of an empty result, so the agent does + // not conclude "this asset has no references". + return textResult( + id, + live?.payload ?? { + index: { state: "no_index", projectDir: projectDir ?? undefined }, + error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`, + }, + ); + } + + const output = live?.payload ?? tool.handler(args, snapshot); + return textResult(id, output); + } + default: + // Notifications have no id; ignore them. + if (id === undefined) return null; + return errorFor(id, -32601, `Method not found: ${method}`); + } +} + +/** Wraps any tool payload into an MCP text content result. */ +function textResult(id: unknown, payload: unknown): unknown { + const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); + return resultFor(id, { content: [{ type: "text", text }] }); +} + +async function main(): Promise { + const args = process.argv.slice(2); + let projectDir: string | null = null; + let snapshotPath: string | null = null; + let agentHome: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--project" || args[i] === "-p") projectDir = args[++i] ?? null; + else if (args[i] === "--snapshot" || args[i] === "-s") snapshotPath = args[++i] ?? null; + else if (args[i] === "--agent-home") agentHome = args[++i] ?? undefined; + } + const resolvedSnapshotPath = + snapshotPath ?? + (projectDir ? snapshotPathForProject(projectDir, agentHome) : null); + let snapshot: AgentIndexSnapshot | null = null; + if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath); + + const rl = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + }); + rl.on("line", (line) => { + if (!line.trim()) return; + let message: Record; + try { + message = JSON.parse(line) as Record; + } catch { + return; + } + void handleRequest(message, snapshot, projectDir, agentHome).then((response) => { + if (response != null) sendMessage(response); + }); + }); +} + +// Only run the stdio loop when executed directly, so the module stays +// importable by tests. +if (typeof require !== "undefined" && require.main === module) { + void main(); +} diff --git a/src/agent/query.ts b/src/agent/query.ts new file mode 100644 index 0000000..49e547c --- /dev/null +++ b/src/agent/query.ts @@ -0,0 +1,158 @@ +/** + * Query helpers over the stable AgentIndexSnapshot. + * + * Pure TypeScript and dependency-free, so the same functions can back a CLI, + * MCP tools, or a local HTTP API. + */ + +import { resolve } from "node:path"; +import type { AssetDef, ReferenceSite } from "../indexer/types"; +import type { + AgentDefine, + AgentIndexSnapshot, + AgentIndexStatus, + AgentReferenceGroup, + AgentStream, +} from "./types"; + +function normalizePath(p: string): string { + return resolve(p).replace(/\\/g, "/").toLowerCase(); +} + +/** Returns all asset definitions whose id equals `id`, optionally filtered by type. */ +export function findAssets( + snapshot: AgentIndexSnapshot, + id: string, + type?: string | null, +): AssetDef[] { + const wanted = id.toLowerCase(); + const wantedType = type?.toLowerCase(); + const out: AssetDef[] = []; + for (const asset of snapshot.assets) { + if (asset.id.toLowerCase() !== wanted) continue; + if (wantedType && asset.type.toLowerCase() !== wantedType) continue; + out.push(asset); + } + return out; +} + +/** Returns asset definitions whose type and id prefix match. */ +export function listAssetsByType( + snapshot: AgentIndexSnapshot, + type: string, + idPrefix = "", + limit?: number, +): AssetDef[] { + const wantedType = type.toLowerCase(); + const wantedPrefix = idPrefix.toLowerCase(); + const out: AssetDef[] = []; + for (const asset of snapshot.assets) { + if (asset.type.toLowerCase() !== wantedType) continue; + if (!asset.id.toLowerCase().startsWith(wantedPrefix)) continue; + out.push(asset); + if (limit != null && out.length >= limit) break; + } + return out; +} + +/** Returns reference groups pointing to definitions matching `id` and optional `type`. */ +export function findReferenceGroups( + snapshot: AgentIndexSnapshot, + id: string, + type?: string | null, +): AgentReferenceGroup[] { + const wanted = id.toLowerCase(); + const wantedType = type?.toLowerCase(); + return snapshot.references.filter((r) => { + if (r.id.toLowerCase() !== wanted) return false; + if (wantedType && r.type.toLowerCase() !== wantedType) return false; + return true; + }); +} + +/** Flattens reference groups into plain reference sites. */ +export function findReferenceSites( + snapshot: AgentIndexSnapshot, + id: string, + type?: string | null, +): ReferenceSite[] { + return findReferenceGroups(snapshot, id, type).flatMap((g) => g.sites); +} + +/** True when a file belongs to at least one indexed stream. */ +export function isFileActive(snapshot: AgentIndexSnapshot, file: string): boolean { + const key = normalizePath(file); + return snapshot.streams.some((s) => + s.files.some((candidate) => normalizePath(candidate) === key), + ); +} + +/** Returns the streams that contain a file. */ +export function streamsForFile( + snapshot: AgentIndexSnapshot, + file: string, +): AgentStream[] { + const key = normalizePath(file); + return snapshot.streams.filter((s) => + s.files.some((candidate) => normalizePath(candidate) === key), + ); +} + +/** Finds a define by case-insensitive name. */ +export function findDefine( + snapshot: AgentIndexSnapshot, + name: string, +): AgentDefine[] { + const wanted = name.toLowerCase(); + return snapshot.defines.filter((d) => d.name.toLowerCase() === wanted); +} + +/** Resolves an Include source using the snapshot's candidate list. */ +export function resolveIncludeSource( + snapshot: AgentIndexSnapshot, + source: string, +): { source: string; path: string } | null { + const wanted = source.toLowerCase(); + const hit = snapshot.sourceCandidates.find( + (c) => c.source.toLowerCase() === wanted, + ); + return hit ? { source: hit.source, path: hit.path } : null; +} + +/** Returns the snapshot's status (ready_xml/ready/stale). */ +export function statusFromSnapshot( + snapshot: AgentIndexSnapshot | null | undefined, +): AgentIndexStatus { + if (!snapshot) { + return { state: "no_index", detail: "No index snapshot is available." }; + } + const state: AgentIndexStatus["state"] = snapshot.stale + ? "stale" + : !snapshot.complete + ? "ready_xml" + : "ready"; + return { + state, + projectDir: snapshot.projectDir, + phase: snapshot.phase, + complete: snapshot.complete, + stale: snapshot.stale, + generatedAt: snapshot.generatedAt, + buildId: snapshot.buildId, + stats: snapshot.stats, + }; +} + +/** Convenience aggregate returned by MCP/CLI query tools. */ +export interface QueryResult { + index: AgentIndexStatus; + data: T; +} + +/** Wraps any query data with current index status. */ +export function withStatus(snapshot: AgentIndexSnapshot | null, data: T): QueryResult { + return { + index: statusFromSnapshot(snapshot), + data, + }; +} diff --git a/src/agent/setup.ts b/src/agent/setup.ts new file mode 100644 index 0000000..284f064 --- /dev/null +++ b/src/agent/setup.ts @@ -0,0 +1,160 @@ +/** + * Helpers for creating the stable MCP launcher and MCP client configuration. + * + * The launcher lives outside the VS Code extension install directory (under + * ~/.ra3modxml) so AI client configs do not break when the extension is + * updated to a new version. The extension refreshes the launcher on every + * activation/update. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { defaultAgentHome } from "./snapshot"; + +export interface McpConfigTarget { + id: string; + label: string; + path: string; +} + +/** File name of the stable launcher on the current platform. */ +export function launcherFileName(): string { + return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp"; +} + +/** Absolute path to the stable launcher under the agent home. */ +export function launcherPath(agentHome = defaultAgentHome()): string { + return join(agentHome, launcherFileName()); +} + +/** + * Path to the bundled MCP server inside an extension install/dev directory. + * The packaged extension ships this file under dist/agent/mcpServer.js. + */ +export function bundledMcpServerPath(extensionRoot: string): string { + return join(extensionRoot, "dist", "agent", "mcpServer.js"); +} + +/** + * Creates the stable launcher script. It points to the current extension's + * bundled MCP server and passes the project directory. + */ +export async function writeLauncher( + extensionRoot: string, + projectDir: string, + agentHome = defaultAgentHome(), +): Promise { + const server = bundledMcpServerPath(extensionRoot); + const launcher = launcherPath(agentHome); + await mkdir(dirname(launcher), { recursive: true }); + if (process.platform === "win32") { + const content = [ + "@echo off", + `node "${server}" --project "${projectDir}"`, + "", + ].join("\r\n"); + await writeFile(launcher, content, "utf8"); + } else { + const content = [ + "#!/usr/bin/env sh", + `exec node "${server}" --project "${projectDir}"`, + "", + ].join("\n"); + await writeFile(launcher, content, "utf8"); + await chmod(launcher, 0o755); + } + return launcher; +} + +/** MCP client config entry for one project. */ +export function mcpServerConfig( + launcher: string, + projectDir: string, +): Record { + return { + mcpServers: { + "ra3-mod-xml": { + command: launcher, + args: ["--project", projectDir], + }, + }, + }; +} + +/** Human-readable JSON config block users can paste into AI clients. */ +export function mcpConfigJson( + launcher: string, + projectDir: string, +): string { + return JSON.stringify(mcpServerConfig(launcher, projectDir), null, 2); +} + +/** Claude Desktop config path (Windows/macOS/Linux common locations). */ +export function claudeDesktopConfigPath(): string { + if (process.env.APPDATA) return join(process.env.APPDATA, "Claude", "claude_desktop_config.json"); + return join(homedir(), ".config", "Claude", "claude_desktop_config.json"); +} + +/** Cursor's global MCP config path. */ +export function cursorGlobalConfigPath(): string { + return join(homedir(), ".cursor", "mcp.json"); +} + +/** Cursor's project-scoped MCP config path. */ +export function cursorProjectConfigPath(projectDir: string): string { + return join(projectDir, ".cursor", "mcp.json"); +} + +/** Common local MCP config files this extension can offer to update. */ +export function commonMcpConfigTargets(projectDir: string): McpConfigTarget[] { + return [ + { + id: "claude-desktop", + label: "Claude Desktop", + path: claudeDesktopConfigPath(), + }, + { + id: "cursor-global", + label: "Cursor (global)", + path: cursorGlobalConfigPath(), + }, + { + id: "cursor-project", + label: "Cursor (current project)", + path: cursorProjectConfigPath(projectDir), + }, + ]; +} + +/** + * Adds the RA3 Mod XML MCP server entry to a JSON config file, preserving any + * existing keys and mcpServers. Creates the file when it does not exist. + */ +export async function addMcpServerToConfigFile( + filePath: string, + launcher: string, + projectDir: string, +): Promise { + let config: Record = {}; + try { + config = JSON.parse(await readFile(filePath, "utf8")) as Record; + } catch { + // File absent or malformed: start fresh. + } + const servers = (config.mcpServers as Record | undefined) ?? {}; + servers["ra3-mod-xml"] = { + command: launcher, + args: ["--project", projectDir], + }; + config.mcpServers = servers; + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +/** Default path used for the agent home. */ +export function defaultAgentHomeForSetup(): string { + return join(homedir(), ".ra3modxml"); +} diff --git a/src/agent/skill.ts b/src/agent/skill.ts new file mode 100644 index 0000000..2af5e08 --- /dev/null +++ b/src/agent/skill.ts @@ -0,0 +1,333 @@ +/** + * Agent Skill generator/installer for the RA3 Mod XML MCP tools. + * + * The Skill focuses on the functionality itself: when to use it and how to + * use the RA3 Mod XML MCP query tools. It intentionally avoids referencing + * any project-specific docs (e.g. docs/codebase-navigation-guide.md) so the + * agent is not distracted by unrelated workspace guidance. + * + * Pure TypeScript: no VS Code dependency. + */ + +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { defaultAgentHome } from "./snapshot"; + +export const SKILL_NAME = "ra3-mod-xml"; +export const SKILL_MARKER_FILE = ".ra3modxml-skill.json"; + +const SKILL_MD = `--- +name: ra3-mod-xml +description: Use the RA3 Mod XML semantic index to find asset definitions, references, outgoing references, active files, defines, and include sources in SAGE / BinaryAssetBuilder XML projects such as Command & Conquer: Red Alert 3 mods. Use this when you need exact facts about mod assets instead of guessing or full-text searching the XML tree. +--- + +# RA3 Mod XML Index + +This skill provides access to the semantic index built by the RA3 Mod XML VS Code extension. + +## When this skill applies + +Use these tools only for SAGE / BinaryAssetBuilder mod XML projects — the kind +used by Command & Conquer: Red Alert 3 mods. + +Positive signals (any single one is enough to try the tools): + +- \`Data/Mod.xml\` exists. +- \`Data/additionalmaps/mapmetadata_*.xml\` exists. +- A \`*.babproj\` file exists. +- XML whose root element is \`\`. +- XML that uses \`\` or declares + \`xmlns="uri:ea.com:eala:asset"\`. + +Do **not** use these tools for unrelated repositories. In particular, do not +use them merely because a project contains XML, a build script, copied \`.xsd\` +files, or a folder named \`Data\`. A file named \`CnC3Types.xsd\` refers to +C&C3 (Tiberium Wars / Kane's Wrath) and is **not** by itself evidence of a +Red Alert 3 mod. When the repository is not a SAGE / RA3 mod project, ignore +this skill entirely. + +If you are unsure whether the current project is in scope, call \`get_status\` +first: it is cheap and reports the \`projectDir\` the index belongs to. Stop +using the index tools when: + +- the state is \`no_index\`, or +- the reported \`projectDir\` does not match the workspace root you are working in. + +In those cases read the files directly instead. Never present index results +from one project as if they belonged to another. + +## When to use + +Use this skill when you need any of the following: + +- Find where an asset id is defined (GameObject, WeaponTemplate, Texture, etc.). +- Find which files/positions reference an asset id. +- Find which assets an asset references, and through which element/attribute. +- List assets of a particular type. +- Check whether a file is actually part of the active include graph (i.e. not a dead file). +- Resolve an Include source string. +- Look up a $DEFINE constant. +- Get the current index status and statistics. + +Do not use full-text search over the XML tree when one of the MCP query tools +can answer the question directly. + +## How to use + +1. Call \`get_status\` first when you are unsure whether an index is available, + current, or belongs to the project you are working in. +2. Use narrow queries: + - \`find_asset(id, type?)\` for definition locations. + - \`find_references(id, type?)\` for incoming semantic references. + - \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing + references (what this asset uses, and where that link is written). + - \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets. + - \`is_file_active(path)\` to determine whether a file is included in an indexed stream. + - \`find_define(name)\` for $DEFINE constants. + - \`resolve_include(source)\` for Include source candidates. +3. Asset ids are case-insensitive. +4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions. +5. If the returned index state is \`stale\`, \`ready_xml\`, or \`building\`, treat + results as provisional. +6. Never read or dump the whole index snapshot; query narrowly. + +## Following references with get_asset_references + +Use \`get_asset_references\` to answer "which weapon / model / upgrade / die-object +does this asset actually use" without reading source first. It returns **edges**, +not a flat list, so provenance is preserved: + +- \`from\` is the asset you asked about. +- \`to\` is the resolved definition (file + line), or null when unresolved. +- \`via.element\` / \`via.parent\` / \`via.attribute\` say exactly which XML + element and attribute created the link. +- \`source\` is the file/line where that link is written. +- \`definedIn\` is present when the link comes from an \`inheritFrom\` ancestor's + XML rather than from the asset's own file. Inheritance is walked at the same + depth, so a base asset's weapon slot configuration is reported together with + the derived asset's own modules. + +Practical rules: + +- Start with the default \`depth: 1\` (the queried asset only). Raise it to 2 or 3 + only for a specific node you already decided to follow. The maximum is 3. +- Pass \`targetTypes\` to cut noise, e.g. \`["WeaponTemplate"]\`, or + \`["GameObject"]\` for \`CreateObjectDie\`-style die-object links. +- If \`truncated\` is true, read \`omittedByTargetType\` and narrow the query + (smaller \`targetTypes\`, lower \`depth\`) instead of blindly retrying. +- Call \`find_references\` in the opposite direction: it tells you who else would + be affected by a change. +- Merged/inherited *effective values* are not computed. When \`xai:joinAction\` + (\`Replace\` / \`Remove\`) appears in the merge path, open the file and confirm + the real result yourself. +`; + +const QUERY_GUIDE_MD = `# RA3 Mod XML query tool reference + +The MCP server exposes these tools: + +- get_status() +- find_asset(id, type?) +- find_references(id, type?) +- get_asset_references(id, type?, depth?, targetTypes?, maxEdges?, includeUnresolved?) +- list_assets_by_type(type, prefix?, limit?) +- is_file_active(path) +- find_define(name) +- resolve_include(source) +- get_usage_guide() + +## Result metadata + +Every query result includes an \`index\` object: + +\`\`\`json +{ + "state": "ready", + "projectDir": "D:/Mods/Example", + "complete": true, + "stale": false +} +\`\`\` + +Possible states: + +- no_index: no index snapshot exists. +- building: a rebuild is in progress. +- ready_xml: XML assets are available but art assets may be incomplete. +- ready: complete index. +- stale: index may be outdated. +- error: last build failed. + +Always check \`index.projectDir\`. If it does not match the project you are +working on, discard the result and read files directly instead. + +## get_asset_references + +Returns outgoing reference edges with full provenance: + +\`\`\`json +{ + "index": { "state": "ready", "projectDir": "D:/Mods/Example" }, + "data": { + "roots": [{ "type": "GameObject", "id": "AthenaCannon", "file": "...", "line": 12 }], + "edges": [ + { + "depth": 1, + "from": { "type": "GameObject", "id": "AthenaCannon" }, + "to": { "type": "WeaponTemplate", "id": "AthenaCannonWeapon", "file": "...", "line": 88 }, + "via": { "kind": "attribute", "element": "Weapon", "parent": "WeaponSlotHardpoint", "attribute": "Template" }, + "source": { "file": "D:/Mods/Example/Data/Allied/Units/AthenaCannon.xml", "line": 40, "character": 24 } + } + ], + "nodes": [], + "truncated": false, + "omittedByTargetType": {}, + "warnings": [] + } +} +\`\`\` + +\`via.kind\` is one of \`attribute\`, \`content\` or \`inheritFrom\`. Edges carrying +\`definedIn\` come from an \`inheritFrom\` ancestor's XML. + +This tool requires a **live** index (VS Code open with the project indexed). +It is not available from the on-disk snapshot because element context is not +stored there. When live is unavailable the tool returns an explicit error +instead of an empty result. +`; + +export interface SkillInstallRecord { + /** Skill directory (the directory containing SKILL.md). */ + path: string; + /** Extension version that installed/updated this copy. */ + sourceVersion: string; +} + +/** + * Writes a managed copy of the Skill into `targetDir` (the directory that + * should contain SKILL.md). + */ +export async function writeSkillTo( + targetDir: string, + sourceVersion: string, +): Promise { + await mkdir(join(targetDir, "references"), { recursive: true }); + await writeFile(join(targetDir, "SKILL.md"), SKILL_MD, "utf8"); + await writeFile( + join(targetDir, "references", "query-guide.md"), + QUERY_GUIDE_MD, + "utf8", + ); + const marker: SkillInstallRecord = { + path: targetDir, + sourceVersion, + }; + await writeFile( + join(targetDir, SKILL_MARKER_FILE), + JSON.stringify(marker, null, 2), + "utf8", + ); +} + +/** Conventional ~/.agents/skills/ path. */ +export function agentsSkillsDirForUser(home = homedir()): string { + return join(home, ".agents", "skills", SKILL_NAME); +} + +/** Conventional ~/.claude/skills/ path (Claude Code). */ +export function claudeSkillsDirForUser(home = homedir()): string { + return join(home, ".claude", "skills", SKILL_NAME); +} + +/** Path to the managed-install record under the agent home. */ +export function skillInstallRecordPath(agentHome = defaultAgentHome()): string { + return join(agentHome, "skill-install.json"); +} + +/** Reads the managed skill install record, or returns an empty list. */ +export async function readSkillInstallRecord( + agentHome = defaultAgentHome(), +): Promise { + try { + const text = await readFile(skillInstallRecordPath(agentHome), "utf8"); + const parsed = JSON.parse(text) as { installed?: SkillInstallRecord[] }; + return Array.isArray(parsed.installed) ? parsed.installed : []; + } catch { + return []; + } +} + +/** Writes the managed skill install record. */ +export async function writeSkillInstallRecord( + installed: SkillInstallRecord[], + agentHome = defaultAgentHome(), +): Promise { + const file = skillInstallRecordPath(agentHome); + await mkdir(dirname(file), { recursive: true }); + await writeFile( + file, + JSON.stringify({ installed }, null, 2), + "utf8", + ); +} + +/** + * Installs the Skill into several directories and records each managed copy. + * Returns the directories successfully written. + */ +export async function installSkillToDirectories( + directories: string[], + sourceVersion: string, + agentHome = defaultAgentHome(), +): Promise { + const installed = await readSkillInstallRecord(agentHome); + const succeeded: string[] = []; + for (const dir of directories) { + try { + await writeSkillTo(dir, sourceVersion); + if (!installed.some((r) => r.path === dir)) { + installed.push({ path: dir, sourceVersion }); + } else { + const record = installed.find((r) => r.path === dir); + if (record) record.sourceVersion = sourceVersion; + } + succeeded.push(dir); + } catch { + // Keep going; caller can surface per-directory failures. + } + } + await writeSkillInstallRecord(installed, agentHome); + return succeeded; +} + +/** Removes one managed Skill directory and its install record entry. */ +export async function uninstallSkillFromDirectory( + directory: string, + agentHome = defaultAgentHome(), +): Promise { + await rm(directory, { recursive: true, force: true }); + const installed = (await readSkillInstallRecord(agentHome)).filter( + (r) => r.path !== directory, + ); + await writeSkillInstallRecord(installed, agentHome); +} + +/** Re-writes every recorded Skill copy with the current extension version. */ +export async function syncInstalledSkills( + sourceVersion: string, + agentHome = defaultAgentHome(), +): Promise { + const installed = await readSkillInstallRecord(agentHome); + const synced: SkillInstallRecord[] = []; + for (const record of installed) { + try { + await writeSkillTo(record.path, sourceVersion); + synced.push({ path: record.path, sourceVersion }); + } catch { + // Skip unreadable/missing targets; the record will be cleaned below. + } + } + await writeSkillInstallRecord(synced, agentHome); + return synced; +} diff --git a/src/agent/snapshot.ts b/src/agent/snapshot.ts new file mode 100644 index 0000000..7813293 --- /dev/null +++ b/src/agent/snapshot.ts @@ -0,0 +1,204 @@ +/** + * Convert an internal ModIndex into a stable, external agent snapshot and + * read/write those snapshots on disk. + * + * Pure TypeScript: no VS Code dependency, so CLI/MCP/tools can reuse this + * module outside the extension. + */ + +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { gunzip, gzip } from "node:zlib"; +import { promisify } from "node:util"; +import type { ModIndex } from "../indexer/types"; +import { + AGENT_SNAPSHOT_SCHEMA_VERSION, + type AgentIndexSnapshot, + type AgentIndexStatus, +} from "./types"; + +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); + +/** Default directory used for launcher/snapshots/skill installation state. */ +export function defaultAgentHome(): string { + return join(homedir(), ".ra3modxml"); +} + +/** Directory where current external snapshots are stored. */ +export function defaultSnapshotDir(agentHome = defaultAgentHome()): string { + return join(agentHome, "snapshots"); +} + +/** + * Stable, collision-resistant identity for one project directory. + * Case-insensitive (Windows paths) and independent of the current drive + * mapping case, so the same project always maps to the same key. + */ +export function projectHash(projectDir: string): string { + return createHash("sha1") + .update(resolve(projectDir).toLowerCase(), "utf8") + .digest("hex") + .slice(0, 12); +} + +/** + * Short, filesystem-safe, human-readable prefix for a project (directory + * basename, sanitized). Falls back to "project" when the basename has no + * usable characters. + */ +export function projectSlug(projectDir: string): string { + const raw = basename(resolve(projectDir)); + const slug = raw + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + return slug || "project"; +} + +/** + * A stable, readable file name for one project: + * `-` (e.g. `corona-9f3a1c2b4d5e`). + */ +export function snapshotBaseName(projectDir: string): string { + return `${projectSlug(projectDir)}-${projectHash(projectDir)}`; +} + +/** Converts the internal ModIndex to the stable external snapshot shape. */ +export function snapshotFromIndex(index: ModIndex, buildId?: number): AgentIndexSnapshot { + const assets: AgentIndexSnapshot["assets"] = []; + for (const byId of index.assets.values()) { + for (const defs of byId.values()) { + assets.push(...defs); + } + } + + const defines: AgentIndexSnapshot["defines"] = []; + for (const defs of index.defines.values()) { + defines.push(...defs); + } + + const references: AgentIndexSnapshot["references"] = []; + for (const [key, sites] of index.references) { + const parts = key.split("\u0000"); + if (parts.length !== 4) continue; + references.push({ + type: parts[0], + id: parts[1], + file: parts[2], + line: Number(parts[3]) || 0, + sites, + }); + } + + const streams: AgentIndexSnapshot["streams"] = index.streams.map((s) => ({ + name: s.name, + entry: s.entry, + files: [...s.files], + })); + + return { + schemaVersion: AGENT_SNAPSHOT_SCHEMA_VERSION, + projectDir: index.projectDir, + sdkDir: index.sdkDir, + phase: index.phase, + complete: index.complete, + stale: index.stale, + generatedAt: new Date().toISOString(), + buildId, + stats: { + assetCount: assets.length, + referenceCount: index.references.size, + defineCount: defines.length, + fileCount: index.files.size, + streamCount: streams.length, + sourceCandidateCount: index.sourceCandidates.length, + manifestFileCount: index.manifests.size, + manifestAssetCount: index.stats.manifestAssetCount, + }, + assets, + defines, + references, + streams, + sourceCandidates: index.sourceCandidates, + diagnostics: index.diagnostics, + }; +} + +/** Returns a status object for a missing/not-yet-built index. */ +export function noIndexStatus(projectDir?: string, detail?: string): AgentIndexStatus { + return { + state: "no_index", + projectDir, + detail: detail ?? "No index has been built yet.", + }; +} + +/** Returns a status object for the current index state. */ +export function statusFromIndex(index: ModIndex | null | undefined, projectDir?: string): AgentIndexStatus { + if (!index) return noIndexStatus(projectDir); + const state: AgentIndexStatus["state"] = index.stale + ? "stale" + : !index.complete + ? "ready_xml" + : "ready"; + return { + state, + projectDir: index.projectDir, + phase: index.phase, + complete: index.complete, + stale: index.stale, + generatedAt: new Date().toISOString(), + stats: { + assetCount: index.stats.assetCount, + referenceCount: index.stats.referenceCount, + defineCount: index.stats.defineCount, + fileCount: index.stats.indexedFiles, + streamCount: index.stats.streams, + sourceCandidateCount: index.stats.sourceCandidates, + manifestFileCount: index.stats.manifestFiles, + manifestAssetCount: index.stats.manifestAssetCount, + }, + }; +} + +/** Serializes a snapshot to a JSON string (not compressed). */ +export function snapshotToJson(snapshot: AgentIndexSnapshot): string { + return JSON.stringify(snapshot); +} + +/** Writes a snapshot as gzip-compressed JSON using atomic temp+rename. */ +export async function writeSnapshotFile( + filePath: string, + snapshot: AgentIndexSnapshot, +): Promise { + const payload = Buffer.from(snapshotToJson(snapshot), "utf8"); + const buf = await gzipAsync(payload); + const target = resolve(filePath); + await mkdir(dirname(target), { recursive: true }); + const tmp = `${target}.tmp`; + await writeFile(tmp, buf); + await rename(tmp, target); + return target; +} + +/** Reads a gzip-compressed JSON snapshot written by writeSnapshotFile. */ +export async function readSnapshotFile(filePath: string): Promise { + try { + const buf = await readFile(filePath); + const text = (await gunzipAsync(buf)).toString("utf8"); + return JSON.parse(text) as AgentIndexSnapshot; + } catch { + return null; + } +} + +/** Builds the conventional snapshot path for a project under the agent home. */ +export function snapshotPathForProject( + projectDir: string, + agentHome = defaultAgentHome(), +): string { + return join(defaultSnapshotDir(agentHome), `${snapshotBaseName(projectDir)}.json.gz`); +} diff --git a/src/agent/types.ts b/src/agent/types.ts new file mode 100644 index 0000000..4f748a9 --- /dev/null +++ b/src/agent/types.ts @@ -0,0 +1,103 @@ +/** + * Public, stable data types for exposing RA3 Mod XML indexes to AI Agents + * and external tools. + * + * These types intentionally mirror the internal index model but use plain + * serializable arrays instead of Maps/Sets. They are independent of the VS + * Code API and of the extension's internal workspaceStorage layout. + */ + +import type { + AssetDef, + DefineDef, + IndexerDiagnostic, + ReferenceSite, + SourceCandidate, +} from "../indexer/types"; + +/** Current external snapshot schema version. */ +export const AGENT_SNAPSHOT_SCHEMA_VERSION = 1; + +export type AgentIndexState = + | "no_index" + | "building" + | "ready_xml" + | "ready" + | "stale" + | "error"; + +export interface AgentIndexStats { + assetCount: number; + referenceCount: number; + defineCount: number; + fileCount: number; + streamCount: number; + sourceCandidateCount: number; + manifestFileCount: number; + manifestAssetCount: number; +} + +export interface AgentAsset extends AssetDef { + // AssetDef is already plain/serializable. +} + +export interface AgentDefine extends DefineDef { + // DefineDef is already plain/serializable. +} + +/** A stream (static or global:) with the normalized file paths in it. */ +export interface AgentStream { + name: string; + entry: string; + files: string[]; +} + +/** Reference sites grouped by the definition they point to. */ +export interface AgentReferenceGroup { + type: string; + id: string; + file: string; + line: number; + sites: ReferenceSite[]; +} + +/** + * Immutable, tool-facing snapshot of one project index. + */ +export interface AgentIndexSnapshot { + schemaVersion: number; + projectDir: string; + sdkDir: string; + /** Last finished phase: "xml" or "art". */ + phase: "xml" | "art"; + complete: boolean; + stale?: boolean; + generatedAt: string; + /** Build counter from the workspace; useful for change detection. */ + buildId?: number; + stats: AgentIndexStats; + assets: AgentAsset[]; + defines: AgentDefine[]; + /** + * Reverse references grouped by target definition key. + * Consumers normally filter by `type` + `id`, then aggregate groups. + */ + references: AgentReferenceGroup[]; + streams: AgentStream[]; + sourceCandidates: SourceCandidate[]; + diagnostics: IndexerDiagnostic[]; +} + +/** Status returned by query interfaces when an index may not be ready. */ +export interface AgentIndexStatus { + state: AgentIndexState; + projectDir?: string; + phase?: "xml" | "art"; + complete?: boolean; + stale?: boolean; + generatedAt?: string; + buildId?: number; + stats?: AgentIndexStats; + /** Human-readable explanation for no_index/error states. */ + detail?: string; +} diff --git a/src/extension.ts b/src/extension.ts index 8650684..e93dc3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,7 @@ import * as vscode from "vscode"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { stripBom } from "./language/xmlParser"; import { ModWorkspace } from "./workspace"; import { SdkSetup } from "./sdkSetup"; import { Ra3CompletionProvider } from "./features/completion"; @@ -21,6 +24,36 @@ import { RA3_SEMANTIC_TOKENS_LEGEND, } from "./features/semanticTokens"; import { t } from "./localize"; +import { + snapshotFromIndex, + snapshotPathForProject, + writeSnapshotFile, +} from "./agent/snapshot"; +import { + addMcpServerToConfigFile, + claudeDesktopConfigPath, + cursorGlobalConfigPath, + cursorProjectConfigPath, + mcpConfigJson, + writeLauncher, +} from "./agent/setup"; +import { + SKILL_NAME, + agentsSkillsDirForUser, + claudeSkillsDirForUser, + installSkillToDirectories, + readSkillInstallRecord, + writeSkillInstallRecord, + writeSkillTo, +} from "./agent/skill"; +import { startLocalServer, type LocalServerHandle } from "./agent/localServer"; +import { parseLoadedXml } from "./agent/forwardRefs"; +import { + clearEndpoint, + clearEndpointForProject, + writeEndpoint, + writeEndpointForProject, +} from "./agent/endpoint"; const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }]; /** Safety-net refresh interval while a rebuild is running. */ @@ -96,9 +129,126 @@ export function activate(context: vscode.ExtensionContext): void { ws.log("[codelens] retry started"); }; ws.onBuildStart = startCodeLensRetry; + // Coalesced agent snapshot refresh: after AI Agent access is enabled, keep + // the external snapshot current without writing on every intermediate + // rebuild. The timer only fires after a quiet period following a complete, + // non-stale final index. + let agentAccessEnabled = context.workspaceState.get( + "ra3modxml.agentAccessEnabled", + false, + ); + const AGENT_SNAPSHOT_QUIET_MS = 5000; + let agentSnapshotTimer: ReturnType | null = null; + const scheduleAgentSnapshot = (): void => { + if (!agentAccessEnabled) return; + if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer); + agentSnapshotTimer = setTimeout(() => { + agentSnapshotTimer = null; + const current = ws.activeIndex(); + if (!current?.complete || current.stale === true) return; + void writeSnapshotFile( + snapshotPathForProject(current.projectDir), + snapshotFromIndex(current, ws.buildCount), + ).catch((err) => { + ws.log( + `[agent-snapshot] export failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + }, AGENT_SNAPSHOT_QUIET_MS); + }; + let agentLocalServer: LocalServerHandle | null = null; + /** Project roots whose per-project endpoint file this window wrote. */ + const agentEndpointProjects = new Set(); + const startAgentLocalServer = async (): Promise => { + if (agentLocalServer) return; + try { + const handle = await startLocalServer({ + // Route by explicit project so a query can never be answered by + // whichever project the active editor happens to point at. + getIndex: (projectDir) => + projectDir ? ws.indexForProject(projectDir) : ws.activeIndex(), + listProjects: () => ws.getProjectRoots(), + loadFile: async (file) => { + const text = stripBom(await readFile(file, "utf8")); + return parseLoadedXml(text); + }, + }); + agentLocalServer = handle; + const url = `http://127.0.0.1:${handle.port}`; + const projects = ws.getProjectRoots(); + const endpoint = { + url, + token: handle.token, + projectDir: ws.projectRoot ?? undefined, + projects, + processId: process.pid, + updatedAt: new Date().toISOString(), + }; + // One file per project, so two open windows cannot shadow each other. + for (const project of projects) { + const file = await writeEndpointForProject(project, { + ...endpoint, + projectDir: project, + }); + agentEndpointProjects.add(project); + ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`); + } + // Legacy/global pointer for tooling that does not know the project. + await writeEndpoint(endpoint); + ws.log(`[agent-local-server] listening on ${url}`); + } catch (err) { + ws.log( + `[agent-local-server] failed to start: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }; + const stopAgentLocalServer = async (): Promise => { + if (agentLocalServer) { + const server = agentLocalServer; + agentLocalServer = null; + await server.close().catch(() => undefined); + } + // Only remove the endpoint files this window wrote: another VS Code + // window may still be serving its own projects. + for (const project of agentEndpointProjects) { + await clearEndpointForProject(project).catch(() => undefined); + } + agentEndpointProjects.clear(); + await clearEndpoint().catch(() => undefined); + }; + /** + * Publishes per-project endpoint files for every project this window now + * knows about. Called on each index update so projects discovered later get + * an endpoint without restarting the server. + */ + const refreshAgentEndpoints = async (): Promise => { + if (!agentLocalServer) return; + const url = `http://127.0.0.1:${agentLocalServer.port}`; + const token = agentLocalServer.token; + const projects = ws.getProjectRoots(); + const base = { + url, + token, + projects, + processId: process.pid, + updatedAt: new Date().toISOString(), + }; + for (const project of projects) { + try { + await writeEndpointForProject(project, { ...base, projectDir: project }); + agentEndpointProjects.add(project); + } catch (err) { + ws.log( + `[agent-local-server] could not write endpoint for ${project}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + }; context.subscriptions.push({ dispose: () => { if (codeLensRetryTimer) clearInterval(codeLensRetryTimer); + if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer); + void stopAgentLocalServer(); }, }); context.subscriptions.push( @@ -123,6 +273,8 @@ export function activate(context: vscode.ExtensionContext): void { `[codelens] refresh (project=${idx.stats.projectDir}, phase=${idx.phase}, assets=${idx.stats.assetCount}, complete=${idx.complete}, stale=${idx.stale === true})`, ); } + scheduleAgentSnapshot(); + void refreshAgentEndpoints(); for (const doc of vscode.workspace.textDocuments) { if (doc.languageId === "xml") void diagnostics.update(doc); } @@ -282,6 +434,190 @@ export function activate(context: vscode.ExtensionContext): void { ); }), ); + context.subscriptions.push( + vscode.commands.registerCommand("ra3modxml.exportIndexSnapshot", async () => { + const idx = ws.activeIndex(); + if (!idx) { + void vscode.window.showInformationMessage( + t( + "RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.", + ), + ); + return; + } + const path = snapshotPathForProject(idx.projectDir); + try { + const snapshot = snapshotFromIndex(idx, ws.buildCount); + await writeSnapshotFile(path, snapshot); + void vscode.window.showInformationMessage( + t( + "RA3 Mod XML: exported AI Agent index snapshot to {0}", + path, + ), + t("Reveal in Explorer"), + ).then((pick) => { + if (pick) void vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(path)); + }); + } catch (err) { + void vscode.window.showErrorMessage( + t( + "RA3 Mod XML: failed to export AI Agent index snapshot: {0}", + err instanceof Error ? err.message : String(err), + ), + ); + } + }), + ); + context.subscriptions.push( + vscode.commands.registerCommand("ra3modxml.enableAgentAccess", async () => { + const idx = ws.activeIndex(); + if (!idx) { + void vscode.window.showInformationMessage( + t( + "RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.", + ), + ); + return; + } + const projectDir = idx.projectDir; + try { + const snapshot = snapshotFromIndex(idx, ws.buildCount); + await writeSnapshotFile(snapshotPathForProject(projectDir), snapshot); + const launcher = await writeLauncher( + context.extensionUri.fsPath, + projectDir, + ); + const configJson = mcpConfigJson(launcher, projectDir); + agentAccessEnabled = true; + await context.workspaceState.update("ra3modxml.agentAccessEnabled", true); + void startAgentLocalServer(); + const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev"); + const installSkill = t("Install Agent Skill (recommended)"); + const writeClaude = t("Write MCP config to Claude Desktop"); + const writeCursorGlobal = t("Write MCP config to Cursor (global)"); + const writeCursorProject = t("Write MCP config to Cursor (project)"); + const copyConfig = t("Copy MCP config"); + const pick = await vscode.window.showQuickPick( + [ + { + label: installSkill, + description: agentsSkillsDirForUser(), + id: "skill", + }, + { + label: writeClaude, + description: claudeDesktopConfigPath(), + id: "claude", + }, + { + label: writeCursorGlobal, + description: cursorGlobalConfigPath(), + id: "cursor-global", + }, + { + label: writeCursorProject, + description: cursorProjectConfigPath(projectDir), + id: "cursor-project", + }, + { + label: copyConfig, + id: "copy", + }, + ], + { + placeHolder: t("RA3 Mod XML AI Agent access enabled. Choose an optional next step."), + }, + ); + if (pick?.id === "skill") { + const target = agentsSkillsDirForUser(); + await writeSkillTo(target, version); + const installed = await readSkillInstallRecord(); + if (!installed.some((r) => r.path === target)) installed.push({ path: target, sourceVersion: version }); + await writeSkillInstallRecord(installed); + void vscode.window.showInformationMessage( + t("RA3 Mod XML Agent Skill installed to {0}", target), + ); + } else if (pick?.id === "claude") { + await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher, projectDir); + void vscode.window.showInformationMessage( + t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()), + ); + } else if (pick?.id === "cursor-global") { + await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher, projectDir); + void vscode.window.showInformationMessage( + t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()), + ); + } else if (pick?.id === "cursor-project") { + await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher, projectDir); + void vscode.window.showInformationMessage( + t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)), + ); + } else if (pick?.id === "copy") { + await vscode.env.clipboard.writeText(configJson); + void vscode.window.showInformationMessage( + t("RA3 Mod XML MCP config copied to clipboard."), + ); + } + } catch (err) { + void vscode.window.showErrorMessage( + t( + "RA3 Mod XML: failed to enable AI Agent access: {0}", + err instanceof Error ? err.message : String(err), + ), + ); + } + }), + ); + context.subscriptions.push( + vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => { + const idx = ws.activeIndex(); + if (!idx) { + void vscode.window.showInformationMessage( + t( + "RA3 Mod XML: no index available yet. Open and index a project before installing the Agent Skill.", + ), + ); + return; + } + const projectDir = idx.projectDir; + const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev"); + const choices = [ + { + label: t("Default (~/.agents/skills)"), + description: agentsSkillsDirForUser(), + path: agentsSkillsDirForUser(), + picked: true, + }, + { + label: t("Claude Code (~/.claude/skills)"), + description: claudeSkillsDirForUser(), + path: claudeSkillsDirForUser(), + }, + { + label: t("Current project .agents/skills"), + description: join(projectDir, ".agents", "skills", SKILL_NAME), + path: join(projectDir, ".agents", "skills", SKILL_NAME), + }, + { + label: t("Current project .claude/skills"), + description: join(projectDir, ".claude", "skills", SKILL_NAME), + path: join(projectDir, ".claude", "skills", SKILL_NAME), + }, + ]; + const picked = await vscode.window.showQuickPick(choices, { + canPickMany: true, + placeHolder: t("Select Agent Skill install locations"), + }); + if (!picked?.length) return; + const succeeded = await installSkillToDirectories( + picked.map((p) => p.path), + version, + ); + void vscode.window.showInformationMessage( + t("RA3 Mod XML Agent Skill installed to {0} location(s).", succeeded.length), + ); + }), + ); context.subscriptions.push( vscode.commands.registerCommand( "ra3modxml.showReferences", @@ -302,8 +638,12 @@ export function activate(context: vscode.ExtensionContext): void { ), ); + if (agentAccessEnabled) void startAgentLocalServer(); void sdkSetup.evaluate(ws); - void ws.initialize().then(() => void sdkSetup.evaluate(ws)); + void ws.initialize().then(() => { + void sdkSetup.evaluate(ws); + if (agentAccessEnabled) void startAgentLocalServer(); + }); } export function deactivate(): void { diff --git a/src/workspace.ts b/src/workspace.ts index 9e49970..2569cb2 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -229,6 +229,18 @@ export class ModWorkspace { return this.activeState()?.indexer ?? null; } + /** + * Index of one specific project (normalized absolute root), or null. + * + * The live agent server routes queries through this instead of + * `activeIndex()`, so a query for project A can never be answered from + * whatever project the user happens to be editing right now. + */ + indexForProject(projectDir: string): ModIndex | null { + if (!projectDir) return null; + return this.states.get(normKey(projectDir))?.index ?? null; + } + /** * Discovers project roots from the current workspace folders and open * documents, registers per-project state and starts the initial build(s): diff --git a/test/agentEndpoint.test.mjs b/test/agentEndpoint.test.mjs new file mode 100644 index 0000000..4845e90 --- /dev/null +++ b/test/agentEndpoint.test.mjs @@ -0,0 +1,102 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + clearEndpoint, + clearEndpointForProject, + endpointPathForProject, + isProcessAlive, + readEndpoint, + readEndpointForProject, + sameProject, + writeEndpoint, + writeEndpointForProject, +} from "../out/agent/endpoint.js"; + +const PROJECT_A = "D:/Mods/ExampleA"; +const PROJECT_B = "D:/Mods/ExampleB"; + +test("endpoint file round-trips and clears", async () => { + const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-test-")); + try { + await writeEndpoint( + { url: "http://127.0.0.1:12345", token: "abc", projectDir: PROJECT_A }, + home, + ); + const loaded = await readEndpoint(home); + assert.equal(loaded?.url, "http://127.0.0.1:12345"); + assert.equal(loaded?.token, "abc"); + + await clearEndpoint(home); + assert.equal(await readEndpoint(home), null); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("per-project endpoints do not shadow each other", async () => { + const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-multi-")); + try { + // Two "windows" enable agent access for different projects. + await writeEndpointForProject( + PROJECT_A, + { url: "http://127.0.0.1:1111", token: "token-a", processId: process.pid }, + home, + ); + await writeEndpointForProject( + PROJECT_B, + { url: "http://127.0.0.1:2222", token: "token-b", processId: process.pid }, + home, + ); + + const a = await readEndpointForProject(PROJECT_A, home); + const b = await readEndpointForProject(PROJECT_B, home); + assert.equal(a?.url, "http://127.0.0.1:1111"); + assert.ok(sameProject(a.projectDir, PROJECT_A)); + assert.equal(b?.url, "http://127.0.0.1:2222"); + assert.ok(sameProject(b.projectDir, PROJECT_B)); + + // Clearing one project must not disturb the other. + await clearEndpointForProject(PROJECT_A, home); + assert.equal(await readEndpointForProject(PROJECT_A, home), null); + assert.equal((await readEndpointForProject(PROJECT_B, home))?.token, "token-b"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("a per-project endpoint recording another project is rejected", async () => { + const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-mismatch-")); + try { + const file = endpointPathForProject(PROJECT_A, home); + // Simulate a stale/edited file that claims to serve a different project. + mkdirSync(dirname(file), { recursive: true }); + writeFileSync( + file, + JSON.stringify({ + url: "http://127.0.0.1:3333", + token: "t", + projectDir: PROJECT_B, + }), + ); + assert.equal(await readEndpointForProject(PROJECT_A, home), null); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("isProcessAlive detects dead pids and trusts unknown ones", () => { + assert.equal(isProcessAlive(process.pid), true); + assert.equal(isProcessAlive(undefined), true); + assert.equal(isProcessAlive(0), true); + assert.equal(isProcessAlive(NaN), true); + // Not a valid Windows PID, so it cannot correspond to a running process. + assert.equal(isProcessAlive(0x7fffffff), false); +}); + +test("sameProject is case-insensitive", () => { + assert.equal(sameProject("D:/Mods/Example", "d:/mods/example"), true); + assert.equal(sameProject("D:/Mods/Example", "D:/Mods/Other"), false); +}); diff --git a/test/agentForwardRefs.test.mjs b/test/agentForwardRefs.test.mjs new file mode 100644 index 0000000..b17f895 --- /dev/null +++ b/test/agentForwardRefs.test.mjs @@ -0,0 +1,308 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + collectAssetReferences, + parseLoadedXml, +} from "../out/agent/forwardRefs.js"; + +// ── Fixture files ──────────────────────────────────────────────────── +// AthenaCannon has no WeaponSetUpdate of its own: it inherits BaseCannon, +// which owns the weapon slot, and has its own die-object content reference. +const ATHENA = ` + + + + AthenaCannon_Die + + +`; + +const BASE = ` + + + + + + + + +`; + +const DIE = ` + + + + + + + + +`; + +const FILES = { + "D:/Mods/Example/Data/AthenaCannon.xml": ATHENA, + "D:/Mods/Example/Data/BaseCannon.xml": BASE, + "D:/Mods/Example/Data/AthenaCannon_Die.xml": DIE, +}; + +function def(type, id, file, line) { + return { type, id, file, line, origin: "project", stream: "static" }; +} + +const ATHENA_DEF = def("GameObject", "AthenaCannon", "D:/Mods/Example/Data/AthenaCannon.xml", 3); +const BASE_DEF = def("GameObject", "BaseCannon", "D:/Mods/Example/Data/BaseCannon.xml", 3); +const DIE_DEF = def("GameObject", "AthenaCannon_Die", "D:/Mods/Example/Data/AthenaCannon_Die.xml", 3); +const WEAPON_DEF = def( + "WeaponTemplate", + "AthenaCannonWeapon", + "D:/Mods/Example/Data/Weapon.xml", + 88, +); +const DIE_WEAPON_DEF = def( + "WeaponTemplate", + "DieExplosionWeapon", + "D:/Mods/Example/Data/Weapon.xml", + 120, +); + +function makeIndex() { + const all = [ATHENA_DEF, BASE_DEF, DIE_DEF, WEAPON_DEF, DIE_WEAPON_DEF]; + const assetsById = new Map(); + for (const d of all) { + const key = d.id.toLowerCase(); + if (!assetsById.has(key)) assetsById.set(key, []); + assetsById.get(key).push(d); + } + const assets = new Map([ + ["GameObject", new Map([["athenacannon", [ATHENA_DEF]], ["basecannon", [BASE_DEF]], ["athenacannon_die", [DIE_DEF]]])], + ["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON_DEF]], ["dieexplosionweapon", [DIE_WEAPON_DEF]]])], + ]); + return { + projectDir: "D:/Mods/Example", + sdkDir: "", + complete: true, + phase: "art", + assets, + assetsById, + defines: new Map(), + files: new Map(), + streams: [], + manifests: new Map(), + sourceCandidates: [], + diagnostics: [], + references: new Map(), + recordsHashes: new Map(), + stats: {}, + }; +} + +function loader(map = FILES) { + return async (file) => { + const text = map[file]; + return text ? parseLoadedXml(text) : null; + }; +} + +function findEdge(edges, predicate) { + return edges.find(predicate); +} + +test("depth 1 returns only the queried asset's own edges", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + {}, + ); + assert.equal(result.roots.length, 1); + // Own content ref + inherited weapon ref + the inheritFrom edge itself. + assert.ok(result.edges.length >= 3); + assert.deepEqual( + [...new Set(result.edges.map((e) => e.depth))], + [1], + "all edges must be at depth 1", + ); + assert.equal(result.truncated, false); +}); + +test("attribute references carry element, parent and attribute provenance", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + ); + const weapon = findEdge( + result.edges, + (e) => e.via.kind === "attribute" && e.to?.id === "AthenaCannonWeapon", + ); + assert.ok(weapon, "weapon edge should exist"); + assert.equal(weapon.via.element, "Weapon"); + assert.equal(weapon.via.parent, "WeaponSlotHardpoint"); + assert.equal(weapon.via.attribute, "Template"); + assert.equal(weapon.to.type, "WeaponTemplate"); + assert.equal(weapon.to.line, 88); + assert.ok(weapon.source.file.endsWith("BaseCannon.xml")); + assert.ok(weapon.source.line > 0); +}); + +test("content references (CreateObjectDie) are reported with kind=content", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + ); + const die = findEdge(result.edges, (e) => e.via.kind === "content"); + assert.ok(die, "die-object content edge should exist"); + assert.equal(die.via.element, "CreateObject"); + assert.equal(die.via.parent, "CreateObjectDie"); + assert.equal(die.via.attribute, null); + assert.equal(die.to.type, "GameObject"); + assert.equal(die.to.id, "AthenaCannon_Die"); + assert.ok(die.source.file.endsWith("AthenaCannon.xml")); +}); + +test("inheritFrom is walked and marked with definedIn", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + ); + const inherit = findEdge(result.edges, (e) => e.via.kind === "inheritFrom"); + assert.ok(inherit, "inheritFrom edge should exist"); + assert.equal(inherit.to.id, "BaseCannon"); + assert.equal(inherit.value, "BaseCannon"); + assert.equal(inherit.definedIn, undefined, "the inheritFrom edge itself is on AthenaCannon"); + + const inheritedWeapon = findEdge( + result.edges, + (e) => e.to?.id === "AthenaCannonWeapon", + ); + assert.ok(inheritedWeapon, "weapon from the ancestor must still be reported"); + assert.deepEqual(inheritedWeapon.definedIn, { type: "GameObject", id: "BaseCannon" }); + assert.equal(inheritedWeapon.from.id, "AthenaCannon", "edge is attributed to the queried asset"); +}); + +test("targetTypes filters edges, but inheritFrom edges always survive", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + { targetTypes: ["WeaponTemplate"] }, + ); + assert.ok(result.edges.length > 0); + assert.ok( + result.edges.some((e) => e.to.type === "WeaponTemplate"), + "expected at least one WeaponTemplate edge", + ); + for (const edge of result.edges) { + // inheritFrom is kept so the caller can see where the weapon is written. + if (edge.via.kind === "inheritFrom") continue; + assert.equal(edge.to.type, "WeaponTemplate", `${edge.via.element} should be filtered out`); + } + // Nodes mirror the kept edges, so the inheritFrom target may appear too. + const inheritIds = new Set( + result.edges + .filter((e) => e.via.kind === "inheritFrom") + .map((e) => e.to.id.toLowerCase()), + ); + for (const node of result.nodes) { + assert.ok( + node.type === "WeaponTemplate" || inheritIds.has(node.id.toLowerCase()), + `unexpected node ${node.type}:${node.id}`, + ); + } +}); + +test("depth 2 expands into referenced assets", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + { depth: 2 }, + ); + const depths = new Set(result.edges.map((e) => e.depth)); + assert.ok(depths.has(2), "expected depth-2 edges from the die-object GameObject"); + const dieWeapon = findEdge(result.edges, (e) => e.to?.id === "DieExplosionWeapon"); + assert.ok(dieWeapon, "the die object's weapon should appear at depth 2"); + assert.equal(dieWeapon.depth, 2); + assert.equal(dieWeapon.from.id, "AthenaCannon_Die"); +}); + +test("depth is clamped to the max of 3", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + { depth: 99 }, + ); + for (const edge of result.edges) { + assert.ok(edge.depth <= 3, `depth ${edge.depth} exceeded the clamp`); + } +}); + +test("maxEdges truncates and reports what was dropped", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + loader(), + { maxEdges: 1 }, + ); + assert.equal(result.edges.length, 1); + assert.equal(result.truncated, true); + const omitted = Object.values(result.omittedByTargetType).reduce((a, b) => a + b, 0); + assert.ok(omitted >= 1, "expected the dropped edges to be summarised"); +}); + +test("unresolved references are opt-in", async () => { + const text = ` + + + +`; + const ghost = def("GameObject", "Ghost", "D:/Mods/Example/Data/Ghost.xml", 2); + const index = makeIndex(); + index.assetsById.set("ghost", [ghost]); + const ghostLoader = loader({ "D:/Mods/Example/Data/Ghost.xml": text }); + + const without = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader); + assert.equal(without.edges.length, 0); + + const withUnresolved = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader, { + includeUnresolved: true, + }); + assert.equal(withUnresolved.edges.length, 1); + assert.equal(withUnresolved.edges[0].to, null); + assert.equal(withUnresolved.edges[0].value, "DoesNotExist"); +}); + +test("unknown ids produce a warning instead of throwing", async () => { + const result = await collectAssetReferences( + makeIndex(), + "NoSuchAsset", + "GameObject", + loader(), + ); + assert.equal(result.edges.length, 0); + assert.equal(result.roots.length, 0); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /No definition found/); +}); + +test("unreadable files produce a warning and no edges", async () => { + const result = await collectAssetReferences( + makeIndex(), + "AthenaCannon", + "GameObject", + async () => null, + ); + assert.equal(result.edges.length, 0); + assert.ok(result.warnings.some((w) => w.includes("Could not read"))); +}); diff --git a/test/agentLocalServer.test.mjs b/test/agentLocalServer.test.mjs new file mode 100644 index 0000000..f5eb95f --- /dev/null +++ b/test/agentLocalServer.test.mjs @@ -0,0 +1,283 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startLocalServer } from "../out/agent/localServer.js"; +import { parseLoadedXml } from "../out/agent/forwardRefs.js"; + +const PROJECT_A = "D:/Mods/Example"; +const PROJECT_B = "D:/Mods/Other"; + +function makeStats() { + return { + projectDir: "P", + sdkDir: "", + phase: "art", + complete: true, + indexedFiles: 1, + parsedFiles: 1, + shallowScannedFiles: 0, + deferredArtFiles: 0, + shallowCacheHits: 0, + recordsCacheHits: 0, + resolveCacheHits: 0, + resolveCalls: 0, + snapshotHits: 0, + snapshotFallbacks: 0, + candidatesMs: 0, + walkMs: 0, + artScanMs: 0, + assetCount: 1, + referenceCount: 1, + defineCount: 1, + manifestFiles: 0, + manifestAssetCount: 0, + streams: 1, + sourceCandidates: 1, + elapsedMs: 1, + }; +} + +const CANNON_XML = ` + + AthenaCannon_Die + +`; + +const BASE_XML = ` + + + +`; + +const FILES = { + "D:/Mods/Example/Data/AthenaCannon.xml": CANNON_XML, + "D:/Mods/Example/Data/BaseCannon.xml": BASE_XML, +}; + +function makeIndex(projectDir, unitId, extraDefs = []) { + const file = `${projectDir}/Data/${unitId}.xml`; + const unit = { type: "GameObject", id: unitId, file, line: 2, origin: "project", stream: "static" }; + const all = [unit, ...extraDefs]; + const assets = new Map(); + const assetsById = new Map(); + for (const d of all) { + if (!assets.has(d.type)) assets.set(d.type, new Map()); + const byId = assets.get(d.type); + if (!byId.has(d.id.toLowerCase())) byId.set(d.id.toLowerCase(), []); + byId.get(d.id.toLowerCase()).push(d); + const key = d.id.toLowerCase(); + if (!assetsById.has(key)) assetsById.set(key, []); + assetsById.get(key).push(d); + } + return { + projectDir, + sdkDir: "", + complete: true, + phase: "art", + stale: false, + assets, + assetsById, + defines: new Map([ + ["exampledefine", [{ name: "ExampleDefine", value: "1", file, line: 2, origin: "project" }]], + ]), + files: new Map(), + streams: [ + { + name: "static", + entry: `${projectDir}/Data/Mod.xml`, + files: new Set([file.toLowerCase().replace(/\\/g, "/")]), + }, + ], + manifests: new Map(), + sourceCandidates: [], + diagnostics: [], + references: new Map([ + [ + `GameObject\u0000${unitId.toLowerCase()}\u0000${file}\u00002`, + [{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }], + ], + ]), + recordsHashes: new Map(), + stats: makeStats(), + }; +} + +const PROJECT_A_RELATED = [ + { + type: "GameObject", + id: "BaseCannon", + file: "D:/Mods/Example/Data/BaseCannon.xml", + line: 2, + origin: "project", + stream: "static", + }, + { + type: "GameObject", + id: "AthenaCannon_Die", + file: "D:/Mods/Example/Data/AthenaCannon_Die.xml", + line: 2, + origin: "project", + stream: "static", + }, + { + type: "WeaponTemplate", + id: "AthenaCannonWeapon", + file: "D:/Mods/Example/Data/Weapon.xml", + line: 88, + origin: "project", + stream: "static", + }, +]; + +/** Routes to a distinct index per requested project, like the extension does. */ +function routedServerOptions() { + const indexes = new Map([ + [PROJECT_A, makeIndex(PROJECT_A, "AthenaCannon", PROJECT_A_RELATED)], + [PROJECT_B, makeIndex(PROJECT_B, "OtherUnit")], + ]); + return { + getIndex: (projectDir) => (projectDir ? indexes.get(projectDir) ?? null : indexes.get(PROJECT_A)), + listProjects: () => [...indexes.keys()], + loadFile: async (file) => { + const text = FILES[file]; + return text ? parseLoadedXml(text) : null; + }, + }; +} + +async function withServer(fn) { + const handle = await startLocalServer({ ...routedServerOptions(), token: "test-token" }); + const base = `http://127.0.0.1:${handle.port}`; + const headers = { authorization: "Bearer test-token" }; + const get = async (path) => (await fetch(`${base}${path}`, { headers })).json(); + try { + await fn({ base, headers, get }); + } finally { + await handle.close(); + } +} + +test("local server requires the bearer token", async () => { + await withServer(async ({ base }) => { + const unauthorized = await fetch(`${base}/status`); + assert.equal(unauthorized.status, 401); + const forbidden = await fetch(`${base}/status`, { + headers: { authorization: "Bearer wrong" }, + }); + assert.equal(forbidden.status, 401); + }); +}); + +test("local server exposes read-only queries", async () => { + await withServer(async ({ get }) => { + const status = await get(`/status?project=${encodeURIComponent(PROJECT_A)}`); + assert.equal(status.state, "ready"); + assert.equal(status.projectDir, PROJECT_A); + + const asset = await get( + `/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`, + ); + assert.equal(asset.data.length, 1); + + const refs = await get( + `/find_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`, + ); + assert.equal(refs.data.length, 1); + + const active = await get( + `/is_file_active?project=${encodeURIComponent(PROJECT_A)}&path=D:/Mods/Example/Data/AthenaCannon.xml`, + ); + assert.equal(active.data.active, true); + }); +}); + +test("?project= selects the index instead of the active editor's project", async () => { + await withServer(async ({ get }) => { + const a = await get(`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`); + assert.equal(a.index.projectDir, PROJECT_A); + assert.equal(a.data.length, 1); + + const b = await get(`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=OtherUnit`); + assert.equal(b.index.projectDir, PROJECT_B); + assert.equal(b.data.length, 1); + + // The same id must not resolve when asked about the other project. + const cross = await get( + `/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=AthenaCannon`, + ); + assert.equal(cross.data.length, 0); + assert.equal(cross.index.projectDir, PROJECT_B); + }); +}); + +test("an unknown project reports no_index without faking a projectDir", async () => { + await withServer(async ({ get }) => { + const unknown = "D:/Mods/Unknown"; + const result = await get(`/status?project=${encodeURIComponent(unknown)}`); + assert.equal(result.state, "no_index"); + assert.equal(result.projectDir, unknown); + }); +}); + +test("/projects lists the known roots", async () => { + await withServer(async ({ get }) => { + const result = await get("/projects"); + assert.deepEqual(new Set(result.data), new Set([PROJECT_A, PROJECT_B])); + }); +}); + +test("/get_asset_references returns provenance-carrying edges", async () => { + await withServer(async ({ get }) => { + const result = await get( + `/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`, + ); + assert.equal(result.index.state, "ready"); + const data = result.data; + assert.ok(data, "expected a data payload"); + assert.equal(data.edges.length >= 3, true); + + const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon"); + assert.ok(weapon, "expected the inherited weapon edge"); + assert.equal(weapon.via.element, "Weapon"); + assert.equal(weapon.via.parent, "WeaponSlotHardpoint"); + assert.equal(weapon.definedIn.id, "BaseCannon"); + assert.equal(weapon.source.file, "D:/Mods/Example/Data/BaseCannon.xml"); + + const die = data.edges.find((e) => e.via.kind === "content"); + assert.ok(die, "expected the CreateObjectDie content edge"); + assert.equal(die.to.id, "AthenaCannon_Die"); + }); +}); + +test("/get_asset_references honours targetTypes and depth parameters", async () => { + await withServer(async ({ get }) => { + const filtered = await get( + `/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&targetTypes=WeaponTemplate`, + ); + for (const edge of filtered.data.edges) { + if (edge.via.kind === "inheritFrom") continue; + assert.equal(edge.to.type, "WeaponTemplate"); + } + + const shallow = await get( + `/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=1`, + ); + for (const edge of shallow.data.edges) { + assert.equal(edge.depth, 1); + } + + const deep = await get( + `/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=2`, + ); + for (const edge of deep.data.edges) { + assert.ok(edge.depth <= 2); + } + }); +}); + +test("/get_asset_references explains itself when live data is unavailable", async () => { + await withServer(async ({ get }) => { + const missing = await get(`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=Nope`); + assert.equal(missing.data.roots.length, 0); + assert.ok(missing.data.warnings.length > 0); + }); +}); diff --git a/test/agentMcpRouting.test.mjs b/test/agentMcpRouting.test.mjs new file mode 100644 index 0000000..b485956 --- /dev/null +++ b/test/agentMcpRouting.test.mjs @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + liveUrlForTool, + normalizePath, + responseProjectMismatch, +} from "../out/agent/mcpServer.js"; + +const PROJECT_A = "D:/Mods/Example"; + +test("live URLs always pin the requested project", () => { + const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "find_asset", { + id: "AthenaCannon", + type: "GameObject", + }); + assert.ok(url); + const parsed = new URL(url); + assert.equal(parsed.pathname, "/find_asset"); + assert.equal(parsed.searchParams.get("project"), PROJECT_A); + assert.equal(parsed.searchParams.get("id"), "AthenaCannon"); + assert.equal(parsed.searchParams.get("type"), "GameObject"); +}); + +test("get_asset_references serialises its list and scalar options", () => { + const url = liveUrlForTool("http://127.0.0.1:1234/", PROJECT_A, "get_asset_references", { + id: "AthenaCannon", + depth: 2, + targetTypes: ["WeaponTemplate", "GameObject"], + maxEdges: 25, + includeUnresolved: true, + }); + assert.ok(url); + const parsed = new URL(url); + assert.equal(parsed.pathname, "/get_asset_references"); + assert.equal(parsed.searchParams.get("depth"), "2"); + assert.equal(parsed.searchParams.get("targetTypes"), "WeaponTemplate,GameObject"); + assert.equal(parsed.searchParams.get("maxEdges"), "25"); + assert.equal(parsed.searchParams.get("includeUnresolved"), "true"); + assert.equal(parsed.searchParams.get("project"), PROJECT_A); +}); + +test("live URLs omit the project selector when none is configured", () => { + const url = liveUrlForTool("http://127.0.0.1:1234", null, "get_status", {}); + assert.equal(url, "http://127.0.0.1:1234/status"); +}); + +test("unknown tools have no live URL", () => { + assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "nope", {}), null); +}); + +test("responseProjectMismatch flags answers from another project", () => { + // Matching project (case/separator-insensitive) is accepted. + assert.equal( + responseProjectMismatch({ index: { projectDir: "d:/mods/example" } }, PROJECT_A), + false, + ); + // A different project must be rejected: this is the multi-window cross-talk guard. + assert.equal( + responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, PROJECT_A), + true, + ); + // No project configured, or no projectDir in the payload: nothing to check. + assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false); + assert.equal( + responseProjectMismatch({ index: { projectDir: "D:/Mods/Other" } }, null), + false, + ); + assert.equal(responseProjectMismatch(null, PROJECT_A), false); +}); + +test("normalizePath is case- and separator-insensitive", () => { + assert.equal(normalizePath("D:\\Mods\\Example\\"), normalizePath("d:/mods/example")); +}); diff --git a/test/agentSetup.test.mjs b/test/agentSetup.test.mjs new file mode 100644 index 0000000..6f5409e --- /dev/null +++ b/test/agentSetup.test.mjs @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + addMcpServerToConfigFile, + mcpConfigJson, + mcpServerConfig, +} from "../out/agent/setup.js"; + +test("mcpServerConfig and mcpConfigJson use the stable launcher", () => { + const config = mcpServerConfig("C:/Users/me/.ra3modxml/ra3-mod-xml-mcp.cmd", "D:/Mods/Example"); + assert.deepEqual(config.mcpServers["ra3-mod-xml"].args, ["--project", "D:/Mods/Example"]); + const json = mcpConfigJson("C:/launcher.cmd", "D:/Proj"); + assert.ok(json.includes("C:/launcher.cmd")); + assert.ok(json.includes("D:/Proj")); +}); + +test("addMcpServerToConfigFile creates and merges config", async () => { + const dir = mkdtempSync(join(tmpdir(), "ra3-setup-test-")); + try { + const file = join(dir, "mcp.json"); + await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj"); + const first = JSON.parse(readFileSync(file, "utf8")); + assert.ok(first.mcpServers["ra3-mod-xml"]); + + writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 2)); + await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj"); + const merged = JSON.parse(readFileSync(file, "utf8")); + assert.ok(merged.mcpServers.other); + assert.ok(merged.mcpServers["ra3-mod-xml"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/agentSkill.test.mjs b/test/agentSkill.test.mjs new file mode 100644 index 0000000..e0ddccf --- /dev/null +++ b/test/agentSkill.test.mjs @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SKILL_MARKER_FILE, + installSkillToDirectories, + readSkillInstallRecord, + uninstallSkillFromDirectory, + writeSkillTo, +} from "../out/agent/skill.js"; + +test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => { + const dir = mkdtempSync(join(tmpdir(), "ra3-skill-test-")); + try { + const skillDir = join(dir, "ra3-mod-xml"); + await writeSkillTo(skillDir, "0.1.25"); + assert.ok(existsSync(join(skillDir, "SKILL.md"))); + assert.ok(existsSync(join(skillDir, "references", "query-guide.md"))); + assert.ok(existsSync(join(skillDir, SKILL_MARKER_FILE))); + const content = readFileSync(join(skillDir, "SKILL.md"), "utf8"); + assert.ok(content.includes("find_asset")); + assert.ok(!content.includes("codebase-navigation-guide")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async () => { + const dir = mkdtempSync(join(tmpdir(), "ra3-skill-scope-")); + try { + const skillDir = join(dir, "ra3-mod-xml"); + await writeSkillTo(skillDir, "0.1.25"); + const content = readFileSync(join(skillDir, "SKILL.md"), "utf8"); + + // Must state its applicability and list concrete positive signals. + assert.match(content, /When this skill applies/); + assert.ok(content.includes("Data/Mod.xml")); + assert.ok(content.includes("babproj")); + assert.ok(content.includes("AssetDeclaration")); + + // Must give an explicit negative rule and a cheap probe. + assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/); + assert.ok(content.includes("get_status")); + assert.ok(content.includes("projectDir")); + // The CnC3 red herring is called out explicitly. + assert.ok(content.includes("CnC3Types.xsd")); + assert.ok(content.includes("C&C3")); + + // Second-phase capability must be documented. + assert.ok(content.includes("get_asset_references")); + assert.ok(content.includes("definedIn")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("installSkillToDirectories records managed copies and uninstall removes them", async () => { + const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-")); + const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-")); + try { + const target = join(dir, "ra3-mod-xml"); + const succeeded = await installSkillToDirectories([target], "0.1.25", home); + assert.deepEqual(succeeded, [target]); + const record = await readSkillInstallRecord(home); + assert.equal(record.length, 1); + assert.equal(record[0].path, target); + + await uninstallSkillFromDirectory(target, home); + assert.equal(existsSync(target), false); + assert.equal((await readSkillInstallRecord(home)).length, 0); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/agentSnapshot.test.mjs b/test/agentSnapshot.test.mjs new file mode 100644 index 0000000..5569ca7 --- /dev/null +++ b/test/agentSnapshot.test.mjs @@ -0,0 +1,184 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { snapshotFromIndex, writeSnapshotFile, readSnapshotFile } from "../out/agent/snapshot.js"; +import { + findAssets, + findReferenceGroups, + isFileActive, + listAssetsByType, + resolveIncludeSource, + statusFromSnapshot, +} from "../out/agent/query.js"; + +function makeStats() { + return { + projectDir: "P", + sdkDir: "", + phase: "art", + complete: true, + indexedFiles: 3, + parsedFiles: 2, + shallowScannedFiles: 1, + deferredArtFiles: 0, + shallowCacheHits: 0, + recordsCacheHits: 0, + resolveCacheHits: 0, + resolveCalls: 0, + snapshotHits: 0, + snapshotFallbacks: 0, + candidatesMs: 0, + walkMs: 0, + artScanMs: 0, + assetCount: 2, + referenceCount: 1, + defineCount: 1, + manifestFiles: 0, + manifestAssetCount: 0, + streams: 1, + sourceCandidates: 1, + elapsedMs: 1, + }; +} + +function makeIndex() { + const file = "D:/Mods/Example/Data/Units/Example.xml"; + const assets = new Map([ + [ + "GameObject", + new Map([ + [ + "exampleunit", + [ + { + type: "GameObject", + id: "ExampleUnit", + file, + line: 5, + origin: "project", + stream: "static", + }, + ], + ], + ]), + ], + ]); + const references = new Map([ + [ + "GameObject\u0000exampleunit\u0000D:/Mods/Example/Data/Units/Example.xml\u00005", + [ + { + file: "D:/Mods/Example/Data/Other.xml", + line: 3, + start: 10, + end: 21, + kind: "attr", + }, + ], + ], + ]); + return { + projectDir: "D:/Mods/Example", + sdkDir: "", + complete: true, + phase: "art", + stale: false, + assets, + assetsById: new Map([ + [ + "exampleunit", + [ + { + type: "GameObject", + id: "ExampleUnit", + file, + line: 5, + origin: "project", + stream: "static", + }, + ], + ], + ]), + defines: new Map([ + [ + "exampledefine", + [ + { + name: "ExampleDefine", + value: "1", + file, + line: 2, + origin: "project", + }, + ], + ], + ]), + files: new Map(), + streams: [ + { + name: "static", + entry: "D:/Mods/Example/Data/Mod.xml", + files: new Set(["d:/mods/example/data/units/example.xml"]), + }, + ], + manifests: new Map(), + sourceCandidates: [ + { + source: "DATA:Units/Example.xml", + path: "D:/Mods/Example/Data/Units/Example.xml", + prefix: "DATA", + baseDir: "D:/Mods/Example/Data", + }, + ], + diagnostics: [], + references, + recordsHashes: new Map(), + stats: makeStats(), + }; +} + +test("snapshotFromIndex flattens assets, defines, streams and references", () => { + const snapshot = snapshotFromIndex(makeIndex(), 42); + assert.equal(snapshot.schemaVersion, 1); + assert.equal(snapshot.assets.length, 1); + assert.equal(snapshot.assets[0].id, "ExampleUnit"); + assert.equal(snapshot.defines.length, 1); + assert.equal(snapshot.streams.length, 1); + assert.deepEqual(snapshot.streams[0].files, [ + "d:/mods/example/data/units/example.xml", + ]); + assert.equal(snapshot.references.length, 1); + assert.equal(snapshot.references[0].sites.length, 1); + assert.equal(snapshot.buildId, 42); +}); + +test("query helpers operate on snapshots", () => { + const snapshot = snapshotFromIndex(makeIndex(), 1); + assert.equal(findAssets(snapshot, "ExampleUnit").length, 1); + assert.equal(findAssets(snapshot, "exampleunit", "GameObject").length, 1); + assert.equal(findAssets(snapshot, "exampleunit", "WeaponTemplate").length, 0); + assert.equal(listAssetsByType(snapshot, "gameobject", "exa").length, 1); + assert.equal(findReferenceGroups(snapshot, "ExampleUnit").length, 1); + assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Units/Example.xml"), true); + assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Dead.xml"), false); + assert.equal(resolveIncludeSource(snapshot, "data:units/example.xml")?.path, "D:/Mods/Example/Data/Units/Example.xml"); + assert.equal(statusFromSnapshot(snapshot).state, "ready"); +}); + +test("snapshot file write/read round-trips", async () => { + const dir = mkdtempSync(join(tmpdir(), "ra3-agent-test-")); + try { + const file = join(dir, "snapshot.json.gz"); + const snapshot = snapshotFromIndex(makeIndex(), 7); + await writeSnapshotFile(file, snapshot); + const loaded = await readSnapshotFile(file); + assert.ok(loaded); + assert.equal(loaded.assets.length, snapshot.assets.length); + assert.equal(loaded.references[0].sites[0].file, "D:/Mods/Example/Data/Other.xml"); + assert.equal(loaded.buildId, 7); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});