@freebuild/pi 0.0.0-stage → 0.0.2
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/LICENSE +64 -0
- package/README.md +103 -2
- package/package.json +26 -4
- package/src/index.ts +1081 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Freebuild Proprietary License
|
|
2
|
+
Copyright (c) 2026 Freebuild. All rights reserved.
|
|
3
|
+
|
|
4
|
+
This license applies to the Freebuild repository and the Freebuild service,
|
|
5
|
+
except for third-party components, which remain under their own licenses.
|
|
6
|
+
|
|
7
|
+
This software, including its source code, documentation, and associated files
|
|
8
|
+
(the "Software"), is proprietary and is not open source.
|
|
9
|
+
|
|
10
|
+
1. Limited license
|
|
11
|
+
|
|
12
|
+
Subject to your compliance with the Freebuild Terms of Service
|
|
13
|
+
(https://freebuild.dev/terms.html), Freebuild grants you a limited,
|
|
14
|
+
non-exclusive, non-transferable, non-sublicensable, revocable license to
|
|
15
|
+
install and use the Software on devices you control, solely for your internal
|
|
16
|
+
personal or business purposes in connection with the Freebuild service.
|
|
17
|
+
|
|
18
|
+
2. Restrictions
|
|
19
|
+
|
|
20
|
+
You may not:
|
|
21
|
+
|
|
22
|
+
(a) copy, redistribute, sublicense, sell, rent, lease, or otherwise make the
|
|
23
|
+
Software available to any third party;
|
|
24
|
+
(b) modify, translate, or create derivative works of the Software;
|
|
25
|
+
(c) reverse engineer, decompile, or disassemble the Software, except to the
|
|
26
|
+
extent permitted by applicable law;
|
|
27
|
+
(d) remove or alter any copyright, trademark, or other proprietary notices;
|
|
28
|
+
(e) use the Software to build, operate, or provide a competing coding-agent
|
|
29
|
+
product or service; or
|
|
30
|
+
(f) use the Software after your Freebuild service access has been terminated.
|
|
31
|
+
|
|
32
|
+
3. Ownership
|
|
33
|
+
|
|
34
|
+
Freebuild and its licensors retain all right, title, and interest in the
|
|
35
|
+
Software, including all intellectual property rights. No rights are granted
|
|
36
|
+
except as expressly stated in this license.
|
|
37
|
+
|
|
38
|
+
4. Trademarks
|
|
39
|
+
|
|
40
|
+
This license does not grant any right to use the Freebuild name, logo, or other
|
|
41
|
+
trademarks.
|
|
42
|
+
|
|
43
|
+
5. No warranty
|
|
44
|
+
|
|
45
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
46
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
47
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
48
|
+
|
|
49
|
+
6. Limitation of liability
|
|
50
|
+
|
|
51
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, FREEBUILD WILL NOT BE LIABLE FOR ANY
|
|
52
|
+
INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY
|
|
53
|
+
LOSS OF PROFITS, DATA, OR BUSINESS, ARISING OUT OF OR IN CONNECTION WITH THE
|
|
54
|
+
SOFTWARE.
|
|
55
|
+
|
|
56
|
+
7. Termination
|
|
57
|
+
|
|
58
|
+
This license terminates automatically if you breach its terms. On termination,
|
|
59
|
+
you must stop using the Software and delete all copies.
|
|
60
|
+
|
|
61
|
+
8. Contact
|
|
62
|
+
|
|
63
|
+
For licensing questions, contact Freebuild through
|
|
64
|
+
https://freebuild.dev/contact.html.
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Freebuild for Pi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pi extension that connects Pi to the Freebuild broker and shows the sponsor panel during sponsored runs.
|
|
4
|
+
|
|
5
|
+
This is the Pi beta.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@freebuild/pi@latest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development from this repository:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install "$(pwd)/pi-extension"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Restart Pi, then:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
/freebuild-license
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Paste a Freebuild license key, or pass it directly:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
/freebuild-license fb_...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The command verifies the key against the broker, saves it to `~/.config/freebuild/credentials.json`, and selects the Freebuild model.
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
/model
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Choose:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
freebuild/deepseek-flash
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The extension registers the provider as `freebuild` and sends:
|
|
46
|
+
|
|
47
|
+
- `Authorization: Bearer <license key>`
|
|
48
|
+
- `X-Freebuild-Install: <install id>`
|
|
49
|
+
- `X-Freebuild-Client: pi/0.0.2`
|
|
50
|
+
- `X-Freebuild-Client: pi/0.0.2-headless` in print and JSON modes
|
|
51
|
+
|
|
52
|
+
## Sponsor panel
|
|
53
|
+
|
|
54
|
+
The panel appears above the editor while a sponsored run is active. On terminals at least 80 columns wide it uses a large full-width layout with a big image and copy block. Narrow terminals fall back to the compact panel.
|
|
55
|
+
|
|
56
|
+
- `SPONSORED` for paid campaigns
|
|
57
|
+
- `AFFILIATE` for affiliate campaigns
|
|
58
|
+
- `FREEBUILD` for house inventory
|
|
59
|
+
- `ADVERTISE` for house advertising inventory
|
|
60
|
+
|
|
61
|
+
Print and JSON modes do not render the panel and do not record impressions. That avoids counting an ad the user could not see.
|
|
62
|
+
|
|
63
|
+
Open the sponsor with `alt+o` or:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
/freebuild-sponsor
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The plugin records `open` and `close` events for each run and uses the tracked click endpoint when possible. Image-only campaigns always render their campaign label, the brand, and the open hint, so Pi never shows a terminal panel with only an image.
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
/freebuild-license [key] Save and verify a license
|
|
75
|
+
/freebuild-sponsor Open the current sponsor
|
|
76
|
+
/freebuild-status Show license, model, install, and broker
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Environment
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
FREEBUILD_CREDENTIALS Override the credentials file path
|
|
83
|
+
FREEBUILD_API_URL Override the web origin
|
|
84
|
+
FREEBUILD_BROKER_URL Override the broker URL
|
|
85
|
+
FREEBUILD_SPONSOR_URL Override the sponsor panel URL
|
|
86
|
+
FREEBUILD_LICENSE_KEY Override the stored license key
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
Load the extension without installing it:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pi --no-extensions -e ./pi-extension --list-models freebuild
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The package has no build step. Pi loads TypeScript through jiti.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
Proprietary. Copyright (c) 2026 Freebuild. All rights reserved.
|
|
102
|
+
|
|
103
|
+
This package is licensed under the Freebuild Proprietary License. See
|
|
104
|
+
[LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freebuild/pi",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"author": "Freebuild <hello@freebuild.dev> (https://freebuild.dev)",
|
|
5
|
+
"description": "Freebuild sponsored model provider and sponsor panel for Pi",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"freebuild",
|
|
11
|
+
"sponsor",
|
|
12
|
+
"coding-agent"
|
|
13
|
+
],
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./src/index.ts"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
getCapabilities,
|
|
10
|
+
HStack,
|
|
11
|
+
Image,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
wrapTextWithAnsi,
|
|
15
|
+
type Component,
|
|
16
|
+
type TUI,
|
|
17
|
+
} from "@earendil-works/pi-tui";
|
|
18
|
+
|
|
19
|
+
const PLUGIN_VERSION = "0.0.2";
|
|
20
|
+
const CLIENT_ID = `pi/${PLUGIN_VERSION}`;
|
|
21
|
+
const WIDGET_KEY = "freebuild-sponsor";
|
|
22
|
+
const DEFAULT_API_URL = "https://freebuild.dev";
|
|
23
|
+
const DEFAULT_BROKER_URL = "https://freebuild.dev/broker";
|
|
24
|
+
const MAX_IMAGE_BYTES = 3_000_000;
|
|
25
|
+
const REQUEST_TIMEOUT_MS = 6_000;
|
|
26
|
+
|
|
27
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
28
|
+
|
|
29
|
+
interface Credentials {
|
|
30
|
+
licenseKey?: string;
|
|
31
|
+
installId?: string;
|
|
32
|
+
brokerUrl?: string;
|
|
33
|
+
apiUrl?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PanelCampaign {
|
|
37
|
+
id: string;
|
|
38
|
+
brand: string;
|
|
39
|
+
label: string;
|
|
40
|
+
kicker: string;
|
|
41
|
+
title: string;
|
|
42
|
+
body: string;
|
|
43
|
+
url: string;
|
|
44
|
+
clickUrl: string;
|
|
45
|
+
eventUrl: string;
|
|
46
|
+
accent: string;
|
|
47
|
+
imageUrl: string;
|
|
48
|
+
imageOnly: boolean;
|
|
49
|
+
wordmark: string;
|
|
50
|
+
showWordmark: boolean;
|
|
51
|
+
align: "left" | "center";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ImageData {
|
|
55
|
+
base64: string;
|
|
56
|
+
mimeType: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const credentialsPath =
|
|
60
|
+
process.env.FREEBUILD_CREDENTIALS ||
|
|
61
|
+
join(homedir(), ".config", "freebuild", "credentials.json");
|
|
62
|
+
|
|
63
|
+
const credentials = readCredentials();
|
|
64
|
+
const apiUrl = (
|
|
65
|
+
process.env.FREEBUILD_API_URL ||
|
|
66
|
+
credentials.apiUrl ||
|
|
67
|
+
DEFAULT_API_URL
|
|
68
|
+
).replace(/\/+$/, "");
|
|
69
|
+
const brokerUrl = (
|
|
70
|
+
process.env.FREEBUILD_BROKER_URL ||
|
|
71
|
+
credentials.brokerUrl ||
|
|
72
|
+
DEFAULT_BROKER_URL
|
|
73
|
+
).replace(/\/+$/, "");
|
|
74
|
+
const sponsorUrl = (
|
|
75
|
+
process.env.FREEBUILD_SPONSOR_URL || `${apiUrl}/api/sponsor/panel`
|
|
76
|
+
).replace(/\/+$/, "");
|
|
77
|
+
|
|
78
|
+
let installId = String(credentials.installId || "").trim();
|
|
79
|
+
if (!installId) {
|
|
80
|
+
installId = createId("install-");
|
|
81
|
+
saveCredentials({ installId });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let licenseKey = (
|
|
85
|
+
process.env.FREEBUILD_LICENSE_KEY ||
|
|
86
|
+
credentials.licenseKey ||
|
|
87
|
+
""
|
|
88
|
+
).trim();
|
|
89
|
+
let sessionId = createId("session-");
|
|
90
|
+
let runId = "";
|
|
91
|
+
let runActive = false;
|
|
92
|
+
let runToken = 0;
|
|
93
|
+
let openSent = false;
|
|
94
|
+
let panelStartedAt = 0;
|
|
95
|
+
let shownCampaign: PanelCampaign | undefined;
|
|
96
|
+
|
|
97
|
+
function readCredentials(): Credentials {
|
|
98
|
+
try {
|
|
99
|
+
const raw = readFileSync(credentialsPath, "utf8").replace(/^\uFEFF/, "");
|
|
100
|
+
const value = JSON.parse(raw) as unknown;
|
|
101
|
+
return value && typeof value === "object" ? (value as Credentials) : {};
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function saveCredentials(patch: Partial<Credentials>) {
|
|
108
|
+
const next = { ...readCredentials(), ...patch };
|
|
109
|
+
try {
|
|
110
|
+
mkdirSync(dirname(credentialsPath), { recursive: true });
|
|
111
|
+
writeFileSync(credentialsPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
112
|
+
try {
|
|
113
|
+
chmodSync(credentialsPath, 0o600);
|
|
114
|
+
} catch {
|
|
115
|
+
// Windows and some filesystems do not implement chmod.
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// A read-only home directory must not break the current process.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createId(prefix: string): string {
|
|
123
|
+
return `${prefix}${randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeSessionId(value: string): string {
|
|
127
|
+
const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 80);
|
|
128
|
+
return cleaned.length >= 6 ? cleaned : createId("session-");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stringValue(value: unknown): string {
|
|
132
|
+
return typeof value === "string" ? value.trim() : "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeLabel(value: unknown): string {
|
|
136
|
+
const label = stringValue(value).toUpperCase();
|
|
137
|
+
return ["SPONSORED", "AFFILIATE", "FREEBUILD", "ADVERTISE"].includes(label)
|
|
138
|
+
? label
|
|
139
|
+
: "SPONSORED";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeCampaign(value: unknown): PanelCampaign | undefined {
|
|
143
|
+
if (!value || typeof value !== "object") {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const raw = value as Record<string, unknown>;
|
|
148
|
+
const id = stringValue(raw.id);
|
|
149
|
+
if (!id) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const accent = stringValue(raw.accent).toLowerCase();
|
|
154
|
+
const align = stringValue(raw.align).toLowerCase();
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
brand: stringValue(raw.brand),
|
|
159
|
+
label: normalizeLabel(raw.label || raw.sponsor),
|
|
160
|
+
kicker: stringValue(raw.kicker),
|
|
161
|
+
title: stringValue(raw.title),
|
|
162
|
+
body: stringValue(raw.body),
|
|
163
|
+
url: stringValue(raw.url),
|
|
164
|
+
clickUrl: stringValue(raw.clickUrl) || stringValue(raw.url),
|
|
165
|
+
eventUrl: stringValue(raw.eventUrl),
|
|
166
|
+
accent: /^#[0-9a-f]{6}$/i.test(accent) ? accent : "#5b8cff",
|
|
167
|
+
imageUrl: stringValue(raw.imageUrl),
|
|
168
|
+
imageOnly: Boolean(raw.imageOnly),
|
|
169
|
+
wordmark: stringValue(raw.wordmark).toUpperCase(),
|
|
170
|
+
showWordmark: raw.showWordmark !== false,
|
|
171
|
+
align: align === "left" ? "left" : "center",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function houseFallback(): PanelCampaign {
|
|
176
|
+
return {
|
|
177
|
+
id: "house",
|
|
178
|
+
brand: "Freebuild",
|
|
179
|
+
label: "FREEBUILD",
|
|
180
|
+
kicker: "",
|
|
181
|
+
title: "Build more for less.",
|
|
182
|
+
body: "The sponsor panel is temporarily unavailable. Check plans or ask about sponsoring Freebuild.",
|
|
183
|
+
url: `${apiUrl}/terminal/pricing.html`,
|
|
184
|
+
clickUrl: `${apiUrl}/terminal/pricing.html`,
|
|
185
|
+
eventUrl: "",
|
|
186
|
+
accent: "#5b8cff",
|
|
187
|
+
imageUrl: "",
|
|
188
|
+
imageOnly: false,
|
|
189
|
+
wordmark: "FREEBUILD",
|
|
190
|
+
showWordmark: true,
|
|
191
|
+
align: "center",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadingPanel(): PanelCampaign {
|
|
196
|
+
return {
|
|
197
|
+
...houseFallback(),
|
|
198
|
+
title: "Sponsored session",
|
|
199
|
+
body: "Loading the current sponsor...",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function request(
|
|
204
|
+
url: string,
|
|
205
|
+
init: RequestInit = {},
|
|
206
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
207
|
+
): Promise<Response> {
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
210
|
+
try {
|
|
211
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function fetchJson(
|
|
218
|
+
url: string,
|
|
219
|
+
init: RequestInit = {},
|
|
220
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
221
|
+
): Promise<any> {
|
|
222
|
+
const response = await request(url, init, timeoutMs);
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
throw new Error(`HTTP ${response.status}`);
|
|
225
|
+
}
|
|
226
|
+
return response.json();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function authorizationHeaders(): Record<string, string> {
|
|
230
|
+
const headers: Record<string, string> = {
|
|
231
|
+
accept: "application/json",
|
|
232
|
+
"x-freebuild-install": installId,
|
|
233
|
+
"x-freebuild-client": CLIENT_ID,
|
|
234
|
+
};
|
|
235
|
+
if (licenseKey) {
|
|
236
|
+
headers.authorization = `Bearer ${licenseKey}`;
|
|
237
|
+
}
|
|
238
|
+
return headers;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function fetchPanel(
|
|
242
|
+
run: string,
|
|
243
|
+
preview = false,
|
|
244
|
+
): Promise<PanelCampaign | undefined> {
|
|
245
|
+
const params = new URLSearchParams();
|
|
246
|
+
if (preview) {
|
|
247
|
+
params.set("preview", "1");
|
|
248
|
+
} else if (run) {
|
|
249
|
+
params.set("runId", run);
|
|
250
|
+
}
|
|
251
|
+
params.set("client", CLIENT_ID);
|
|
252
|
+
|
|
253
|
+
const separator = sponsorUrl.includes("?") ? "&" : "?";
|
|
254
|
+
const data = await fetchJson(
|
|
255
|
+
`${sponsorUrl}${separator}${params.toString()}`,
|
|
256
|
+
{ headers: authorizationHeaders() },
|
|
257
|
+
);
|
|
258
|
+
return normalizeCampaign(data?.campaign ?? data);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function loadImage(campaign: PanelCampaign): Promise<ImageData | undefined> {
|
|
262
|
+
if (!campaign.imageUrl || !getCapabilities().images) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
if (!/^https?:\/\//i.test(campaign.imageUrl)) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const response = await request(
|
|
271
|
+
campaign.imageUrl,
|
|
272
|
+
{ headers: { accept: "image/*" } },
|
|
273
|
+
REQUEST_TIMEOUT_MS,
|
|
274
|
+
);
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const mimeType =
|
|
279
|
+
(response.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
|
|
280
|
+
if (!mimeType.startsWith("image/")) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
284
|
+
if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
return { base64: buffer.toString("base64"), mimeType };
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sendEvent(
|
|
294
|
+
campaign: PanelCampaign,
|
|
295
|
+
event: "open" | "close",
|
|
296
|
+
durationMs: number,
|
|
297
|
+
eventRunId: string,
|
|
298
|
+
) {
|
|
299
|
+
if (!licenseKey || !campaign.eventUrl || !eventRunId || campaign.id === "house") {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
void request(
|
|
304
|
+
campaign.eventUrl,
|
|
305
|
+
{
|
|
306
|
+
method: "POST",
|
|
307
|
+
keepalive: true,
|
|
308
|
+
headers: {
|
|
309
|
+
...authorizationHeaders(),
|
|
310
|
+
"content-type": "application/json",
|
|
311
|
+
},
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
campaignId: campaign.id,
|
|
314
|
+
sessionId,
|
|
315
|
+
runId: eventRunId,
|
|
316
|
+
client: CLIENT_ID,
|
|
317
|
+
event,
|
|
318
|
+
durationMs,
|
|
319
|
+
tokens: 0,
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
REQUEST_TIMEOUT_MS,
|
|
323
|
+
).catch(() => {
|
|
324
|
+
// Exposure tracking is best-effort and must never interrupt the agent.
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function panelLabel(campaign: PanelCampaign): string {
|
|
329
|
+
return campaign.label || (campaign.id === "house" ? "FREEBUILD" : "SPONSORED");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function panelBrand(campaign: PanelCampaign): string {
|
|
333
|
+
return (
|
|
334
|
+
campaign.brand ||
|
|
335
|
+
(campaign.showWordmark ? campaign.wordmark : "") ||
|
|
336
|
+
(campaign.id === "house" ? "Freebuild" : "Sponsor")
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function panelHeading(campaign: PanelCampaign): string {
|
|
341
|
+
const label = panelLabel(campaign);
|
|
342
|
+
const brand = panelBrand(campaign);
|
|
343
|
+
return brand && brand.toUpperCase() !== label ? `${label} ${brand}` : label;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function styledHeading(theme: Theme, campaign: PanelCampaign): string {
|
|
347
|
+
const label = panelLabel(campaign);
|
|
348
|
+
const brand = panelBrand(campaign);
|
|
349
|
+
return brand && brand.toUpperCase() !== label
|
|
350
|
+
? `${theme.fg("accent", theme.bold(label))} ${theme.bold(brand)}`
|
|
351
|
+
: theme.fg("accent", theme.bold(label));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function panelTextLines(campaign: PanelCampaign, width: number): string[] {
|
|
355
|
+
const inner = Math.max(20, Math.min(width, 96));
|
|
356
|
+
const lines = [panelHeading(campaign)];
|
|
357
|
+
|
|
358
|
+
if (campaign.kicker) {
|
|
359
|
+
lines.push(...wrapTextWithAnsi(campaign.kicker, inner).slice(0, 1));
|
|
360
|
+
}
|
|
361
|
+
if (campaign.title) {
|
|
362
|
+
lines.push(...wrapTextWithAnsi(campaign.title, inner).slice(0, 2));
|
|
363
|
+
}
|
|
364
|
+
if (campaign.body) {
|
|
365
|
+
lines.push("");
|
|
366
|
+
lines.push(...wrapTextWithAnsi(campaign.body, inner).slice(0, 3));
|
|
367
|
+
}
|
|
368
|
+
lines.push("");
|
|
369
|
+
lines.push("alt+o or /freebuild-sponsor to open");
|
|
370
|
+
|
|
371
|
+
return lines.map((line) => truncateToWidth(line, inner));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
class SponsorCopy implements Component {
|
|
375
|
+
private readonly theme: Theme;
|
|
376
|
+
private readonly campaign: PanelCampaign;
|
|
377
|
+
|
|
378
|
+
constructor(theme: Theme, campaign: PanelCampaign) {
|
|
379
|
+
this.theme = theme;
|
|
380
|
+
this.campaign = campaign;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
invalidate() {}
|
|
384
|
+
|
|
385
|
+
render(width: number): string[] {
|
|
386
|
+
const theme = this.theme;
|
|
387
|
+
const lines = [styledHeading(theme, this.campaign)];
|
|
388
|
+
|
|
389
|
+
if (this.campaign.kicker) {
|
|
390
|
+
lines.push(
|
|
391
|
+
...wrapTextWithAnsi(
|
|
392
|
+
theme.fg("muted", this.campaign.kicker),
|
|
393
|
+
width,
|
|
394
|
+
).slice(0, 1),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (this.campaign.title) {
|
|
398
|
+
lines.push(
|
|
399
|
+
...wrapTextWithAnsi(theme.bold(this.campaign.title), width).slice(
|
|
400
|
+
0,
|
|
401
|
+
3,
|
|
402
|
+
),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (this.campaign.body) {
|
|
406
|
+
lines.push("");
|
|
407
|
+
lines.push(
|
|
408
|
+
...wrapTextWithAnsi(
|
|
409
|
+
theme.fg("muted", this.campaign.body),
|
|
410
|
+
width,
|
|
411
|
+
).slice(0, 4),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
lines.push("");
|
|
416
|
+
lines.push(theme.fg("dim", "alt+o or /freebuild-sponsor to open"));
|
|
417
|
+
|
|
418
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
type StackItem = {
|
|
423
|
+
component: Component;
|
|
424
|
+
basis?: number;
|
|
425
|
+
grow?: number;
|
|
426
|
+
shrink?: number;
|
|
427
|
+
minSize?: number;
|
|
428
|
+
maxSize?: number;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
export class SponsorPanel implements Component {
|
|
432
|
+
private readonly theme: Theme;
|
|
433
|
+
private readonly campaign: PanelCampaign;
|
|
434
|
+
private readonly image?: Image;
|
|
435
|
+
private readonly tui?: TUI;
|
|
436
|
+
private readonly copy: SponsorCopy;
|
|
437
|
+
|
|
438
|
+
constructor(
|
|
439
|
+
theme: Theme,
|
|
440
|
+
campaign: PanelCampaign,
|
|
441
|
+
image?: Image,
|
|
442
|
+
tui?: TUI,
|
|
443
|
+
) {
|
|
444
|
+
this.theme = theme;
|
|
445
|
+
this.campaign = campaign;
|
|
446
|
+
this.image = image;
|
|
447
|
+
this.tui = tui;
|
|
448
|
+
this.copy = new SponsorCopy(theme, campaign);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
invalidate() {
|
|
452
|
+
this.image?.invalidate();
|
|
453
|
+
this.copy.invalidate();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
render(width: number): string[] {
|
|
457
|
+
const rows = this.tui?.terminal.rows ?? 24;
|
|
458
|
+
if (width >= 80 && rows >= 30) {
|
|
459
|
+
return this.renderLarge(width, rows);
|
|
460
|
+
}
|
|
461
|
+
return this.renderCompact(width);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private renderLarge(width: number, rows: number): string[] {
|
|
465
|
+
const theme = this.theme;
|
|
466
|
+
const inner = Math.max(40, width - 2);
|
|
467
|
+
const targetHeight = Math.max(14, Math.min(28, Math.floor(rows * 0.5)));
|
|
468
|
+
const bodyHeight = Math.max(10, targetHeight - 2);
|
|
469
|
+
const hasImage = Boolean(this.image);
|
|
470
|
+
const imageWidth = hasImage
|
|
471
|
+
? Math.max(24, Math.min(56, Math.floor(inner * 0.38)))
|
|
472
|
+
: 0;
|
|
473
|
+
const copyWidth = hasImage
|
|
474
|
+
? Math.max(24, inner - imageWidth - 2)
|
|
475
|
+
: inner;
|
|
476
|
+
const children: StackItem[] = [];
|
|
477
|
+
|
|
478
|
+
if (this.image) {
|
|
479
|
+
children.push({
|
|
480
|
+
component: this.image,
|
|
481
|
+
basis: imageWidth,
|
|
482
|
+
minSize: imageWidth,
|
|
483
|
+
maxSize: imageWidth,
|
|
484
|
+
shrink: 0,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
children.push({
|
|
488
|
+
component: this.copy,
|
|
489
|
+
basis: copyWidth,
|
|
490
|
+
grow: 1,
|
|
491
|
+
minSize: 20,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const stack = new HStack(children, {
|
|
495
|
+
gap: hasImage ? 2 : 0,
|
|
496
|
+
align: "center",
|
|
497
|
+
});
|
|
498
|
+
let body = stack.render(inner);
|
|
499
|
+
if (body.length > bodyHeight) {
|
|
500
|
+
body = body.slice(0, bodyHeight);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const padTop = Math.max(0, Math.floor((bodyHeight - body.length) / 2));
|
|
504
|
+
const padBottom = Math.max(0, bodyHeight - body.length - padTop);
|
|
505
|
+
const divider = theme.fg("borderMuted", "\u2500".repeat(inner));
|
|
506
|
+
|
|
507
|
+
return [
|
|
508
|
+
divider,
|
|
509
|
+
...Array<string>(padTop).fill(""),
|
|
510
|
+
...body,
|
|
511
|
+
...Array<string>(padBottom).fill(""),
|
|
512
|
+
divider,
|
|
513
|
+
].map((line) => truncateToWidth(line, inner));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private renderCompact(width: number): string[] {
|
|
517
|
+
const theme = this.theme;
|
|
518
|
+
const inner = Math.max(20, Math.min(width - 2, 96));
|
|
519
|
+
const lines: string[] = [];
|
|
520
|
+
const border = (text: string) => theme.fg("borderMuted", text);
|
|
521
|
+
|
|
522
|
+
lines.push(border(`\u250c${"\u2500".repeat(inner)}\u2510`));
|
|
523
|
+
lines.push(this.row(styledHeading(theme, this.campaign), inner));
|
|
524
|
+
|
|
525
|
+
if (this.campaign.kicker) {
|
|
526
|
+
lines.push(
|
|
527
|
+
...this.wrapped(
|
|
528
|
+
theme.fg("muted", this.campaign.kicker),
|
|
529
|
+
inner,
|
|
530
|
+
1,
|
|
531
|
+
),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (this.campaign.title) {
|
|
535
|
+
lines.push(
|
|
536
|
+
...this.wrapped(theme.bold(this.campaign.title), inner, 2),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (this.campaign.body) {
|
|
540
|
+
lines.push(
|
|
541
|
+
...this.wrapped(theme.fg("muted", this.campaign.body), inner, 2),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (this.image) {
|
|
546
|
+
try {
|
|
547
|
+
const imageWidth = Math.min(width, 14);
|
|
548
|
+
lines.push(...this.image.render(imageWidth).slice(0, 6));
|
|
549
|
+
} catch {
|
|
550
|
+
// A terminal-level image failure leaves the text panel intact.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
lines.push(
|
|
555
|
+
this.row(
|
|
556
|
+
theme.fg("dim", "alt+o or /freebuild-sponsor to open"),
|
|
557
|
+
inner,
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
lines.push(border(`\u2514${"\u2500".repeat(inner)}\u2518`));
|
|
561
|
+
|
|
562
|
+
return lines;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private wrapped(text: string, width: number, limit: number): string[] {
|
|
566
|
+
return wrapTextWithAnsi(text, width)
|
|
567
|
+
.slice(0, limit)
|
|
568
|
+
.map((line) => this.row(line, width));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private row(text: string, width: number): string {
|
|
572
|
+
const clipped = truncateToWidth(text, width);
|
|
573
|
+
const padding = Math.max(0, width - visibleWidth(clipped));
|
|
574
|
+
return `${this.theme.fg("borderMuted", "\u2502")}${clipped}${" ".repeat(padding)}${this.theme.fg("borderMuted", "\u2502")}`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function showPanel(
|
|
579
|
+
ctx: ExtensionContext,
|
|
580
|
+
campaign: PanelCampaign,
|
|
581
|
+
image?: ImageData,
|
|
582
|
+
): boolean {
|
|
583
|
+
try {
|
|
584
|
+
if (ctx.mode === "rpc") {
|
|
585
|
+
// RPC clients receive string widget lines. Component factories and
|
|
586
|
+
// terminal images are ignored in this mode.
|
|
587
|
+
ctx.ui.setWidget(WIDGET_KEY, panelTextLines(campaign, 96), {
|
|
588
|
+
placement: "aboveEditor",
|
|
589
|
+
});
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
ctx.ui.setWidget(
|
|
594
|
+
WIDGET_KEY,
|
|
595
|
+
(tui, theme) => {
|
|
596
|
+
let renderedImage: Image | undefined;
|
|
597
|
+
if (image && getCapabilities().images) {
|
|
598
|
+
const maxImageWidth = Math.max(
|
|
599
|
+
24,
|
|
600
|
+
Math.min(56, Math.floor(tui.terminal.columns * 0.35)),
|
|
601
|
+
);
|
|
602
|
+
const maxImageHeight = Math.max(
|
|
603
|
+
8,
|
|
604
|
+
Math.min(22, Math.floor(tui.terminal.rows * 0.45)),
|
|
605
|
+
);
|
|
606
|
+
renderedImage = new Image(
|
|
607
|
+
image.base64,
|
|
608
|
+
image.mimeType,
|
|
609
|
+
{ fallbackColor: (value) => theme.fg("dim", value) },
|
|
610
|
+
{
|
|
611
|
+
maxWidthCells: maxImageWidth,
|
|
612
|
+
maxHeightCells: maxImageHeight,
|
|
613
|
+
filename: "sponsor",
|
|
614
|
+
},
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return new SponsorPanel(theme, campaign, renderedImage, tui);
|
|
618
|
+
},
|
|
619
|
+
{ placement: "aboveEditor" },
|
|
620
|
+
);
|
|
621
|
+
return true;
|
|
622
|
+
} catch {
|
|
623
|
+
// The panel must not take down a session during a terminal transition.
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function clearPanel(ctx: ExtensionContext) {
|
|
629
|
+
try {
|
|
630
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
631
|
+
} catch {
|
|
632
|
+
// The widget may already be gone during shutdown.
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function isFreebuildModel(ctx: ExtensionContext): boolean {
|
|
637
|
+
return String(ctx.model?.provider || "") === "freebuild";
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function syncStatus(ctx: ExtensionContext) {
|
|
641
|
+
try {
|
|
642
|
+
if (isFreebuildModel(ctx)) {
|
|
643
|
+
ctx.ui.setStatus(
|
|
644
|
+
"freebuild",
|
|
645
|
+
licenseKey ? "Freebuild sponsored" : "Freebuild: no license",
|
|
646
|
+
);
|
|
647
|
+
} else {
|
|
648
|
+
ctx.ui.setStatus("freebuild", undefined);
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
// Status display is cosmetic.
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function showRunPanel(ctx: ExtensionContext, token: number) {
|
|
656
|
+
const currentRun = runId;
|
|
657
|
+
let campaign = await fetchPanel(currentRun).catch(() => undefined);
|
|
658
|
+
if (!campaign) {
|
|
659
|
+
campaign = houseFallback();
|
|
660
|
+
}
|
|
661
|
+
if (!runActive || token !== runToken) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Image-only campaigns still render the campaign label, the brand, and the
|
|
666
|
+
// open hint. Pi never shows a terminal panel with only an image.
|
|
667
|
+
|
|
668
|
+
if (!showPanel(ctx, campaign)) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
shownCampaign = campaign;
|
|
673
|
+
panelStartedAt = Date.now();
|
|
674
|
+
|
|
675
|
+
if (campaign.id !== "house" && campaign.eventUrl) {
|
|
676
|
+
openSent = true;
|
|
677
|
+
sendEvent(campaign, "open", 0, currentRun);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
void loadImage(campaign).then((image) => {
|
|
681
|
+
if (
|
|
682
|
+
!image ||
|
|
683
|
+
!runActive ||
|
|
684
|
+
token !== runToken ||
|
|
685
|
+
shownCampaign?.id !== campaign.id
|
|
686
|
+
) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
showPanel(ctx, campaign, image);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function beginRun(ctx: ExtensionContext) {
|
|
694
|
+
if (runActive) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
syncStatus(ctx);
|
|
698
|
+
if (!isFreebuildModel(ctx)) {
|
|
699
|
+
clearPanel(ctx);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Print and JSON modes cannot render a panel. Do not record an impression
|
|
704
|
+
// when the user never had a chance to see one.
|
|
705
|
+
if (!ctx.hasUI) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!licenseKey) {
|
|
710
|
+
ctx.ui.notify(
|
|
711
|
+
"Freebuild license missing. Run /freebuild-license.",
|
|
712
|
+
"warning",
|
|
713
|
+
);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
runActive = true;
|
|
718
|
+
const token = ++runToken;
|
|
719
|
+
runId = createId("run-");
|
|
720
|
+
openSent = false;
|
|
721
|
+
panelStartedAt = 0;
|
|
722
|
+
shownCampaign = undefined;
|
|
723
|
+
|
|
724
|
+
// Show the mandatory panel immediately, then replace it when the selected
|
|
725
|
+
// campaign arrives. The loading panel is house inventory and is not tracked.
|
|
726
|
+
showPanel(ctx, loadingPanel());
|
|
727
|
+
void showRunPanel(ctx, token);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function endRun(ctx: ExtensionContext) {
|
|
731
|
+
if (!runActive) {
|
|
732
|
+
clearPanel(ctx);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const campaign = shownCampaign;
|
|
737
|
+
const closingRunId = runId;
|
|
738
|
+
const durationMs = panelStartedAt ? Date.now() - panelStartedAt : 0;
|
|
739
|
+
const shouldClose =
|
|
740
|
+
openSent &&
|
|
741
|
+
Boolean(campaign) &&
|
|
742
|
+
campaign!.id !== "house" &&
|
|
743
|
+
Boolean(campaign!.eventUrl) &&
|
|
744
|
+
Boolean(closingRunId);
|
|
745
|
+
|
|
746
|
+
runActive = false;
|
|
747
|
+
runToken++;
|
|
748
|
+
runId = "";
|
|
749
|
+
openSent = false;
|
|
750
|
+
panelStartedAt = 0;
|
|
751
|
+
shownCampaign = undefined;
|
|
752
|
+
clearPanel(ctx);
|
|
753
|
+
|
|
754
|
+
if (shouldClose) {
|
|
755
|
+
sendEvent(campaign!, "close", durationMs, closingRunId);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function registerProvider(pi: ExtensionAPI, key: string) {
|
|
760
|
+
if (!key) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
pi.registerProvider("freebuild", {
|
|
765
|
+
name: "Freebuild",
|
|
766
|
+
baseUrl: `${brokerUrl}/v1`,
|
|
767
|
+
apiKey: key,
|
|
768
|
+
api: "openai-completions",
|
|
769
|
+
authHeader: true,
|
|
770
|
+
headers: {
|
|
771
|
+
"X-Freebuild-Install": installId,
|
|
772
|
+
"X-Freebuild-Client": CLIENT_ID,
|
|
773
|
+
},
|
|
774
|
+
models: [
|
|
775
|
+
{
|
|
776
|
+
id: "deepseek-flash",
|
|
777
|
+
name: "DeepSeek V4.1 Flash 1017",
|
|
778
|
+
reasoning: true,
|
|
779
|
+
thinkingLevelMap: {
|
|
780
|
+
minimal: null,
|
|
781
|
+
low: "low",
|
|
782
|
+
medium: null,
|
|
783
|
+
high: "high",
|
|
784
|
+
max: "max",
|
|
785
|
+
},
|
|
786
|
+
input: ["text", "image"],
|
|
787
|
+
inputLimits: {
|
|
788
|
+
images: {
|
|
789
|
+
resize: {
|
|
790
|
+
maxWidth: 2000,
|
|
791
|
+
maxHeight: 2000,
|
|
792
|
+
maxBytes: 4718592,
|
|
793
|
+
jpegQuality: 80,
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
798
|
+
contextWindow: 1_000_000,
|
|
799
|
+
maxTokens: 384_000,
|
|
800
|
+
compat: {
|
|
801
|
+
supportsStore: false,
|
|
802
|
+
supportsDeveloperRole: false,
|
|
803
|
+
supportsReasoningEffort: true,
|
|
804
|
+
supportsUsageInStreaming: true,
|
|
805
|
+
supportsFinishReason: true,
|
|
806
|
+
maxTokensField: "max_tokens",
|
|
807
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
808
|
+
thinkingFormat: "deepseek",
|
|
809
|
+
supportsStrictMode: true,
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
],
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function probeLicense(
|
|
817
|
+
key: string,
|
|
818
|
+
): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
819
|
+
try {
|
|
820
|
+
const response = await request(
|
|
821
|
+
`${brokerUrl}/api/me/entitlement`,
|
|
822
|
+
{
|
|
823
|
+
headers: {
|
|
824
|
+
accept: "application/json",
|
|
825
|
+
authorization: `Bearer ${key}`,
|
|
826
|
+
"x-freebuild-install": installId,
|
|
827
|
+
"x-freebuild-client": CLIENT_ID,
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
REQUEST_TIMEOUT_MS,
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
if (response.status === 401 || response.status === 404) {
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
message:
|
|
837
|
+
"That license key is not active. It may have been rotated, revoked, or cancelled.",
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
if (response.status === 403) {
|
|
841
|
+
return {
|
|
842
|
+
ok: false,
|
|
843
|
+
message:
|
|
844
|
+
"That license is not on the sponsored plan, or it is bound to another install.",
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
if (!response.ok) {
|
|
848
|
+
return {
|
|
849
|
+
ok: false,
|
|
850
|
+
message: `The Freebuild broker returned ${response.status}. Try again in a moment.`,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const data = (await response.json()) as any;
|
|
855
|
+
if (!data?.sponsored || data?.panel === false) {
|
|
856
|
+
return {
|
|
857
|
+
ok: false,
|
|
858
|
+
message:
|
|
859
|
+
"That license is not on the sponsored plan. The broker rejected it.",
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
return { ok: true };
|
|
863
|
+
} catch {
|
|
864
|
+
return {
|
|
865
|
+
ok: false,
|
|
866
|
+
message:
|
|
867
|
+
"Could not reach the Freebuild broker. Check your connection and try again.",
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function openUrl(url: string): boolean {
|
|
873
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const commands: Array<[string, string[]]> =
|
|
878
|
+
process.platform === "win32"
|
|
879
|
+
? [
|
|
880
|
+
["rundll32.exe", ["url.dll,FileProtocolHandler", url]],
|
|
881
|
+
["explorer.exe", [url]],
|
|
882
|
+
["cmd.exe", ["/d", "/s", "/c", "start", "", url]],
|
|
883
|
+
]
|
|
884
|
+
: process.platform === "darwin"
|
|
885
|
+
? [["open", [url]]]
|
|
886
|
+
: [
|
|
887
|
+
["xdg-open", [url]],
|
|
888
|
+
["gio", ["open", url]],
|
|
889
|
+
];
|
|
890
|
+
|
|
891
|
+
const attempt = (index: number): boolean => {
|
|
892
|
+
const command = commands[index];
|
|
893
|
+
if (!command) {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const child = spawn(command[0], command[1], {
|
|
898
|
+
detached: true,
|
|
899
|
+
stdio: "ignore",
|
|
900
|
+
});
|
|
901
|
+
child.once("error", () => {
|
|
902
|
+
if (index + 1 < commands.length) {
|
|
903
|
+
attempt(index + 1);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
child.unref();
|
|
907
|
+
return true;
|
|
908
|
+
} catch {
|
|
909
|
+
return index + 1 < commands.length ? attempt(index + 1) : false;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
return attempt(0);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function openSponsor(ctx: ExtensionContext) {
|
|
917
|
+
if (!licenseKey) {
|
|
918
|
+
ctx.ui.notify("Run /freebuild-license first.", "warning");
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let campaign = shownCampaign;
|
|
923
|
+
if (!campaign) {
|
|
924
|
+
campaign = await fetchPanel("", true).catch(() => undefined);
|
|
925
|
+
}
|
|
926
|
+
campaign ||= houseFallback();
|
|
927
|
+
|
|
928
|
+
const target = campaign.url || campaign.clickUrl;
|
|
929
|
+
if (!target) {
|
|
930
|
+
ctx.ui.notify("No sponsor link is available.", "warning");
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const activeRun = runId;
|
|
935
|
+
if (!activeRun || campaign.id === "house" || !campaign.clickUrl) {
|
|
936
|
+
if (!openUrl(target)) {
|
|
937
|
+
ctx.ui.notify(`Sponsor: ${target}`, "info");
|
|
938
|
+
}
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
try {
|
|
943
|
+
const url = new URL(campaign.clickUrl);
|
|
944
|
+
url.searchParams.set("sid", sessionId);
|
|
945
|
+
url.searchParams.set("iid", installId);
|
|
946
|
+
url.searchParams.set("rid", activeRun);
|
|
947
|
+
url.searchParams.set("format", "json");
|
|
948
|
+
|
|
949
|
+
const data = await fetchJson(url.toString(), {
|
|
950
|
+
headers: authorizationHeaders(),
|
|
951
|
+
keepalive: true,
|
|
952
|
+
});
|
|
953
|
+
const destination = String(data?.url || "");
|
|
954
|
+
if (!destination || !openUrl(destination)) {
|
|
955
|
+
throw new Error("missing click destination");
|
|
956
|
+
}
|
|
957
|
+
} catch {
|
|
958
|
+
if (!openUrl(target)) {
|
|
959
|
+
ctx.ui.notify(`Sponsor: ${target}`, "info");
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export default function (pi: ExtensionAPI) {
|
|
965
|
+
if (licenseKey) {
|
|
966
|
+
registerProvider(pi, licenseKey);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
pi.on("before_provider_headers", (event, ctx) => {
|
|
970
|
+
const installKey = Object.keys(event.headers).find(
|
|
971
|
+
(key) => key.toLowerCase() === "x-freebuild-install",
|
|
972
|
+
);
|
|
973
|
+
if (!installKey) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Mark headless runs separately. The abuse report can then ignore
|
|
978
|
+
// usage that never had a panel to show.
|
|
979
|
+
const clientKey = Object.keys(event.headers).find(
|
|
980
|
+
(key) => key.toLowerCase() === "x-freebuild-client",
|
|
981
|
+
);
|
|
982
|
+
if (clientKey) {
|
|
983
|
+
event.headers[clientKey] = null;
|
|
984
|
+
}
|
|
985
|
+
event.headers["x-freebuild-client"] =
|
|
986
|
+
ctx.mode === "tui" || ctx.mode === "rpc"
|
|
987
|
+
? CLIENT_ID
|
|
988
|
+
: `${CLIENT_ID}-headless`;
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
pi.on("session_start", (_event, ctx) => {
|
|
992
|
+
sessionId = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
993
|
+
syncStatus(ctx);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
997
|
+
beginRun(ctx);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
1001
|
+
endRun(ctx);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
pi.on("model_select", (_event, ctx) => {
|
|
1005
|
+
syncStatus(ctx);
|
|
1006
|
+
if (!isFreebuildModel(ctx)) {
|
|
1007
|
+
endRun(ctx);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
1012
|
+
endRun(ctx);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
pi.registerCommand("freebuild-license", {
|
|
1016
|
+
description: "Save and verify a Freebuild license key",
|
|
1017
|
+
handler: async (args, ctx) => {
|
|
1018
|
+
const entered =
|
|
1019
|
+
args.trim() ||
|
|
1020
|
+
(await ctx.ui.input(
|
|
1021
|
+
"Freebuild license key",
|
|
1022
|
+
"fb_...",
|
|
1023
|
+
)) ||
|
|
1024
|
+
"";
|
|
1025
|
+
const key = entered.trim();
|
|
1026
|
+
if (!key) {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const probe = await probeLicense(key);
|
|
1031
|
+
if (!probe.ok) {
|
|
1032
|
+
ctx.ui.notify(probe.message, "error");
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
licenseKey = key;
|
|
1037
|
+
saveCredentials({ licenseKey: key, installId, brokerUrl, apiUrl });
|
|
1038
|
+
registerProvider(pi, key);
|
|
1039
|
+
|
|
1040
|
+
const model = ctx.modelRegistry.find("freebuild", "deepseek-flash");
|
|
1041
|
+
const selected = model ? await pi.setModel(model) : false;
|
|
1042
|
+
ctx.ui.notify(
|
|
1043
|
+
selected
|
|
1044
|
+
? "Freebuild activated. Model set to DeepSeek V4.1 Flash 1017."
|
|
1045
|
+
: "Freebuild activated. Run /model and choose Freebuild.",
|
|
1046
|
+
"info",
|
|
1047
|
+
);
|
|
1048
|
+
syncStatus(ctx);
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
pi.registerCommand("freebuild-sponsor", {
|
|
1053
|
+
description: "Open the current Freebuild sponsor",
|
|
1054
|
+
handler: async (_args, ctx) => {
|
|
1055
|
+
await openSponsor(ctx);
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
pi.registerCommand("freebuild-status", {
|
|
1060
|
+
description: "Show Freebuild license and sponsor status",
|
|
1061
|
+
handler: async (_args, ctx) => {
|
|
1062
|
+
const model = ctx.model
|
|
1063
|
+
? `${ctx.model.provider}/${ctx.model.id}`
|
|
1064
|
+
: "none";
|
|
1065
|
+
const maskedInstall = installId
|
|
1066
|
+
? `${installId.slice(0, 12)}...`
|
|
1067
|
+
: "missing";
|
|
1068
|
+
ctx.ui.notify(
|
|
1069
|
+
`Freebuild: ${licenseKey ? "license saved" : "no license"} | model ${model} | install ${maskedInstall} | broker ${brokerUrl}`,
|
|
1070
|
+
"info",
|
|
1071
|
+
);
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
pi.registerShortcut("alt+o", {
|
|
1076
|
+
description: "Open the Freebuild sponsor",
|
|
1077
|
+
handler: async (ctx) => {
|
|
1078
|
+
await openSponsor(ctx);
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
}
|