latent-opencode 0.0.0-stage → 0.1.0-alpha.1

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 CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # latent-opencode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Earn while your agent works, without leaving your own OpenCode TUI (v2.2+).
4
+ One labelled sponsored line sits in the prompt footer during a turn. The
5
+ plugin only draws in the terminal: it never reads or changes the model's
6
+ request or reply.
7
+
8
+ opencode plugin add latent-opencode
9
+
10
+ | Command | |
11
+ | --- | --- |
12
+ | `/latent` | wallet, balance, sponsored line on/off |
13
+ | `/latent-login` | connect the wallet Earn pays into |
14
+ | `/latent-open` | open the sponsored link |
15
+
16
+ The wallet is shared with every Latent surface (`~/.latent-protocol`), so a
17
+ login from `npx latentcode login` works here too. Prefer a dedicated app?
18
+ `npx latentcode`.
@@ -0,0 +1,920 @@
1
+ // ../src/ads/controller.ts
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ // ../src/ads/sanitize.ts
5
+ var ESC_SEQ = /\x1b\][\s\S]*?(?:\x07|\x1b\\|$)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]|\x1b[@-_]/g;
6
+ var CONTROL = /[\x00-\x1f\x7f-\x9f]/g;
7
+ var BIDI = /[‪-‮⁦-⁩‎‏؜]/g;
8
+ function sanitizeAdText(input, max = 140) {
9
+ let s = String(input ?? "");
10
+ s = s.replace(ESC_SEQ, "").replace(CONTROL, " ").replace(BIDI, "");
11
+ s = s.replace(/\s+/g, " ").trim();
12
+ if (max > 0 && s.length > max) s = `${s.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`;
13
+ return s;
14
+ }
15
+ var AD_LIMITS = { title: 30, body: 140, cta_text: 24 };
16
+
17
+ // ../src/ads/adline.ts
18
+ var WORD = /[\p{L}\p{N}]/u;
19
+ var LEAD_SEP = /^[\s\-:–—]+/;
20
+ function clampField(value, max) {
21
+ const t = value.trim();
22
+ if (t.length <= max) return t;
23
+ return `${t.slice(0, Math.max(1, max - 1)).trimEnd()}\u2026`;
24
+ }
25
+ function dedupeBrand(brand, body) {
26
+ let t = body.trim();
27
+ const b = brand.trim();
28
+ if (!b) return t;
29
+ const lb = b.toLowerCase();
30
+ while (t.toLowerCase().startsWith(lb)) {
31
+ const after = t.charAt(b.length);
32
+ if (after !== "" && WORD.test(after)) break;
33
+ const rest = t.slice(b.length).replace(LEAD_SEP, "");
34
+ if (rest === t) break;
35
+ t = rest;
36
+ if (t === "") break;
37
+ }
38
+ return t;
39
+ }
40
+ function composeAdCopy(title, body) {
41
+ const brandRaw = sanitizeAdText(title, 0);
42
+ const bodyRaw = sanitizeAdText(body, 0);
43
+ return { brand: clampField(brandRaw, 30), line: clampField(dedupeBrand(brandRaw, bodyRaw), 60) };
44
+ }
45
+ function isSafeUrl(raw, allowLoopbackHttp = false) {
46
+ if (typeof raw !== "string" || !raw) return false;
47
+ if (/[\x00-\x20\x7f-\x9f]/.test(raw)) return false;
48
+ try {
49
+ const u = new URL(raw);
50
+ if (u.username || u.password) return false;
51
+ if (u.protocol === "https:") return true;
52
+ return allowLoopbackHttp && u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost");
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+ function displayUrl(url) {
58
+ return url.startsWith("https://") ? url.slice("https://".length).replace(/\/$/, "") : "";
59
+ }
60
+ function clickLink(server, ad, wallet, ctaUrl) {
61
+ const base = server.replace(/\/+$/, "");
62
+ if (!ad.click_token || !isSafeUrl(base, true)) return ctaUrl;
63
+ return `${base}/ad/click?ad=${encodeURIComponent(ad.ad_id)}&w=${encodeURIComponent(wallet)}&t=${encodeURIComponent(ad.click_token)}`;
64
+ }
65
+ var BADGE_COLORS = ["#F87171", "#FB923C", "#FBBF24", "#A3E635", "#34D399", "#22D3EE", "#60A5FA", "#A78BFA", "#F472B6"];
66
+ function adBadge(brand) {
67
+ const letter = ([...brand].find((c) => /[\p{L}\p{N}]/u.test(c)) ?? "").toLocaleUpperCase();
68
+ let h = 2166136261;
69
+ for (const c of brand.toLowerCase()) h = Math.imul(h ^ c.codePointAt(0), 16777619);
70
+ return { letter: letter || "\u2022", color: BADGE_COLORS[(h >>> 0) % BADGE_COLORS.length] };
71
+ }
72
+
73
+ // ../src/ads/classify.ts
74
+ import { existsSync, readFileSync } from "node:fs";
75
+ import { join } from "node:path";
76
+ var KEYWORDS = {
77
+ "frontend-ui": [
78
+ "react",
79
+ "vue",
80
+ "svelte",
81
+ "angular",
82
+ "next",
83
+ "nuxt",
84
+ "tailwind",
85
+ "css",
86
+ "component",
87
+ "frontend",
88
+ "ui",
89
+ "ux",
90
+ "webpack",
91
+ "vite",
92
+ "dom",
93
+ "jsx",
94
+ "tsx"
95
+ ],
96
+ backend: [
97
+ "api",
98
+ "server",
99
+ "express",
100
+ "fastapi",
101
+ "flask",
102
+ "django",
103
+ "rails",
104
+ "spring",
105
+ "endpoint",
106
+ "rest",
107
+ "graphql",
108
+ "grpc",
109
+ "middleware",
110
+ "auth",
111
+ "jwt",
112
+ "route"
113
+ ],
114
+ databases: [
115
+ "sql",
116
+ "postgres",
117
+ "postgresql",
118
+ "mysql",
119
+ "sqlite",
120
+ "mongodb",
121
+ "mongo",
122
+ "redis",
123
+ "prisma",
124
+ "query",
125
+ "schema",
126
+ "migration",
127
+ "index",
128
+ "orm",
129
+ "database"
130
+ ],
131
+ "devops-infra": [
132
+ "docker",
133
+ "kubernetes",
134
+ "k8s",
135
+ "terraform",
136
+ "ansible",
137
+ "ci",
138
+ "cd",
139
+ "pipeline",
140
+ "deploy",
141
+ "nginx",
142
+ "aws",
143
+ "gcp",
144
+ "azure",
145
+ "helm",
146
+ "infra",
147
+ "devops",
148
+ "compose"
149
+ ],
150
+ "ai-ml": [
151
+ "llm",
152
+ "gpt",
153
+ "openai",
154
+ "anthropic",
155
+ "claude",
156
+ "embedding",
157
+ "vector",
158
+ "rag",
159
+ "pytorch",
160
+ "tensorflow",
161
+ "model",
162
+ "training",
163
+ "inference",
164
+ "prompt",
165
+ "agent",
166
+ "ml"
167
+ ],
168
+ "web3-crypto": [
169
+ "solidity",
170
+ "ethereum",
171
+ "evm",
172
+ "wallet",
173
+ "web3",
174
+ "onchain",
175
+ "contract",
176
+ "erc20",
177
+ "erc721",
178
+ "base",
179
+ "usdc",
180
+ "x402",
181
+ "defi",
182
+ "token",
183
+ "crypto",
184
+ "blockchain"
185
+ ],
186
+ mobile: [
187
+ "swift",
188
+ "swiftui",
189
+ "kotlin",
190
+ "android",
191
+ "ios",
192
+ "flutter",
193
+ "dart",
194
+ "react-native",
195
+ "expo",
196
+ "xcode",
197
+ "mobile"
198
+ ],
199
+ "data-eng": [
200
+ "pandas",
201
+ "spark",
202
+ "airflow",
203
+ "etl",
204
+ "dbt",
205
+ "kafka",
206
+ "snowflake",
207
+ "bigquery",
208
+ "warehouse",
209
+ "dataframe",
210
+ "parquet",
211
+ "pipeline",
212
+ "analytics"
213
+ ]
214
+ };
215
+ var MANIFESTS = [
216
+ "package.json",
217
+ "requirements.txt",
218
+ "pyproject.toml",
219
+ "Cargo.toml",
220
+ "go.mod",
221
+ "Gemfile",
222
+ "pom.xml",
223
+ "build.gradle"
224
+ ];
225
+ function readManifestText(cwd) {
226
+ const parts = [];
227
+ for (const name of MANIFESTS) {
228
+ const p = join(cwd, name);
229
+ if (!existsSync(p)) continue;
230
+ try {
231
+ parts.push(readFileSync(p, "utf8"));
232
+ } catch {
233
+ }
234
+ }
235
+ return parts.join("\n");
236
+ }
237
+ function scoreText(haystack) {
238
+ const scores = /* @__PURE__ */ new Map();
239
+ const lower = haystack.toLowerCase();
240
+ for (const [cat, words] of Object.entries(KEYWORDS)) {
241
+ let hits = 0;
242
+ for (const w of words) {
243
+ const re = new RegExp(`(?:^|[^a-z0-9])${w}(?=[^a-z0-9]|$)`, "g");
244
+ const m = lower.match(re);
245
+ if (m) hits += m.length;
246
+ }
247
+ if (hits) scores.set(cat, hits);
248
+ }
249
+ return scores;
250
+ }
251
+ function classifyPrompt(prompt, cwd = process.cwd()) {
252
+ const promptScores = scoreText(prompt || "");
253
+ const manifestScores = scoreText(readManifestText(cwd));
254
+ const total = /* @__PURE__ */ new Map();
255
+ for (const [c, n] of promptScores) total.set(c, (total.get(c) ?? 0) + n);
256
+ for (const [c, n] of manifestScores) total.set(c, (total.get(c) ?? 0) + n * 2);
257
+ let best = "general";
258
+ let bestScore = 0;
259
+ for (const [c, n] of total) {
260
+ if (n > bestScore) {
261
+ best = c;
262
+ bestScore = n;
263
+ }
264
+ }
265
+ return best;
266
+ }
267
+
268
+ // ../src/ads/controller.ts
269
+ var MIN_VISIBLE_MS = 1e4;
270
+ var ROTATE_MS = 1e4;
271
+ var HOLD_MS = 27e4;
272
+ function safeUrl(raw) {
273
+ return raw && isSafeUrl(raw) ? new URL(raw).toString() : "";
274
+ }
275
+ function toDisplayAd(ad, opts = {}) {
276
+ const { brand, line } = composeAdCopy(ad.title, ad.body);
277
+ if (!brand && !line) return null;
278
+ const url = safeUrl(ad.cta_url);
279
+ return {
280
+ ad,
281
+ title: brand,
282
+ body: line,
283
+ ctaText: sanitizeAdText(ad.cta_text, AD_LIMITS.cta_text) || "Learn more",
284
+ url,
285
+ link: url && opts.server && opts.wallet ? clickLink(opts.server, ad, opts.wallet, url) : url,
286
+ shownUrl: displayUrl(url),
287
+ earn: typeof ad.earn_amount === "number" && Number.isFinite(ad.earn_amount) ? ad.earn_amount : 0,
288
+ eventUuid: randomUUID(),
289
+ fetchedAt: opts.now ?? Date.now()
290
+ };
291
+ }
292
+ var AdController = class {
293
+ o;
294
+ working = false;
295
+ current = null;
296
+ shownAt = 0;
297
+ visibleMs = 0;
298
+ billed = false;
299
+ lastTick = 0;
300
+ fetching = false;
301
+ generation = 0;
302
+ context = "general";
303
+ impressions = 0;
304
+ sessionEarned = 0;
305
+ listeners = /* @__PURE__ */ new Set();
306
+ /** Cached for useSyncExternalStore — must be referentially stable between emits. */
307
+ snap = null;
308
+ constructor(opts) {
309
+ this.o = {
310
+ ...opts,
311
+ cwd: opts.cwd ?? process.cwd(),
312
+ now: opts.now ?? Date.now,
313
+ minVisibleMs: opts.minVisibleMs ?? MIN_VISIBLE_MS,
314
+ rotateMs: opts.rotateMs ?? ROTATE_MS,
315
+ holdMs: opts.holdMs ?? HOLD_MS,
316
+ classify: opts.classify ?? ((p, cwd) => classifyPrompt(p, cwd))
317
+ };
318
+ opts.focus.onChange(() => this.emit());
319
+ }
320
+ get status() {
321
+ if (!this.o.enabled) return "disabled";
322
+ if (!this.o.wallet) return "no-wallet";
323
+ return "ok";
324
+ }
325
+ /** The wallet changed (in-app login). Earnings from here on go to it. */
326
+ setWallet(wallet) {
327
+ if (wallet === this.o.wallet) return;
328
+ this.o.wallet = wallet;
329
+ this.clear();
330
+ if (this.working) void this.fetchNext();
331
+ this.emit();
332
+ }
333
+ setEnabled(enabled) {
334
+ this.o.enabled = enabled;
335
+ if (!enabled) this.clear();
336
+ this.emit();
337
+ }
338
+ subscribe(fn) {
339
+ this.listeners.add(fn);
340
+ return () => this.listeners.delete(fn);
341
+ }
342
+ snapshot() {
343
+ if (this.snap) return this.snap;
344
+ return this.snap = {
345
+ status: this.status,
346
+ current: this.working ? this.current : null,
347
+ visibleMs: this.visibleMs,
348
+ billed: this.billed,
349
+ focusTracked: this.o.focus.known,
350
+ impressions: this.impressions,
351
+ sessionEarned: this.sessionEarned,
352
+ context: this.context
353
+ };
354
+ }
355
+ /** Called on execution start/end. `prompt` is classified locally, never sent. */
356
+ setWorking(working, prompt = "") {
357
+ if (working === this.working) return;
358
+ this.working = working;
359
+ this.lastTick = this.o.now();
360
+ if (working) {
361
+ this.context = this.o.classify(prompt, this.o.cwd);
362
+ if (!this.holding()) {
363
+ this.clear();
364
+ void this.fetchNext();
365
+ }
366
+ } else if (this.billed || !this.current) {
367
+ this.clear();
368
+ } else {
369
+ this.generation += 1;
370
+ }
371
+ this.emit();
372
+ }
373
+ /** An un-billed ad that may still be shown and billed. */
374
+ holding() {
375
+ return !!this.current && !this.billed && this.o.now() - this.current.fetchedAt < this.o.holdMs;
376
+ }
377
+ /** Advance the attention clock. Call every ~250ms. */
378
+ tick() {
379
+ const now = this.o.now();
380
+ const dt = Math.max(0, now - this.lastTick);
381
+ this.lastTick = now;
382
+ if (!this.working || !this.current) return;
383
+ if (!this.billed && !this.holding()) {
384
+ this.clear();
385
+ void this.fetchNext();
386
+ return;
387
+ }
388
+ if (this.o.focus.focused) this.visibleMs += dt;
389
+ if (!this.billed && this.visibleMs >= this.o.minVisibleMs) {
390
+ this.billed = true;
391
+ const shown = this.current;
392
+ void this.o.api.impression({
393
+ ad: shown.ad,
394
+ wallet: this.o.wallet,
395
+ context: this.context,
396
+ displayedMs: this.visibleMs,
397
+ focusTracked: this.o.focus.known,
398
+ eventUuid: shown.eventUuid
399
+ }).then((result) => {
400
+ if (result !== "tracked") return;
401
+ this.impressions += 1;
402
+ this.sessionEarned += shown.earn;
403
+ this.emit();
404
+ }).catch(() => {
405
+ });
406
+ this.emit();
407
+ }
408
+ if (this.billed && now - this.shownAt >= this.o.rotateMs && !this.fetching) {
409
+ void this.fetchNext();
410
+ }
411
+ }
412
+ /** User asked to open the ad (keyboard): the tracked link, or null. The server counts the click on redirect. */
413
+ click() {
414
+ const ad = this.working ? this.current : null;
415
+ return ad?.link || null;
416
+ }
417
+ clear() {
418
+ this.generation += 1;
419
+ this.current = null;
420
+ this.visibleMs = 0;
421
+ this.billed = false;
422
+ }
423
+ async fetchNext() {
424
+ if (this.status !== "ok" || this.fetching) return;
425
+ this.fetching = true;
426
+ const gen = this.generation;
427
+ try {
428
+ const ad = await this.o.api.request({ wallet: this.o.wallet, context: this.context, sessionId: this.o.sessionId });
429
+ if (gen !== this.generation || !this.working || this.status !== "ok") return;
430
+ const display = ad ? toDisplayAd(ad, { server: this.o.api.server, wallet: this.o.wallet, now: this.o.now() }) : null;
431
+ if (!display) {
432
+ if (this.current) this.shownAt = this.o.now();
433
+ return;
434
+ }
435
+ this.current = display;
436
+ this.shownAt = this.o.now();
437
+ this.lastTick = this.shownAt;
438
+ this.visibleMs = 0;
439
+ this.billed = false;
440
+ this.emit();
441
+ } finally {
442
+ this.fetching = false;
443
+ }
444
+ }
445
+ emit() {
446
+ this.snap = null;
447
+ for (const fn of this.listeners) fn();
448
+ }
449
+ };
450
+
451
+ // ../src/ads/config.ts
452
+ import { homedir } from "node:os";
453
+ import { join as join2 } from "node:path";
454
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
455
+ var DEFAULT_SERVER = "https://api.latentprotocol.xyz";
456
+ var AGENT_LATENTCODE = "latentcode";
457
+ function configDir() {
458
+ return join2(homedir(), ".latent-protocol");
459
+ }
460
+ function configFile() {
461
+ return join2(configDir(), "config.json");
462
+ }
463
+ function deviceIdFile() {
464
+ return join2(configDir(), "device_id");
465
+ }
466
+ var DEVICE_CREDENTIAL_RE = /^[0-9a-f]{32}\.\d+\.[A-Za-z0-9_-]+$/i;
467
+ var WALLET_RE = /^0x[0-9a-fA-F]{40}$/;
468
+ function loadConfig() {
469
+ try {
470
+ return JSON.parse(readFileSync2(configFile(), "utf8"));
471
+ } catch {
472
+ return {};
473
+ }
474
+ }
475
+ function saveConfig(data) {
476
+ mkdirSync(configDir(), { recursive: true });
477
+ const next = { ...loadConfig(), ...data };
478
+ writeFileSync(configFile(), JSON.stringify(next, null, 2) + "\n");
479
+ return next;
480
+ }
481
+ function resolveServer(cfg = loadConfig()) {
482
+ return (process.env.LATENTCODE_ADS_SERVER || cfg.server || process.env.ADS_SERVER || DEFAULT_SERVER).replace(/\/+$/, "");
483
+ }
484
+ function resolveWallet(cfg = loadConfig()) {
485
+ const w = cfg.wallet || process.env.ADS_WALLET || "";
486
+ return WALLET_RE.test(w) ? w : "";
487
+ }
488
+ function adsEnabled(cfg = loadConfig(), key = "latentcode_ads") {
489
+ const env = process.env.ADS_ENABLED;
490
+ if (env !== void 0) return !["false", "0", "no"].includes(env.toLowerCase());
491
+ return cfg[key] !== false;
492
+ }
493
+ function deviceId() {
494
+ try {
495
+ const existing = readFileSync2(deviceIdFile(), "utf8").trim();
496
+ if (DEVICE_CREDENTIAL_RE.test(existing)) return existing;
497
+ } catch {
498
+ }
499
+ return "";
500
+ }
501
+ async function ensureDeviceCredential(server) {
502
+ const current = deviceId();
503
+ if (current) return current;
504
+ let credential = "";
505
+ try {
506
+ const res = await fetch(`${server}/device/register`, {
507
+ method: "POST",
508
+ headers: { "Content-Type": "application/json" },
509
+ body: "{}",
510
+ signal: AbortSignal.timeout(2e3)
511
+ });
512
+ if (!res.ok) return "";
513
+ credential = ((await res.json()).credential || "").trim();
514
+ } catch {
515
+ return "";
516
+ }
517
+ if (!DEVICE_CREDENTIAL_RE.test(credential)) return "";
518
+ try {
519
+ mkdirSync(configDir(), { recursive: true });
520
+ writeFileSync(deviceIdFile(), credential, { flag: "wx" });
521
+ return credential;
522
+ } catch {
523
+ return deviceId() || credential;
524
+ }
525
+ }
526
+
527
+ // ../src/ads/killswitch.ts
528
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
529
+ import { join as join3 } from "node:path";
530
+ var KILL_TTL_MS = 5 * 6e4;
531
+ var KILL_STALE_GRACE_MS = 60 * 6e4;
532
+ var KILL_SOFT_GRACE_MS = 10 * 6e4;
533
+ var GUARD_TRIP_AFTER = 5;
534
+ var GUARD_COOLDOWN_MS = 10 * 6e4;
535
+ function healthFile() {
536
+ return join3(configDir(), "latentcode-health.json");
537
+ }
538
+ function load() {
539
+ try {
540
+ const s = JSON.parse(readFileSync3(healthFile(), "utf8"));
541
+ return s.server === resolveServer() ? s : {};
542
+ } catch {
543
+ return {};
544
+ }
545
+ }
546
+ function save(s) {
547
+ try {
548
+ mkdirSync2(configDir(), { recursive: true });
549
+ writeFileSync2(healthFile(), JSON.stringify({ ...s, server: resolveServer() }));
550
+ } catch {
551
+ }
552
+ }
553
+ function shouldServe(now = Date.now(), state = load()) {
554
+ if (state.killed && state.killCheckedAt !== void 0) {
555
+ const grace = KILL_TTL_MS + (state.killKind === "unreachable" ? KILL_SOFT_GRACE_MS : KILL_STALE_GRACE_MS);
556
+ if (now - state.killCheckedAt < grace) return { ok: false, reason: "killswitch" };
557
+ }
558
+ if (state.guardOpenUntil !== void 0 && now < state.guardOpenUntil) {
559
+ return { ok: false, reason: "incident-backoff" };
560
+ }
561
+ return { ok: true };
562
+ }
563
+ async function refreshKillswitch(server = resolveServer(), now = Date.now()) {
564
+ const s = load();
565
+ if (s.killCheckedAt !== void 0 && now - s.killCheckedAt < KILL_TTL_MS) return;
566
+ const failSafe = (reason) => save({ ...s, killCheckedAt: now, killed: true, killKind: "unreachable", killReason: reason });
567
+ try {
568
+ const res = await fetch(`${server.replace(/\/+$/, "")}/killswitch`, {
569
+ signal: AbortSignal.timeout(2e3)
570
+ });
571
+ if (res.status === 404) {
572
+ save({ ...s, killCheckedAt: now, killed: false, killKind: void 0, killReason: void 0 });
573
+ return;
574
+ }
575
+ if (!res.ok) {
576
+ failSafe(`server ${res.status}`);
577
+ return;
578
+ }
579
+ const j = await res.json();
580
+ save({
581
+ ...s,
582
+ killCheckedAt: now,
583
+ killed: Boolean(j.killed),
584
+ killKind: j.killed ? "explicit" : void 0,
585
+ killReason: j.reason
586
+ });
587
+ } catch {
588
+ failSafe("server-unreachable");
589
+ }
590
+ }
591
+ function recordServerResult(ok, now = Date.now()) {
592
+ const s = load();
593
+ if (ok) {
594
+ if (s.consecutiveFailures || s.guardOpenUntil !== void 0) {
595
+ save({ ...s, consecutiveFailures: 0, guardOpenUntil: void 0 });
596
+ }
597
+ return;
598
+ }
599
+ const n = (s.consecutiveFailures ?? 0) + 1;
600
+ const next = { ...s, consecutiveFailures: n };
601
+ if (n >= GUARD_TRIP_AFTER) next.guardOpenUntil = now + GUARD_COOLDOWN_MS;
602
+ save(next);
603
+ }
604
+
605
+ // ../src/ads/api.ts
606
+ var SURFACE_WORKING_BAR = "status_line";
607
+ function httpAdApi(server = resolveServer(), identity = {}) {
608
+ const agent = identity.agent ?? AGENT_LATENTCODE;
609
+ const surface = identity.surface ?? SURFACE_WORKING_BAR;
610
+ return {
611
+ server,
612
+ async request({ wallet, context, sessionId }) {
613
+ void refreshKillswitch(server);
614
+ if (!shouldServe().ok) return null;
615
+ const device = await ensureDeviceCredential(server);
616
+ try {
617
+ const res = await fetch(`${server}/ad/request`, {
618
+ method: "POST",
619
+ headers: { "Content-Type": "application/json" },
620
+ body: JSON.stringify({
621
+ user_wallet: wallet,
622
+ agent,
623
+ context: context.slice(0, 100),
624
+ surface,
625
+ tags: context ? [context] : [],
626
+ session_id: sessionId,
627
+ device_id: device
628
+ }),
629
+ signal: AbortSignal.timeout(2e3)
630
+ });
631
+ recordServerResult(res.status < 500);
632
+ if (res.status !== 200) return null;
633
+ const ad = await res.json();
634
+ return ad && typeof ad.ad_id === "string" ? ad : null;
635
+ } catch {
636
+ recordServerResult(false);
637
+ return null;
638
+ }
639
+ },
640
+ async impression({ ad, wallet, context, displayedMs, focusTracked, eventUuid }) {
641
+ if (!shouldServe().ok) return "error";
642
+ const device = await ensureDeviceCredential(server);
643
+ try {
644
+ const res = await fetch(`${server}/ad/impression`, {
645
+ method: "POST",
646
+ headers: { "Content-Type": "application/json" },
647
+ body: JSON.stringify({
648
+ ad_id: ad.ad_id,
649
+ user_wallet: wallet,
650
+ token: ad.impression_token ?? "",
651
+ agent,
652
+ surface,
653
+ context,
654
+ device_id: device,
655
+ event_uuid: eventUuid,
656
+ displayed_ms: Math.round(displayedMs),
657
+ // Attention signal: the dwell above was measured with real terminal
658
+ // focus events, not assumed. Servers that don't know it ignore it.
659
+ focus_tracked: focusTracked
660
+ }),
661
+ signal: AbortSignal.timeout(2e3)
662
+ });
663
+ recordServerResult(res.status < 500);
664
+ if (!res.ok) return "error";
665
+ const status = (await res.json().catch(() => ({}))).status;
666
+ return status === "tracked" || status === "skipped" || status === "unearned" ? status : "error";
667
+ } catch {
668
+ recordServerResult(false);
669
+ return "error";
670
+ }
671
+ },
672
+ async balance(wallet) {
673
+ try {
674
+ const res = await fetch(`${server}/earnings/${wallet}`, { signal: AbortSignal.timeout(4e3) });
675
+ if (!res.ok) return null;
676
+ return Number((await res.json()).balance ?? 0);
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+ };
682
+ }
683
+
684
+ // ../src/ads/attention.ts
685
+ var FOCUS_IN = "\x1B[I";
686
+ var FOCUS_OUT = "\x1B[O";
687
+ var FocusTracker = class {
688
+ /** True once the terminal has sent at least one focus event. */
689
+ known = false;
690
+ focusedNow = true;
691
+ listeners = /* @__PURE__ */ new Set();
692
+ get focused() {
693
+ return this.focusedNow;
694
+ }
695
+ onChange(fn) {
696
+ this.listeners.add(fn);
697
+ return () => this.listeners.delete(fn);
698
+ }
699
+ set(focused) {
700
+ this.known = true;
701
+ if (focused === this.focusedNow) return;
702
+ this.focusedNow = focused;
703
+ for (const fn of this.listeners) fn(focused);
704
+ }
705
+ /**
706
+ * Consume focus sequences from a raw stdin chunk and return the rest, so the
707
+ * UI never sees them as keystrokes.
708
+ */
709
+ filter(chunk) {
710
+ if (!chunk.includes("\x1B[")) return chunk;
711
+ let out = "";
712
+ let i = 0;
713
+ while (i < chunk.length) {
714
+ if (chunk.startsWith(FOCUS_IN, i)) {
715
+ this.set(true);
716
+ i += FOCUS_IN.length;
717
+ } else if (chunk.startsWith(FOCUS_OUT, i)) {
718
+ this.set(false);
719
+ i += FOCUS_OUT.length;
720
+ } else {
721
+ out += chunk[i];
722
+ i += 1;
723
+ }
724
+ }
725
+ return out;
726
+ }
727
+ };
728
+
729
+ // ../src/account/privy.ts
730
+ function trimServer(server) {
731
+ return server.replace(/\/+$/, "");
732
+ }
733
+ async function readJson(res) {
734
+ try {
735
+ return await res.json();
736
+ } catch {
737
+ return { detail: await res.text().catch(() => `HTTP ${res.status}`) };
738
+ }
739
+ }
740
+ async function fetchAuthConfig(server, fetchImpl = fetch) {
741
+ const res = await fetchImpl(`${trimServer(server)}/auth/config`);
742
+ const body = await readJson(res);
743
+ if (!res.ok) {
744
+ throw new Error(
745
+ typeof body.detail === "string" ? body.detail : `auth config HTTP ${res.status}`
746
+ );
747
+ }
748
+ if (!body.privy_app_id) {
749
+ throw new Error(
750
+ "Privy is not configured on the ad server (PRIVY_APP_ID). Use `latentcode login --wallet 0x\u2026` instead."
751
+ );
752
+ }
753
+ return {
754
+ privy_app_id: String(body.privy_app_id),
755
+ authorize_url: String(body.authorize_url || "https://www.latentprotocol.xyz/authorize"),
756
+ configured: Boolean(body.configured)
757
+ };
758
+ }
759
+ async function startDeviceAuth(appId, fetchImpl = fetch) {
760
+ const res = await fetchImpl("https://auth.privy.io/api/oauth/v2/device_authorization", {
761
+ method: "POST",
762
+ headers: { "Content-Type": "application/json", "privy-app-id": appId },
763
+ body: "{}"
764
+ });
765
+ const body = await readJson(res);
766
+ if (!res.ok) {
767
+ if (body.error === "device_auth_not_enabled" || /device authorization is not enabled/i.test(String(body.error || ""))) {
768
+ throw new Error(
769
+ "Privy CLI/agent access is off. Enable it in the Privy Dashboard (Authentication \u2192 Advanced) or use `latentcode login --wallet 0x\u2026`."
770
+ );
771
+ }
772
+ if (res.status === 403) {
773
+ throw new Error(
774
+ "auth.privy.io refused the request (403). If you are behind a proxy or VPN, that is the likely cause; otherwise check that CLI/agent access is on in the Privy Dashboard. Or use `latentcode login --wallet 0x\u2026`."
775
+ );
776
+ }
777
+ throw new Error(`device_authorization HTTP ${res.status}`);
778
+ }
779
+ if (!body.device_code || !body.user_code) {
780
+ throw new Error("device_authorization returned no codes");
781
+ }
782
+ return {
783
+ device_code: String(body.device_code),
784
+ user_code: String(body.user_code),
785
+ verification_uri: String(body.verification_uri || ""),
786
+ verification_uri_complete: String(
787
+ body.verification_uri_complete || `${body.verification_uri}?user_code=${body.user_code}`
788
+ ),
789
+ expires_in: Number(body.expires_in || 600),
790
+ interval: Math.max(1, Number(body.interval || 5))
791
+ };
792
+ }
793
+ async function pollDeviceToken(appId, deviceCode, opts) {
794
+ const fetchImpl = opts.fetchImpl ?? fetch;
795
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
796
+ const now = opts.now ?? Date.now;
797
+ const deadline = now() + opts.expiresInSec * 1e3;
798
+ let interval = opts.intervalSec;
799
+ while (now() < deadline) {
800
+ await sleep(interval * 1e3);
801
+ const res = await fetchImpl("https://auth.privy.io/api/oauth/v2/token", {
802
+ method: "POST",
803
+ headers: { "Content-Type": "application/json", "privy-app-id": appId },
804
+ body: JSON.stringify({
805
+ // RFC 8628 §3.4 — Privy rejects the bare "device_code" with a 400.
806
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
807
+ device_code: deviceCode
808
+ })
809
+ });
810
+ const body = await readJson(res);
811
+ if (res.ok && body.access_token) {
812
+ return {
813
+ access_token: body.access_token,
814
+ refresh_token: body.refresh_token,
815
+ expires_in: body.expires_in
816
+ };
817
+ }
818
+ const err = String(body.error || "");
819
+ if (err === "authorization_pending") continue;
820
+ if (err === "slow_down") {
821
+ interval += 5;
822
+ continue;
823
+ }
824
+ if (err === "expired_token") throw new Error("Auth link expired \u2014 run `latentcode login` again.");
825
+ if (err === "access_denied") throw new Error("Authorization denied in the browser.");
826
+ if (!res.ok && !err) throw new Error(`token HTTP ${res.status}`);
827
+ throw new Error(err || `token HTTP ${res.status}`);
828
+ }
829
+ throw new Error("Auth link timed out \u2014 run `latentcode login` again.");
830
+ }
831
+ async function resolveSession(server, accessToken, fetchImpl = fetch) {
832
+ const res = await fetchImpl(`${trimServer(server)}/auth/session`, {
833
+ method: "POST",
834
+ headers: { "Content-Type": "application/json" },
835
+ body: JSON.stringify({ access_token: accessToken })
836
+ });
837
+ const body = await readJson(res);
838
+ if (!res.ok) {
839
+ throw new Error(typeof body.detail === "string" ? body.detail : `session HTTP ${res.status}`);
840
+ }
841
+ if (!body.wallet || !body.privy_user_id) {
842
+ throw new Error("session returned no wallet");
843
+ }
844
+ return { wallet: body.wallet, privy_user_id: body.privy_user_id };
845
+ }
846
+
847
+ // ../src/account/login.ts
848
+ function short(w) {
849
+ return `${w.slice(0, 6)}\u2026${w.slice(-4)}`;
850
+ }
851
+ async function loginWithAddress(address, deps) {
852
+ if (!WALLET_RE.test(address)) throw new Error(`Not an EVM address: ${address}`);
853
+ const prev = deps.load().wallet;
854
+ deps.save({ wallet: address, auth: "address", privy_user_id: void 0 });
855
+ deps.log(`Wallet set: ${short(address)}`);
856
+ return { wallet: address, method: "address", replaced: prev && prev !== address ? prev : void 0 };
857
+ }
858
+ async function loginWithAuthLink(deps) {
859
+ let cfg;
860
+ try {
861
+ cfg = await fetchAuthConfig(deps.server, deps.fetchImpl);
862
+ } catch (e) {
863
+ const why = e instanceof Error ? e.message : String(e);
864
+ throw new Error(
865
+ `Sign-in is unavailable on ${deps.server.replace(/\/+$/, "")} (${why}).
866
+ You can still connect an address you control: latentcode login --wallet 0x\u2026`
867
+ );
868
+ }
869
+ const start = await startDeviceAuth(cfg.privy_app_id, deps.fetchImpl);
870
+ deps.log("Open this link to sign in and connect your Latent wallet:");
871
+ deps.log(` ${start.verification_uri_complete}`);
872
+ deps.log(`Code: ${start.user_code}`);
873
+ deps.onLink?.({ url: start.verification_uri_complete, code: start.user_code });
874
+ deps.open(start.verification_uri_complete);
875
+ deps.log("Waiting for the browser\u2026");
876
+ const tokens = await pollDeviceToken(cfg.privy_app_id, start.device_code, {
877
+ intervalSec: start.interval,
878
+ expiresInSec: start.expires_in,
879
+ fetchImpl: deps.fetchImpl,
880
+ sleep: deps.sleep,
881
+ now: deps.now
882
+ });
883
+ const earner = await resolveSession(deps.server, tokens.access_token, deps.fetchImpl);
884
+ if (!WALLET_RE.test(earner.wallet)) throw new Error("The server returned an invalid wallet address.");
885
+ const prev = deps.load().wallet;
886
+ deps.save({ wallet: earner.wallet, privy_user_id: earner.privy_user_id, auth: "privy" });
887
+ deps.log(`Signed in. Earn mode pays into ${short(earner.wallet)}.`);
888
+ return { wallet: earner.wallet, method: "privy", replaced: prev && prev !== earner.wallet ? prev : void 0 };
889
+ }
890
+
891
+ // ../src/util/open.ts
892
+ import { spawn } from "node:child_process";
893
+ function openUrl(url) {
894
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
895
+ try {
896
+ const child = spawn(opener, [url], { detached: true, stdio: "ignore" });
897
+ child.on("error", () => {
898
+ });
899
+ child.unref();
900
+ } catch {
901
+ }
902
+ }
903
+
904
+ // src/runtime.ts
905
+ var AGENT_OPENCODE = "opencode";
906
+ export {
907
+ AGENT_OPENCODE,
908
+ AdController,
909
+ FocusTracker,
910
+ adBadge,
911
+ adsEnabled,
912
+ httpAdApi,
913
+ loadConfig,
914
+ loginWithAddress,
915
+ loginWithAuthLink,
916
+ openUrl,
917
+ resolveServer,
918
+ resolveWallet,
919
+ saveConfig
920
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,225 @@
1
+ import { effect as _$effect } from "@opentui/solid";
2
+ import { memo as _$memo } from "@opentui/solid";
3
+ import { insert as _$insert } from "@opentui/solid";
4
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
5
+ import { insertNode as _$insertNode } from "@opentui/solid";
6
+ import { setProp as _$setProp } from "@opentui/solid";
7
+ import { createElement as _$createElement } from "@opentui/solid";
8
+ /**
9
+ * Latent for OpenCode — a TUI-only plugin.
10
+ *
11
+ * One labelled sponsored line in the prompt footer while the agent works,
12
+ * billed by the same AdController as LatentCode (10 s view floor, one
13
+ * impression per ad, tracked clicks, kill switch). It only draws in the
14
+ * terminal: nothing here reads or rewrites the model's request or reply, and
15
+ * only the local classifier's slug leaves the machine.
16
+ *
17
+ * opencode plugin add latent-opencode
18
+ * /latent status, wallet, on/off
19
+ * /latent-login connect the wallet Earn pays into (shared ~/.latent-protocol)
20
+ * /latent-open open the sponsored link (tracked redirect)
21
+ */
22
+ import { Plugin } from "@opencode/plugin/tui";
23
+ import { createSignal } from "solid-js";
24
+ import { randomUUID } from "node:crypto";
25
+ import { AGENT_OPENCODE, AdController, FocusTracker, adBadge, adsEnabled, httpAdApi, loadConfig, loginWithAuthLink, openUrl, resolveServer, resolveWallet, saveConfig } from "./runtime.js";
26
+ const TICK_MS = 250;
27
+ const EXECUTION_END = ["succeeded", "failed", "interrupted"];
28
+ function short(w) {
29
+ return `${w.slice(0, 6)}…${w.slice(-4)}`;
30
+ }
31
+
32
+ /** The session id of an execution event, whatever envelope the host passes. */
33
+ function sessionOf(e) {
34
+ return e?.data?.sessionID ?? e?.details?.data?.sessionID ?? e?.sessionID ?? "default";
35
+ }
36
+ export default Plugin.define({
37
+ id: "latent.ads",
38
+ setup(context) {
39
+ const server = resolveServer();
40
+ const api = httpAdApi(server, {
41
+ agent: AGENT_OPENCODE
42
+ });
43
+ const cfg = loadConfig();
44
+ const controller = new AdController({
45
+ api,
46
+ wallet: resolveWallet(cfg),
47
+ enabled: adsEnabled(cfg, "opencode_ads"),
48
+ sessionId: randomUUID(),
49
+ // A plugin does not own stdin, so terminal focus is unknown: dwell counts
50
+ // as focused and impressions say focus_tracked=false.
51
+ focus: new FocusTracker(),
52
+ cwd: context.location?.directory
53
+ });
54
+ const [snap, setSnap] = createSignal(controller.snapshot());
55
+ const unsubscribe = controller.subscribe(() => setSnap(controller.snapshot()));
56
+ const timer = setInterval(() => controller.tick(), TICK_MS);
57
+
58
+ // Working = at least one session executing.
59
+ const running = new Set();
60
+ const sync = () => controller.setWorking(running.size > 0);
61
+ const offs = [context.data.on("session.execution.started", e => {
62
+ running.add(sessionOf(e));
63
+ sync();
64
+ }), ...EXECUTION_END.map(k => context.data.on(`session.execution.${k}`, e => {
65
+ running.delete(sessionOf(e));
66
+ sync();
67
+ }))];
68
+ const t = context.theme;
69
+ const muted = t?.text?.muted ?? t?.text?.base;
70
+ const sponsoredLine = () => {
71
+ const s = snap();
72
+ if (s.status === "no-wallet") return (() => {
73
+ var _el$ = _$createElement("text");
74
+ _$insertNode(_el$, _$createTextNode(`◆ Latent: /latent-login to earn while you wait`));
75
+ _$setProp(_el$, "fg", muted);
76
+ return _el$;
77
+ })();
78
+ const ad = s.current;
79
+ if (s.status !== "ok" || !ad) return null;
80
+ const badge = adBadge(ad.title || ad.body);
81
+ return (() => {
82
+ var _el$3 = _$createElement("text"),
83
+ _el$4 = _$createTextNode(`◆ Sponsored │ `),
84
+ _el$5 = _$createElement("span"),
85
+ _el$6 = _$createTextNode(` `);
86
+ _$insertNode(_el$3, _el$4);
87
+ _$insertNode(_el$3, _el$5);
88
+ _$insertNode(_el$3, _el$6);
89
+ _$setProp(_el$3, "fg", muted);
90
+ _$insert(_el$5, () => ` ${badge.letter} `);
91
+ _$insert(_el$3, () => ad.title || ad.body, null);
92
+ _$insert(_el$3, (() => {
93
+ var _c$ = _$memo(() => !!(ad.title && ad.body));
94
+ return () => _c$() ? ` — ${ad.body}` : "";
95
+ })(), null);
96
+ _$insert(_el$3, (() => {
97
+ var _c$2 = _$memo(() => !!ad.shownUrl);
98
+ return () => _c$2() ? ` · ${ad.shownUrl}` : "";
99
+ })(), null);
100
+ _$insert(_el$3, (() => {
101
+ var _c$3 = _$memo(() => !!ad.link);
102
+ return () => _c$3() ? ` · /latent-open ${ad.ctaText}` : "";
103
+ })(), null);
104
+ _$insert(_el$3, (() => {
105
+ var _c$4 = _$memo(() => !!(s.billed && ad.earn > 0));
106
+ return () => _c$4() ? ` +$${ad.earn.toFixed(4)}` : "";
107
+ })(), null);
108
+ _$effect(_$p => _$setProp(_el$5, "style", {
109
+ bg: badge.color,
110
+ fg: "#111827"
111
+ }, _$p));
112
+ return _el$3;
113
+ })();
114
+ };
115
+ // Called inside a JSX expression so Solid re-runs it whenever the snapshot changes
116
+ // (a component body would run once and never update).
117
+ const unslot = context.ui.slot({
118
+ append: "prompt.footer.status",
119
+ render: () => _$memo(sponsoredLine)
120
+ });
121
+ let loggingIn = false;
122
+ const login = async () => {
123
+ if (loggingIn) return;
124
+ loggingIn = true;
125
+ try {
126
+ const result = await loginWithAuthLink({
127
+ server,
128
+ log: () => {},
129
+ onLink: ({
130
+ url,
131
+ code
132
+ }) => {
133
+ void context.ui.dialog.alert({
134
+ title: "Latent login",
135
+ message: `Open ${url}\nCode: ${code}`
136
+ });
137
+ },
138
+ open: openUrl,
139
+ load: loadConfig,
140
+ save: patch => void saveConfig(patch)
141
+ });
142
+ context.ui.dialog.clear();
143
+ controller.setWallet(result.wallet);
144
+ context.ui.toast.show({
145
+ title: "Latent",
146
+ message: `Earning into ${short(result.wallet)}`,
147
+ variant: "success"
148
+ });
149
+ } catch (e) {
150
+ context.ui.toast.show({
151
+ title: "Latent",
152
+ message: e instanceof Error ? e.message : String(e),
153
+ variant: "error"
154
+ });
155
+ } finally {
156
+ loggingIn = false;
157
+ }
158
+ };
159
+ const status = async () => {
160
+ const s = controller.snapshot();
161
+ const wallet = resolveWallet(loadConfig());
162
+ const balance = wallet ? await api.balance(wallet) : null;
163
+ const choice = await context.ui.dialog.select({
164
+ title: wallet ? `Latent — ${short(wallet)}${balance !== null ? ` · $${balance.toFixed(4)}` : ""}` : "Latent — no wallet",
165
+ options: [{
166
+ title: `Sponsored line: ${s.status === "disabled" ? "off" : "on"}`,
167
+ value: "toggle",
168
+ description: `${s.impressions} paid views this session`
169
+ }, {
170
+ title: wallet ? "Switch wallet" : "Connect wallet",
171
+ value: "login"
172
+ }]
173
+ });
174
+ if (choice === "toggle") {
175
+ const on = s.status === "disabled";
176
+ saveConfig({
177
+ opencode_ads: on
178
+ });
179
+ controller.setEnabled(on);
180
+ } else if (choice === "login") void login();
181
+ };
182
+ context.keymap.layer(() => ({
183
+ mode: "global",
184
+ commands: [{
185
+ id: "latent.status",
186
+ title: "Latent: status / on-off",
187
+ group: "Latent",
188
+ palette: true,
189
+ slash: {
190
+ name: "latent"
191
+ },
192
+ run: () => void status()
193
+ }, {
194
+ id: "latent.login",
195
+ title: "Latent: connect wallet",
196
+ group: "Latent",
197
+ palette: true,
198
+ slash: {
199
+ name: "latent-login"
200
+ },
201
+ run: () => void login()
202
+ }, {
203
+ id: "latent.open",
204
+ title: "Latent: open sponsored link",
205
+ group: "Latent",
206
+ palette: true,
207
+ slash: {
208
+ name: "latent-open"
209
+ },
210
+ run: () => {
211
+ const link = controller.click();
212
+ if (link) openUrl(link);else context.ui.toast.show({
213
+ message: "No sponsored line on screen"
214
+ });
215
+ }
216
+ }]
217
+ }));
218
+ return () => {
219
+ clearInterval(timer);
220
+ unsubscribe();
221
+ offs.forEach(off => off());
222
+ unslot?.();
223
+ };
224
+ }
225
+ });
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "latent-opencode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Earn from one labelled sponsored line in your own OpenCode terminal UI. Never reads or changes the model's request or reply.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ "./tui": "./dist/tui.js"
9
+ },
10
+ "files": [
11
+ "dist/tui.js",
12
+ "dist/runtime.js",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "node scripts/build.mjs",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "@opencode/plugin": "^2.2.0"
21
+ },
22
+ "peerDependencies": {
23
+ "@opentui/core": ">=0.6.6",
24
+ "@opentui/solid": ">=0.6.6",
25
+ "solid-js": ">=1.9.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/enzoonchain/latentcode.git",
30
+ "directory": "opencode-plugin"
31
+ },
32
+ "keywords": [
33
+ "opencode",
34
+ "opencode-plugin",
35
+ "latent"
36
+ ]
37
+ }