@cortexkit/aft-opencode 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/bridge.d.ts.map +1 -1
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +25413 -182
  6. package/dist/notifications.d.ts +48 -0
  7. package/dist/notifications.d.ts.map +1 -0
  8. package/dist/onnx-runtime.d.ts +35 -0
  9. package/dist/onnx-runtime.d.ts.map +1 -0
  10. package/dist/pool.d.ts +2 -0
  11. package/dist/pool.d.ts.map +1 -1
  12. package/dist/resolver.d.ts.map +1 -1
  13. package/dist/shared/opencode-config-dir.d.ts +16 -0
  14. package/dist/shared/opencode-config-dir.d.ts.map +1 -0
  15. package/dist/shared/rpc-client.d.ts +16 -0
  16. package/dist/shared/rpc-client.d.ts.map +1 -0
  17. package/dist/shared/rpc-server.d.ts +17 -0
  18. package/dist/shared/rpc-server.d.ts.map +1 -0
  19. package/dist/shared/rpc-utils.d.ts +11 -0
  20. package/dist/shared/rpc-utils.d.ts.map +1 -0
  21. package/dist/shared/runtime.d.ts +10 -0
  22. package/dist/shared/runtime.d.ts.map +1 -0
  23. package/dist/shared/status.d.ts +37 -0
  24. package/dist/shared/status.d.ts.map +1 -0
  25. package/dist/shared/tui-config.d.ts +2 -0
  26. package/dist/shared/tui-config.d.ts.map +1 -0
  27. package/dist/shared/url-fetch.d.ts +11 -0
  28. package/dist/shared/url-fetch.d.ts.map +1 -0
  29. package/dist/tools/reading.d.ts.map +1 -1
  30. package/dist/tools/search.d.ts.map +1 -1
  31. package/dist/tools/semantic.d.ts +4 -0
  32. package/dist/tools/semantic.d.ts.map +1 -0
  33. package/dist/types.d.ts +2 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +29 -9
  36. package/src/shared/opencode-config-dir.ts +46 -0
  37. package/src/shared/rpc-client.ts +123 -0
  38. package/src/shared/rpc-server.ts +135 -0
  39. package/src/shared/rpc-utils.ts +21 -0
  40. package/src/shared/runtime.ts +26 -0
  41. package/src/shared/status.ts +206 -0
  42. package/src/shared/tui-config.ts +58 -0
  43. package/src/shared/url-fetch.ts +237 -0
  44. package/src/tui/index.tsx +209 -0
  45. package/src/tui/types/opencode-plugin-tui.d.ts +232 -0
  46. package/dist/bridge.js +0 -311
  47. package/dist/bridge.js.map +0 -1
  48. package/dist/config.js +0 -302
  49. package/dist/config.js.map +0 -1
  50. package/dist/downloader.js +0 -198
  51. package/dist/downloader.js.map +0 -1
  52. package/dist/index.js.map +0 -1
  53. package/dist/logger.js +0 -78
  54. package/dist/logger.js.map +0 -1
  55. package/dist/lsp.js +0 -79
  56. package/dist/lsp.js.map +0 -1
  57. package/dist/metadata-store.js +0 -53
  58. package/dist/metadata-store.js.map +0 -1
  59. package/dist/normalize-schemas.js +0 -45
  60. package/dist/normalize-schemas.js.map +0 -1
  61. package/dist/patch-parser.js +0 -247
  62. package/dist/patch-parser.js.map +0 -1
  63. package/dist/platform.js +0 -31
  64. package/dist/platform.js.map +0 -1
  65. package/dist/pool.js +0 -111
  66. package/dist/pool.js.map +0 -1
  67. package/dist/resolver.js +0 -168
  68. package/dist/resolver.js.map +0 -1
  69. package/dist/tools/ast.js +0 -200
  70. package/dist/tools/ast.js.map +0 -1
  71. package/dist/tools/conflicts.js +0 -20
  72. package/dist/tools/conflicts.js.map +0 -1
  73. package/dist/tools/hoisted.js +0 -870
  74. package/dist/tools/hoisted.js.map +0 -1
  75. package/dist/tools/imports.js +0 -90
  76. package/dist/tools/imports.js.map +0 -1
  77. package/dist/tools/lsp.js +0 -58
  78. package/dist/tools/lsp.js.map +0 -1
  79. package/dist/tools/navigation.js +0 -58
  80. package/dist/tools/navigation.js.map +0 -1
  81. package/dist/tools/permissions.js +0 -50
  82. package/dist/tools/permissions.js.map +0 -1
  83. package/dist/tools/reading.js +0 -229
  84. package/dist/tools/reading.js.map +0 -1
  85. package/dist/tools/refactoring.js +0 -129
  86. package/dist/tools/refactoring.js.map +0 -1
  87. package/dist/tools/safety.js +0 -83
  88. package/dist/tools/safety.js.map +0 -1
  89. package/dist/tools/search.js +0 -88
  90. package/dist/tools/search.js.map +0 -1
  91. package/dist/tools/structure.js +0 -157
  92. package/dist/tools/structure.js.map +0 -1
  93. package/dist/types.js +0 -2
  94. package/dist/types.js.map +0 -1
@@ -0,0 +1,135 @@
1
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
+ import { dirname } from "node:path";
4
+ import { log, warn } from "../logger.js";
5
+ import { rpcPortFilePath } from "./rpc-utils.js";
6
+
7
+ type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
8
+
9
+ export class AftRpcServer {
10
+ private server: Server | null = null;
11
+ private port = 0;
12
+ private handlers = new Map<string, RpcHandler>();
13
+ private portFilePath: string;
14
+
15
+ constructor(storageDir: string, directory: string) {
16
+ this.portFilePath = rpcPortFilePath(storageDir, directory);
17
+ }
18
+
19
+ /** Register an RPC method handler. */
20
+ handle(method: string, handler: RpcHandler): void {
21
+ this.handlers.set(method, handler);
22
+ }
23
+
24
+ /** Start the server on a random port, write port to disk. */
25
+ async start(): Promise<number> {
26
+ return new Promise((resolve, reject) => {
27
+ const server = createServer((req, res) => this.dispatch(req, res));
28
+
29
+ server.on("error", (err) => {
30
+ warn(`RPC server error: ${err.message}`);
31
+ reject(err);
32
+ });
33
+
34
+ server.listen(0, "127.0.0.1", () => {
35
+ const addr = server.address();
36
+ if (!addr || typeof addr === "string") {
37
+ reject(new Error("Failed to get server address"));
38
+ return;
39
+ }
40
+ this.port = addr.port;
41
+ this.server = server;
42
+
43
+ // Write port file atomically
44
+ try {
45
+ const dir = dirname(this.portFilePath);
46
+ mkdirSync(dir, { recursive: true });
47
+ const tmpPath = `${this.portFilePath}.tmp`;
48
+ writeFileSync(tmpPath, String(this.port), "utf-8");
49
+ renameSync(tmpPath, this.portFilePath);
50
+ log(`RPC server listening on 127.0.0.1:${this.port}`);
51
+ } catch (err) {
52
+ warn(`Failed to write RPC port file: ${err}`);
53
+ }
54
+
55
+ resolve(this.port);
56
+ });
57
+
58
+ // Don't keep the process alive just for the RPC server
59
+ server.unref();
60
+ });
61
+ }
62
+
63
+ /** Stop the server and clean up port file. */
64
+ stop(): void {
65
+ if (this.server) {
66
+ this.server.close();
67
+ this.server = null;
68
+ }
69
+ try {
70
+ unlinkSync(this.portFilePath);
71
+ } catch {
72
+ // ignore
73
+ }
74
+ }
75
+
76
+ private dispatch(req: IncomingMessage, res: ServerResponse): void {
77
+ const url = req.url ?? "";
78
+
79
+ if (req.method === "GET" && url === "/health") {
80
+ res.writeHead(200, { "Content-Type": "application/json" });
81
+ res.end(JSON.stringify({ ok: true, pid: process.pid }));
82
+ return;
83
+ }
84
+
85
+ if (req.method !== "POST" || !url.startsWith("/rpc/")) {
86
+ res.writeHead(404);
87
+ res.end("Not Found");
88
+ return;
89
+ }
90
+
91
+ const method = url.slice(5);
92
+ const handler = this.handlers.get(method);
93
+ if (!handler) {
94
+ res.writeHead(404, { "Content-Type": "application/json" });
95
+ res.end(JSON.stringify({ error: `Unknown method: ${method}` }));
96
+ return;
97
+ }
98
+
99
+ let body = "";
100
+ req.on("data", (chunk: Buffer) => {
101
+ body += chunk.toString();
102
+ if (body.length > 1_048_576) {
103
+ res.writeHead(413);
104
+ res.end("Request too large");
105
+ req.destroy();
106
+ }
107
+ });
108
+
109
+ req.on("end", () => {
110
+ let params: Record<string, unknown> = {};
111
+ try {
112
+ if (body.length > 0) {
113
+ params = JSON.parse(body);
114
+ }
115
+ } catch {
116
+ res.writeHead(400, { "Content-Type": "application/json" });
117
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
118
+ return;
119
+ }
120
+
121
+ log(`RPC call: ${method} params=${JSON.stringify(params).slice(0, 200)}`);
122
+ handler(params)
123
+ .then((result) => {
124
+ log(`RPC result: ${method} => ${JSON.stringify(result).slice(0, 200)}`);
125
+ res.writeHead(200, { "Content-Type": "application/json" });
126
+ res.end(JSON.stringify(result));
127
+ })
128
+ .catch((err) => {
129
+ log(`RPC error: ${method} => ${err}`);
130
+ res.writeHead(500, { "Content-Type": "application/json" });
131
+ res.end(JSON.stringify({ error: String(err) }));
132
+ });
133
+ });
134
+ }
135
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Compute a stable hash for a project directory.
6
+ * Used to scope RPC port files per-project so multiple
7
+ * OpenCode Desktop instances don't overwrite each other.
8
+ */
9
+ export function projectHash(directory: string): string {
10
+ // Normalize: strip trailing slashes
11
+ const normalized = directory.replace(/\/+$/, "");
12
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
13
+ }
14
+
15
+ /**
16
+ * Get the per-project RPC port file path.
17
+ */
18
+ export function rpcPortFilePath(storageDir: string, directory: string): string {
19
+ const hash = projectHash(directory);
20
+ return join(storageDir, "rpc", hash, "port");
21
+ }
@@ -0,0 +1,26 @@
1
+ type BridgeLike = {
2
+ getBridge: (
3
+ directory: string,
4
+ sessionID: string,
5
+ ) => {
6
+ send: (command: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
7
+ };
8
+ };
9
+
10
+ const GLOBAL_KEY = "__AFT_SHARED_BRIDGE_POOL__";
11
+
12
+ function getGlobalState(): { [GLOBAL_KEY]?: BridgeLike | null } {
13
+ return globalThis as { [GLOBAL_KEY]?: BridgeLike | null };
14
+ }
15
+
16
+ export function setSharedBridgePool(pool: BridgeLike): void {
17
+ getGlobalState()[GLOBAL_KEY] = pool;
18
+ }
19
+
20
+ export function getSharedBridgePool(): BridgeLike | null {
21
+ return getGlobalState()[GLOBAL_KEY] ?? null;
22
+ }
23
+
24
+ export function clearSharedBridgePool(): void {
25
+ getGlobalState()[GLOBAL_KEY] = null;
26
+ }
@@ -0,0 +1,206 @@
1
+ export interface AftStatusSnapshot {
2
+ version: string;
3
+ project_root: string | null;
4
+ features: {
5
+ format_on_edit: boolean;
6
+ validate_on_edit: string;
7
+ restrict_to_project_root: boolean;
8
+ experimental_search_index: boolean;
9
+ experimental_semantic_search: boolean;
10
+ };
11
+ search_index: {
12
+ status: string;
13
+ files: number | null;
14
+ trigrams: number | null;
15
+ };
16
+ semantic_index: {
17
+ status: string;
18
+ entries: number | null;
19
+ dimension: number | null;
20
+ };
21
+ disk: {
22
+ storage_dir: string | null;
23
+ trigram_disk_bytes: number;
24
+ semantic_disk_bytes: number;
25
+ };
26
+ lsp_servers: number;
27
+ symbol_cache: {
28
+ local_entries: number;
29
+ warm_entries: number;
30
+ };
31
+ storage_dir: string | null;
32
+ }
33
+
34
+ function asRecord(value: unknown): Record<string, unknown> {
35
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
36
+ }
37
+
38
+ function readString(value: unknown, fallback = ""): string {
39
+ return typeof value === "string" ? value : fallback;
40
+ }
41
+
42
+ function readNullableString(value: unknown): string | null {
43
+ return typeof value === "string" ? value : null;
44
+ }
45
+
46
+ function readBoolean(value: unknown, fallback = false): boolean {
47
+ return typeof value === "boolean" ? value : fallback;
48
+ }
49
+
50
+ function readNumber(value: unknown, fallback = 0): number {
51
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
52
+ }
53
+
54
+ function readOptionalNumber(value: unknown): number | null {
55
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
56
+ }
57
+
58
+ function formatFlag(enabled: boolean): string {
59
+ return enabled ? "enabled" : "disabled";
60
+ }
61
+
62
+ function formatCount(value: number | null): string {
63
+ return value == null ? "—" : value.toLocaleString("en-US");
64
+ }
65
+
66
+ export function formatBytes(bytes: number): string {
67
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
68
+ const units = ["B", "KB", "MB", "GB", "TB"];
69
+ let value = bytes;
70
+ let unitIndex = 0;
71
+
72
+ while (value >= 1024 && unitIndex < units.length - 1) {
73
+ value /= 1024;
74
+ unitIndex++;
75
+ }
76
+
77
+ const decimals = value >= 10 || unitIndex === 0 ? 0 : 1;
78
+ return `${value.toFixed(decimals)} ${units[unitIndex]}`;
79
+ }
80
+
81
+ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSnapshot {
82
+ const features = asRecord(response.features);
83
+ const searchIndex = asRecord(response.search_index);
84
+ const semanticIndex = asRecord(response.semantic_index);
85
+ const disk = asRecord(response.disk);
86
+ const symbolCache = asRecord(response.symbol_cache);
87
+
88
+ return {
89
+ version: readString(response.version, "unknown"),
90
+ project_root: readNullableString(response.project_root),
91
+ features: {
92
+ format_on_edit: readBoolean(features.format_on_edit),
93
+ validate_on_edit: readString(features.validate_on_edit, "off"),
94
+ restrict_to_project_root: readBoolean(features.restrict_to_project_root),
95
+ experimental_search_index: readBoolean(features.experimental_search_index),
96
+ experimental_semantic_search: readBoolean(features.experimental_semantic_search),
97
+ },
98
+ search_index: {
99
+ status: readString(searchIndex.status, "unknown"),
100
+ files: readOptionalNumber(searchIndex.files),
101
+ trigrams: readOptionalNumber(searchIndex.trigrams),
102
+ },
103
+ semantic_index: {
104
+ status: readString(semanticIndex.status, "unknown"),
105
+ entries: readOptionalNumber(semanticIndex.entries),
106
+ dimension: readOptionalNumber(semanticIndex.dimension),
107
+ },
108
+ disk: {
109
+ storage_dir: readNullableString(disk.storage_dir),
110
+ trigram_disk_bytes: readNumber(disk.trigram_disk_bytes),
111
+ semantic_disk_bytes: readNumber(disk.semantic_disk_bytes),
112
+ },
113
+ lsp_servers: readNumber(response.lsp_servers),
114
+ symbol_cache: {
115
+ local_entries: readNumber(symbolCache.local_entries),
116
+ warm_entries: readNumber(symbolCache.warm_entries),
117
+ },
118
+ storage_dir: readNullableString(response.storage_dir),
119
+ };
120
+ }
121
+
122
+ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
123
+ const lines = [
124
+ `AFT version: ${status.version}`,
125
+ `Project root: ${status.project_root ?? "(not configured)"}`,
126
+ "",
127
+ "Enabled features",
128
+ `- format_on_edit: ${formatFlag(status.features.format_on_edit)}`,
129
+ `- experimental_search_index: ${formatFlag(status.features.experimental_search_index)}`,
130
+ `- experimental_semantic_search: ${formatFlag(status.features.experimental_semantic_search)}`,
131
+ "",
132
+ "Search index",
133
+ `- status: ${status.search_index.status}`,
134
+ `- files: ${formatCount(status.search_index.files)}`,
135
+ `- trigrams: ${formatCount(status.search_index.trigrams)}`,
136
+ "",
137
+ "Semantic index",
138
+ `- status: ${status.semantic_index.status}`,
139
+ `- entries: ${formatCount(status.semantic_index.entries)}`,
140
+ ];
141
+
142
+ if (status.semantic_index.dimension != null) {
143
+ lines.push(`- dimension: ${formatCount(status.semantic_index.dimension)}`);
144
+ }
145
+
146
+ lines.push(
147
+ "",
148
+ "Disk usage",
149
+ `- trigram index: ${formatBytes(status.disk.trigram_disk_bytes)}`,
150
+ `- semantic index: ${formatBytes(status.disk.semantic_disk_bytes)}`,
151
+ "",
152
+ "Runtime",
153
+ `- LSP servers: ${formatCount(status.lsp_servers)}`,
154
+ `- symbol cache: ${formatCount(status.symbol_cache.local_entries)} local / ${formatCount(status.symbol_cache.warm_entries)} warm`,
155
+ );
156
+
157
+ if (status.storage_dir ?? status.disk.storage_dir) {
158
+ lines.push(`- storage dir: ${status.storage_dir ?? status.disk.storage_dir}`);
159
+ }
160
+
161
+ return lines.join("\n");
162
+ }
163
+
164
+ export function formatStatusMarkdown(status: AftStatusSnapshot): string {
165
+ const lines = [
166
+ "## AFT Status",
167
+ "",
168
+ `- **Version:** \`${status.version}\``,
169
+ `- **Project root:** \`${status.project_root ?? "(not configured)"}\``,
170
+ "",
171
+ "### Enabled features",
172
+ `- \`format_on_edit\`: ${formatFlag(status.features.format_on_edit)}`,
173
+ `- \`experimental_search_index\`: ${formatFlag(status.features.experimental_search_index)}`,
174
+ `- \`experimental_semantic_search\`: ${formatFlag(status.features.experimental_semantic_search)}`,
175
+ "",
176
+ "### Search index",
177
+ `- **Status:** \`${status.search_index.status}\``,
178
+ `- **Files:** ${formatCount(status.search_index.files)}`,
179
+ `- **Trigrams:** ${formatCount(status.search_index.trigrams)}`,
180
+ "",
181
+ "### Semantic index",
182
+ `- **Status:** \`${status.semantic_index.status}\``,
183
+ `- **Entries:** ${formatCount(status.semantic_index.entries)}`,
184
+ ];
185
+
186
+ if (status.semantic_index.dimension != null) {
187
+ lines.push(`- **Dimension:** ${formatCount(status.semantic_index.dimension)}`);
188
+ }
189
+
190
+ lines.push(
191
+ "",
192
+ "### Disk usage",
193
+ `- **Trigram index:** ${formatBytes(status.disk.trigram_disk_bytes)}`,
194
+ `- **Semantic index:** ${formatBytes(status.disk.semantic_disk_bytes)}`,
195
+ "",
196
+ "### Runtime",
197
+ `- **LSP servers:** ${formatCount(status.lsp_servers)}`,
198
+ `- **Symbol cache:** ${formatCount(status.symbol_cache.local_entries)} local / ${formatCount(status.symbol_cache.warm_entries)} warm`,
199
+ );
200
+
201
+ if (status.storage_dir ?? status.disk.storage_dir) {
202
+ lines.push(`- **Storage dir:** \`${status.storage_dir ?? status.disk.storage_dir}\``);
203
+ }
204
+
205
+ return lines.join("\n");
206
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { parse, stringify } from "comment-json";
4
+ import { log } from "../logger.js";
5
+ import { getOpenCodeConfigPaths } from "./opencode-config-dir.js";
6
+
7
+ const PLUGIN_NAME = "@cortexkit/aft-opencode";
8
+ const PLUGIN_ENTRY = `${PLUGIN_NAME}@latest`;
9
+
10
+ function resolveTuiConfigPath(): string {
11
+ const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
12
+ const jsoncPath = join(configDir, "tui.jsonc");
13
+ const jsonPath = join(configDir, "tui.json");
14
+
15
+ if (existsSync(jsoncPath)) return jsoncPath;
16
+ if (existsSync(jsonPath)) return jsonPath;
17
+ return jsonPath;
18
+ }
19
+
20
+ export function ensureTuiPluginEntry(): boolean {
21
+ try {
22
+ const configPath = resolveTuiConfigPath();
23
+
24
+ let config: Record<string, unknown> = {};
25
+ if (existsSync(configPath)) {
26
+ config = (parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>) ?? {};
27
+ }
28
+
29
+ const plugins = Array.isArray(config.plugin)
30
+ ? config.plugin.filter((value): value is string => typeof value === "string")
31
+ : [];
32
+
33
+ if (
34
+ plugins.some(
35
+ (plugin) =>
36
+ plugin === PLUGIN_NAME ||
37
+ plugin.startsWith(`${PLUGIN_NAME}@`) ||
38
+ plugin.includes("opencode-plugin") ||
39
+ plugin.includes("aft-opencode"),
40
+ )
41
+ ) {
42
+ return false;
43
+ }
44
+
45
+ plugins.push(PLUGIN_ENTRY);
46
+ config.plugin = plugins;
47
+
48
+ mkdirSync(dirname(configPath), { recursive: true });
49
+ writeFileSync(configPath, `${stringify(config, null, 2)}\n`);
50
+ log(`[aft-plugin] added TUI plugin entry to ${configPath}`);
51
+ return true;
52
+ } catch (error) {
53
+ log(
54
+ `[aft-plugin] failed to update tui.json: ${error instanceof Error ? error.message : String(error)}`,
55
+ );
56
+ return false;
57
+ }
58
+ }
@@ -0,0 +1,237 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { log, warn } from "../logger.js";
12
+
13
+ /** Max response body size (10 MB) */
14
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
15
+ /** Cache TTL: 1 day */
16
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
17
+ /** Fetch timeout: 30 seconds */
18
+ const FETCH_TIMEOUT_MS = 30_000;
19
+
20
+ interface CacheMeta {
21
+ url: string;
22
+ contentType: string;
23
+ extension: string;
24
+ fetchedAt: number;
25
+ }
26
+
27
+ function cacheDir(storageDir: string): string {
28
+ return join(storageDir, "url_cache");
29
+ }
30
+
31
+ function hashUrl(url: string): string {
32
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
33
+ }
34
+
35
+ function metaPath(storageDir: string, hash: string): string {
36
+ return join(cacheDir(storageDir), `${hash}.meta.json`);
37
+ }
38
+
39
+ function contentPath(storageDir: string, hash: string, extension: string): string {
40
+ return join(cacheDir(storageDir), `${hash}${extension}`);
41
+ }
42
+
43
+ /**
44
+ * Map a Content-Type header to a file extension AFT can parse.
45
+ * Returns null for unsupported types.
46
+ */
47
+ function resolveExtension(contentType: string): string | null {
48
+ // Normalize: strip parameters (after `;`) AND pick the first value if the server
49
+ // echoed back a comma-separated list (GitHub API does this for /readme endpoints).
50
+ const lower = contentType.toLowerCase().split(";")[0].split(",")[0].trim();
51
+ if (
52
+ lower === "text/html" ||
53
+ lower === "application/xhtml+xml" ||
54
+ lower === "application/vnd.github.html" ||
55
+ lower === "application/vnd.github+html"
56
+ ) {
57
+ return ".html";
58
+ }
59
+ if (
60
+ lower === "text/markdown" ||
61
+ lower === "text/x-markdown" ||
62
+ lower === "application/markdown" ||
63
+ // GitHub API raw content type — returns raw markdown for README endpoints
64
+ lower === "application/vnd.github.raw" ||
65
+ lower === "application/vnd.github+raw" ||
66
+ lower === "application/vnd.github.v3.raw"
67
+ ) {
68
+ return ".md";
69
+ }
70
+ if (lower === "text/plain") {
71
+ // treat plain text as markdown so aft_outline can show headings if present
72
+ return ".md";
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Fetch a URL to a cached temp file. Uses disk cache with 1-day TTL.
79
+ * Returns the cached file path the Rust outline/zoom command can read.
80
+ * Throws on errors (invalid URL, network failure, unsupported content type, oversized body).
81
+ */
82
+ export async function fetchUrlToTempFile(url: string, storageDir: string): Promise<string> {
83
+ let parsed: URL;
84
+ try {
85
+ parsed = new URL(url);
86
+ } catch {
87
+ throw new Error(`Invalid URL: ${url}`);
88
+ }
89
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
90
+ throw new Error(`Only http:// and https:// URLs are supported, got: ${parsed.protocol}`);
91
+ }
92
+
93
+ const dir = cacheDir(storageDir);
94
+ mkdirSync(dir, { recursive: true });
95
+
96
+ const hash = hashUrl(url);
97
+ const metaFile = metaPath(storageDir, hash);
98
+
99
+ // Check cache
100
+ if (existsSync(metaFile)) {
101
+ try {
102
+ const meta = JSON.parse(readFileSync(metaFile, "utf8")) as CacheMeta;
103
+ const age = Date.now() - meta.fetchedAt;
104
+ const cached = contentPath(storageDir, hash, meta.extension);
105
+ if (age < CACHE_TTL_MS && existsSync(cached)) {
106
+ log(`URL cache hit: ${url} (${Math.round(age / 1000)}s old)`);
107
+ return cached;
108
+ }
109
+ } catch {
110
+ // corrupted meta, re-fetch
111
+ }
112
+ }
113
+
114
+ log(`Fetching URL: ${url}`);
115
+
116
+ const controller = new AbortController();
117
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
118
+ let response: Response;
119
+ try {
120
+ response = await fetch(url, {
121
+ signal: controller.signal,
122
+ redirect: "follow",
123
+ headers: {
124
+ "user-agent": "aft-opencode-plugin",
125
+ // Prioritize markdown for content-negotiating servers (GitHub API, many docs sites).
126
+ // `application/vnd.github.raw` is GitHub's custom type — returns raw markdown from
127
+ // repo file and readme endpoints. Falls back to HTML if markdown is not available.
128
+ accept:
129
+ "application/vnd.github.raw, text/markdown, text/x-markdown, text/html;q=0.9, text/plain;q=0.5",
130
+ },
131
+ });
132
+ } catch (err) {
133
+ throw new Error(`Failed to fetch ${url}: ${(err as Error).message}`);
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+
138
+ if (!response.ok) {
139
+ throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);
140
+ }
141
+
142
+ const contentType = response.headers.get("content-type") || "text/plain";
143
+ const extension = resolveExtension(contentType);
144
+ if (!extension) {
145
+ throw new Error(
146
+ `Unsupported content type '${contentType}' for ${url}. Supported: text/html, text/markdown, text/plain`,
147
+ );
148
+ }
149
+
150
+ const lengthHeader = response.headers.get("content-length");
151
+ if (lengthHeader) {
152
+ const length = Number.parseInt(lengthHeader, 10);
153
+ if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
154
+ throw new Error(`Response too large: ${length} bytes (max ${MAX_RESPONSE_BYTES})`);
155
+ }
156
+ }
157
+
158
+ // Stream with size cap
159
+ const reader = response.body?.getReader();
160
+ if (!reader) {
161
+ throw new Error(`Failed to read response body for ${url}`);
162
+ }
163
+ const chunks: Uint8Array[] = [];
164
+ let total = 0;
165
+ while (true) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ if (value) {
169
+ total += value.length;
170
+ if (total > MAX_RESPONSE_BYTES) {
171
+ reader.cancel().catch(() => {});
172
+ throw new Error(`Response exceeded ${MAX_RESPONSE_BYTES} bytes, aborted`);
173
+ }
174
+ chunks.push(value);
175
+ }
176
+ }
177
+
178
+ // Write content and meta atomically
179
+ const body = Buffer.concat(chunks);
180
+ const contentFile = contentPath(storageDir, hash, extension);
181
+ const tmpContent = `${contentFile}.tmp-${process.pid}`;
182
+ writeFileSync(tmpContent, body);
183
+ const { renameSync } = await import("node:fs");
184
+ renameSync(tmpContent, contentFile);
185
+
186
+ const meta: CacheMeta = {
187
+ url,
188
+ contentType,
189
+ extension,
190
+ fetchedAt: Date.now(),
191
+ };
192
+ const tmpMeta = `${metaFile}.tmp-${process.pid}`;
193
+ writeFileSync(tmpMeta, JSON.stringify(meta));
194
+ renameSync(tmpMeta, metaFile);
195
+
196
+ log(`URL cached (${total} bytes): ${url}`);
197
+ return contentFile;
198
+ }
199
+
200
+ /**
201
+ * Remove cache entries older than TTL. Called periodically at plugin startup.
202
+ */
203
+ export function cleanupUrlCache(storageDir: string): void {
204
+ const dir = cacheDir(storageDir);
205
+ if (!existsSync(dir)) return;
206
+
207
+ let removed = 0;
208
+ try {
209
+ for (const entry of readdirSync(dir)) {
210
+ if (!entry.endsWith(".meta.json")) continue;
211
+ const metaFile = join(dir, entry);
212
+ try {
213
+ const meta = JSON.parse(readFileSync(metaFile, "utf8")) as CacheMeta;
214
+ const age = Date.now() - meta.fetchedAt;
215
+ if (age > CACHE_TTL_MS) {
216
+ const hash = entry.slice(0, -".meta.json".length);
217
+ const content = contentPath(storageDir, hash, meta.extension);
218
+ if (existsSync(content)) unlinkSync(content);
219
+ unlinkSync(metaFile);
220
+ removed++;
221
+ }
222
+ } catch {
223
+ // corrupted meta, remove it too
224
+ try {
225
+ unlinkSync(metaFile);
226
+ removed++;
227
+ } catch {}
228
+ }
229
+ }
230
+ } catch (err) {
231
+ warn(`URL cache cleanup failed: ${(err as Error).message}`);
232
+ return;
233
+ }
234
+ if (removed > 0) {
235
+ log(`URL cache cleanup: removed ${removed} stale entries`);
236
+ }
237
+ }