yappr 0.1.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.
Files changed (149) hide show
  1. package/.env.example +115 -0
  2. package/config/context/personality.md +7 -0
  3. package/config/context/security.md +10 -0
  4. package/config/hooks/example.ts +47 -0
  5. package/config/hooks/holder.ts +154 -0
  6. package/config/hooks/user-memory.ts +102 -0
  7. package/config/skills/compute/handler.ts +6 -0
  8. package/config/skills/compute/skill.md +7 -0
  9. package/config/skills/cron/handler.ts +89 -0
  10. package/config/skills/cron/skill.md +36 -0
  11. package/config/skills/generate-image/handler.ts +133 -0
  12. package/config/skills/generate-image/skill.md +20 -0
  13. package/config/skills/generate-meme-prompt/handler.ts +40 -0
  14. package/config/skills/generate-meme-prompt/skill.md +23 -0
  15. package/config/skills/stats/handler.ts +76 -0
  16. package/config/skills/stats/skill.md +18 -0
  17. package/config/skills/wallet/handler.ts +56 -0
  18. package/config/skills/wallet/skill.md +17 -0
  19. package/config/skills/x/handler.ts +135 -0
  20. package/config/skills/x/skill.md +163 -0
  21. package/dist/config/hooks/example.d.ts +2 -0
  22. package/dist/config/hooks/example.js +37 -0
  23. package/dist/config/hooks/holder.d.ts +2 -0
  24. package/dist/config/hooks/holder.js +147 -0
  25. package/dist/config/hooks/user-memory.d.ts +2 -0
  26. package/dist/config/hooks/user-memory.js +79 -0
  27. package/dist/config/skills/compute/handler.d.ts +2 -0
  28. package/dist/config/skills/compute/handler.js +5 -0
  29. package/dist/config/skills/cron/handler.d.ts +2 -0
  30. package/dist/config/skills/cron/handler.js +84 -0
  31. package/dist/config/skills/generate-image/handler.d.ts +2 -0
  32. package/dist/config/skills/generate-image/handler.js +122 -0
  33. package/dist/config/skills/generate-meme/handler.d.ts +2 -0
  34. package/dist/config/skills/generate-meme/handler.js +121 -0
  35. package/dist/config/skills/generate-meme-prompt/handler.d.ts +2 -0
  36. package/dist/config/skills/generate-meme-prompt/handler.js +38 -0
  37. package/dist/config/skills/stats/handler.d.ts +2 -0
  38. package/dist/config/skills/stats/handler.js +71 -0
  39. package/dist/config/skills/wallet/handler.d.ts +2 -0
  40. package/dist/config/skills/wallet/handler.js +54 -0
  41. package/dist/config/skills/x/handler.d.ts +2 -0
  42. package/dist/config/skills/x/handler.js +115 -0
  43. package/dist/src/agent-prompt.d.ts +1 -0
  44. package/dist/src/agent-prompt.js +45 -0
  45. package/dist/src/bankr.d.ts +41 -0
  46. package/dist/src/bankr.js +76 -0
  47. package/dist/src/cli/backup.d.ts +7 -0
  48. package/dist/src/cli/backup.js +78 -0
  49. package/dist/src/cli/charts.d.ts +32 -0
  50. package/dist/src/cli/charts.js +222 -0
  51. package/dist/src/cli/config-sync.d.ts +7 -0
  52. package/dist/src/cli/config-sync.js +71 -0
  53. package/dist/src/cli/deploy.d.ts +2 -0
  54. package/dist/src/cli/deploy.js +1059 -0
  55. package/dist/src/cli/env.d.ts +4 -0
  56. package/dist/src/cli/env.js +50 -0
  57. package/dist/src/cli/host-key.d.ts +4 -0
  58. package/dist/src/cli/host-key.js +50 -0
  59. package/dist/src/cli/index.d.ts +2 -0
  60. package/dist/src/cli/index.js +71 -0
  61. package/dist/src/cli/init.d.ts +1 -0
  62. package/dist/src/cli/init.js +51 -0
  63. package/dist/src/cli/ssh.d.ts +2 -0
  64. package/dist/src/cli/ssh.js +141 -0
  65. package/dist/src/cli/status.d.ts +7 -0
  66. package/dist/src/cli/status.js +1184 -0
  67. package/dist/src/cli/tui.d.ts +18 -0
  68. package/dist/src/cli/tui.js +115 -0
  69. package/dist/src/cli/ui.d.ts +30 -0
  70. package/dist/src/cli/ui.js +164 -0
  71. package/dist/src/cli/update.d.ts +1 -0
  72. package/dist/src/cli/update.js +263 -0
  73. package/dist/src/cli/x-login.d.ts +6 -0
  74. package/dist/src/cli/x-login.js +70 -0
  75. package/dist/src/compute.d.ts +11 -0
  76. package/dist/src/compute.js +109 -0
  77. package/dist/src/config-loader.d.ts +19 -0
  78. package/dist/src/config-loader.js +82 -0
  79. package/dist/src/config.d.ts +29 -0
  80. package/dist/src/config.js +68 -0
  81. package/dist/src/cron/capability.d.ts +6 -0
  82. package/dist/src/cron/capability.js +66 -0
  83. package/dist/src/cron/runner.d.ts +2 -0
  84. package/dist/src/cron/runner.js +113 -0
  85. package/dist/src/cron/schedule.d.ts +19 -0
  86. package/dist/src/cron/schedule.js +154 -0
  87. package/dist/src/cron/store.d.ts +46 -0
  88. package/dist/src/cron/store.js +220 -0
  89. package/dist/src/db.d.ts +4 -0
  90. package/dist/src/db.js +53 -0
  91. package/dist/src/hooks/loader.d.ts +1 -0
  92. package/dist/src/hooks/loader.js +17 -0
  93. package/dist/src/hooks/registry.d.ts +17 -0
  94. package/dist/src/hooks/registry.js +78 -0
  95. package/dist/src/hooks/types.d.ts +45 -0
  96. package/dist/src/hooks/types.js +1 -0
  97. package/dist/src/index.d.ts +25 -0
  98. package/dist/src/index.js +35 -0
  99. package/dist/src/llm/index.d.ts +23 -0
  100. package/dist/src/llm/index.js +213 -0
  101. package/dist/src/llm/prompts.d.ts +6 -0
  102. package/dist/src/llm/prompts.js +99 -0
  103. package/dist/src/log.d.ts +2 -0
  104. package/dist/src/log.js +30 -0
  105. package/dist/src/reply/agent.d.ts +20 -0
  106. package/dist/src/reply/agent.js +215 -0
  107. package/dist/src/reply/context-blocks.d.ts +12 -0
  108. package/dist/src/reply/context-blocks.js +22 -0
  109. package/dist/src/reply/gating.d.ts +3 -0
  110. package/dist/src/reply/gating.js +35 -0
  111. package/dist/src/reply/pipeline.d.ts +3 -0
  112. package/dist/src/reply/pipeline.js +144 -0
  113. package/dist/src/reply/poller.d.ts +5 -0
  114. package/dist/src/reply/poller.js +79 -0
  115. package/dist/src/skills/holder-access.d.ts +7 -0
  116. package/dist/src/skills/holder-access.js +53 -0
  117. package/dist/src/skills/loader.d.ts +2 -0
  118. package/dist/src/skills/loader.js +64 -0
  119. package/dist/src/skills/registry.d.ts +4 -0
  120. package/dist/src/skills/registry.js +10 -0
  121. package/dist/src/skills/types.d.ts +16 -0
  122. package/dist/src/skills/types.js +1 -0
  123. package/dist/src/state.d.ts +5 -0
  124. package/dist/src/state.js +26 -0
  125. package/dist/src/stats-cli.d.ts +1 -0
  126. package/dist/src/stats-cli.js +82 -0
  127. package/dist/src/stats.d.ts +41 -0
  128. package/dist/src/stats.js +236 -0
  129. package/dist/src/storage.d.ts +16 -0
  130. package/dist/src/storage.js +107 -0
  131. package/dist/src/treasury/abi.d.ts +99 -0
  132. package/dist/src/treasury/abi.js +71 -0
  133. package/dist/src/treasury/cycle.d.ts +16 -0
  134. package/dist/src/treasury/cycle.js +154 -0
  135. package/dist/src/treasury/index.d.ts +28 -0
  136. package/dist/src/treasury/index.js +222 -0
  137. package/dist/src/util.d.ts +3 -0
  138. package/dist/src/util.js +18 -0
  139. package/dist/src/wallet.d.ts +5 -0
  140. package/dist/src/wallet.js +241 -0
  141. package/dist/src/x/client.d.ts +74 -0
  142. package/dist/src/x/client.js +323 -0
  143. package/dist/src/x/types.d.ts +61 -0
  144. package/dist/src/x/types.js +1 -0
  145. package/dist/src/x402.d.ts +6 -0
  146. package/dist/src/x402.js +11 -0
  147. package/dist/src/yappr.d.ts +1 -0
  148. package/dist/src/yappr.js +85 -0
  149. package/package.json +52 -0
@@ -0,0 +1,133 @@
1
+ import { payFetch, log, type SkillHandler } from "yappr";
2
+
3
+ // Generate an image from a prompt via BlockRun's x402 image gateway, then hand the
4
+ // asker the resulting URL. payFetch signs the EIP-3009 payment on Base automatically
5
+ // (same wallet as every other agent spend), so this module just speaks plain HTTP.
6
+
7
+ const ORIGIN = "https://blockrun.ai";
8
+ const ENDPOINT = `${ORIGIN}/api/v1/images/generations`;
9
+
10
+ // Available image models and their x402 price (USDC on Base, quoted at 1024x1024, 1
11
+ // image — prices are dynamic and non-square sizes may cost more). gpt-image-1 is the
12
+ // cheapest frontier model and what we use here; swap MODEL to trade cost for quality:
13
+ // openai/gpt-image-1 $0.021
14
+ // openai/gpt-image-2 $0.063
15
+ // google/nano-banana $0.053
16
+ // google/nano-banana-pro $0.105
17
+ // zai/cogview-4 $0.016
18
+ // xai/grok-imagine-image $0.021
19
+ // xai/grok-imagine-image-pro $0.074
20
+ const MODEL = "openai/gpt-image-1"; // $0.021 / image (1024x1024)
21
+
22
+ // Orientation keyword → the pixel dimensions sent to the endpoint as `size`. Square is
23
+ // the default when the caller gives no (or an unrecognised) size.
24
+ // gpt-image-1 only accepts these three sizes (1024x1024, 1536x1024, 1024x1536) — the
25
+ // 1792-wide DALL·E 3 sizes are rejected. Update these if you switch MODEL.
26
+ const SIZES: Record<string, string> = {
27
+ square: "1024x1024",
28
+ landscape: "1536x1024",
29
+ portrait: "1024x1536",
30
+ };
31
+ const DEFAULT_SIZE = "square";
32
+
33
+ // Map a caller-supplied size to a dimension string: a known orientation keyword, an
34
+ // explicit WxH the endpoint already understands, or — failing both — the square default.
35
+ function resolveSize(raw: string | undefined): string {
36
+ const key = (raw ?? "").trim().toLowerCase();
37
+ if (key in SIZES) return SIZES[key];
38
+ if (/^\d{3,4}x\d{3,4}$/.test(key)) return key;
39
+ return SIZES[DEFAULT_SIZE];
40
+ }
41
+
42
+ // gpt-image-1 routinely runs past the server's 30s inline window: the POST then returns
43
+ // a queued job instead of the image, and we poll it to completion. Bound the wait so a
44
+ // stuck job can't hang the reply loop forever.
45
+ const POST_TIMEOUT_MS = 60_000; // POST holds inline up to ~30s before handing back a job
46
+ const POLL_TIMEOUT_MS = 30_000; // per-poll request timeout
47
+ const POLL_INTERVAL_MS = 5_000; // pause between polls
48
+ const POLL_MAX_ATTEMPTS = 24; // ~2 min of polling after the inline window
49
+
50
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
51
+
52
+ // BlockRun returns the image under data[0].url on both the inline and the completed-poll
53
+ // responses; absent until the job finishes.
54
+ function imageUrl(body: any): string | undefined {
55
+ return body?.data?.[0]?.url;
56
+ }
57
+
58
+ // Returned as `mediaUrl` on success: the reply pipeline uploads it to X and attaches it
59
+ // to the reply, so the model should caption the image rather than paste a URL.
60
+ const GENERATED_NOTE =
61
+ "Image generated — it will be attached to your reply automatically. Write a short caption for it and do NOT include any URL in your reply.";
62
+
63
+ export const handler: SkillHandler = async (params) => {
64
+ const prompt = (params.prompt ?? "").trim();
65
+ if (!prompt) return { text: "missing prompt — describe the image to generate" };
66
+ const size = resolveSize(params.size ?? params.orientation);
67
+
68
+ // 1) Kick off generation. The server holds the request inline for up to ~30s: if the
69
+ // image is ready it comes back directly, otherwise we get a queued job to poll.
70
+ let body: any;
71
+ try {
72
+ const res = await payFetch(ENDPOINT, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify({ prompt, model: MODEL, size }),
76
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS),
77
+ });
78
+ if (!res.ok) {
79
+ const detail = await res.text().catch(() => "");
80
+ log.warn({ status: res.status, detail }, "generate-image: POST failed");
81
+ return { text: `image generation failed (HTTP ${res.status})` };
82
+ }
83
+ body = await res.json();
84
+ } catch (err) {
85
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "generate-image: POST errored");
86
+ return { text: "image generation failed — the request errored before any image was produced" };
87
+ }
88
+
89
+ // 2a) Fast path — the image came back inline within the 30s window.
90
+ const inline = imageUrl(body);
91
+ if (inline) {
92
+ log.info({ model: MODEL, size, url: inline }, "generate-image: inline result");
93
+ return { text: GENERATED_NOTE, mediaUrl: inline };
94
+ }
95
+
96
+ // 2b) Slow path — poll the job until it completes. payFetch re-signs the poll's x402
97
+ // challenge each time; BlockRun only settles the charge on the first completed poll
98
+ // (the unused authorizations for in-progress polls are never submitted on-chain), so
99
+ // polling does not double-bill. The poll must come from the same wallet as the POST,
100
+ // which it always does (one agent wallet).
101
+ const pollPath = body?.poll_url as string | undefined;
102
+ if (!pollPath) {
103
+ log.warn({ body }, "generate-image: no image and no poll_url to follow");
104
+ return { text: "image generation failed — no image and no job to poll" };
105
+ }
106
+ const pollUrl = new URL(pollPath, ORIGIN).toString();
107
+ log.info({ jobId: body?.id, status: body?.status, size, pollUrl }, "generate-image: job queued, polling");
108
+
109
+ for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
110
+ await sleep(POLL_INTERVAL_MS);
111
+ try {
112
+ const pr = await payFetch(pollUrl, { method: "GET", signal: AbortSignal.timeout(POLL_TIMEOUT_MS) });
113
+ const pb: any = await pr.json().catch(() => null);
114
+ const url = imageUrl(pb);
115
+ if (url) {
116
+ log.info({ attempt, url }, "generate-image: completed");
117
+ return { text: GENERATED_NOTE, mediaUrl: url };
118
+ }
119
+ if (pb?.status === "failed") {
120
+ log.warn({ attempt, body: pb }, "generate-image: job reported failed");
121
+ return { text: "image generation failed while processing the job" };
122
+ }
123
+ log.info({ attempt, status: pb?.status }, "generate-image: still generating");
124
+ } catch (err) {
125
+ // A transient poll error (timeout/network) shouldn't abort — the job may still
126
+ // finish, so log and try the next tick.
127
+ log.warn({ attempt, err: err instanceof Error ? err.message : String(err) }, "generate-image: poll errored — retrying");
128
+ }
129
+ }
130
+
131
+ log.warn({ jobId: body?.id }, "generate-image: polling exhausted before completion");
132
+ return { text: "image generation timed out — the job did not finish in time, try again" };
133
+ };
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: generate-image
3
+ description: Generate an image from a text prompt and reply with its URL. Use when the user asks you to create, generate, draw, paint, or make an image/picture of something.
4
+ access: admin
5
+ ---
6
+
7
+ Generate an image from a text description using OpenAI `gpt-image-1` (via the BlockRun x402 gateway, paid from the agent wallet — so this costs money per call).
8
+
9
+ Extract these parameters:
10
+ - `prompt` (required) — the image description, in the user's own words (e.g. "anime style white cat playing on green grass"). Build it from what the user actually asked for; don't invent detail they didn't request.
11
+ - `size` (optional) — the image orientation, one of:
12
+ - `square` — 1024×1024 (**default** — use this whenever the user doesn't ask for a shape)
13
+ - `landscape` — 1536×1024 (wide; for "landscape", "wide", "banner")
14
+ - `portrait` — 1024×1536 (tall; for "portrait", "vertical", "tall", "phone wallpaper")
15
+
16
+ The skill returns `image_url: <url>` once the image is ready. Generation runs inline for up to ~30s; if it takes longer the skill polls the job in the background, so a call can take up to ~1–2 minutes. Wait for it.
17
+
18
+ When you get the result back:
19
+ - The generated image is **attached to your reply automatically** — just write a short, natural caption for it. Do **not** paste any URL or link in the reply text.
20
+ - If the skill reports a failure or timeout, tell the user it couldn't generate the image. Never invent or describe an image that wasn't actually generated.
@@ -0,0 +1,40 @@
1
+ import { chat, log, type SkillHandler, type ChatMessage } from "yappr";
2
+
3
+ // generate-meme-prompt: craft a funny, Crypto-Twitter-flavored image PROMPT from a tweet.
4
+ // It does NOT render anything — it returns the prompt for the model to hand to the
5
+ // generate-image skill, which does the actual (paid) rendering and attaches the result.
6
+ // Keeping the two apart means one render path (generate-image) and no duplicated image-gen
7
+ // code here. Use it when the user wants a meme but hasn't described the visual themselves.
8
+
9
+ // The prompt-crafting brief. Bakes in Crypto Twitter context so the meme lands.
10
+ const MEME_SYSTEM = `You are a Crypto Twitter (CT) meme director. Given the subject of a tweet, write ONE image-generation prompt for a single funny meme image.
11
+
12
+ - Lean into CT humor and culture where it fits — degens, leverage and liquidations, rugs and exit liquidity, gm/wagmi/ngmi, "ser"/"anon", diamond vs paper hands, copium, "few understand this", and wojak/pepe/chad/virgin-vs-chad visual archetypes. Use what suits the subject; never force every trope in.
13
+ - Describe a clear, vivid visual scene, and specify the EXACT short bold meme caption text to render in the image (impact-font style, just a few words).
14
+ - Funny and a little edgy is good; never hateful, sexual, or harassing toward private individuals.
15
+ - Output ONLY the image prompt itself — no preamble, no quotes, no explanation, no markdown.`;
16
+
17
+ export const handler: SkillHandler = async (params) => {
18
+ const subject = (params.subject ?? params.prompt ?? "").trim();
19
+ if (!subject) return { text: "missing subject — pass the tweet's content/topic to meme about" };
20
+ const angle = params.angle?.trim() || undefined;
21
+
22
+ const user = angle ? `Tweet subject: ${subject}\n\nExtra angle to play on: ${angle}` : `Tweet subject: ${subject}`;
23
+ const messages: ChatMessage[] = [
24
+ { role: "system", content: MEME_SYSTEM },
25
+ { role: "user", content: user },
26
+ ];
27
+
28
+ try {
29
+ const memePrompt = (await chat(messages)).trim();
30
+ if (!memePrompt) throw new Error("empty prompt");
31
+ log.info({ subject, memePrompt }, "generate-meme-prompt: crafted prompt");
32
+ // Return just the prompt (as data). The chaining — "now call generate-image with this"
33
+ // — is driven by skill.md (a trusted instruction), since observations are fed to the
34
+ // model as data, not instructions.
35
+ return { text: `Meme image prompt (pass verbatim to the generate-image skill's "prompt"):\n\n${memePrompt}` };
36
+ } catch (err) {
37
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "generate-meme-prompt: prompt crafting failed");
38
+ return { text: "couldn't come up with a meme for that — try again" };
39
+ }
40
+ };
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: generate-meme-prompt
3
+ description: Invent a funny Crypto-Twitter image prompt for a meme about a tweet — for when the user wants a meme but hasn't said what the image should show. generate-image then renders it. Use for "make a meme", "meme this".
4
+ access: admin
5
+ ---
6
+
7
+ Use this when the user wants a meme about a tweet but **hasn't described what the image should actually show** — it invents the funny visual for you (a cheap LLM call) by writing a Crypto-Twitter-aware image prompt. It does **not** render anything; after it returns the prompt, you call the **generate-image** skill to create the meme and attach it.
8
+
9
+ **When NOT to use it:** if the user already told you exactly what the image should depict (e.g. "make an image of a green pepe crying at a red chart"), skip this skill and call `generate-image` directly with their description — you don't need help inventing the visual.
10
+
11
+ Extract these parameters:
12
+ - `subject` (required) — what the meme is about. Pull it from the relevant tweet in the context: quote its key line or summarize the joke/topic, and say *why* it's meme-able.
13
+ - `angle` (optional) — a specific spin if the user asked for one (e.g. "make fun of the leverage", "diamond-hands angle", "him coping"). Omit if there's no particular angle.
14
+
15
+ Do **not** write the meme prompt yourself — this skill writes it for you.
16
+
17
+ ## How to make the meme (two steps)
18
+
19
+ 1. Call `generate-meme-prompt` with the `subject` (and optional `angle`). It returns a ready-made image prompt.
20
+ 2. Then call the **generate-image** skill, setting its `prompt` param to that returned prompt **verbatim** (and a `size` such as `landscape` only if the user asked for a shape). generate-image renders the meme and attaches it to your reply automatically.
21
+ 3. Reply with a short, natural caption — do **not** paste any URL or the prompt text.
22
+
23
+ If `generate-meme-prompt` reports it couldn't come up with anything, tell the user and don't fabricate a meme.
@@ -0,0 +1,76 @@
1
+ import { summary, getTreasury, llmCreditBalance, log, type SkillHandler } from "yappr";
2
+
3
+ // stats: the agent's all-time metrics from its own ledger, plus a runway estimate (how
4
+ // long the treasury lasts at the current burn). The runway mirrors the status dashboard's
5
+ // two-tank model: USDC pays x-api + compute + x402, LLM credits pay inference — runway is
6
+ // whichever tank empties first. (Burn math kept in sync with cli/status.ts by hand.)
7
+
8
+ const RUNWAY_MIN_DATA_HOURS = 1; // trust the measured burn only after this much recorded window
9
+ const X_API_POLL_COST_USD = 0.005; // always-on mentions-poll cost — the cold-start/floor burn
10
+
11
+ function fmtDuration(hours: number): string {
12
+ if (!Number.isFinite(hours)) return "∞";
13
+ if (hours < 1) return `${Math.round(hours * 60)}m`;
14
+ if (hours < 48) return `${hours.toFixed(1)}h`;
15
+ return `${(hours / 24).toFixed(1)}d`;
16
+ }
17
+
18
+ export const handler: SkillHandler = async () => {
19
+ const s = summary();
20
+
21
+ // Burn rates from the trailing window. usdcBurn = x-api+compute+x402 (total minus the
22
+ // inference slice), floored at the always-on poll cost so downtime can't inflate runway;
23
+ // llmBurn = inference. Before there's enough recorded window, fall back to the poll-cost
24
+ // estimate for USDC and treat inference as not-yet-binding.
25
+ const pollSeconds = Math.round((Number(process.env.POLL_INTERVAL_MS) || 20_000) / 1000);
26
+ const predictedUsdcBurn = pollSeconds > 0 ? (3600 / pollSeconds) * X_API_POLL_COST_USD : 0;
27
+ const hasRate = s.rateWindowHours >= RUNWAY_MIN_DATA_HOURS && s.spentUsdWindow > 0;
28
+ const usdcBurn = hasRate
29
+ ? Math.max((s.spentUsdWindow - s.inferenceUsdWindow) / s.rateWindowHours, predictedUsdcBurn)
30
+ : predictedUsdcBurn;
31
+ const llmBurn = hasRate ? s.inferenceUsdWindow / s.rateWindowHours : 0;
32
+
33
+ // Treasury value per tank: the USDC balance (live, on-chain) and the LLM credit balance.
34
+ let usdcUsd: number | null = null;
35
+ try {
36
+ usdcUsd = Number((await getTreasury().balances()).usdc) / 1e6;
37
+ } catch (err) {
38
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "stats: balances fetch failed");
39
+ }
40
+ const creditUsd = await llmCreditBalance();
41
+
42
+ const usdcRunwayH = usdcBurn > 0 ? (usdcUsd != null ? usdcUsd / usdcBurn : Infinity) : Infinity;
43
+ const llmRunwayH = llmBurn > 0 ? (creditUsd != null ? creditUsd / llmBurn : Infinity) : Infinity;
44
+ const runwayHours = Math.min(usdcRunwayH, llmRunwayH);
45
+ const runwayKnown = usdcUsd != null || creditUsd != null;
46
+
47
+ const runway = !runwayKnown
48
+ ? { available: false as const }
49
+ : {
50
+ available: true as const,
51
+ human: fmtDuration(runwayHours),
52
+ hours: Number.isFinite(runwayHours) ? Number(runwayHours.toFixed(1)) : null, // null ≈ effectively infinite
53
+ days: Number.isFinite(runwayHours) ? Number((runwayHours / 24).toFixed(2)) : null,
54
+ estimated: !hasRate, // cold-start estimate — not enough recorded burn yet
55
+ limitedBy: usdcRunwayH <= llmRunwayH ? "usdc" : "llm-credits",
56
+ usdcBalanceUsd: usdcUsd,
57
+ llmCreditUsd: creditUsd,
58
+ usdcBurnUsdPerHour: Number(usdcBurn.toFixed(4)),
59
+ llmBurnUsdPerHour: Number(llmBurn.toFixed(4)),
60
+ };
61
+
62
+ return {
63
+ data: {
64
+ mentions: s.mentions,
65
+ replies: s.replies,
66
+ llmCalls: s.llm,
67
+ // warns/errors are deliberately omitted — internal health metrics, not user-facing
68
+ // (and this skill is public). They stay in summary() for the admin status dashboard.
69
+ spentUsd: Number(s.spentUsd.toFixed(4)),
70
+ spentByType: s.spentByType, // { "x-api", inference, compute, x402 }
71
+ earnedWeth: s.earnedWeth, // lifetime gross creator fees, in ETH/WETH
72
+ devWeth: s.devWeth, // dev cut within earnedWeth
73
+ runway,
74
+ },
75
+ };
76
+ };
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: stats
3
+ description: Report the agent's all-time stats — mentions handled, replies, LLM calls, total spent, earned — plus a live runway estimate (how long the treasury lasts at the current burn). Use when asked "your stats", "how are you doing", "how much have you spent/earned", "what's your runway".
4
+ access: all
5
+ ---
6
+
7
+ Returns the agent's lifetime metrics from its own ledger, plus a live runway estimate. Call it with no params. It returns structured data:
8
+
9
+ - `mentions`, `replies`, `llmCalls` — lifetime counts.
10
+ - `spentUsd` — total USD spent, with `spentByType` broken out into `x-api`, `inference`, `compute` and `x402`.
11
+ - `earnedWeth` — lifetime gross creator fees, in **ETH/WETH**; `devWeth` is the dev cut within it.
12
+ - `runway` — how long the treasury lasts at the current burn (ignores incoming earnings):
13
+ - `human` — a ready string like `"12.5d"`, `"8.0h"`, or `"∞"`; plus `hours` and `days` numbers (both `null` when effectively infinite).
14
+ - `estimated: true` — a cold-start estimate (not enough recorded burn yet); say "roughly".
15
+ - `limitedBy` — the tank that runs out first: `"usdc"` (X data + compute) or `"llm-credits"` (inference).
16
+ - `available: false` — balances couldn't be read right now; say the runway is temporarily unavailable rather than guessing.
17
+
18
+ When you reply: present the figures naturally and concisely — you don't have to list every one. Spend is in USD, earnings are in ETH. If the user asked about just one figure (e.g. "what's your runway"), lead with that. Never mention internal health metrics like warnings or error counts — those are operational internals, not something to share.
@@ -0,0 +1,56 @@
1
+ import { agentPrompt, type SkillHandler } from "yappr";
2
+
3
+ // Bankr returns a natural-language response, so we pull transaction hashes out of
4
+ // it and append the right block-explorer link. Hash format tells us the chain:
5
+ // EVM: 0x + 64 hex chars -> basescan.org (our EVM actions run on Base)
6
+ // Solana: base58 signature ~88 chars -> solscan.io
7
+ // (EVM addresses are 0x+40 hex, so they never match the 64-hex tx pattern; Solana
8
+ // addresses are <=44 base58 chars, so they never match the long-signature pattern.)
9
+ const EVM_TX = /0x[0-9a-fA-F]{64}/g;
10
+ const SOL_TX = /\b[1-9A-HJ-NP-Za-km-z]{80,90}\b/g;
11
+
12
+ function appendTxLinks(text: string): string {
13
+ const links: string[] = [];
14
+ const seen = new Set<string>();
15
+ const add = (hash: string, base: string) => {
16
+ if (seen.has(hash)) return;
17
+ seen.add(hash);
18
+ links.push(`${base}${hash}`);
19
+ };
20
+ for (const h of text.match(EVM_TX) ?? []) add(h, "https://basescan.org/tx/");
21
+ for (const s of text.match(SOL_TX) ?? []) add(s, "https://solscan.io/tx/");
22
+ if (links.length === 0) return text;
23
+ return `${text}\n\nTransaction${links.length > 1 ? "s" : ""}:\n${links.join("\n")}`;
24
+ }
25
+
26
+ export const handler: SkillHandler = async (params, _tweet) => {
27
+ switch (params.action) {
28
+ case "claim":
29
+ return { text: appendTxLinks(await agentPrompt("claim my token fees on base")) };
30
+
31
+ case "burn": {
32
+ const amount = params.amount ?? "50%";
33
+ return { text: appendTxLinks(await agentPrompt(`burn ${amount} of my tokens on base`)) };
34
+ }
35
+
36
+ case "swap": {
37
+ const from = params.from ?? "WETH";
38
+ const to = params.to ?? "USDC";
39
+ const amount = params.swap_amount ?? "all";
40
+ return { text: appendTxLinks(await agentPrompt(`swap ${amount} of ${from} to ${to} on base`)) };
41
+ }
42
+
43
+ case "send": {
44
+ if (!params.send_to) return { text: "missing recipient — specify an address, ENS name, or X @handle" };
45
+ if (!params.send_amount) return { text: "missing amount — specify how much to send" };
46
+ const token = params.send_token ?? "ETH";
47
+ return { text: appendTxLinks(await agentPrompt(`send ${params.send_amount} ${token} to ${params.send_to} on base`)) };
48
+ }
49
+
50
+ case "balance":
51
+ return { text: await agentPrompt("what are my token balances on base") };
52
+
53
+ default:
54
+ return { text: `unknown action "${params.action}" — try: claim, burn, swap, send, balance` };
55
+ }
56
+ };
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: wallet
3
+ description: Manage the agent's wallet. Use when the user asks to claim fees, burn tokens, swap tokens, send tokens, or check balances.
4
+ access: admin
5
+ ---
6
+
7
+ Extract these params from the request:
8
+ - action (required): one of "claim", "burn", "swap", "send", "balance"
9
+ - amount (optional): for "burn" — a token amount (e.g. "100"), a percentage (e.g. "50%"), or "all". Defaults to configured burn percentage.
10
+ - from (optional): for "swap" — source token symbol or address
11
+ - to (optional): for "swap" — destination token symbol or address
12
+ - swap_amount (optional): for "swap" — amount or percentage of source token (e.g. "10%", "0.5", "all")
13
+ - send_token (optional): for "send" — token symbol or address to send (e.g. "USDC", "ETH")
14
+ - send_amount (optional): for "send" — amount to send (e.g. "100", "0.5")
15
+ - send_to (optional): for "send" — recipient: wallet address, ENS name, or X handle (e.g. "0x123...", "vitalik.eth", "@someone")
16
+
17
+ For on-chain actions (claim, burn, swap, send), the skill result includes a block-explorer link for each transaction. Always include the transaction link(s) verbatim in your reply so the user can verify the on-chain action.
@@ -0,0 +1,135 @@
1
+ import {
2
+ type SkillHandler, type SkillResult,
3
+ extractTweetId,
4
+ getTweetById, getUserTweets, searchTweets,
5
+ getTweetReplies, getRetweetedBy, getQuoteTweets,
6
+ postTweet, deleteTweet,
7
+ likeTweet, unlikeTweet, retweetTweet, unretweetTweet,
8
+ bookmarkTweet, unbookmarkTweet,
9
+ getUserByUsername, getUserById, getUsers, searchUsers,
10
+ getFollowers, getFollowing, followUser, unfollowUser, setProfile,
11
+ uploadMediaFromUrl,
12
+ getArticle,
13
+ getList, getListMembers, getListFollowers, getListTweets,
14
+ getCommunity, getCommunityMembers, getCommunityPosts,
15
+ getUserInsights,
16
+ } from "yappr";
17
+
18
+ type Params = Record<string, string>;
19
+ type Action = (p: Params) => Promise<SkillResult>;
20
+
21
+ // Wrap an action that needs an id (tweet/user/list/community — accepts a raw id or X URL).
22
+ function withId(label: string, fn: (id: string, p: Params) => Promise<SkillResult>): Action {
23
+ return (p) => (p.id ? fn(extractTweetId(p.id), p) : Promise.resolve({ text: `missing ${label}` }));
24
+ }
25
+
26
+ // Wrap a write action that just acknowledges success with a short message.
27
+ function ack(label: string, fn: (id: string) => Promise<void>, verb: string): Action {
28
+ return withId(label, async (id) => {
29
+ await fn(id);
30
+ return { text: `${verb} ${id}` };
31
+ });
32
+ }
33
+
34
+ const ID = "tweet id or URL";
35
+
36
+ // Upload one or more image URLs (comma-separated) to X and return their media_ids to
37
+ // attach to a post. Bounded to X's 4-images-per-tweet limit.
38
+ async function uploadMediaUrls(raw?: string): Promise<string[] | undefined> {
39
+ const urls = (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
40
+ if (urls.length === 0) return undefined;
41
+ const ids: string[] = [];
42
+ for (const url of urls.slice(0, 4)) ids.push(await uploadMediaFromUrl(url));
43
+ return ids.length ? ids : undefined;
44
+ }
45
+
46
+ const actions: Record<string, Action> = {
47
+ // ── tweets (read) ──
48
+ "tweet": withId(ID, async (id) => ({ data: await getTweetById(id) })),
49
+ "tweet-search": async (p) => ({
50
+ data: await searchTweets({
51
+ words: p.words ?? p.query, phrase: p.phrase, anyWords: p.any_words, noneWords: p.none_words,
52
+ hashtags: p.hashtags, from: p.from, to: p.to, mentioning: p.mentioning,
53
+ minReplies: p.min_replies ? Number(p.min_replies) : undefined,
54
+ minLikes: p.min_likes ? Number(p.min_likes) : undefined,
55
+ minReposts: p.min_reposts ? Number(p.min_reposts) : undefined,
56
+ since: p.since, until: p.until,
57
+ }),
58
+ }),
59
+ "tweet-replies": withId(ID, async (id) => ({ data: await getTweetReplies(id) })),
60
+ "tweet-retweeters": withId(ID, async (id) => ({ data: await getRetweetedBy(id) })),
61
+ "tweet-quotes": withId(ID, async (id) => ({ data: await getQuoteTweets(id) })),
62
+ "timeline": async (p) => (p.username ? { data: await getUserTweets(p.username) } : { text: "missing username" }),
63
+
64
+ // ── tweets (write) ──
65
+ "post": async (p) => {
66
+ if (!p.text) return { text: "missing tweet text" };
67
+ await postTweet(p.text, {
68
+ replyTo: p.reply_to ? extractTweetId(p.reply_to) : undefined,
69
+ quoteTweetId: p.quote_id ? extractTweetId(p.quote_id) : undefined,
70
+ mediaIds: await uploadMediaUrls(p.media_url),
71
+ });
72
+ return { text: "posted" };
73
+ },
74
+ "delete": ack(ID, deleteTweet, "deleted"),
75
+ "like": ack(ID, likeTweet, "liked"),
76
+ "unlike": ack(ID, unlikeTweet, "unliked"),
77
+ "retweet": ack(ID, retweetTweet, "retweeted"),
78
+ "unretweet": ack(ID, unretweetTweet, "unretweeted"),
79
+ "bookmark": ack(ID, bookmarkTweet, "bookmarked"),
80
+ "unbookmark": ack(ID, unbookmarkTweet, "removed bookmark"),
81
+
82
+ // ── users (read) ──
83
+ "user": async (p) => {
84
+ if (!p.username && !p.id) return { text: "missing username or id" };
85
+ return { data: p.username ? await getUserByUsername(p.username) : await getUserById(p.id) };
86
+ },
87
+ "users": async (p) => {
88
+ const ids = (p.ids ?? "").split(",").map((s) => s.trim()).filter(Boolean);
89
+ return ids.length ? { data: await getUsers(ids) } : { text: "missing ids (comma-separated numeric user IDs)" };
90
+ },
91
+ "user-search": async (p) => (p.query ? { data: await searchUsers(p.query) } : { text: "missing search query" }),
92
+ "followers": withId("user id", async (id) => ({ data: await getFollowers(id) })),
93
+ "following": withId("user id", async (id) => ({ data: await getFollowing(id) })),
94
+
95
+ // ── users (write) ──
96
+ "follow": async (p) => {
97
+ if (!p.username && !p.id) return { text: "missing username or id" };
98
+ await followUser({ id: p.id, username: p.username });
99
+ return { text: `followed ${p.username ?? p.id}` };
100
+ },
101
+ "unfollow": async (p) => {
102
+ if (!p.username && !p.id) return { text: "missing username or id" };
103
+ await unfollowUser({ id: p.id, username: p.username });
104
+ return { text: `unfollowed ${p.username ?? p.id}` };
105
+ },
106
+ "set-profile": async (p) => {
107
+ // All four fields are required (a profile set replaces the whole thing). `name` must
108
+ // be non-empty; bio/location/url may be an empty string, which CLEARS that field on X.
109
+ for (const f of ["name", "bio", "location", "url"] as const) {
110
+ if (p[f] === undefined) {
111
+ return { text: "set-profile requires all of: name, bio, location, url (pass an empty string for bio/location/url to clear them)" };
112
+ }
113
+ }
114
+ if (p.name.trim() === "") return { text: "name cannot be empty" };
115
+ await setProfile({ name: p.name, bio: p.bio, location: p.location, url: p.url });
116
+ return { text: "profile updated" };
117
+ },
118
+
119
+ // ── other ──
120
+ "article": withId(ID, async (id) => ({ data: await getArticle(id) })),
121
+ "list": withId("list id", async (id) => ({ data: await getList(id) })),
122
+ "list-members": withId("list id", async (id) => ({ data: await getListMembers(id) })),
123
+ "list-followers": withId("list id", async (id) => ({ data: await getListFollowers(id) })),
124
+ "list-tweets": withId("list id", async (id) => ({ data: await getListTweets(id) })),
125
+ "community": withId("community id", async (id) => ({ data: await getCommunity(id) })),
126
+ "community-members": withId("community id", async (id) => ({ data: await getCommunityMembers(id) })),
127
+ "community-posts": withId("community id", async (id) => ({ data: await getCommunityPosts(id) })),
128
+ "user-insights": async (p) => (p.username ? { data: await getUserInsights(p.username) } : { text: "missing username" }),
129
+ };
130
+
131
+ export const handler: SkillHandler = async (params) => {
132
+ const action = actions[params.action];
133
+ if (!action) return { text: `unknown action "${params.action}"` };
134
+ return action(params);
135
+ };