@cortexkit/aft-opencode 0.10.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 (83) hide show
  1. package/dist/config.d.ts.map +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +25395 -345
  4. package/dist/pool.d.ts +1 -1
  5. package/dist/pool.d.ts.map +1 -1
  6. package/dist/shared/tui-config.d.ts.map +1 -1
  7. package/dist/shared/url-fetch.d.ts +11 -0
  8. package/dist/shared/url-fetch.d.ts.map +1 -0
  9. package/dist/tools/reading.d.ts.map +1 -1
  10. package/dist/types.d.ts +2 -0
  11. package/dist/types.d.ts.map +1 -1
  12. package/package.json +9 -9
  13. package/src/shared/tui-config.ts +3 -100
  14. package/src/shared/url-fetch.ts +237 -0
  15. package/dist/bridge.js +0 -325
  16. package/dist/bridge.js.map +0 -1
  17. package/dist/config.js +0 -304
  18. package/dist/config.js.map +0 -1
  19. package/dist/downloader.js +0 -198
  20. package/dist/downloader.js.map +0 -1
  21. package/dist/index.js.map +0 -1
  22. package/dist/logger.js +0 -78
  23. package/dist/logger.js.map +0 -1
  24. package/dist/lsp.js +0 -79
  25. package/dist/lsp.js.map +0 -1
  26. package/dist/metadata-store.js +0 -53
  27. package/dist/metadata-store.js.map +0 -1
  28. package/dist/normalize-schemas.js +0 -45
  29. package/dist/normalize-schemas.js.map +0 -1
  30. package/dist/notifications.js +0 -307
  31. package/dist/notifications.js.map +0 -1
  32. package/dist/onnx-runtime.js +0 -203
  33. package/dist/onnx-runtime.js.map +0 -1
  34. package/dist/patch-parser.js +0 -247
  35. package/dist/patch-parser.js.map +0 -1
  36. package/dist/platform.js +0 -31
  37. package/dist/platform.js.map +0 -1
  38. package/dist/pool.js +0 -130
  39. package/dist/pool.js.map +0 -1
  40. package/dist/resolver.js +0 -179
  41. package/dist/resolver.js.map +0 -1
  42. package/dist/shared/opencode-config-dir.js +0 -26
  43. package/dist/shared/opencode-config-dir.js.map +0 -1
  44. package/dist/shared/rpc-client.js +0 -108
  45. package/dist/shared/rpc-client.js.map +0 -1
  46. package/dist/shared/rpc-server.js +0 -120
  47. package/dist/shared/rpc-server.js.map +0 -1
  48. package/dist/shared/rpc-utils.js +0 -20
  49. package/dist/shared/rpc-utils.js.map +0 -1
  50. package/dist/shared/runtime.js +0 -14
  51. package/dist/shared/runtime.js.map +0 -1
  52. package/dist/shared/status.js +0 -135
  53. package/dist/shared/status.js.map +0 -1
  54. package/dist/shared/tui-config.js +0 -134
  55. package/dist/shared/tui-config.js.map +0 -1
  56. package/dist/tools/ast.js +0 -200
  57. package/dist/tools/ast.js.map +0 -1
  58. package/dist/tools/conflicts.js +0 -20
  59. package/dist/tools/conflicts.js.map +0 -1
  60. package/dist/tools/hoisted.js +0 -870
  61. package/dist/tools/hoisted.js.map +0 -1
  62. package/dist/tools/imports.js +0 -90
  63. package/dist/tools/imports.js.map +0 -1
  64. package/dist/tools/lsp.js +0 -58
  65. package/dist/tools/lsp.js.map +0 -1
  66. package/dist/tools/navigation.js +0 -58
  67. package/dist/tools/navigation.js.map +0 -1
  68. package/dist/tools/permissions.js +0 -50
  69. package/dist/tools/permissions.js.map +0 -1
  70. package/dist/tools/reading.js +0 -229
  71. package/dist/tools/reading.js.map +0 -1
  72. package/dist/tools/refactoring.js +0 -129
  73. package/dist/tools/refactoring.js.map +0 -1
  74. package/dist/tools/safety.js +0 -83
  75. package/dist/tools/safety.js.map +0 -1
  76. package/dist/tools/search.js +0 -108
  77. package/dist/tools/search.js.map +0 -1
  78. package/dist/tools/semantic.js +0 -35
  79. package/dist/tools/semantic.js.map +0 -1
  80. package/dist/tools/structure.js +0 -157
  81. package/dist/tools/structure.js.map +0 -1
  82. package/dist/types.js +0 -2
  83. package/dist/types.js.map +0 -1
package/dist/pool.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare class BridgePool {
23
23
  * The bridge's working directory is set to `directory`.
24
24
  */
25
25
  /** Get any existing bridge that is configured and alive, preferring the given directory. */
26
- getAnyActiveBridge(directory: string): BinaryBridge | null;
26
+ getAnyActiveBridge(_directory: string): BinaryBridge | null;
27
27
  getBridge(directory: string, sessionID: string): BinaryBridge;
28
28
  /** Shut down idle bridges that haven't been used within the timeout. */
29
29
  private cleanup;
@@ -1 +1 @@
1
- {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAY5D,MAAM,WAAW,WAAY,SAAQ,aAAa;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA0B;IAC1D,OAAO,CAAC,YAAY,CAA+C;gBAGjE,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,WAAgB,EACzB,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAmB/C;;;;OAIG;IACH,4FAA4F;IAC5F,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAW1D,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY;IAwB7D,wEAAwE;IACxE,OAAO,CAAC,OAAO;IAUf,yDAAyD;IACzD,OAAO,CAAC,QAAQ;IAgBhB,wDAAwD;IAClD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAU/B;;;OAGG;IACG,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBnD,4CAA4C;IAC5C,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAY5D,MAAM,WAAW,WAAY,SAAQ,aAAa;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA0B;IAC1D,OAAO,CAAC,YAAY,CAA+C;gBAGjE,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,WAAgB,EACzB,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAmB/C;;;;OAIG;IACH,4FAA4F;IAC5F,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAW3D,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,YAAY;IAwB7D,wEAAwE;IACxE,OAAO,CAAC,OAAO;IAUf,yDAAyD;IACzD,OAAO,CAAC,QAAQ;IAgBhB,wDAAwD;IAClD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAU/B;;;OAGG;IACG,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBnD,4CAA4C;IAC5C,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"tui-config.d.ts","sourceRoot":"","sources":["../../src/shared/tui-config.ts"],"names":[],"mappings":"AAoHA,wBAAgB,oBAAoB,IAAI,OAAO,CAsC9C"}
1
+ {"version":3,"file":"tui-config.d.ts","sourceRoot":"","sources":["../../src/shared/tui-config.ts"],"names":[],"mappings":"AAmBA,wBAAgB,oBAAoB,IAAI,OAAO,CAsC9C"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Fetch a URL to a cached temp file. Uses disk cache with 1-day TTL.
3
+ * Returns the cached file path the Rust outline/zoom command can read.
4
+ * Throws on errors (invalid URL, network failure, unsupported content type, oversized body).
5
+ */
6
+ export declare function fetchUrlToTempFile(url: string, storageDir: string): Promise<string>;
7
+ /**
8
+ * Remove cache entries older than TTL. Called periodically at plugin startup.
9
+ */
10
+ export declare function cleanupUrlCache(storageDir: string): void;
11
+ //# sourceMappingURL=url-fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-fetch.d.ts","sourceRoot":"","sources":["../../src/shared/url-fetch.ts"],"names":[],"mappings":"AA4EA;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAoHzF;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAkCxD"}
@@ -1 +1 @@
1
- {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/tools/reading.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA0CjD;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAiJ/E"}
1
+ {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/tools/reading.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA0CjD;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAuK/E"}
package/dist/types.d.ts CHANGED
@@ -9,5 +9,7 @@ export interface PluginContext {
9
9
  pool: BridgePool;
10
10
  client: PluginInput["client"];
11
11
  config: AftConfig;
12
+ /** Absolute path to AFT's storage dir (e.g. ~/.local/share/opencode/storage/plugin/aft) */
13
+ storageDir: string;
12
14
  }
13
15
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;IAC9B,MAAM,EAAE,SAAS,CAAC;CACnB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;IAC9B,MAAM,EAAE,SAAS,CAAC;IAClB,2FAA2F;IAC3F,UAAU,EAAE,MAAM,CAAC;CACpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
6
6
  "main": "dist/index.js",
@@ -17,28 +17,28 @@
17
17
  "README.md"
18
18
  ],
19
19
  "scripts": {
20
- "build": "tsc",
20
+ "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin && tsc --emitDeclarationOnly",
21
21
  "typecheck": "tsc --noEmit",
22
22
  "pretest": "cd ../.. && cargo build",
23
23
  "test": "bun test",
24
24
  "test:e2e": "bun test src/__tests__/e2e/",
25
- "prepublishOnly": "tsc"
25
+ "prepublishOnly": "bun run build"
26
26
  },
27
27
  "dependencies": {
28
28
  "@opencode-ai/plugin": "^1.2.26",
29
29
  "@opencode-ai/sdk": "^1.2.26",
30
- "jsonc-parser": "^3.3.1",
31
30
  "zod": "^4.1.8"
32
31
  },
33
32
  "optionalDependencies": {
34
- "@cortexkit/aft-darwin-arm64": "0.10.1",
35
- "@cortexkit/aft-darwin-x64": "0.10.1",
36
- "@cortexkit/aft-linux-arm64": "0.10.1",
37
- "@cortexkit/aft-linux-x64": "0.10.1",
38
- "@cortexkit/aft-win32-x64": "0.10.1"
33
+ "@cortexkit/aft-darwin-arm64": "0.11.0",
34
+ "@cortexkit/aft-darwin-x64": "0.11.0",
35
+ "@cortexkit/aft-linux-arm64": "0.11.0",
36
+ "@cortexkit/aft-linux-x64": "0.11.0",
37
+ "@cortexkit/aft-win32-x64": "0.11.0"
39
38
  },
40
39
  "devDependencies": {
41
40
  "@types/node": "^22.0.0",
41
+ "comment-json": "^4.6.2",
42
42
  "typescript": "^5.8.0"
43
43
  },
44
44
  "exports": {
@@ -1,109 +1,12 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { parse, stringify } from "comment-json";
3
4
  import { log } from "../logger.js";
4
5
  import { getOpenCodeConfigPaths } from "./opencode-config-dir.js";
5
6
 
6
7
  const PLUGIN_NAME = "@cortexkit/aft-opencode";
7
8
  const PLUGIN_ENTRY = `${PLUGIN_NAME}@latest`;
8
9
 
9
- function stripJsoncComments(text: string): string {
10
- let result = "";
11
- let i = 0;
12
- let inString = false;
13
- let escaped = false;
14
-
15
- while (i < text.length) {
16
- const ch = text[i];
17
-
18
- if (inString) {
19
- result += ch;
20
- if (escaped) {
21
- escaped = false;
22
- } else if (ch === "\\") {
23
- escaped = true;
24
- } else if (ch === '"') {
25
- inString = false;
26
- }
27
- i++;
28
- continue;
29
- }
30
-
31
- if (ch === '"') {
32
- inString = true;
33
- result += ch;
34
- i++;
35
- continue;
36
- }
37
-
38
- if (ch === "/" && text[i + 1] === "/") {
39
- i += 2;
40
- while (i < text.length && text[i] !== "\n") i++;
41
- continue;
42
- }
43
-
44
- if (ch === "/" && text[i + 1] === "*") {
45
- i += 2;
46
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
47
- i += 2;
48
- continue;
49
- }
50
-
51
- result += ch;
52
- i++;
53
- }
54
-
55
- return result;
56
- }
57
-
58
- function stripTrailingCommas(text: string): string {
59
- let result = "";
60
- let i = 0;
61
- let inString = false;
62
- let escaped = false;
63
-
64
- while (i < text.length) {
65
- const ch = text[i];
66
-
67
- if (inString) {
68
- result += ch;
69
- if (escaped) {
70
- escaped = false;
71
- } else if (ch === "\\") {
72
- escaped = true;
73
- } else if (ch === '"') {
74
- inString = false;
75
- }
76
- i++;
77
- continue;
78
- }
79
-
80
- if (ch === '"') {
81
- inString = true;
82
- result += ch;
83
- i++;
84
- continue;
85
- }
86
-
87
- if (ch === ",") {
88
- let j = i + 1;
89
- while (j < text.length && /\s/.test(text[j])) j++;
90
- if (text[j] === "}" || text[j] === "]") {
91
- i++;
92
- continue;
93
- }
94
- }
95
-
96
- result += ch;
97
- i++;
98
- }
99
-
100
- return result;
101
- }
102
-
103
- function parseJsonc<T>(content: string): T {
104
- return JSON.parse(stripTrailingCommas(stripJsoncComments(content))) as T;
105
- }
106
-
107
10
  function resolveTuiConfigPath(): string {
108
11
  const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
109
12
  const jsoncPath = join(configDir, "tui.jsonc");
@@ -120,7 +23,7 @@ export function ensureTuiPluginEntry(): boolean {
120
23
 
121
24
  let config: Record<string, unknown> = {};
122
25
  if (existsSync(configPath)) {
123
- config = parseJsonc<Record<string, unknown>>(readFileSync(configPath, "utf-8")) ?? {};
26
+ config = (parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>) ?? {};
124
27
  }
125
28
 
126
29
  const plugins = Array.isArray(config.plugin)
@@ -143,7 +46,7 @@ export function ensureTuiPluginEntry(): boolean {
143
46
  config.plugin = plugins;
144
47
 
145
48
  mkdirSync(dirname(configPath), { recursive: true });
146
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
49
+ writeFileSync(configPath, `${stringify(config, null, 2)}\n`);
147
50
  log(`[aft-plugin] added TUI plugin entry to ${configPath}`);
148
51
  return true;
149
52
  } catch (error) {
@@ -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
+ }