@promptai.credit/cli 0.4.0 → 0.4.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/README.md +29 -8
- package/claude-plugin/.claude-plugin/plugin.json +11 -0
- package/claude-plugin/README.md +33 -0
- package/claude-plugin/hooks/ads.tsx +557 -0
- package/claude-plugin/hooks/hooks.json +4 -0
- package/claude-plugin/hooks/logo.ts +167 -0
- package/dist/index.js +246 -85
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -6,17 +6,36 @@ Cursor Agents Window are supported today; Codex CLI is next.
|
|
|
6
6
|
|
|
7
7
|
## How it works
|
|
8
8
|
|
|
9
|
-
1. `promptai install claude` merges two hooks into `~/.claude/settings.json
|
|
9
|
+
1. `promptai install claude` merges two hooks into `~/.claude/settings.json` and
|
|
10
|
+
installs the Claude Mods plugin into `~/.claude/skills/promptai/`:
|
|
10
11
|
- **UserPromptSubmit**: baselines the session's transcript watermark and (if opted
|
|
11
12
|
in, at most every 90s, never headless) opens the hosted rewarded-ad page
|
|
12
|
-
(`/watch`) in your browser
|
|
13
|
+
(`/watch`) in your browser — **unless** the Mods plugin is live (heartbeat in
|
|
14
|
+
`~/.promptai/native-plugin.json`), in which case the native AbovePrompt card
|
|
15
|
+
shows the ad instead.
|
|
13
16
|
- **Stop** (async): reads the session transcript JSONL since the watermark, prices
|
|
14
17
|
the turn with the shared model price table, and redeems one banked
|
|
15
18
|
server-verified ad session against it via the API.
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
- **Mods plugin** (`product/claude-plugin`): on `prompt.submit` fetches an ad
|
|
20
|
+
session and renders a compact sponsored card via `ui.render` / `AbovePrompt`.
|
|
21
|
+
2. The `/watch` page (and the native card) create the ad session server-side, so SSV
|
|
22
|
+
wall-clock timing is enforced by the server; countdowns are cosmetic.
|
|
18
23
|
3. `promptai claim` pays out your verified balance as USDC on Base.
|
|
19
24
|
|
|
25
|
+
### Claude Mods (native card)
|
|
26
|
+
|
|
27
|
+
Native AbovePrompt ads need Claude Code function hooks (early access until GA).
|
|
28
|
+
`promptai install claude` sets `env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` in
|
|
29
|
+
`~/.claude/settings.json` (an explicit value you already set is left alone, and
|
|
30
|
+
`promptai uninstall claude` removes only what it added). This also enables any other
|
|
31
|
+
function-hook plugins you use. Where Mods are unavailable, command hooks open `/watch`
|
|
32
|
+
in the browser.
|
|
33
|
+
Dev-load the plugin without installing:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir product/claude-plugin
|
|
37
|
+
```
|
|
38
|
+
|
|
20
39
|
## Cursor Agents Window (Glass)
|
|
21
40
|
|
|
22
41
|
The Agents Window doesn't run VS Code extensions, so the promptai extension can't
|
|
@@ -45,17 +64,17 @@ State lives in `~/.promptai/` (`config.json`, `state.json`, `cli.log`).
|
|
|
45
64
|
|
|
46
65
|
```bash
|
|
47
66
|
npm install -g @promptai.credit/cli
|
|
48
|
-
promptai install claude # Claude Code hooks
|
|
67
|
+
promptai install claude # Claude Code hooks + Mods plugin
|
|
49
68
|
promptai install cursor # Cursor Agents Window / classic IDE hooks
|
|
50
69
|
```
|
|
51
70
|
|
|
52
|
-
`promptai install` then asks for your email and payout wallet
|
|
71
|
+
`promptai install` then asks for your email and payout wallet. Ads dock on the **right** by default. Enter accepts a detected email.
|
|
53
72
|
|
|
54
73
|
```bash
|
|
55
74
|
promptai status # device, balance, banked ad watches, recent prompts
|
|
56
75
|
promptai watch # open a rewarded ad now (banks a credit for later)
|
|
57
76
|
promptai claim # USDC on Base
|
|
58
|
-
promptai
|
|
77
|
+
promptai off # toggle ads off (`promptai on` to turn them back on)
|
|
59
78
|
promptai set side left # or `default` for a normal browser tab
|
|
60
79
|
```
|
|
61
80
|
|
|
@@ -77,7 +96,9 @@ Developing from the repo instead: `pnpm --filter @promptai.credit/cli build`, th
|
|
|
77
96
|
as the default during `promptai install`. Auto-detected emails link without
|
|
78
97
|
verification; typing a different address (or `promptai set email`) sends a
|
|
79
98
|
6-digit code to prove ownership.
|
|
99
|
+
- Ads dock on the right by default. Toggle them anytime with `promptai on` /
|
|
100
|
+
`promptai off`.
|
|
80
101
|
- Headless environments (CI, ssh without a display) never get browser tabs and
|
|
81
102
|
simply skip crediting.
|
|
82
103
|
- Uninstall with `promptai uninstall claude`; other hooks in your settings are
|
|
83
|
-
preserved.
|
|
104
|
+
preserved, and the Mods plugin under `~/.claude/skills/promptai/` is removed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "promptai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Opt-in rewarded ads above the Claude Code prompt. Watch a short sponsored card while the agent works; verified watches fund prompt credits via promptai.credit.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "PromptAI",
|
|
7
|
+
"url": "https://promptai.credit"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://promptai.credit",
|
|
10
|
+
"keywords": ["ads", "credits", "promptai", "rewarded"]
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# PromptAI Claude Code plugin
|
|
2
|
+
|
|
3
|
+
Native rewarded-ad card for Claude Code via **Claude Mods** (function hooks).
|
|
4
|
+
|
|
5
|
+
While the agent works, a compact sponsored row renders above the prompt
|
|
6
|
+
(`AbovePrompt`). After the server-verified watch window, the credit is banked
|
|
7
|
+
the same way as the browser `/watch` path. Transcript pricing and redeem still
|
|
8
|
+
run through the `promptai` CLI `Stop` command hook.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Claude Code with function hooks enabled (early access). `promptai install claude`
|
|
13
|
+
sets `env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` in `~/.claude/settings.json`; to enable
|
|
14
|
+
it by hand, use `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude`.
|
|
15
|
+
|
|
16
|
+
- `promptai install claude` (wires command hooks, enables the flag, copies this plugin)
|
|
17
|
+
|
|
18
|
+
## Dev load (without install)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/product/claude-plugin
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Notes
|
|
25
|
+
|
|
26
|
+
- If Mods are off or the module fails to load, the CLI falls back to opening
|
|
27
|
+
`/watch` in the browser (fail open).
|
|
28
|
+
- The plugin writes `~/.promptai/native-plugin.json` on `session.start` so the
|
|
29
|
+
CLI can skip the browser tab while the native card is live.
|
|
30
|
+
- The brand logo (PNG, favicon or SVG) is drawn as half-block cells, so it works in any
|
|
31
|
+
terminal. It is fetched with `curl` and converted with `sips` (macOS) or ImageMagick;
|
|
32
|
+
SVGs need `qlmanage` (macOS) or `rsvg-convert`. If any step is missing the card simply
|
|
33
|
+
draws without a logo. Results are cached under `~/.promptai/logos/`.
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptAI native rewarded-ad card for Claude Code (Mods / function hooks).
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle:
|
|
5
|
+
* 1. session.start — load ~/.promptai config; heartbeat so CLI skips /watch
|
|
6
|
+
* 2. prompt.submit — fire-and-forget POST /ads/session (never await before next)
|
|
7
|
+
* 3. ui.render AbovePrompt — compact sponsored card + countdown
|
|
8
|
+
* 4. clock tick — when minWatchMs elapsed, POST /ads/complete
|
|
9
|
+
* 5. CTA press — beacon click, open destination in OS browser
|
|
10
|
+
*
|
|
11
|
+
* Stop / transcript pricing / credit redeem stay on the command-hook CLI path.
|
|
12
|
+
*
|
|
13
|
+
* Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 until Mods are generally available.
|
|
14
|
+
*/
|
|
15
|
+
import type { EngineInterface as Engine, Register, Timer } from "claude-code";
|
|
16
|
+
import { WORK_PX, bitmapToCells, decodeBase64, hashKey, parseBmp, type LogoCells } from "./logo.ts";
|
|
17
|
+
|
|
18
|
+
const SOURCE = "claude-mods";
|
|
19
|
+
/** Logo cell grid: half-blocks, so 12 columns × 6 rows is a square of 12×12 pixels. */
|
|
20
|
+
const LOGO_COLS = 12;
|
|
21
|
+
const LOGO_ROWS = 6;
|
|
22
|
+
const HEARTBEAT_FILE = "native-plugin.json";
|
|
23
|
+
/** How long the CLI treats a heartbeat as "plugin is live" before falling back to /watch. */
|
|
24
|
+
const HEARTBEAT_FRESH_MS = 2 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
interface AdCreative {
|
|
27
|
+
id: string;
|
|
28
|
+
brand: string;
|
|
29
|
+
tag: string;
|
|
30
|
+
tagline: string;
|
|
31
|
+
lines: Array<{ cmd: boolean; text: string }>;
|
|
32
|
+
ctaLabel: string;
|
|
33
|
+
ctaUrl: string;
|
|
34
|
+
destinationUrl?: string;
|
|
35
|
+
logoUrl?: string;
|
|
36
|
+
minWatchMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PromptaiConfig {
|
|
40
|
+
deviceId: string;
|
|
41
|
+
serverUrl: string;
|
|
42
|
+
adsOptIn: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type AdPhase = "idle" | "loading" | "showing" | "verified" | "failed";
|
|
46
|
+
|
|
47
|
+
interface ActiveAd {
|
|
48
|
+
phase: AdPhase;
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
ad?: AdCreative;
|
|
51
|
+
shownAt?: number;
|
|
52
|
+
error?: string;
|
|
53
|
+
tick?: Timer;
|
|
54
|
+
logo?: LogoCells;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const active: ActiveAd = { phase: "idle" };
|
|
58
|
+
let config: PromptaiConfig | null = null;
|
|
59
|
+
let homeDir = "";
|
|
60
|
+
let startingSession = false;
|
|
61
|
+
|
|
62
|
+
function configPath(): string {
|
|
63
|
+
return `${homeDir}/.promptai/config.json`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function statePath(): string {
|
|
67
|
+
return `${homeDir}/.promptai/state.json`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function heartbeatPath(): string {
|
|
71
|
+
return `${homeDir}/.promptai/${HEARTBEAT_FILE}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function resolveHome($: Engine): Promise<string> {
|
|
75
|
+
try {
|
|
76
|
+
return ((await $.env.get("HOME")) ?? "").trim();
|
|
77
|
+
} catch {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readJson<T>($: Engine, path: string, fallback: T): Promise<T> {
|
|
83
|
+
try {
|
|
84
|
+
if (!(await $.fs.exists(path))) return fallback;
|
|
85
|
+
const raw = await $.fs.read(path);
|
|
86
|
+
return { ...fallback, ...(JSON.parse(raw) as T) };
|
|
87
|
+
} catch {
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeJson($: Engine, path: string, value: unknown): Promise<void> {
|
|
93
|
+
try {
|
|
94
|
+
await $.fs.write(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
95
|
+
} catch {
|
|
96
|
+
// never break the session for local state
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadConfig($: Engine): Promise<PromptaiConfig | null> {
|
|
101
|
+
if (!homeDir) return null;
|
|
102
|
+
const cfg = await readJson<PromptaiConfig>($, configPath(), {
|
|
103
|
+
deviceId: "",
|
|
104
|
+
serverUrl: "https://api.promptai.credit",
|
|
105
|
+
adsOptIn: true,
|
|
106
|
+
});
|
|
107
|
+
if (!cfg.deviceId) return null;
|
|
108
|
+
if (!cfg.serverUrl) cfg.serverUrl = "https://api.promptai.credit";
|
|
109
|
+
return cfg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function bumpLastAdOpenedAt($: Engine, at: number): Promise<void> {
|
|
113
|
+
const state = await readJson<Record<string, unknown>>($, statePath(), {});
|
|
114
|
+
state.lastAdOpenedAt = at;
|
|
115
|
+
await writeJson($, statePath(), state);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function writeHeartbeat($: Engine): Promise<void> {
|
|
119
|
+
await writeJson($, heartbeatPath(), {
|
|
120
|
+
active: true,
|
|
121
|
+
at: Date.now(),
|
|
122
|
+
source: SOURCE,
|
|
123
|
+
freshMs: HEARTBEAT_FRESH_MS,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Same palette as the /watch page so the terminal card reads as one brand.
|
|
128
|
+
const C = {
|
|
129
|
+
fg: "#fafafa",
|
|
130
|
+
soft: "#b9b9b9",
|
|
131
|
+
dim: "#8f8f8f",
|
|
132
|
+
faint: "#525252",
|
|
133
|
+
green: "#4ade80",
|
|
134
|
+
red: "#f87171",
|
|
135
|
+
amber: "#fbbf24",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function progressBar(elapsed: number, total: number, width = 20): { filled: string; empty: string } {
|
|
139
|
+
const ratio = Math.min(1, Math.max(0, elapsed / Math.max(1, total)));
|
|
140
|
+
const n = Math.round(ratio * width);
|
|
141
|
+
return { filled: "█".repeat(n), empty: "░".repeat(width - n) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function clearTick(): void {
|
|
145
|
+
const tick = active.tick;
|
|
146
|
+
active.tick = undefined;
|
|
147
|
+
try {
|
|
148
|
+
tick?.cancel();
|
|
149
|
+
} catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resetAd(phase: AdPhase = "idle"): void {
|
|
155
|
+
clearTick();
|
|
156
|
+
active.phase = phase;
|
|
157
|
+
active.sessionId = undefined;
|
|
158
|
+
active.ad = undefined;
|
|
159
|
+
active.shownAt = undefined;
|
|
160
|
+
active.error = undefined;
|
|
161
|
+
active.logo = undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseBody<T>(text: string): T {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(text) as T;
|
|
167
|
+
} catch {
|
|
168
|
+
return {} as T;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True while /ads/complete is in flight, so a slow response is never asked twice. */
|
|
173
|
+
let completing = false;
|
|
174
|
+
|
|
175
|
+
async function completeAd($: Engine): Promise<void> {
|
|
176
|
+
if (completing || !config || !active.sessionId || active.phase !== "showing") return;
|
|
177
|
+
completing = true;
|
|
178
|
+
// Stop the countdown first: with the request in flight the phase is still
|
|
179
|
+
// "showing", and another tick would send a second complete ("already verified").
|
|
180
|
+
clearTick();
|
|
181
|
+
const sessionId = active.sessionId;
|
|
182
|
+
try {
|
|
183
|
+
const res = await $.http.fetch(`${config.serverUrl}/ads/complete`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({ sessionId, deviceId: config.deviceId }),
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const body = parseBody<{ error?: string }>(res.text);
|
|
190
|
+
active.phase = "failed";
|
|
191
|
+
active.error = body.error ?? `complete ${res.status}`;
|
|
192
|
+
clearTick();
|
|
193
|
+
$.ui.invalidate("ui.render");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
active.phase = "verified";
|
|
197
|
+
clearTick();
|
|
198
|
+
$.ui.invalidate("ui.render");
|
|
199
|
+
// Auto-collapse the verified strip after a short beat.
|
|
200
|
+
$.clock.after(8_000, () => {
|
|
201
|
+
if (active.phase === "verified") {
|
|
202
|
+
resetAd("idle");
|
|
203
|
+
$.ui.invalidate("ui.render");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
active.phase = "failed";
|
|
208
|
+
active.error = String(err);
|
|
209
|
+
clearTick();
|
|
210
|
+
$.ui.invalidate("ui.render");
|
|
211
|
+
} finally {
|
|
212
|
+
completing = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function startCountdown($: Engine): void {
|
|
217
|
+
clearTick();
|
|
218
|
+
active.tick = $.clock.every(1_000, () => {
|
|
219
|
+
if (active.phase !== "showing" || !active.ad || active.shownAt == null) return;
|
|
220
|
+
const elapsed = Date.now() - active.shownAt;
|
|
221
|
+
if (elapsed >= active.ad.minWatchMs) {
|
|
222
|
+
void completeAd($);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
$.ui.invalidate("ui.render");
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function openDestination($: Engine, url: string): Promise<void> {
|
|
230
|
+
const candidates: string[][] = [
|
|
231
|
+
["xdg-open", url],
|
|
232
|
+
["open", url],
|
|
233
|
+
["cmd", "/c", "start", "", url],
|
|
234
|
+
];
|
|
235
|
+
for (const argv of candidates) {
|
|
236
|
+
try {
|
|
237
|
+
const result = await $.process.run(argv, { timeoutMs: 5_000 });
|
|
238
|
+
if (result.exitCode === 0) return;
|
|
239
|
+
} catch {
|
|
240
|
+
// try next opener
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
$.ui.toast(`Open in browser: ${url}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function onCtaPress($: Engine): Promise<void> {
|
|
247
|
+
if (!config || !active.sessionId || !active.ad) return;
|
|
248
|
+
const ad = active.ad;
|
|
249
|
+
const clickUrl = ad.ctaUrl.includes("/ads/click/")
|
|
250
|
+
? ad.ctaUrl
|
|
251
|
+
: `${config.serverUrl}/ads/click/${active.sessionId}`;
|
|
252
|
+
try {
|
|
253
|
+
void $.http.fetch(clickUrl, { method: "GET" });
|
|
254
|
+
} catch {
|
|
255
|
+
// click is best-effort
|
|
256
|
+
}
|
|
257
|
+
const target =
|
|
258
|
+
ad.destinationUrl ||
|
|
259
|
+
(ad.ctaUrl.includes("/ads/click/") ? undefined : ad.ctaUrl);
|
|
260
|
+
if (target) await openDestination($, target);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- logo host pipeline (curl → svg→png → bmp; pure image code is in logo.ts) ---
|
|
264
|
+
|
|
265
|
+
async function tryRun($: Engine, argv: string[], timeoutMs = 8_000): Promise<{ ok: boolean; stdout: string }> {
|
|
266
|
+
try {
|
|
267
|
+
const res = await $.process.run(argv, { timeoutMs });
|
|
268
|
+
return { ok: res.exitCode === 0, stdout: res.stdout };
|
|
269
|
+
} catch {
|
|
270
|
+
return { ok: false, stdout: "" };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function exists($: Engine, path: string): Promise<boolean> {
|
|
275
|
+
try {
|
|
276
|
+
return await $.fs.exists(path);
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function svgToPng($: Engine, svg: string, dir: string, png: string): Promise<boolean> {
|
|
283
|
+
// macOS Quick Look thumbnailer: writes <dir>/<file>.png
|
|
284
|
+
const ql = await tryRun($, ["qlmanage", "-t", "-s", String(WORK_PX * 2), "-o", dir, svg]);
|
|
285
|
+
if (ql.ok && (await exists($, `${svg}.png`))) {
|
|
286
|
+
await tryRun($, ["mv", "-f", `${svg}.png`, png]);
|
|
287
|
+
if (await exists($, png)) return true;
|
|
288
|
+
}
|
|
289
|
+
const rsvg = await tryRun($, ["rsvg-convert", "-w", String(WORK_PX), "-h", String(WORK_PX), "-f", "png", "-o", png, svg]);
|
|
290
|
+
return rsvg.ok && (await exists($, png));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function toBmp($: Engine, input: string, bmp: string): Promise<boolean> {
|
|
294
|
+
const attempts: string[][] = [
|
|
295
|
+
["sips", "-s", "format", "bmp", "-Z", String(WORK_PX), input, "--out", bmp],
|
|
296
|
+
["magick", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
|
|
297
|
+
["convert", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
|
|
298
|
+
];
|
|
299
|
+
for (const argv of attempts) {
|
|
300
|
+
const res = await tryRun($, argv);
|
|
301
|
+
if (res.ok && (await exists($, bmp))) return true;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const failedLogos = new Set<string>();
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Load `url` as terminal cells, caching the result under
|
|
310
|
+
* `<home>/.promptai/logos/`. Resolves null when anything is unavailable.
|
|
311
|
+
*/
|
|
312
|
+
async function loadLogoCells(
|
|
313
|
+
$: Engine,
|
|
314
|
+
opts: { url: string; home: string; columns: number; rows: number },
|
|
315
|
+
): Promise<LogoCells | null> {
|
|
316
|
+
const { url, home, columns, rows } = opts;
|
|
317
|
+
if (!home || !/^https?:\/\//i.test(url)) return null;
|
|
318
|
+
|
|
319
|
+
const key = `${hashKey(url)}-${columns}x${rows}`;
|
|
320
|
+
if (failedLogos.has(key)) return null;
|
|
321
|
+
|
|
322
|
+
const dir = `${home}/.promptai/logos`;
|
|
323
|
+
const cacheFile = `${dir}/${key}.cells.json`;
|
|
324
|
+
try {
|
|
325
|
+
if (await exists($, cacheFile)) {
|
|
326
|
+
const cached = JSON.parse(await $.fs.read(cacheFile)) as LogoCells;
|
|
327
|
+
if (cached.cells && cached.columns === columns && cached.rows === rows) return cached;
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// rebuild below
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const src = `${dir}/${key}.src`;
|
|
334
|
+
const png = `${dir}/${key}.png`;
|
|
335
|
+
const bmp = `${dir}/${key}.bmp`;
|
|
336
|
+
try {
|
|
337
|
+
const dl = await tryRun($, [
|
|
338
|
+
"curl", "-sSL", "--max-time", "5", "--max-filesize", "2000000", "--create-dirs",
|
|
339
|
+
"-o", src, "-w", "%{content_type}", url,
|
|
340
|
+
]);
|
|
341
|
+
if (!dl.ok || !(await exists($, src))) throw new Error("download");
|
|
342
|
+
|
|
343
|
+
const isSvg = /svg/i.test(dl.stdout) || /\.svg(\?|$)/i.test(url);
|
|
344
|
+
let input = src;
|
|
345
|
+
if (isSvg) {
|
|
346
|
+
const svgFile = `${src}.svg`;
|
|
347
|
+
await tryRun($, ["mv", "-f", src, svgFile]);
|
|
348
|
+
if (!(await svgToPng($, svgFile, dir, png))) throw new Error("svg");
|
|
349
|
+
input = png;
|
|
350
|
+
}
|
|
351
|
+
if (!(await toBmp($, input, bmp))) throw new Error("bmp");
|
|
352
|
+
|
|
353
|
+
const { base64 } = await $.fs.read(bmp, { as: "bytes" });
|
|
354
|
+
const bitmap = parseBmp(decodeBase64(base64));
|
|
355
|
+
if (!bitmap) throw new Error("parse");
|
|
356
|
+
|
|
357
|
+
const logo = bitmapToCells(bitmap, columns, rows);
|
|
358
|
+
await $.fs.write(cacheFile, JSON.stringify(logo));
|
|
359
|
+
return logo;
|
|
360
|
+
} catch {
|
|
361
|
+
failedLogos.add(key);
|
|
362
|
+
return null;
|
|
363
|
+
} finally {
|
|
364
|
+
await tryRun($, ["rm", "-f", src, `${src}.svg`, png, bmp]);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Fire-and-forget: the card draws at once, the logo joins it when ready. */
|
|
369
|
+
async function loadLogo($: Engine, ad: AdCreative): Promise<void> {
|
|
370
|
+
if (!ad.logoUrl) return;
|
|
371
|
+
const logo = await loadLogoCells($, { url: ad.logoUrl, home: homeDir, columns: LOGO_COLS, rows: LOGO_ROWS });
|
|
372
|
+
if (logo && active.ad?.id === ad.id) {
|
|
373
|
+
active.logo = logo;
|
|
374
|
+
$.ui.invalidate("ui.render");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function beginAdSession($: Engine): Promise<void> {
|
|
379
|
+
if (!config || !config.adsOptIn || startingSession) return;
|
|
380
|
+
if (active.phase === "loading" || active.phase === "showing") return;
|
|
381
|
+
|
|
382
|
+
startingSession = true;
|
|
383
|
+
active.phase = "loading";
|
|
384
|
+
$.ui.invalidate("ui.render");
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const res = await $.http.fetch(`${config.serverUrl}/ads/session`, {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: { "Content-Type": "application/json" },
|
|
390
|
+
body: JSON.stringify({ deviceId: config.deviceId, source: SOURCE }),
|
|
391
|
+
});
|
|
392
|
+
const body = parseBody<{
|
|
393
|
+
sessionId?: string;
|
|
394
|
+
ad?: AdCreative;
|
|
395
|
+
error?: string;
|
|
396
|
+
}>(res.text);
|
|
397
|
+
if (!res.ok || !body.sessionId || !body.ad) {
|
|
398
|
+
active.phase = "failed";
|
|
399
|
+
active.error = body.error ?? `session ${res.status}`;
|
|
400
|
+
$.ui.invalidate("ui.render");
|
|
401
|
+
$.clock.after(5_000, () => {
|
|
402
|
+
if (active.phase === "failed") {
|
|
403
|
+
resetAd("idle");
|
|
404
|
+
$.ui.invalidate("ui.render");
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
active.phase = "showing";
|
|
412
|
+
active.sessionId = body.sessionId;
|
|
413
|
+
active.ad = body.ad;
|
|
414
|
+
active.shownAt = now;
|
|
415
|
+
active.error = undefined;
|
|
416
|
+
await bumpLastAdOpenedAt($, now);
|
|
417
|
+
startCountdown($);
|
|
418
|
+
$.ui.invalidate("ui.render");
|
|
419
|
+
void loadLogo($, body.ad);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
active.phase = "failed";
|
|
422
|
+
active.error = String(err);
|
|
423
|
+
$.ui.invalidate("ui.render");
|
|
424
|
+
$.clock.after(5_000, () => {
|
|
425
|
+
if (active.phase === "failed") {
|
|
426
|
+
resetAd("idle");
|
|
427
|
+
$.ui.invalidate("ui.render");
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
} finally {
|
|
431
|
+
startingSession = false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function remainingSeconds($: Engine): number {
|
|
436
|
+
if (!active.ad || active.shownAt == null) return 0;
|
|
437
|
+
const left = active.ad.minWatchMs - (Date.now() - active.shownAt);
|
|
438
|
+
return Math.max(0, Math.ceil(left / 1000));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export const register: Register = (on) => {
|
|
442
|
+
on("session.start", async ($, e, next) => {
|
|
443
|
+
homeDir = await resolveHome($);
|
|
444
|
+
config = await loadConfig($);
|
|
445
|
+
if (config?.adsOptIn) {
|
|
446
|
+
await writeHeartbeat($);
|
|
447
|
+
}
|
|
448
|
+
return next(e);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Fire-and-forget: never await the session fetch before next(e).
|
|
452
|
+
on("prompt.submit", ($, e, next) => {
|
|
453
|
+
if (config?.adsOptIn) {
|
|
454
|
+
void writeHeartbeat($);
|
|
455
|
+
void beginAdSession($);
|
|
456
|
+
}
|
|
457
|
+
return next(e);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
|
|
461
|
+
if (!config?.adsOptIn || active.phase === "idle") {
|
|
462
|
+
return next(e);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const t = await $.ui.resolve(e);
|
|
466
|
+
|
|
467
|
+
if (active.phase === "loading") {
|
|
468
|
+
return (
|
|
469
|
+
<t.Box borderStyle="round" borderColor={C.faint} paddingX={1} gap={1}>
|
|
470
|
+
<t.Text color={C.amber}>●</t.Text>
|
|
471
|
+
<t.Text color={C.dim}>promptai · loading sponsored card…</t.Text>
|
|
472
|
+
</t.Box>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (active.phase === "failed") {
|
|
477
|
+
return (
|
|
478
|
+
<t.Box borderStyle="round" borderColor={C.red} paddingX={1} gap={1}>
|
|
479
|
+
<t.Text color={C.red}>✕</t.Text>
|
|
480
|
+
<t.Text color={C.dim}>{`promptai · ad unavailable${active.error ? ` (${active.error})` : ""}`}</t.Text>
|
|
481
|
+
</t.Box>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (active.phase === "verified") {
|
|
486
|
+
return (
|
|
487
|
+
<t.Box borderStyle="round" borderColor={C.green} paddingX={1} gap={1}>
|
|
488
|
+
<t.Text color={C.green} bold>✓ verified</t.Text>
|
|
489
|
+
<t.Text color={C.soft}>credit banked</t.Text>
|
|
490
|
+
<t.Text color={C.faint}>·</t.Text>
|
|
491
|
+
<t.Button
|
|
492
|
+
key="hide"
|
|
493
|
+
label="Hide"
|
|
494
|
+
plain
|
|
495
|
+
onPress={() => {
|
|
496
|
+
resetAd("idle");
|
|
497
|
+
$.ui.invalidate("ui.render");
|
|
498
|
+
}}
|
|
499
|
+
/>
|
|
500
|
+
</t.Box>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (active.phase !== "showing" || !active.ad || active.shownAt == null) {
|
|
505
|
+
return next(e);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const ad = active.ad;
|
|
509
|
+
const elapsed = Date.now() - active.shownAt;
|
|
510
|
+
const bar = progressBar(elapsed, ad.minWatchMs);
|
|
511
|
+
const secs = remainingSeconds($);
|
|
512
|
+
const shownLines = (ad.lines ?? []).slice(0, 4);
|
|
513
|
+
|
|
514
|
+
const Raster = "Raster" in t ? t.Raster : null;
|
|
515
|
+
const logo = active.logo;
|
|
516
|
+
|
|
517
|
+
const details = (
|
|
518
|
+
<t.Box flexDirection="column" flexGrow={1}>
|
|
519
|
+
<t.Box gap={1}>
|
|
520
|
+
<t.Text color={C.green}>◆</t.Text>
|
|
521
|
+
<t.Text color={C.fg} bold>{ad.brand}</t.Text>
|
|
522
|
+
{ad.tag ? <t.Text color={C.dim}>{`· ${ad.tag}`}</t.Text> : null}
|
|
523
|
+
<t.Text color={C.faint}>· sponsored</t.Text>
|
|
524
|
+
</t.Box>
|
|
525
|
+
<t.Text color={C.soft} wrap="truncate-end">{ad.tagline}</t.Text>
|
|
526
|
+
{shownLines.map((line, i) => (
|
|
527
|
+
<t.Box key={`line-${i}`} gap={1}>
|
|
528
|
+
{line.cmd ? <t.Text color={C.green}>$</t.Text> : <t.Text color={C.faint}>│</t.Text>}
|
|
529
|
+
<t.Text color={line.cmd ? C.fg : C.dim} wrap="truncate-end">{line.text}</t.Text>
|
|
530
|
+
</t.Box>
|
|
531
|
+
))}
|
|
532
|
+
</t.Box>
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<t.Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1}>
|
|
537
|
+
{Raster && logo ? (
|
|
538
|
+
<t.Box gap={2}>
|
|
539
|
+
<Raster key="logo" columns={logo.columns} rows={logo.rows} cells={logo.cells} />
|
|
540
|
+
{details}
|
|
541
|
+
</t.Box>
|
|
542
|
+
) : (
|
|
543
|
+
details
|
|
544
|
+
)}
|
|
545
|
+
<t.Box gap={1}>
|
|
546
|
+
<t.Box>
|
|
547
|
+
<t.Text color={C.green}>{bar.filled}</t.Text>
|
|
548
|
+
<t.Text color={C.faint}>{bar.empty}</t.Text>
|
|
549
|
+
</t.Box>
|
|
550
|
+
<t.Text color={C.fg} bold>{`${secs}s`}</t.Text>
|
|
551
|
+
<t.Text color={C.faint}>opt-in rewarded ad</t.Text>
|
|
552
|
+
</t.Box>
|
|
553
|
+
<t.Button key="cta" label={`${ad.ctaLabel || "Learn more"} →`} onPress={() => void onCtaPress($)} />
|
|
554
|
+
</t.Box>
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
};
|