@plannotator/pi-extension 0.17.10 → 0.18.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.
- package/generated/ai/providers/opencode-sdk.ts +83 -27
- package/generated/ai/types.ts +1 -1
- package/generated/resolve-file.ts +89 -53
- package/generated/storage.ts +3 -6
- package/generated/url-to-markdown.ts +63 -2
- package/index.ts +3 -3
- package/package.json +7 -1
- package/plannotator-browser.ts +1 -0
- package/plannotator.html +438 -418
- package/review-editor.html +3 -3
- package/server/integrations.ts +4 -4
- package/server/reference.ts +14 -8
- package/server/serverReview.ts +4 -0
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server.
|
|
4
4
|
*
|
|
5
|
-
* Uses @opencode-ai/sdk to
|
|
6
|
-
*
|
|
7
|
-
* `opencode` CLI installed and
|
|
5
|
+
* Uses @opencode-ai/sdk to connect to an existing `opencode serve` first and
|
|
6
|
+
* only spawns a new server when nothing is reachable. One server is shared
|
|
7
|
+
* across all sessions. The user must have the `opencode` CLI installed and
|
|
8
|
+
* authenticated.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
10
12
|
import { BaseSession } from "../base-session.ts";
|
|
11
13
|
import { buildSystemPrompt } from "../context.ts";
|
|
12
14
|
import type {
|
|
@@ -55,17 +57,17 @@ export class OpenCodeProvider implements AIProvider {
|
|
|
55
57
|
private config: OpenCodeConfig;
|
|
56
58
|
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
|
|
57
59
|
private server: { url: string; close: () => void } | null = null;
|
|
58
|
-
|
|
59
|
-
private client: any = null;
|
|
60
|
+
private client: OpencodeClient | null = null;
|
|
60
61
|
private startPromise: Promise<void> | null = null;
|
|
62
|
+
private lastAttachError: string | null = null;
|
|
61
63
|
|
|
62
64
|
constructor(config: OpenCodeConfig) {
|
|
63
65
|
this.config = config;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
/**
|
|
68
|
+
/** Attach to an existing OpenCode server or spawn one if needed. */
|
|
67
69
|
async ensureServer(): Promise<void> {
|
|
68
|
-
if (this.
|
|
70
|
+
if (this.client) return;
|
|
69
71
|
this.startPromise ??= this.doStart().catch((err) => {
|
|
70
72
|
this.startPromise = null;
|
|
71
73
|
throw err;
|
|
@@ -74,13 +76,27 @@ export class OpenCodeProvider implements AIProvider {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
private async doStart(): Promise<void> {
|
|
79
|
+
this.lastAttachError = null;
|
|
77
80
|
const { createOpencodeServer, createOpencodeClient } = await getSDK();
|
|
81
|
+
const attachedClient = await this.tryAttachExistingServer(createOpencodeClient);
|
|
82
|
+
if (attachedClient) {
|
|
83
|
+
this.client = attachedClient;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
try {
|
|
88
|
+
this.server = await createOpencodeServer({
|
|
89
|
+
hostname: this.config.hostname ?? "127.0.0.1",
|
|
90
|
+
...(this.config.port != null && { port: this.config.port }),
|
|
91
|
+
timeout: 15_000,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const spawnMessage = err instanceof Error ? err.message : String(err);
|
|
95
|
+
if (this.lastAttachError) {
|
|
96
|
+
throw new Error(`${this.lastAttachError}\nFallback startup also failed: ${spawnMessage}`);
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
84
100
|
|
|
85
101
|
this.client = createOpencodeClient({
|
|
86
102
|
baseUrl: this.server!.url,
|
|
@@ -88,18 +104,52 @@ export class OpenCodeProvider implements AIProvider {
|
|
|
88
104
|
});
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
private async tryAttachExistingServer(
|
|
108
|
+
createOpencodeClient: (config?: { baseUrl?: string; directory?: string }) => OpencodeClient,
|
|
109
|
+
): Promise<OpencodeClient | null> {
|
|
110
|
+
const cwd = this.config.cwd ?? process.cwd();
|
|
111
|
+
const baseUrl = `http://${this.config.hostname ?? "127.0.0.1"}:${this.config.port ?? 4096}`;
|
|
112
|
+
const client = createOpencodeClient({
|
|
113
|
+
baseUrl,
|
|
114
|
+
directory: cwd,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await client.config.get({
|
|
119
|
+
throwOnError: true,
|
|
120
|
+
signal: AbortSignal.timeout(1_000),
|
|
121
|
+
});
|
|
122
|
+
return client;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
125
|
+
this.lastAttachError = `Failed to attach to existing OpenCode server at ${baseUrl}: ${message}`;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private getClient(): OpencodeClient {
|
|
131
|
+
if (!this.client) {
|
|
132
|
+
throw new Error("OpenCode client is not initialized.");
|
|
133
|
+
}
|
|
134
|
+
return this.client;
|
|
135
|
+
}
|
|
136
|
+
|
|
91
137
|
async createSession(options: CreateSessionOptions): Promise<AISession> {
|
|
92
138
|
await this.ensureServer();
|
|
139
|
+
const client = this.getClient();
|
|
93
140
|
|
|
94
|
-
const result = await
|
|
141
|
+
const result = await client.session.create({
|
|
95
142
|
query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() },
|
|
96
143
|
});
|
|
97
144
|
const sessionData = result.data;
|
|
145
|
+
if (!sessionData) {
|
|
146
|
+
throw new Error("OpenCode did not return session data.");
|
|
147
|
+
}
|
|
98
148
|
|
|
99
149
|
const session = new OpenCodeSession({
|
|
100
150
|
sessionId: sessionData.id,
|
|
101
151
|
systemPrompt: buildSystemPrompt(options.context),
|
|
102
|
-
client
|
|
152
|
+
client,
|
|
103
153
|
model: options.model,
|
|
104
154
|
parentSessionId: null,
|
|
105
155
|
});
|
|
@@ -108,21 +158,25 @@ export class OpenCodeProvider implements AIProvider {
|
|
|
108
158
|
|
|
109
159
|
async forkSession(options: CreateSessionOptions): Promise<AISession> {
|
|
110
160
|
await this.ensureServer();
|
|
161
|
+
const client = this.getClient();
|
|
111
162
|
|
|
112
163
|
const parentId = options.context.parent?.sessionId;
|
|
113
164
|
if (!parentId) {
|
|
114
165
|
throw new Error("Fork requires a parent session ID.");
|
|
115
166
|
}
|
|
116
167
|
|
|
117
|
-
const result = await
|
|
168
|
+
const result = await client.session.fork({
|
|
118
169
|
path: { id: parentId },
|
|
119
170
|
});
|
|
120
171
|
const sessionData = result.data;
|
|
172
|
+
if (!sessionData) {
|
|
173
|
+
throw new Error("OpenCode did not return forked session data.");
|
|
174
|
+
}
|
|
121
175
|
|
|
122
176
|
return new OpenCodeSession({
|
|
123
177
|
sessionId: sessionData.id,
|
|
124
178
|
systemPrompt: buildSystemPrompt(options.context),
|
|
125
|
-
client
|
|
179
|
+
client,
|
|
126
180
|
model: options.model,
|
|
127
181
|
parentSessionId: parentId,
|
|
128
182
|
});
|
|
@@ -130,14 +184,15 @@ export class OpenCodeProvider implements AIProvider {
|
|
|
130
184
|
|
|
131
185
|
async resumeSession(sessionId: string): Promise<AISession> {
|
|
132
186
|
await this.ensureServer();
|
|
187
|
+
const client = this.getClient();
|
|
133
188
|
|
|
134
189
|
// Verify session exists
|
|
135
|
-
await
|
|
190
|
+
await client.session.get({ path: { id: sessionId } });
|
|
136
191
|
|
|
137
192
|
return new OpenCodeSession({
|
|
138
193
|
sessionId,
|
|
139
194
|
systemPrompt: null,
|
|
140
|
-
client
|
|
195
|
+
client,
|
|
141
196
|
model: undefined,
|
|
142
197
|
parentSessionId: null,
|
|
143
198
|
});
|
|
@@ -147,32 +202,33 @@ export class OpenCodeProvider implements AIProvider {
|
|
|
147
202
|
if (this.server) {
|
|
148
203
|
this.server.close();
|
|
149
204
|
this.server = null;
|
|
150
|
-
this.client = null;
|
|
151
|
-
this.startPromise = null;
|
|
152
205
|
}
|
|
206
|
+
this.client = null;
|
|
207
|
+
this.startPromise = null;
|
|
153
208
|
}
|
|
154
209
|
|
|
155
210
|
/** Fetch available models from OpenCode. Call before registering the provider. */
|
|
156
211
|
async fetchModels(): Promise<void> {
|
|
157
212
|
try {
|
|
158
213
|
await this.ensureServer();
|
|
214
|
+
const client = this.getClient();
|
|
159
215
|
|
|
160
|
-
const result = await
|
|
216
|
+
const result = await client.provider.list({
|
|
161
217
|
query: { directory: this.config.cwd ?? process.cwd() },
|
|
162
218
|
});
|
|
163
219
|
const data = result.data;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
220
|
+
if (!data) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const connected = new Set(data.connected ?? []);
|
|
224
|
+
const allProviders = data.all ?? [];
|
|
169
225
|
|
|
170
226
|
const models: Array<{ id: string; label: string; default?: boolean }> = [];
|
|
171
227
|
for (const provider of allProviders) {
|
|
172
228
|
if (!connected.has(provider.id)) continue;
|
|
173
229
|
for (const model of Object.values(provider.models)) {
|
|
174
230
|
models.push({
|
|
175
|
-
id: `${
|
|
231
|
+
id: `${provider.id}/${model.id}`,
|
|
176
232
|
label: model.name ?? model.id,
|
|
177
233
|
});
|
|
178
234
|
}
|
package/generated/ai/types.ts
CHANGED
|
@@ -365,6 +365,6 @@ export interface OpenCodeConfig extends AIProviderConfig {
|
|
|
365
365
|
type: "opencode-sdk";
|
|
366
366
|
/** Hostname for the OpenCode server. Default: "127.0.0.1". */
|
|
367
367
|
hostname?: string;
|
|
368
|
-
/** Port for the OpenCode server. Default:
|
|
368
|
+
/** Port for the OpenCode server. Default: 4096. */
|
|
369
369
|
port?: number;
|
|
370
370
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { homedir } from "os";
|
|
13
14
|
import { isAbsolute, join, resolve, win32 } from "path";
|
|
14
15
|
import { existsSync, readdirSync, type Dirent } from "fs";
|
|
15
16
|
|
|
@@ -43,8 +44,93 @@ function stripTrailingSlashes(input: string): string {
|
|
|
43
44
|
return input.replace(/\/+$/, "");
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function expandHomePath(input: string, home = homedir()): string {
|
|
48
|
+
if (input === "~") {
|
|
49
|
+
return home;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
53
|
+
return join(home, input.slice(2));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return input;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stripWrappingQuotes(input: string): string {
|
|
60
|
+
if (input.length < 2) {
|
|
61
|
+
return input;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const first = input[0];
|
|
65
|
+
const last = input[input.length - 1];
|
|
66
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
67
|
+
return input.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return input;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function normalizeUserPathInput(
|
|
74
|
+
input: string,
|
|
75
|
+
platform = process.platform,
|
|
76
|
+
): string {
|
|
77
|
+
const trimmedInput = input.trim();
|
|
78
|
+
const unquotedInput = stripWrappingQuotes(trimmedInput);
|
|
79
|
+
const expandedInput = expandHomePath(unquotedInput);
|
|
80
|
+
|
|
81
|
+
if (platform !== "win32") {
|
|
82
|
+
return expandedInput;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) {
|
|
86
|
+
const match = expandedInput.match(pattern);
|
|
87
|
+
if (!match) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const [, driveLetter, rest] = match;
|
|
92
|
+
return `${driveLetter.toUpperCase()}:/${rest}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return expandedInput;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isAbsoluteNormalizedUserPath(
|
|
99
|
+
input: string,
|
|
100
|
+
platform = process.platform,
|
|
101
|
+
): boolean {
|
|
102
|
+
if (hasWindowsDriveLetter(input)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return platform === "win32"
|
|
107
|
+
? win32.isAbsolute(input)
|
|
108
|
+
: isAbsolute(input);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isAbsoluteUserPath(
|
|
112
|
+
input: string,
|
|
113
|
+
platform = process.platform,
|
|
114
|
+
): boolean {
|
|
115
|
+
return isAbsoluteNormalizedUserPath(normalizeUserPathInput(input, platform), platform);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function resolveUserPath(
|
|
119
|
+
input: string,
|
|
120
|
+
baseDir = process.cwd(),
|
|
121
|
+
platform = process.platform,
|
|
122
|
+
): string {
|
|
123
|
+
const normalizedInput = normalizeUserPathInput(input, platform);
|
|
124
|
+
if (!normalizedInput) {
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
return isAbsoluteNormalizedUserPath(normalizedInput, platform)
|
|
128
|
+
? resolveAbsolutePath(normalizedInput, platform)
|
|
129
|
+
: resolve(baseDir, normalizedInput);
|
|
130
|
+
}
|
|
131
|
+
|
|
46
132
|
function normalizeComparablePath(input: string): string {
|
|
47
|
-
return stripTrailingSlashes(normalizeSeparators(
|
|
133
|
+
return stripTrailingSlashes(normalizeSeparators(resolveUserPath(input)));
|
|
48
134
|
}
|
|
49
135
|
|
|
50
136
|
export function isWithinProjectRoot(candidate: string, projectRoot: string): boolean {
|
|
@@ -79,20 +165,6 @@ function isSearchableMarkdownPath(input: string): boolean {
|
|
|
79
165
|
return MARKDOWN_PATH_REGEX.test(input.trim());
|
|
80
166
|
}
|
|
81
167
|
|
|
82
|
-
function stripWrappingQuotes(input: string): string {
|
|
83
|
-
if (input.length < 2) {
|
|
84
|
-
return input;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const first = input[0];
|
|
88
|
-
const last = input[input.length - 1];
|
|
89
|
-
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
90
|
-
return input.slice(1, -1);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return input;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
168
|
/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
|
|
97
169
|
function hasWindowsDriveLetter(input: string): boolean {
|
|
98
170
|
return /^[a-zA-Z]:[/\\]/.test(input);
|
|
@@ -128,42 +200,6 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], ignored
|
|
|
128
200
|
}
|
|
129
201
|
}
|
|
130
202
|
|
|
131
|
-
export function normalizeMarkdownPathInput(
|
|
132
|
-
input: string,
|
|
133
|
-
platform = process.platform,
|
|
134
|
-
): string {
|
|
135
|
-
if (platform !== "win32") {
|
|
136
|
-
return input;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) {
|
|
140
|
-
const match = input.match(pattern);
|
|
141
|
-
if (!match) {
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const [, driveLetter, rest] = match;
|
|
146
|
-
return `${driveLetter.toUpperCase()}:/${rest}`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return input;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function isAbsoluteMarkdownPath(
|
|
153
|
-
input: string,
|
|
154
|
-
platform = process.platform,
|
|
155
|
-
): boolean {
|
|
156
|
-
const normalizedInput = normalizeMarkdownPathInput(input, platform);
|
|
157
|
-
// Always check for Windows drive letters (handles compiled Bun exes where
|
|
158
|
-
// process.platform may not reflect the actual OS correctly)
|
|
159
|
-
if (hasWindowsDriveLetter(normalizedInput)) {
|
|
160
|
-
return true;
|
|
161
|
-
}
|
|
162
|
-
return platform === "win32"
|
|
163
|
-
? win32.isAbsolute(normalizedInput)
|
|
164
|
-
: isAbsolute(normalizedInput);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
203
|
/**
|
|
168
204
|
* Resolve a markdown file path within a project root.
|
|
169
205
|
*
|
|
@@ -174,7 +210,7 @@ function resolveMarkdownFileCore(
|
|
|
174
210
|
input: string,
|
|
175
211
|
projectRoot: string,
|
|
176
212
|
): ResolveResult {
|
|
177
|
-
const normalizedInput =
|
|
213
|
+
const normalizedInput = normalizeUserPathInput(input);
|
|
178
214
|
const searchInput = normalizeSeparators(normalizedInput);
|
|
179
215
|
const isBareFilename = !searchInput.includes("/");
|
|
180
216
|
const targetLookupKey = getLookupKey(searchInput, isBareFilename);
|
|
@@ -186,7 +222,7 @@ function resolveMarkdownFileCore(
|
|
|
186
222
|
|
|
187
223
|
// 1. Absolute path — use as-is (no project root restriction;
|
|
188
224
|
// the user explicitly typed the full path)
|
|
189
|
-
if (
|
|
225
|
+
if (isAbsoluteNormalizedUserPath(normalizedInput)) {
|
|
190
226
|
const absolutePath = resolveAbsolutePath(normalizedInput);
|
|
191
227
|
if (fileExists(absolutePath)) {
|
|
192
228
|
return { kind: "found", path: absolutePath };
|
package/generated/storage.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { homedir } from "os";
|
|
|
12
12
|
import { join, resolve, sep } from "path";
|
|
13
13
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
14
14
|
import { sanitizeTag } from "./project";
|
|
15
|
+
import { resolveUserPath } from "./resolve-file";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Get the plan storage directory, creating it if needed.
|
|
@@ -21,16 +22,12 @@ import { sanitizeTag } from "./project";
|
|
|
21
22
|
export function getPlanDir(customPath?: string | null): string {
|
|
22
23
|
let planDir: string;
|
|
23
24
|
|
|
24
|
-
if (customPath) {
|
|
25
|
-
|
|
26
|
-
planDir = customPath.startsWith("~")
|
|
27
|
-
? join(homedir(), customPath.slice(1))
|
|
28
|
-
: customPath;
|
|
25
|
+
if (customPath?.trim()) {
|
|
26
|
+
planDir = resolveUserPath(customPath);
|
|
29
27
|
} else {
|
|
30
28
|
planDir = join(homedir(), ".plannotator", "plans");
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
planDir = resolve(planDir);
|
|
34
31
|
mkdirSync(planDir, { recursive: true });
|
|
35
32
|
return planDir;
|
|
36
33
|
}
|
|
@@ -15,7 +15,7 @@ export interface UrlToMarkdownOptions {
|
|
|
15
15
|
|
|
16
16
|
export interface UrlToMarkdownResult {
|
|
17
17
|
markdown: string;
|
|
18
|
-
source: "jina" | "fetch+turndown" | "fetch-raw";
|
|
18
|
+
source: "jina" | "fetch+turndown" | "fetch-raw" | "content-negotiation";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
@@ -87,7 +87,17 @@ export async function urlToMarkdown(
|
|
|
87
87
|
// Server returned HTML for this .md URL — fall through to normal conversion
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
if
|
|
90
|
+
// Content negotiation fast path — if the server natively returns markdown
|
|
91
|
+
// (e.g. Cloudflare's Markdown for Agents), skip Jina/Turndown entirely.
|
|
92
|
+
const local = isLocalUrl(url);
|
|
93
|
+
if (!local) {
|
|
94
|
+
const negotiated = await fetchViaContentNegotiation(url);
|
|
95
|
+
if (negotiated !== null) {
|
|
96
|
+
return { markdown: negotiated, source: "content-negotiation" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (options.useJina && !local) {
|
|
91
101
|
try {
|
|
92
102
|
const markdown = await fetchViaJina(url);
|
|
93
103
|
return { markdown, source: "jina" };
|
|
@@ -191,6 +201,57 @@ async function fetchRawText(url: string): Promise<string | null> {
|
|
|
191
201
|
}
|
|
192
202
|
}
|
|
193
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Content negotiation fast path — request `text/markdown` via the Accept header.
|
|
206
|
+
* Sites that support Cloudflare's "Markdown for Agents" (or similar) will return
|
|
207
|
+
* markdown directly, letting us skip Jina and Turndown entirely.
|
|
208
|
+
* Returns null if the server doesn't serve markdown.
|
|
209
|
+
*/
|
|
210
|
+
const NEGOTIATION_TIMEOUT_MS = 5_000; // Short timeout — this is a best-effort optimization
|
|
211
|
+
|
|
212
|
+
async function fetchViaContentNegotiation(url: string): Promise<string | null> {
|
|
213
|
+
const controller = new AbortController();
|
|
214
|
+
const timer = setTimeout(() => controller.abort(), NEGOTIATION_TIMEOUT_MS);
|
|
215
|
+
const headers = {
|
|
216
|
+
"User-Agent": "Mozilla/5.0 (compatible; Plannotator/1.0; +https://plannotator.ai)",
|
|
217
|
+
Accept: "text/markdown, text/html;q=0.9",
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
let currentUrl = url;
|
|
222
|
+
let res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i < MAX_REDIRECTS && REDIRECT_STATUSES.has(res.status); i++) {
|
|
225
|
+
const location = res.headers.get("location");
|
|
226
|
+
if (!location) break;
|
|
227
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
228
|
+
if (isLocalUrl(currentUrl)) {
|
|
229
|
+
res.body?.cancel();
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
res.body?.cancel();
|
|
233
|
+
res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
res.body?.cancel();
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const ct = res.headers.get("content-type") || "";
|
|
242
|
+
if (!ct.includes("text/markdown")) {
|
|
243
|
+
res.body?.cancel();
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return await readBodyWithLimit(res);
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
} finally {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
194
255
|
/** Fetch via Jina Reader — returns markdown directly. */
|
|
195
256
|
async function fetchViaJina(url: string): Promise<string> {
|
|
196
257
|
// Strip fragment (never sent to server) and encode for Jina's path-based API
|
package/index.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
22
|
-
import {
|
|
22
|
+
import { basename, resolve } from "node:path";
|
|
23
23
|
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
24
24
|
import { Type } from "@mariozechner/pi-ai";
|
|
25
25
|
import type {
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
parseChecklist,
|
|
35
35
|
} from "./generated/checklist.js";
|
|
36
36
|
import { planDenyFeedback } from "./generated/feedback-templates.js";
|
|
37
|
-
import { hasMarkdownFiles } from "./generated/resolve-file.js";
|
|
37
|
+
import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
|
|
38
38
|
import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
|
|
39
39
|
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
|
|
40
40
|
import { urlToMarkdown } from "./generated/url-to-markdown.js";
|
|
@@ -417,7 +417,7 @@ export default function plannotator(pi: ExtensionAPI): void {
|
|
|
417
417
|
absolutePath = filePath;
|
|
418
418
|
sourceInfo = filePath;
|
|
419
419
|
} else {
|
|
420
|
-
absolutePath =
|
|
420
|
+
absolutePath = resolveUserPath(filePath, ctx.cwd);
|
|
421
421
|
if (!existsSync(absolutePath)) {
|
|
422
422
|
ctx.ui.notify(`File not found: ${absolutePath}`, "error");
|
|
423
423
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/pi-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Plannotator extension for Pi coding agent - interactive plan review with visual annotation",
|
|
6
6
|
"author": "backnotprop",
|
|
@@ -44,5 +44,11 @@
|
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@mariozechner/pi-coding-agent": ">=0.53.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@mariozechner/pi-coding-agent": ">=0.53.0",
|
|
50
|
+
"@mariozechner/pi-agent-core": ">=0.53.0",
|
|
51
|
+
"@mariozechner/pi-ai": ">=0.53.0",
|
|
52
|
+
"@mariozechner/pi-tui": ">=0.53.0"
|
|
47
53
|
}
|
|
48
54
|
}
|
package/plannotator-browser.ts
CHANGED
|
@@ -356,6 +356,7 @@ export async function openCodeReview(
|
|
|
356
356
|
htmlContent: reviewHtmlContent,
|
|
357
357
|
sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
|
|
358
358
|
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
|
|
359
|
+
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
|
|
359
360
|
onCleanup: worktreeCleanup,
|
|
360
361
|
});
|
|
361
362
|
|