ai agent
This commit is contained in:
@@ -155,6 +155,49 @@ to an empty string opts out of SDK features permanently.
|
||||
* `RA3 Mod XML: Show cache report`
|
||||
* `RA3 Mod XML: Find unreferenced assets…`
|
||||
* `RA3 Mod XML: Find unreferenced assets of this type`
|
||||
* `RA3 Mod XML: Enable AI Agent access…`
|
||||
* `RA3 Mod XML: Install Agent Skill…`
|
||||
* `RA3 Mod XML: Export AI Agent index snapshot`
|
||||
|
||||
## AI Agent Access
|
||||
|
||||
The extension can expose its semantic asset index to AI Agent clients through
|
||||
a local, read-only MCP (Model Context Protocol) server. This is optional and
|
||||
does not modify `PATH` or install global commands.
|
||||
|
||||
Run:
|
||||
|
||||
`RA3 Mod XML: Enable AI Agent access…`
|
||||
|
||||
The command:
|
||||
|
||||
1. Exports a stable index snapshot for the active project.
|
||||
2. Creates a stable launcher under `~/.ra3modxml/`.
|
||||
3. Starts a local read-only query server while VS Code is running.
|
||||
4. Offers to:
|
||||
* install the `ra3-mod-xml` Agent Skill to `~/.agents/skills/` (and
|
||||
optionally to Claude Code or project-local skill directories),
|
||||
* write the MCP client configuration for Claude Desktop or Cursor,
|
||||
* copy a generic MCP configuration block.
|
||||
|
||||
The MCP server prefers the live in-memory index while the extension is
|
||||
running and falls back to the last exported snapshot when VS Code is closed.
|
||||
The exposed tools include asset lookup, incoming semantic references,
|
||||
**outgoing reference edges** (`get_asset_references`: which assets an asset
|
||||
uses, through which element/attribute, and where in the XML), active-file
|
||||
checks, `$DEFINE` lookup, and Include source resolution.
|
||||
|
||||
`get_asset_references` follows `inheritFrom` ancestors and marks the
|
||||
ancestor's entries with `definedIn`, so "this unit has no `WeaponSetUpdate`,
|
||||
but the base unit it inherits from does" is answerable in a single call. It is
|
||||
bounded by `depth` (default 1, max 3), `targetTypes` and `maxEdges`, and
|
||||
reports truncation instead of silently dropping results.
|
||||
|
||||
Discovery is per project (`~/.ra3modxml/endpoints/<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
|
||||
|
||||
|
||||
@@ -152,6 +152,35 @@
|
||||
* `RA3 Mod XML: Show cache report`(显示缓存报告)
|
||||
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
|
||||
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
|
||||
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…)
|
||||
* `RA3 Mod XML: Install Agent Skill…`(安装 Agent Skill…)
|
||||
* `RA3 Mod XML: Export AI Agent index snapshot`(导出 AI Agent 索引快照)
|
||||
|
||||
## AI Agent 访问
|
||||
|
||||
扩展可以通过本地只读 MCP(Model Context Protocol)Server,把语义索引提供给 AI Agent 使用。该功能可选,不会修改 `PATH`,也不会注册全局命令。
|
||||
|
||||
运行:
|
||||
|
||||
`RA3 Mod XML: Enable AI Agent access…`
|
||||
|
||||
该命令会:
|
||||
|
||||
1. 为当前项目导出稳定的索引快照;
|
||||
2. 在 `~/.ra3modxml/` 下创建稳定 launcher;
|
||||
3. 在 VS Code 运行期间启动本地只读查询服务;
|
||||
4. 让用户选择:
|
||||
* 安装 `ra3-mod-xml` Agent Skill 到 `~/.agents/skills/`(也可选择 Claude Code 或项目级目录);
|
||||
* 写入 Claude Desktop / Cursor 的 MCP 配置;
|
||||
* 复制通用 MCP 配置。
|
||||
|
||||
MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭后回退到最近一次导出的快照。暴露的工具包括资产定义查询、语义引用查询、**正向引用边**(`get_asset_references`:该资产用了哪些资产、通过哪个元素/属性、写在 XML 的哪一行)、文件是否有效 include、`$DEFINE` 查询以及 Include source 解析。
|
||||
|
||||
`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。
|
||||
|
||||
Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。
|
||||
|
||||
完整设计与进度见 `docs/ai-agent-integration-plan.md`。
|
||||
|
||||
## 环境要求
|
||||
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
# RA3 Mod XML AI Agent 接入计划与进度追踪
|
||||
|
||||
> 状态:实施中
|
||||
> 创建时间:2026-09-09
|
||||
> 目标:让 AI Agent / 其他工具能够方便、可靠地使用 RA3 Mod XML 扩展构建的语义索引。
|
||||
|
||||
---
|
||||
|
||||
## 进度追踪
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| Phase 0 | 计划与进度文件 | ✅ 完成 |
|
||||
| Phase 1 | 外部快照格式与导出命令 | ✅ 完成 |
|
||||
| Phase 2 | 查询核心与 CLI | ✅ 完成 |
|
||||
| Phase 3 | MCP Server | ✅ 完成(基础 stdio MCP) |
|
||||
| Phase 4 | MCP 配置助手 / 稳定 launcher | ✅ 完成(launcher + 启用命令 + Claude/Cursor 配置写入 + 复制配置) |
|
||||
| Phase 5 | Agent Skill 安装器 | ✅ 完成(默认安装 + 多位置选择 + 安装记录 + 卸载/同步函数) |
|
||||
| Phase 6 | Live 查询与快照合并策略 | ✅ 完成(本地只读 HTTP live server + endpoint 文件 + MCP 在线优先/离线兜底 + final 索引静默 5 秒自动快照) |
|
||||
| Phase 7 | 测试、文档、发布 | 🟡 部分完成(README + 计划文档已更新;全部 31 个 test/*.test.mjs 逐个直接运行通过;已用直接 esbuild CLI 验证 dist 构建与 MCP smoke test;`node --test` 受沙箱限制,尚未 VSIX 打包/发布) |
|
||||
|
||||
### 二期进度(2026-09-10)
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| 二期 A1 | live 响应携带 `projectDir`,MCP 侧校验 | ✅ 完成 |
|
||||
| 二期 A2 | endpoint 按项目分文件(保留全局兜底) | ✅ 完成 |
|
||||
| 二期 A3 | live server 支持 `?project=` 路由(不再只看活动编辑器) | ✅ 完成 |
|
||||
| 二期 A4 | 关闭窗口只清理自己写的 endpoint | ✅ 完成 |
|
||||
| 二期 A5 | `processId` 存活校验 | ✅ 完成 |
|
||||
| 二期 A6 | live 失败负缓存 | ✅ 完成 |
|
||||
| 二期 A7 | `snapshotBaseName` 改为 sha1 前缀 + 可读 slug | ✅ 完成 |
|
||||
| 二期 B1 | Skill 适用范围段落(正/负信号 + `get_status` 探针) | ✅ 完成 |
|
||||
| 二期 C1 | `get_asset_references`:有界、带 provenance 的正向引用查询 | ✅ 完成 |
|
||||
| 二期 C2 | 本地 HTTP `/get_asset_references` 与 MCP 工具接入 | ✅ 完成 |
|
||||
| 二期 C3 | 二期测试与文档 | ✅ 完成(264/264 测试通过) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 RA3 Mod XML 扩展已经能构建大型项目的语义索引,包括:
|
||||
|
||||
- asset 定义:`type / id / file / line / origin / stream`
|
||||
- `$DEFINE`
|
||||
- `<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 3:MCP Server
|
||||
|
||||
- 新增 `src/agent/mcpServer.ts`
|
||||
- 支持 stdio 协议;
|
||||
- 启动时读取连接信息/快照;
|
||||
- 提供前述 MCP 工具;
|
||||
- 每个响应带索引状态。
|
||||
|
||||
### Phase 4:MCP 配置助手
|
||||
|
||||
- 新增 `src/agent/setup.ts`
|
||||
- 创建稳定 launcher;
|
||||
- 检测/写入常见客户端配置:
|
||||
- Claude Desktop
|
||||
- Cursor
|
||||
- Codex
|
||||
- 通用 JSON
|
||||
- 提供“复制 MCP 配置”;
|
||||
- 提供手动操作指南。
|
||||
|
||||
### Phase 5:Agent Skill 安装器
|
||||
|
||||
- 新增 `src/agent/skill.ts`
|
||||
- 生成 `SKILL.md` + `references/query-guide.md`
|
||||
- 默认安装到 `~/.agents/skills/ra3-mod-xml/`
|
||||
- 可选安装到:
|
||||
- `~/.claude/skills/ra3-mod-xml/`
|
||||
- 项目 `.agents/skills/`
|
||||
- 项目 `.claude/skills/`
|
||||
- 自定义位置
|
||||
- 记录已安装位置,扩展升级时自动同步;
|
||||
- 不覆盖用户自定义内容。
|
||||
|
||||
### Phase 6:Live 查询与快照合并
|
||||
|
||||
- 在扩展内启动本地只读查询服务;
|
||||
- MCP Server 在线时优先转发到扩展;
|
||||
- 离线时读取 `current.json.gz`;
|
||||
- 实现快照 quiet-period 合并写入,避免频繁全量落盘;
|
||||
- 实现状态机:`no_index / building / ready_xml / ready / stale / error`。
|
||||
|
||||
### Phase 7:测试、文档、发布
|
||||
|
||||
- 测试快照导出/导入一致性;
|
||||
- 测试 MCP 工具查询;
|
||||
- 测试 Skill 安装/更新/卸载;
|
||||
- 测试持续编辑场景下的状态与 stale 行为;
|
||||
- 更新 README / docs;
|
||||
- 发布新版本。
|
||||
|
||||
---
|
||||
|
||||
## 6. 预估文件改动
|
||||
|
||||
### 新增文件
|
||||
|
||||
```text
|
||||
src/agent/
|
||||
types.ts
|
||||
snapshot.ts
|
||||
query.ts
|
||||
mcpServer.ts
|
||||
setup.ts
|
||||
skill.ts
|
||||
localServer.ts
|
||||
launcher.ts
|
||||
|
||||
docs/
|
||||
ai-agent-integration-plan.md
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```text
|
||||
package.json # commands / configuration
|
||||
src/extension.ts # 注册命令、设置、初始化
|
||||
src/workspace.ts # 暴露 index status / snapshot 发布钩子
|
||||
src/features/agentSetup.ts # 用户引导 UI(或并入 agent/setup.ts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 用户安装/更新扩展并完成一次索引后,可以看到“启用 AI Agent 访问”的引导。
|
||||
2. 用户点击启用后,不需要手动配置 PATH,不需要手动查找 workspaceStorage。
|
||||
3. 常见 AI 客户端能通过 MCP 调用索引工具。
|
||||
4. Agent 能正确回答:
|
||||
- “某 asset 定义在哪个文件?”
|
||||
- “谁引用了这个 asset?”
|
||||
- “这个 XML 文件是否真的被 Include?”
|
||||
5. 查询接口在无索引/索引更新中不会返回误导性空结果。
|
||||
6. 用户连续编辑保存时,不会因为频繁快照写入而产生明显卡顿或磁盘膨胀。
|
||||
7. Skill 安装后,Agent 知道优先使用 MCP 查询,而不是全文 grep。
|
||||
8. 扩展升级后,MCP 配置仍然有效,Skill 能自动同步到已安装位置。
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# 第二部分:二期计划(讨论结论 + 实施)
|
||||
|
||||
> 讨论时间:2026-09-10
|
||||
> 主题:Skill 的项目识别边界、正向/间接引用查询的取舍、MCP 与 localhost 架构现状
|
||||
|
||||
---
|
||||
|
||||
## 8. 讨论结论一:谁来判定"这是不是一个红警 3 模组项目"
|
||||
|
||||
### 问题
|
||||
|
||||
Skill 需要说明"何时应该被使用"和"适用范围"。这要求 Agent 能理解什么是
|
||||
"红警 3 模组项目里的 XML",同时避免把无关项目误判为 RA3 模组。
|
||||
|
||||
问题在于:这个判断应该由扩展/Skill 自己解释(甚至给出推断规则),还是应该
|
||||
由项目开发者在自己的项目文档 / `AGENTS.md` 里声明?
|
||||
|
||||
### 结论:分层,且 Skill 不做复杂推断
|
||||
|
||||
| 层 | 职责 | 理由 |
|
||||
|---|---|---|
|
||||
| **Skill** | 说明能力覆盖范围 + 少量正/负信号 + 便宜的探测方式 | Skill 是用户全局的,会在**所有**对话里加载,必须简短且能自我限制 |
|
||||
| **项目开发者**(`AGENTS.md` / README / 项目标记) | 声明"这个仓库是什么项目、要不要用这些工具" | 只有项目自己知道自己是什么,这是每个仓库的局部事实 |
|
||||
| **MCP 工具自身** | 用响应证明自己是否可用 | `get_status` 是最便宜的判定,不应靠读文档猜 |
|
||||
|
||||
### 为什么不把判断规则写进 Skill
|
||||
|
||||
- Skill 在 `~/.agents/skills/` 下全局生效;如果写一堆判定规则,会在与 RA3
|
||||
无关的项目里制造"要不要用这个工具"的注意力噪音。
|
||||
- 规则永远不完备。真实反例:
|
||||
- 目录里有 `Schemas/xsd/CnC3Types.xsd` → 可能只是 SDK 本身,不是 mod;
|
||||
- 只有一堆从 SDK 抄来的 `.xsd` → 是"RA3 相关",但没有 mod 数据;
|
||||
- `CnC3Types.xsd` 里的 `CnC3` 指 **C&C3**(命令与征服 3 / 凯恩之怒),
|
||||
所以"存在 CnC3Types.xsd" **不等于** "是 RA3 模组";
|
||||
- 有 `Data/Mod.xml` / `*.babproj` / `Data/additionalmaps/mapmetadata_*.xml`
|
||||
→ 基本可确认是 **SAGE / BinaryAssetBuilder** 系项目(RA3 只是其中之一)。
|
||||
- 一旦误判,Agent 会拿 RA3 的索引数据去回答另一个项目的问题,比不使用更糟。
|
||||
|
||||
### 落地方案
|
||||
|
||||
**Skill 侧**(`src/agent/skill.ts` 的 `SKILL_MD`)新增 applicability 段落:
|
||||
|
||||
- 正向信号(任一命中即可尝试):
|
||||
`Data/Mod.xml`;`Data/additionalmaps/mapmetadata_*.xml`;`*.babproj`;
|
||||
XML 根为 `<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. **丢失 provenance,Agent 仍然要读源码。** 到底是 `FireWeaponUpdate` 的武器、
|
||||
`WeaponSetUpdate` 的武器、`CreateObjectDie` 临时物体的武器,还是
|
||||
`inheritFrom` 继承来的武器?这决定了 Agent 改哪个文件、加不加
|
||||
`xai:joinAction`。丢掉上下文,列表基本没用。
|
||||
2. **尺寸会失控。** 一个 GameObject 的可达闭包可能是
|
||||
`Weapon → Projectile → Warhead → DamageNugget → FX → Particle → Texture`、
|
||||
`Model → Mesh → Material → Texture`、`AttributeModifier`、`Upgrade`、`Command`、
|
||||
`Button`……Corona 规模下单个单位几百到上千节点完全可能。200 条边就是几万
|
||||
token,**比直接读 3 个小 XML 文件贵得多**。
|
||||
3. **注意力噪音。** 列表里 80% 是与当前问题无关的 Texture / FX 时,推理质量下降。
|
||||
4. **"间接"的诱惑会持续膨胀。** 一旦提供无界闭包,Agent 会习惯性调用它,
|
||||
然后被淹没。
|
||||
|
||||
**但以下特性确实值得工具化:**
|
||||
|
||||
- 多跳遍历是**纯机械**的,正是工具该做的事;
|
||||
- `inheritFrom` 链 + `xai:joinAction` 语义很微妙,Agent 手推容易错;
|
||||
- 每跳都 read 一个文件,Corona 下成本和 token 都很高;
|
||||
- 工具可以做到**确定性、不遗漏**(按 XSD 判定"哪些属性是引用",
|
||||
而不是靠 Agent 逐个注意)。
|
||||
|
||||
**设计要点(二期实现遵循):**
|
||||
|
||||
- 返回**边列表**而不是节点集合。每条边携带:
|
||||
- `from`(effective asset)、`to`(解析出的定义位置)、
|
||||
- `via.kind`(`attribute` / `content` / `inheritFrom`)、
|
||||
- `via.element`、`via.parent`、`via.attribute`,
|
||||
- `source.file` / `source.line`(XML 实际所在位置),
|
||||
- `definedIn`(当该边来自 `inheritFrom` 祖先的 XML 时,标明真正写它的资产)。
|
||||
- **默认 `depth: 1`,硬上限 3。** Agent 拿到直接引用后自己决定是否对某个具体
|
||||
节点递归,比一次性 dump 闭包更省 token、更有针对性。
|
||||
- **`targetTypes` 过滤**是抑制噪音最有效的手段。雅典娜炮场景就是
|
||||
`targetTypes: ["WeaponTemplate"]`(可选再带 `GameObject` 给 `CreateObjectDie`),
|
||||
返回可能只有 3–10 条边。
|
||||
- **`inheritFrom` 不消耗 depth**:继承是资产自身定义的一部分,不是运行时引用。
|
||||
因此在同一 depth 内递归遍历继承链(链长上限 8),并把 `definedIn` 标出来。
|
||||
- **截断时返回 `omittedByTargetType` 摘要**,让 Agent 知道被砍掉了什么形状的数据,
|
||||
可以精确下钻,而不是无脑重试或放弃。
|
||||
- **不提供"合并后的有效值"**:`joinAction` 的 Replace/Remove/merge 语义算错的风险
|
||||
太高,一期不做。
|
||||
|
||||
**关于爆炸和成本:**
|
||||
|
||||
- 算法上不是指数:带 visited-set 的 BFS 是 `O(V+E)`。真正的风险是**语义宽度**,
|
||||
不是复杂度,因此用 `depth` + `targetTypes` + `maxEdges` 三重限制。
|
||||
- **只做 live 查询**(VS Code 打开时可用)。要拿元素上下文,最干净的做法是按需
|
||||
解析定义所在文件的 DOM,而不是往 records 缓存里塞元素名/属性名 —— Corona 有
|
||||
~98k 条引用记录,再塞 5 个字符串字段会让常驻内存明显膨胀,为一个默认 `depth=1`
|
||||
的功能付出这个成本不划算。
|
||||
- 离线快照路径明确返回"需要 live index",而不是静默返回空结果。
|
||||
|
||||
---
|
||||
|
||||
## 10. 讨论结论三:MCP / localhost 的实际架构与多窗口缺陷
|
||||
|
||||
### 当前架构(事实)
|
||||
|
||||
```text
|
||||
AI 客户端
|
||||
│ stdio (JSON-RPC, MCP)
|
||||
▼
|
||||
dist/agent/mcpServer.js ← 由 AI 客户端启动,参数 --project <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、关闭时只清自己写过的那些。
|
||||
|
||||
### 二期 B:Skill 适用范围
|
||||
|
||||
- `src/agent/skill.ts`:`SKILL_MD` 增加 applicability 段落(正向信号、明确否定、
|
||||
`get_status` 探针);保持"不引用工作区文档"的原则。
|
||||
|
||||
### 二期 C:`get_asset_references`
|
||||
|
||||
- `src/agent/forwardRefs.ts`(新增):DOM-based、有界、带 provenance 的正向引用遍历。
|
||||
- `src/agent/localServer.ts`:新增 `GET /get_asset_references`。
|
||||
- `src/agent/mcpServer.ts`:新增 `get_asset_references` 工具(live-only,
|
||||
离线时返回明确的"需要 live index"说明)。
|
||||
- `src/extension.ts`:为 live server 提供 `loadFile()`(读文本 + 解析 + LineMap)。
|
||||
|
||||
### 二期 D:测试
|
||||
|
||||
- `test/agentEndpoint.test.mjs`:按项目 endpoint、进程存活、清理隔离。
|
||||
- `test/agentLocalServer.test.mjs`:`?project=` 路由、`projectDir` 校验、
|
||||
`/get_asset_references`。
|
||||
- `test/agentForwardRefs.test.mjs`:直接引用、继承链、`CreateObjectDie` 内容引用、
|
||||
`targetTypes` 过滤、`maxEdges` 截断、`depth` 上限。
|
||||
- `test/agentSkill.test.mjs`:Skill 文本包含 applicability 段落且不含工作区文档引用。
|
||||
- `test/agentMcpRouting.test.mjs`:live URL 固定 `project`、跨项目响应拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 12. 二期实施结果
|
||||
|
||||
全部完成,`npx tsc --noEmit` 通过,`test/*.test.mjs` 共 **264/264** 通过。
|
||||
|
||||
### 新增 / 修改文件
|
||||
|
||||
```text
|
||||
新增:
|
||||
src/agent/forwardRefs.ts 有界、带 provenance 的正向引用遍历
|
||||
test/agentForwardRefs.test.mjs
|
||||
test/agentMcpRouting.test.mjs
|
||||
|
||||
修改:
|
||||
src/agent/endpoint.ts 按项目 endpoint + isProcessAlive()
|
||||
src/agent/snapshot.ts projectHash() / projectSlug() / snapshotBaseName()
|
||||
src/agent/liveQuery.ts liveStatus() 回落请求的 projectDir
|
||||
src/agent/localServer.ts 异步 handler、?project= 路由、/projects、/get_asset_references
|
||||
src/agent/mcpServer.ts 按项目 endpoint、projectDir 校验、负缓存、get_asset_references
|
||||
src/agent/skill.ts applicability 段落 + get_asset_references 用法
|
||||
src/workspace.ts 新增 indexForProject()
|
||||
src/extension.ts 按项目写/清 endpoint、loadFile、endpoint 刷新
|
||||
test/agentEndpoint.test.mjs
|
||||
test/agentLocalServer.test.mjs
|
||||
test/agentSkill.test.mjs
|
||||
```
|
||||
|
||||
### 关键行为
|
||||
|
||||
**多窗口 / 多项目**
|
||||
|
||||
- `~/.ra3modxml/endpoints/<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
@@ -3,9 +3,14 @@ import * as esbuild from "esbuild";
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["./src/extension.ts"],
|
||||
entryPoints: [
|
||||
"./src/extension.ts",
|
||||
"./src/agent/mcpServer.ts",
|
||||
"./src/agent/cli.ts",
|
||||
],
|
||||
bundle: true,
|
||||
outfile: "dist/extension.js",
|
||||
outdir: "dist",
|
||||
outbase: "src",
|
||||
external: ["vscode"],
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
|
||||
@@ -126,6 +126,18 @@
|
||||
"command": "ra3modxml.showCacheReport",
|
||||
"title": "%ra3modxml.command.showCacheReport.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.enableAgentAccess",
|
||||
"title": "%ra3modxml.command.enableAgentAccess.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.installAgentSkill",
|
||||
"title": "%ra3modxml.command.installAgentSkill.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.exportIndexSnapshot",
|
||||
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.findUnreferencedAssets",
|
||||
"title": "%ra3modxml.command.findUnreferencedAssets.title%"
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild",
|
||||
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
|
||||
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report",
|
||||
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: Enable AI Agent access…",
|
||||
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: Install Agent Skill…",
|
||||
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot",
|
||||
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
|
||||
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建",
|
||||
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
|
||||
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告",
|
||||
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: 启用 AI Agent 访问…",
|
||||
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: 安装 Agent Skill…",
|
||||
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照",
|
||||
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
|
||||
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] : [];
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
@@ -1,4 +1,7 @@
|
||||
import * as vscode from "vscode";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { stripBom } from "./language/xmlParser";
|
||||
import { ModWorkspace } from "./workspace";
|
||||
import { SdkSetup } from "./sdkSetup";
|
||||
import { Ra3CompletionProvider } from "./features/completion";
|
||||
@@ -21,6 +24,36 @@ import {
|
||||
RA3_SEMANTIC_TOKENS_LEGEND,
|
||||
} from "./features/semanticTokens";
|
||||
import { t } from "./localize";
|
||||
import {
|
||||
snapshotFromIndex,
|
||||
snapshotPathForProject,
|
||||
writeSnapshotFile,
|
||||
} from "./agent/snapshot";
|
||||
import {
|
||||
addMcpServerToConfigFile,
|
||||
claudeDesktopConfigPath,
|
||||
cursorGlobalConfigPath,
|
||||
cursorProjectConfigPath,
|
||||
mcpConfigJson,
|
||||
writeLauncher,
|
||||
} from "./agent/setup";
|
||||
import {
|
||||
SKILL_NAME,
|
||||
agentsSkillsDirForUser,
|
||||
claudeSkillsDirForUser,
|
||||
installSkillToDirectories,
|
||||
readSkillInstallRecord,
|
||||
writeSkillInstallRecord,
|
||||
writeSkillTo,
|
||||
} from "./agent/skill";
|
||||
import { startLocalServer, type LocalServerHandle } from "./agent/localServer";
|
||||
import { parseLoadedXml } from "./agent/forwardRefs";
|
||||
import {
|
||||
clearEndpoint,
|
||||
clearEndpointForProject,
|
||||
writeEndpoint,
|
||||
writeEndpointForProject,
|
||||
} from "./agent/endpoint";
|
||||
|
||||
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
|
||||
/** Safety-net refresh interval while a rebuild is running. */
|
||||
@@ -96,9 +129,126 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
ws.log("[codelens] retry started");
|
||||
};
|
||||
ws.onBuildStart = startCodeLensRetry;
|
||||
// Coalesced agent snapshot refresh: after AI Agent access is enabled, keep
|
||||
// the external snapshot current without writing on every intermediate
|
||||
// rebuild. The timer only fires after a quiet period following a complete,
|
||||
// non-stale final index.
|
||||
let agentAccessEnabled = context.workspaceState.get<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({
|
||||
dispose: () => {
|
||||
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
|
||||
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
|
||||
void stopAgentLocalServer();
|
||||
},
|
||||
});
|
||||
context.subscriptions.push(
|
||||
@@ -123,6 +273,8 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
`[codelens] refresh (project=${idx.stats.projectDir}, phase=${idx.phase}, assets=${idx.stats.assetCount}, complete=${idx.complete}, stale=${idx.stale === true})`,
|
||||
);
|
||||
}
|
||||
scheduleAgentSnapshot();
|
||||
void refreshAgentEndpoints();
|
||||
for (const doc of vscode.workspace.textDocuments) {
|
||||
if (doc.languageId === "xml") void diagnostics.update(doc);
|
||||
}
|
||||
@@ -282,6 +434,190 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.exportIndexSnapshot", async () => {
|
||||
const idx = ws.activeIndex();
|
||||
if (!idx) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const path = snapshotPathForProject(idx.projectDir);
|
||||
try {
|
||||
const snapshot = snapshotFromIndex(idx, ws.buildCount);
|
||||
await writeSnapshotFile(path, snapshot);
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: exported AI Agent index snapshot to {0}",
|
||||
path,
|
||||
),
|
||||
t("Reveal in Explorer"),
|
||||
).then((pick) => {
|
||||
if (pick) void vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(path));
|
||||
});
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(
|
||||
t(
|
||||
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.enableAgentAccess", async () => {
|
||||
const idx = ws.activeIndex();
|
||||
if (!idx) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const projectDir = idx.projectDir;
|
||||
try {
|
||||
const snapshot = snapshotFromIndex(idx, ws.buildCount);
|
||||
await writeSnapshotFile(snapshotPathForProject(projectDir), snapshot);
|
||||
const launcher = await writeLauncher(
|
||||
context.extensionUri.fsPath,
|
||||
projectDir,
|
||||
);
|
||||
const configJson = mcpConfigJson(launcher, projectDir);
|
||||
agentAccessEnabled = true;
|
||||
await context.workspaceState.update("ra3modxml.agentAccessEnabled", true);
|
||||
void startAgentLocalServer();
|
||||
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
|
||||
const installSkill = t("Install Agent Skill (recommended)");
|
||||
const writeClaude = t("Write MCP config to Claude Desktop");
|
||||
const writeCursorGlobal = t("Write MCP config to Cursor (global)");
|
||||
const writeCursorProject = t("Write MCP config to Cursor (project)");
|
||||
const copyConfig = t("Copy MCP config");
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
[
|
||||
{
|
||||
label: installSkill,
|
||||
description: agentsSkillsDirForUser(),
|
||||
id: "skill",
|
||||
},
|
||||
{
|
||||
label: writeClaude,
|
||||
description: claudeDesktopConfigPath(),
|
||||
id: "claude",
|
||||
},
|
||||
{
|
||||
label: writeCursorGlobal,
|
||||
description: cursorGlobalConfigPath(),
|
||||
id: "cursor-global",
|
||||
},
|
||||
{
|
||||
label: writeCursorProject,
|
||||
description: cursorProjectConfigPath(projectDir),
|
||||
id: "cursor-project",
|
||||
},
|
||||
{
|
||||
label: copyConfig,
|
||||
id: "copy",
|
||||
},
|
||||
],
|
||||
{
|
||||
placeHolder: t("RA3 Mod XML AI Agent access enabled. Choose an optional next step."),
|
||||
},
|
||||
);
|
||||
if (pick?.id === "skill") {
|
||||
const target = agentsSkillsDirForUser();
|
||||
await writeSkillTo(target, version);
|
||||
const installed = await readSkillInstallRecord();
|
||||
if (!installed.some((r) => r.path === target)) installed.push({ path: target, sourceVersion: version });
|
||||
await writeSkillInstallRecord(installed);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML Agent Skill installed to {0}", target),
|
||||
);
|
||||
} else if (pick?.id === "claude") {
|
||||
await addMcpServerToConfigFile(claudeDesktopConfigPath(), launcher, projectDir);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
|
||||
);
|
||||
} else if (pick?.id === "cursor-global") {
|
||||
await addMcpServerToConfigFile(cursorGlobalConfigPath(), launcher, projectDir);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
|
||||
);
|
||||
} else if (pick?.id === "cursor-project") {
|
||||
await addMcpServerToConfigFile(cursorProjectConfigPath(projectDir), launcher, projectDir);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
|
||||
);
|
||||
} else if (pick?.id === "copy") {
|
||||
await vscode.env.clipboard.writeText(configJson);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config copied to clipboard."),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(
|
||||
t(
|
||||
"RA3 Mod XML: failed to enable AI Agent access: {0}",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
|
||||
const idx = ws.activeIndex();
|
||||
if (!idx) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: no index available yet. Open and index a project before installing the Agent Skill.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const projectDir = idx.projectDir;
|
||||
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
|
||||
const choices = [
|
||||
{
|
||||
label: t("Default (~/.agents/skills)"),
|
||||
description: agentsSkillsDirForUser(),
|
||||
path: agentsSkillsDirForUser(),
|
||||
picked: true,
|
||||
},
|
||||
{
|
||||
label: t("Claude Code (~/.claude/skills)"),
|
||||
description: claudeSkillsDirForUser(),
|
||||
path: claudeSkillsDirForUser(),
|
||||
},
|
||||
{
|
||||
label: t("Current project .agents/skills"),
|
||||
description: join(projectDir, ".agents", "skills", SKILL_NAME),
|
||||
path: join(projectDir, ".agents", "skills", SKILL_NAME),
|
||||
},
|
||||
{
|
||||
label: t("Current project .claude/skills"),
|
||||
description: join(projectDir, ".claude", "skills", SKILL_NAME),
|
||||
path: join(projectDir, ".claude", "skills", SKILL_NAME),
|
||||
},
|
||||
];
|
||||
const picked = await vscode.window.showQuickPick(choices, {
|
||||
canPickMany: true,
|
||||
placeHolder: t("Select Agent Skill install locations"),
|
||||
});
|
||||
if (!picked?.length) return;
|
||||
const succeeded = await installSkillToDirectories(
|
||||
picked.map((p) => p.path),
|
||||
version,
|
||||
);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML Agent Skill installed to {0} location(s).", succeeded.length),
|
||||
);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"ra3modxml.showReferences",
|
||||
@@ -302,8 +638,12 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
),
|
||||
);
|
||||
|
||||
if (agentAccessEnabled) void startAgentLocalServer();
|
||||
void sdkSetup.evaluate(ws);
|
||||
void ws.initialize().then(() => void sdkSetup.evaluate(ws));
|
||||
void ws.initialize().then(() => {
|
||||
void sdkSetup.evaluate(ws);
|
||||
if (agentAccessEnabled) void startAgentLocalServer();
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
|
||||
@@ -229,6 +229,18 @@ export class ModWorkspace {
|
||||
return this.activeState()?.indexer ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of one specific project (normalized absolute root), or null.
|
||||
*
|
||||
* The live agent server routes queries through this instead of
|
||||
* `activeIndex()`, so a query for project A can never be answered from
|
||||
* whatever project the user happens to be editing right now.
|
||||
*/
|
||||
indexForProject(projectDir: string): ModIndex | null {
|
||||
if (!projectDir) return null;
|
||||
return this.states.get(normKey(projectDir))?.index ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers project roots from the current workspace folders and open
|
||||
* documents, registers per-project state and starts the initial build(s):
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user