ai agent
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user