This commit is contained in:
2026-09-10 17:03:10 +02:00
parent 90dc18a167
commit 3a3d70efeb
27 changed files with 4973 additions and 3 deletions
+43
View File
@@ -155,6 +155,49 @@ to an empty string opts out of SDK features permanently.
* `RA3 Mod XML: Show cache report` * `RA3 Mod XML: Show cache report`
* `RA3 Mod XML: Find unreferenced assets…` * `RA3 Mod XML: Find unreferenced assets…`
* `RA3 Mod XML: Find unreferenced assets of this type` * `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/<project>.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 ## Requirements
+29
View File
@@ -152,6 +152,35 @@
* `RA3 Mod XML: Show cache report`(显示缓存报告) * `RA3 Mod XML: Show cache report`(显示缓存报告)
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…) * `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产) * `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 访问
扩展可以通过本地只读 MCPModel Context ProtocolServer,把语义索引提供给 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/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。
完整设计与进度见 `docs/ai-agent-integration-plan.md`
## 环境要求 ## 环境要求
+773
View File
@@ -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`
- `<Include>` / `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 3MCP Server
- 新增 `src/agent/mcpServer.ts`
- 支持 stdio 协议;
- 启动时读取连接信息/快照;
- 提供前述 MCP 工具;
- 每个响应带索引状态。
### Phase 4MCP 配置助手
- 新增 `src/agent/setup.ts`
- 创建稳定 launcher
- 检测/写入常见客户端配置:
- Claude Desktop
- Cursor
- Codex
- 通用 JSON
- 提供“复制 MCP 配置”;
- 提供手动操作指南。
### Phase 5Agent 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 6Live 查询与快照合并
- 在扩展内启动本地只读查询服务;
- 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 根为 `<AssetDeclaration>`;使用 `<Includes><Include source="DATA:…"/></Includes>`
- 明确否定:无关仓库、仅含拷贝 `.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. 读源码,发现 `<FireWeaponUpdate />` / `<WeaponSetUpdate />` 等子元素,
在这些子元素里找到武器引用;
3.`<CreateObjectDie />` 引用了临时物体,还要去读临时物体的 GameObject
在它的 `FireWeaponUpdate` 里找武器;
4. 若雅典娜炮只有 `FireWeaponUpdate``WeaponSetUpdate`
`inheritFrom``BaseCannon` 上,还要读 `BaseCannon` 并综合判断。
评估问题:
- 是否有必要提供"某个 Asset 直接或间接引用的所有 Asset"
- 只给精炼 asset 列表,Agent 不知道是哪个子元素导致的引用,有用吗?
- 若同时提供上下文,磁盘/内存/性能允许吗?内容长度会不会反而比直接读源码更贵?
- 相对直接读源码未必有显著提升,反而增加注意力噪音?
- 间接引用会不会多项式/指数爆炸?
### 先指出当前实现的真实缺口
现在的索引只有**反向**引用(谁引用了我),没有**正向**查询(我引用了谁)。
`find_references(AthenaCannon)` 返回的是"谁引用了雅典娜炮",而不是"雅典娜炮
用了哪些武器"。所以上述 Agent 工作流**目前完全无法用工具完成**,只能读源码。
因此问题不是"要不要锦上添花",而是这里确实缺一个基础能力。
### 结论:不提供"所有可达 Asset 的扁平闭包",改提供"有界 + 带 provenance 的边列表"
**不采用扁平静态闭包的理由:**
1. **丢失 provenanceAgent 仍然要读源码。** 到底是 `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`),
返回可能只有 310 条边。
- **`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 <dir>
│ HTTP (127.0.0.1:临时端口, Bearer token)
VS Code 扩展进程内的 localServer ← 仅在"启用 AI Agent 访问"后启动
├─ 在线 → ws.activeIndex() 实时内存索引
└─ 离线 → ~/.ra3modxml/snapshots/project-<hash>.json.gz
```
- **AI 客户端不需要知道端口。** MCP Server 自己读
`~/.ra3modxml/endpoint.json``url` + `token``fetch`。localhost 层是
纯实现细节。
- **端口**`server.listen(port ?? 0, "127.0.0.1")`,即由操作系统分配临时端口,
不会冲突;当前未传 `port`,所以永远是临时端口。
- **鉴权**`Authorization: Bearer <token>`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/<project-hash>.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()` 改为
`<slug>-<sha1-12>`
- `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、关闭时只清自己写过的那些。
### 二期 BSkill 适用范围
- `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/<slug>-<sha1-12>.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``<AssetDeclaration>`
`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` 改为 `<slug>-<sha1-12>`,旧的 `project-<hash>.json.gz`
快照文件不会被自动清理(该功能尚未发布,无兼容负担)。
- 本沙箱禁止 spawn 子进程,因此 `node --test``npm run build` 无法直接运行;
验证方式为:逐个 `node test/*.test.mjs`、直接调用 esbuild CLI 构建 dist、
用管道驱动 `dist/agent/mcpServer.js` 做端到端 smoke test。
+7 -2
View File
@@ -3,9 +3,14 @@ import * as esbuild from "esbuild";
const watch = process.argv.includes("--watch"); const watch = process.argv.includes("--watch");
const ctx = await esbuild.context({ const ctx = await esbuild.context({
entryPoints: ["./src/extension.ts"], entryPoints: [
"./src/extension.ts",
"./src/agent/mcpServer.ts",
"./src/agent/cli.ts",
],
bundle: true, bundle: true,
outfile: "dist/extension.js", outdir: "dist",
outbase: "src",
external: ["vscode"], external: ["vscode"],
format: "cjs", format: "cjs",
platform: "node", platform: "node",
+12
View File
@@ -126,6 +126,18 @@
"command": "ra3modxml.showCacheReport", "command": "ra3modxml.showCacheReport",
"title": "%ra3modxml.command.showCacheReport.title%" "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", "command": "ra3modxml.findUnreferencedAssets",
"title": "%ra3modxml.command.findUnreferencedAssets.title%" "title": "%ra3modxml.command.findUnreferencedAssets.title%"
+3
View File
@@ -13,6 +13,9 @@
"ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild", "ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild",
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…", "ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report", "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.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type" "ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
} }
+3
View File
@@ -13,6 +13,9 @@
"ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建", "ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建",
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…", "ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告", "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.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产" "ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
} }
+159
View File
@@ -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 <dir> <command> [args...]
node out/agent/cli.js --snapshot <file> <command> [args...]
Commands:
status
find <id> [type]
refs <id> [type]
list <type> [prefix]
active <file>
define <name>
resolve <source>
help
`);
}
async function main(): Promise<void> {
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();
+153
View File
@@ -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/<slug>-<hash>.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<void> {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function readJson<T>(file: string): Promise<T | null> {
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<string> {
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<string> {
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<AgentEndpoint | null> {
const parsed = await readJson<AgentEndpoint>(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<AgentEndpoint | null> {
const parsed = await readJson<AgentEndpoint>(
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<void> {
await rm(endpointPath(agentHome), { force: true });
}
/** Removes one project's endpoint file. */
export async function clearEndpointForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): Promise<void> {
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";
}
}
+503
View File
@@ -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<string, number>;
/** 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<LoadedXmlFile | null>;
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<ForwardRefResult> {
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<ForwardRefResult> {
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<string, ForwardRefNode>();
const omitted = new Map<string, number>();
const warnings: string[] = [];
let truncated = false;
let edgeCount = 0;
const fileCache = new Map<string, LoadedXmlFile | null>();
const load = async (file: string): Promise<LoadedXmlFile | null> => {
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<string>();
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. <CreateObject>temp_id</CreateObject>).
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<AssetDef, "id" | "line">,
): 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;
}
+147
View File
@@ -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] : [];
}
+268
View File
@@ -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=<dir>` 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 <token>):
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=<absolute dir> 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<void>;
}
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<void> {
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<LocalServerHandle> {
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<void>((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<void>((resolveClose, rejectClose) => {
server.close((err) => (err ? rejectClose(err) : resolveClose()));
}),
};
}
function randomToken(): string {
return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
+499
View File
@@ -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<string, unknown>;
handler: (args: Record<string, unknown>, 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, unknown>,
): 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<string, unknown>,
projectDir: string | null,
agentHome?: string,
): Promise<LiveResult | null> {
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<string, unknown>,
snapshot: AgentIndexSnapshot | null,
projectDir: string | null,
agentHome?: string,
): Promise<unknown | null> {
const method = String(message.method ?? "");
const id = message.id;
const params = (message.params ?? {}) as Record<string, unknown>;
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<string, unknown>;
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<void> {
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<string, unknown>;
try {
message = JSON.parse(line) as Record<string, unknown>;
} 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();
}
+158
View File
@@ -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<T> {
index: AgentIndexStatus;
data: T;
}
/** Wraps any query data with current index status. */
export function withStatus<T>(snapshot: AgentIndexSnapshot | null, data: T): QueryResult<T> {
return {
index: statusFromSnapshot(snapshot),
data,
};
}
+160
View File
@@ -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<string> {
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<string, unknown> {
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<void> {
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
} catch {
// File absent or malformed: start fresh.
}
const servers = (config.mcpServers as Record<string, unknown> | 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");
}
+333
View File
@@ -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 \`<AssetDeclaration>\`.
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` 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<void> {
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/<skill-name> path. */
export function agentsSkillsDirForUser(home = homedir()): string {
return join(home, ".agents", "skills", SKILL_NAME);
}
/** Conventional ~/.claude/skills/<skill-name> 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<SkillInstallRecord[]> {
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<void> {
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<string[]> {
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<void> {
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<SkillInstallRecord[]> {
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;
}
+204
View File
@@ -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:
* `<slug>-<sha1-12>` (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<string> {
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<AgentIndexSnapshot | null> {
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`);
}
+103
View File
@@ -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:<name>) 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;
}
+341 -1
View File
@@ -1,4 +1,7 @@
import * as vscode from "vscode"; 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 { ModWorkspace } from "./workspace";
import { SdkSetup } from "./sdkSetup"; import { SdkSetup } from "./sdkSetup";
import { Ra3CompletionProvider } from "./features/completion"; import { Ra3CompletionProvider } from "./features/completion";
@@ -21,6 +24,36 @@ import {
RA3_SEMANTIC_TOKENS_LEGEND, RA3_SEMANTIC_TOKENS_LEGEND,
} from "./features/semanticTokens"; } from "./features/semanticTokens";
import { t } from "./localize"; 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" }]; const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */ /** 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.log("[codelens] retry started");
}; };
ws.onBuildStart = startCodeLensRetry; 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<boolean>(
"ra3modxml.agentAccessEnabled",
false,
);
const AGENT_SNAPSHOT_QUIET_MS = 5000;
let agentSnapshotTimer: ReturnType<typeof setTimeout> | 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<string>();
const startAgentLocalServer = async (): Promise<void> => {
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<void> => {
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<void> => {
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({ context.subscriptions.push({
dispose: () => { dispose: () => {
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer); if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
void stopAgentLocalServer();
}, },
}); });
context.subscriptions.push( 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})`, `[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) { for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc); 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( context.subscriptions.push(
vscode.commands.registerCommand( vscode.commands.registerCommand(
"ra3modxml.showReferences", "ra3modxml.showReferences",
@@ -302,8 +638,12 @@ export function activate(context: vscode.ExtensionContext): void {
), ),
); );
if (agentAccessEnabled) void startAgentLocalServer();
void sdkSetup.evaluate(ws); 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 { export function deactivate(): void {
+12
View File
@@ -229,6 +229,18 @@ export class ModWorkspace {
return this.activeState()?.indexer ?? null; 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 * Discovers project roots from the current workspace folders and open
* documents, registers per-project state and starts the initial build(s): * documents, registers per-project state and starts the initial build(s):
+102
View File
@@ -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);
});
+308
View File
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie>
<CreateObject>AthenaCannon_Die</CreateObject>
</CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="AthenaCannonWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const DIE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon_Die">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="DieExplosionWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
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 = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Ghost">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="DoesNotExist" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
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")));
});
+283
View File
@@ -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 = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
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);
});
});
+73
View File
@@ -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"));
});
+36
View File
@@ -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 });
}
});
+77
View File
@@ -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 });
}
});
+184
View File
@@ -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 });
}
});