acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/parallel.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ SEVERAL TASKS AT ONCE — ROMAN HAS ASKED FOR THIS FOUR TIMES ──────────
|
|
3
|
+
*
|
|
4
|
+
* *"so people can do mass work and multiple terminals, that's a good ass
|
|
5
|
+
* feature."* It is, and nobody in this category ships it well.
|
|
6
|
+
*
|
|
7
|
+
* The naive version is `for (const t of tasks) await run(t)`, which is not
|
|
8
|
+
* parallel, and the naive-parallel version is `Promise.all(tasks.map(run))`,
|
|
9
|
+
* which is parallel and dangerous. This file is the difference.
|
|
10
|
+
*
|
|
11
|
+
* ── ⚠️ THE THING THAT MAKES THIS HARD IS NOT CONCURRENCY, IT IS COLLISION ────
|
|
12
|
+
* Two agents in ONE workspace will eventually write the same file. Whoever
|
|
13
|
+
* finishes second wins, silently, and the first task reports success for work
|
|
14
|
+
* that no longer exists. ⭐ That is the failure to design around — not speed.
|
|
15
|
+
*
|
|
16
|
+
* Three honest options, and why this one:
|
|
17
|
+
*
|
|
18
|
+
* · **Merge them.** No. Merging two model-authored versions of a file without
|
|
19
|
+
* a human is how you get code that compiles and means nothing.
|
|
20
|
+
* · **Lock per file.** Tempting, but a lock held across a model call is a
|
|
21
|
+
* minute of one task blocking another, and deadlock is a real outcome once
|
|
22
|
+
* two tasks want two files in different orders.
|
|
23
|
+
* · **Let them run, DETECT the overlap, and refuse to pretend.** ⭐ This one.
|
|
24
|
+
* Every task records the files it wrote; an overlap is reported loudly with
|
|
25
|
+
* both task names, and the run is marked conflicted. Nothing is silently
|
|
26
|
+
* lost, and the human decides.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ Sequential remains the default and always will be. Parallelism is opt-in
|
|
29
|
+
* because the safe thing must be what happens when you do not think about it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { changedPaths } from './changed-paths.mjs';
|
|
33
|
+
|
|
34
|
+
/** Sensible ceiling. Beyond this the provider rate-limits and everything slows. */
|
|
35
|
+
export const MAX_CONCURRENCY = 4;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run tasks with bounded concurrency, preserving input order in the results.
|
|
39
|
+
*
|
|
40
|
+
* `runOne(task, index)` performs one task and resolves to its outcome.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ A REJECTION BECOMES A RESULT, NEVER AN UNHANDLED THROW. One task failing
|
|
43
|
+
* must not abandon three that are mid-flight — `Promise.all` semantics here
|
|
44
|
+
* would strand real work in a half-written state.
|
|
45
|
+
*/
|
|
46
|
+
export async function runPool(tasks, runOne, { concurrency = 2 } = {}) {
|
|
47
|
+
const limit = Math.max(1, Math.min(concurrency, MAX_CONCURRENCY));
|
|
48
|
+
const results = new Array(tasks.length);
|
|
49
|
+
let next = 0;
|
|
50
|
+
|
|
51
|
+
async function worker() {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const i = next++;
|
|
54
|
+
if (i >= tasks.length) return;
|
|
55
|
+
try {
|
|
56
|
+
results[i] = { ok: true, index: i, task: tasks[i], outcome: await runOne(tasks[i], i) };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
results[i] = { ok: false, index: i, task: tasks[i], error: err?.message ?? String(err) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Which files did each task write, and did any two collide?
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ READS `mutated`, NOT THE TOOL NAME. A future tool that also touches disk
|
|
71
|
+
* would be invisible to a name-based check — the dispatcher already sets
|
|
72
|
+
* `mutated` for exactly this reason, and trusting it keeps one source of truth.
|
|
73
|
+
*/
|
|
74
|
+
export function detectConflicts(results) {
|
|
75
|
+
/** @type {Map<string, string[]>} path -> task labels that wrote it */
|
|
76
|
+
const byPath = new Map();
|
|
77
|
+
for (const r of results) {
|
|
78
|
+
if (!r?.ok || !r.outcome?.executed) continue;
|
|
79
|
+
const label = shortLabel(r.task, r.index);
|
|
80
|
+
for (const rec of r.outcome.executed) {
|
|
81
|
+
/**
|
|
82
|
+
* ⭐ ONE SHARED ANSWER — see `changed-paths.mjs`. This used to be
|
|
83
|
+
* `rec.mutatedPath ?? rec.result.path` inline, which was right for the
|
|
84
|
+
* shapes that existed then and BLIND to two that exist now: a bulk
|
|
85
|
+
* `write_files` reports only its first path that way, and a `delegate`
|
|
86
|
+
* that built reports none of them. Both are file writes; a collision
|
|
87
|
+
* detector that cannot see a file write is the one thing it must not be.
|
|
88
|
+
*/
|
|
89
|
+
for (const path of changedPaths(rec)) {
|
|
90
|
+
const list = byPath.get(path) ?? [];
|
|
91
|
+
if (!list.includes(label)) list.push(label);
|
|
92
|
+
byPath.set(path, list);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const conflicts = [...byPath.entries()]
|
|
97
|
+
.filter(([, who]) => who.length > 1)
|
|
98
|
+
.map(([path, who]) => ({ path, tasks: who }));
|
|
99
|
+
return { conflicts, filesTouched: byPath.size };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A stable, short name for a task, for use in reports. */
|
|
103
|
+
export function shortLabel(task, index) {
|
|
104
|
+
const t = String(task ?? '').trim().replace(/\s+/g, ' ');
|
|
105
|
+
return `#${index + 1} ${t.length > 42 ? `${t.slice(0, 39)}…` : t}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Render the whole run.
|
|
110
|
+
*
|
|
111
|
+
* ⭐ CONFLICTS ARE THE HEADLINE, NOT A FOOTNOTE. If two tasks wrote the same
|
|
112
|
+
* file, that is the most important fact on the screen — more important than any
|
|
113
|
+
* of them "succeeding", because one of those successes is a lie.
|
|
114
|
+
*/
|
|
115
|
+
export function formatParallelSummary(results, { conflicts, filesTouched }) {
|
|
116
|
+
const lines = ['', '─'.repeat(66)];
|
|
117
|
+
const done = results.filter((r) => r?.ok && r.outcome?.ok).length;
|
|
118
|
+
const cost = results.reduce((a, r) => a + (r?.outcome?.usage?.cost ?? 0), 0);
|
|
119
|
+
|
|
120
|
+
for (const r of results) {
|
|
121
|
+
if (!r) continue;
|
|
122
|
+
const label = shortLabel(r.task, r.index);
|
|
123
|
+
if (!r.ok) { lines.push(` ✖ ${label} — ${r.error}`); continue; }
|
|
124
|
+
const o = r.outcome;
|
|
125
|
+
const wrote = (o?.executed ?? []).filter((e) => e.mutated).length;
|
|
126
|
+
const verdict = o?.ok === false ? '✖ failed' : o?.verification?.passed ? '✔ verified' : '· done';
|
|
127
|
+
lines.push(` ${verdict} ${label} ${wrote} file${wrote === 1 ? '' : 's'}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push('', ` ${done}/${results.length} tasks · ${filesTouched} file${filesTouched === 1 ? '' : 's'} touched${cost ? ` · $${cost.toFixed(4)}` : ''}`);
|
|
131
|
+
|
|
132
|
+
if (conflicts.length > 0) {
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(` ⚠ ${conflicts.length} FILE${conflicts.length === 1 ? '' : 'S'} WRITTEN BY MORE THAN ONE TASK:`);
|
|
135
|
+
for (const c of conflicts) {
|
|
136
|
+
lines.push(` ${c.path}`);
|
|
137
|
+
lines.push(` ${c.tasks.join(' · ')}`);
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push(' ⚠ The last writer won. Whatever the other task did to that file is GONE —');
|
|
141
|
+
lines.push(' check it before trusting either result. Run them sequentially to avoid this.');
|
|
142
|
+
}
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ PERCHANCE, NATIVELY. NO BROWSER, NO MODAL, NO DEPENDENCY. ───────────
|
|
3
|
+
*
|
|
4
|
+
* Until today this capability needed a headless Chromium: either running on the
|
|
5
|
+
* user's machine or hosted on Modal, where it took 49s at best and returned
|
|
6
|
+
* "no finished image within 300s" at worst. Both are gone.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ AND I SAID THIS WAS IMPOSSIBLE. I tried `fetch()` once, got Cloudflare's
|
|
9
|
+
* "Just a moment...", and concluded a native path needed curl_cffi. Roman did
|
|
10
|
+
* not accept that. The measurement that followed is in `h2.mjs`: it takes
|
|
11
|
+
* HTTP/2 **and** Safari's cipher ordering, both in `node:http2`, and neither
|
|
12
|
+
* alone is enough. Three trials per cell, 200 200 200 against 403 403 403.
|
|
13
|
+
*
|
|
14
|
+
* ── THE PROTOCOL, CAPTURED FROM THE REAL CLIENT ─────────────────────────────
|
|
15
|
+
* Reverse-engineered by intercepting the generator's own requests, because the
|
|
16
|
+
* parameter shape is not documented anywhere and guessing it produced exactly
|
|
17
|
+
* one useful signal: `{"status":"invalid_parameter"}`.
|
|
18
|
+
*
|
|
19
|
+
* 1. GET /api/verifyUser -> { userKey }
|
|
20
|
+
* 2. POST /api/generate?userKey&requestId… -> { status }
|
|
21
|
+
* ⚠️ A POST WITH A JSON BODY. My first attempt used a GET with query
|
|
22
|
+
* params — every field in the right place, and completely wrong.
|
|
23
|
+
* 3. GET /api/getUserQueuePosition?… -> poll until status:"success"
|
|
24
|
+
* 4. GET /api/downloadTemporaryImageViaProxy -> the bytes
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ FRAGILE BY CONSTRUCTION, AND THAT IS STATED RATHER THAN HIDDEN. This is an
|
|
27
|
+
* undocumented private API reached through a fingerprint that a CDN may stop
|
|
28
|
+
* honouring at any time. It has no SLA and owes us nothing. Everything that uses
|
|
29
|
+
* it must keep a fallback that does not depend on it — which is why the image
|
|
30
|
+
* chain still has a second provider.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { h2Request, h2Json } from './h2.mjs';
|
|
34
|
+
|
|
35
|
+
const HOST = 'https://image-generation.perchance.org';
|
|
36
|
+
const CHANNEL = 'ai-text-to-image-generator';
|
|
37
|
+
|
|
38
|
+
/** The generator's own cadence: it polls about once a second. */
|
|
39
|
+
const POLL_INTERVAL_MS = 1_200;
|
|
40
|
+
/** Total budget for one image. Beyond this an interactive loop has been abandoned. */
|
|
41
|
+
const DEFAULT_BUDGET_MS = 90_000;
|
|
42
|
+
|
|
43
|
+
/** Perchance uses a bare `Math.random()` string for both of these. Match it. */
|
|
44
|
+
const randomId = () => String(Math.random());
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get a userKey. No browser: the endpoint answers a plain h2 GET.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ THE KEY IS NOT A SECRET AND MUST NOT BE TREATED AS ONE — it is a
|
|
50
|
+
* rate-limiting handle the site hands to anyone who asks. It is never written to
|
|
51
|
+
* disk here, because a value that looks like a credential eventually gets
|
|
52
|
+
* committed by someone who assumed it was one.
|
|
53
|
+
*/
|
|
54
|
+
export async function verifyUser({ requestImpl = h2Json } = {}) {
|
|
55
|
+
const r = await requestImpl(`${HOST}/api/verifyUser`, { headers: { origin: HOST, referer: `${HOST}/` } });
|
|
56
|
+
if (!r.ok) return { ok: false, error: r.error ?? `verifyUser returned ${r.status}`, challenged: r.challenged === true };
|
|
57
|
+
const key = r.json?.userKey;
|
|
58
|
+
if (typeof key !== 'string' || key.length < 16) {
|
|
59
|
+
return { ok: false, error: `verifyUser gave no usable key: ${JSON.stringify(r.json).slice(0, 140)}` };
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, userKey: key, status: r.json.status ?? null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ask for an image. Returns the requestId the queue is polled with. */
|
|
65
|
+
export async function requestGeneration({
|
|
66
|
+
prompt, userKey, resolution = '768x768', negativePrompt = '', guidanceScale = 7, seed = -1,
|
|
67
|
+
requestImpl = h2Json,
|
|
68
|
+
}) {
|
|
69
|
+
const requestId = randomId();
|
|
70
|
+
const query = new URLSearchParams({ userKey, requestId, adAccessCode: '', __cacheBust: randomId() });
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
prompt: String(prompt), negativePrompt, seed, resolution, guidanceScale,
|
|
73
|
+
channel: CHANNEL, subChannel: 'public', userKey, adAccessCode: '', requestId,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const r = await requestImpl(`${HOST}/api/generate?${query}`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { origin: HOST, referer: `${HOST}/`, 'content-type': 'text/plain;charset=UTF-8' },
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
if (!r.ok) return { ok: false, error: r.error ?? `generate returned ${r.status}`, challenged: r.challenged === true };
|
|
82
|
+
|
|
83
|
+
const status = r.json?.status;
|
|
84
|
+
/**
|
|
85
|
+
* ⚠️ `invalid_parameter` IS OUR BUG, NOT AN OUTAGE, and saying so saves the
|
|
86
|
+
* next person the afternoon it cost me. It means the body shape drifted — the
|
|
87
|
+
* endpoint is reachable and answering.
|
|
88
|
+
*/
|
|
89
|
+
if (status === 'invalid_parameter') {
|
|
90
|
+
return { ok: false, error: 'perchance rejected the request shape (invalid_parameter) — the private API changed and lib/perchance.mjs needs re-capturing; this is not a transient failure and retrying will not help' };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* ⚠️⭐ THE REQUEST IS SYNCHRONOUS AND I BUILT A QUEUE FOR NOTHING.
|
|
94
|
+
*
|
|
95
|
+
* The browser capture showed `getUserQueuePosition` being polled, so I wrote a
|
|
96
|
+
* poller — and it sat there reading `not_in_queue` for ninety seconds while
|
|
97
|
+
* the image had been ready the whole time. The POST itself returns
|
|
98
|
+
* `{ status:"success", imageId, imageDownloadUrl, seed, width, height,
|
|
99
|
+
* maybeNsfw }` in **2.7 seconds**. The queue only appears when a previous
|
|
100
|
+
* request of yours is still running, which is why the browser hit it and a
|
|
101
|
+
* fresh caller does not.
|
|
102
|
+
*
|
|
103
|
+
* ⭐ Reading the capture is not the same as reading the RESPONSE. I had every
|
|
104
|
+
* field I needed in the first reply and threw all but one of them away.
|
|
105
|
+
*/
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
requestId,
|
|
109
|
+
status: status ?? null,
|
|
110
|
+
imageId: r.json?.imageId ?? null,
|
|
111
|
+
downloadPath: r.json?.imageDownloadUrl ?? null,
|
|
112
|
+
seed: r.json?.seed ?? null,
|
|
113
|
+
// Surfaced, not swallowed: the caller is writing this into someone's page.
|
|
114
|
+
maybeNsfw: r.json?.maybeNsfw === true,
|
|
115
|
+
fileExtension: r.json?.fileExtension ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Poll until the image exists.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ BOUNDED BY WALL CLOCK, NOT BY ATTEMPTS. A queue that says "position 4"
|
|
125
|
+
* forever is indistinguishable from a broken one, and an unbounded poll inside a
|
|
126
|
+
* coding agent's round is how a session disappears.
|
|
127
|
+
*/
|
|
128
|
+
export async function awaitImage({ userKey, requestId, budgetMs = DEFAULT_BUDGET_MS, requestImpl = h2Json, sleepImpl = sleep }) {
|
|
129
|
+
const deadline = Date.now() + budgetMs;
|
|
130
|
+
let lastStatus = null;
|
|
131
|
+
|
|
132
|
+
while (Date.now() < deadline) {
|
|
133
|
+
const q = new URLSearchParams({ userKey, requestId });
|
|
134
|
+
const r = await requestImpl(`${HOST}/api/getUserQueuePosition?${q}`, { headers: { origin: HOST, referer: `${HOST}/` } });
|
|
135
|
+
if (!r.ok) return { ok: false, error: r.error ?? `queue poll returned ${r.status}` };
|
|
136
|
+
|
|
137
|
+
lastStatus = r.json?.status ?? null;
|
|
138
|
+
if (lastStatus === 'success') return { ok: true, imageId: r.json?.imageId ?? null, raw: r.json };
|
|
139
|
+
if (r.json?.havingTechnicalDifficulties === true) {
|
|
140
|
+
return { ok: false, error: 'perchance reports technical difficulties on its own side — not our request; use another provider for now' };
|
|
141
|
+
}
|
|
142
|
+
await sleepImpl(POLL_INTERVAL_MS);
|
|
143
|
+
}
|
|
144
|
+
return { ok: false, error: `no image within ${Math.round(budgetMs / 1000)}s (last status: ${lastStatus ?? 'none'}) — use another provider rather than waiting again` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fetch the finished bytes.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ THE PATH COMES FROM THE RESPONSE, NOT FROM US. `imageDownloadUrl` carries a
|
|
151
|
+
* signed token; reconstructing the URL from an imageId gets a rejection, and
|
|
152
|
+
* hand-building URLs for someone else's private API is how this breaks silently
|
|
153
|
+
* the next time they rotate the signing scheme.
|
|
154
|
+
*/
|
|
155
|
+
export async function downloadImage({ downloadPath, requestImpl = h2Request }) {
|
|
156
|
+
if (!downloadPath) return { ok: false, error: 'no download path in the generate response' };
|
|
157
|
+
const url = downloadPath.startsWith('http') ? downloadPath : `${HOST}${downloadPath}`;
|
|
158
|
+
const r = await requestImpl(url, { headers: { origin: HOST, referer: `${HOST}/` } });
|
|
159
|
+
if (!r.ok) return { ok: false, error: r.error ?? `download returned ${r.status}` };
|
|
160
|
+
|
|
161
|
+
const bytes = r.body;
|
|
162
|
+
/**
|
|
163
|
+
* ⚠️ MAGIC BYTES, NOT STATUS. The same rule the Pollinations path had to
|
|
164
|
+
* learn: a 200 carrying an error page written to disk as `hero.jpg` is worse
|
|
165
|
+
* than an outage, because the page looks built and is broken.
|
|
166
|
+
*/
|
|
167
|
+
const isJpeg = bytes?.[0] === 0xff && bytes?.[1] === 0xd8;
|
|
168
|
+
const isPng = bytes?.subarray(0, 4).toString('hex') === '89504e47';
|
|
169
|
+
if (!bytes || bytes.length < 1000 || (!isJpeg && !isPng)) {
|
|
170
|
+
return { ok: false, error: `perchance returned ${bytes?.length ?? 0} bytes that are not an image` };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, bytes, mimeType: isJpeg ? 'image/jpeg' : 'image/png' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The whole flow, start to bytes. */
|
|
176
|
+
export async function generateNative({ prompt, resolution = '768x768', budgetMs = DEFAULT_BUDGET_MS, userKey = null } = {}) {
|
|
177
|
+
if (!prompt || !String(prompt).trim()) return { ok: false, error: 'an image needs a prompt' };
|
|
178
|
+
|
|
179
|
+
let key = userKey;
|
|
180
|
+
if (!key) {
|
|
181
|
+
const v = await verifyUser();
|
|
182
|
+
if (!v.ok) return v;
|
|
183
|
+
key = v.userKey;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const started = await requestGeneration({ prompt, userKey: key, resolution });
|
|
187
|
+
if (!started.ok) return started;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* ⭐ THE COMMON PATH IS SYNCHRONOUS — the image is ready in the POST reply.
|
|
191
|
+
* The queue is the exception, entered only when a previous request of this
|
|
192
|
+
* userKey is still running, so it is a fallback rather than the main road.
|
|
193
|
+
*/
|
|
194
|
+
let downloadPath = started.downloadPath;
|
|
195
|
+
let { maybeNsfw, seed } = started;
|
|
196
|
+
|
|
197
|
+
if (!downloadPath) {
|
|
198
|
+
const waited = await awaitImage({ userKey: key, requestId: started.requestId, budgetMs });
|
|
199
|
+
if (!waited.ok) return waited;
|
|
200
|
+
downloadPath = waited.raw?.imageDownloadUrl ?? null;
|
|
201
|
+
if (!downloadPath) return { ok: false, error: 'perchance reported success without a download url — the response shape changed' };
|
|
202
|
+
maybeNsfw = waited.raw?.maybeNsfw === true;
|
|
203
|
+
seed = waited.raw?.seed ?? null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const got = await downloadImage({ downloadPath });
|
|
207
|
+
if (!got.ok) return got;
|
|
208
|
+
|
|
209
|
+
return { ok: true, bytes: got.bytes, mimeType: got.mimeType, provider: 'perchance-native', userKey: key, seed, maybeNsfw };
|
|
210
|
+
}
|