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,163 @@
1
+ ---
2
+ name: x
3
+ description: Read or search tweets and users, post, engage (like/retweet/bookmark/follow), and pull lists, communities, articles, or deep user analysis on X/Twitter. Use whenever the user asks to look something up on X or to act on their account.
4
+ access: admin
5
+ ---
6
+
7
+ Pick exactly one `action` from the list below and pass it (plus its parameters) in `params` when you call this skill, e.g. `params: {"action":"<action>", ...}`.
8
+
9
+ Shared parameters:
10
+ - `id` — a tweet, user, list, or community ID. A full X URL also works (e.g. `https://x.com/user/status/123456`); the ID is extracted automatically.
11
+ - `username` — a handle written without the leading `@`.
12
+
13
+ ---
14
+
15
+ ## Tweets — read
16
+
17
+ ### tweet
18
+ Get one tweet's full data (text, author, engagement metrics).
19
+ - When: the user points at a single tweet or URL and wants its content or stats.
20
+ - `id` (required) — the tweet ID or URL.
21
+
22
+ ### tweet-search
23
+ Search recent tweets by keyword and filters. This is the action for "find/search tweets about …".
24
+ - When: "find tweets about X", "what are people saying about Y", "search posts from Z this week".
25
+ - Put the user's plain search terms in `words`. All parameters are optional — combine the ones that match the request:
26
+ - `words` — all of these words must appear. **This is the default keyword field — use it for a normal search.**
27
+ - `phrase` — match this exact phrase.
28
+ - `any_words` — match if any one of these words appears.
29
+ - `none_words` — exclude tweets containing these words.
30
+ - `hashtags` — required hashtags, without the `#`.
31
+ - `from` — only tweets authored by this handle.
32
+ - `to` — only tweets that are replies to this handle.
33
+ - `mentioning` — only tweets that mention this handle.
34
+ - `min_likes` / `min_replies` / `min_reposts` — minimum engagement, as numbers.
35
+ - `since` / `until` — date bounds in `YYYY-MM-DD` form.
36
+
37
+ ### tweet-replies
38
+ List the replies under a tweet.
39
+ - When: "what are people replying", "show the replies to this".
40
+ - `id` (required) — the tweet being replied to.
41
+
42
+ ### tweet-retweeters
43
+ List the users who reposted a tweet.
44
+ - When: "who retweeted this".
45
+ - `id` (required).
46
+
47
+ ### tweet-quotes
48
+ List quote-tweets of a tweet.
49
+ - When: "who quoted this", "show the quote tweets".
50
+ - `id` (required).
51
+
52
+ ### timeline
53
+ Fetch a user's recent tweets.
54
+ - When: "show me what @user has been posting", "latest tweets from this account".
55
+ - `username` (required).
56
+
57
+ ## Tweets — write
58
+
59
+ ### post
60
+ Publish a new tweet, a reply, or a quote tweet.
61
+ - When: the user asks to tweet, reply, or quote something.
62
+ - `text` (required) — the tweet body.
63
+ - `reply_to` (optional) — a tweet ID/URL to reply under.
64
+ - `quote_id` (optional) — a tweet ID/URL to quote.
65
+ - `media_url` (optional) — an image URL to attach (or several, comma-separated, up to 4). Each is uploaded to X automatically and embedded in the post.
66
+
67
+ ### delete
68
+ Delete one of the agent's own tweets.
69
+ - `id` (required) — the tweet to delete.
70
+
71
+ ### like / unlike
72
+ Like or remove a like from a tweet.
73
+ - `id` (required).
74
+
75
+ ### retweet / unretweet
76
+ Repost a tweet or undo a repost.
77
+ - `id` (required).
78
+
79
+ ### bookmark / unbookmark
80
+ Add or remove a bookmark.
81
+ - `id` (required).
82
+
83
+ ## Users — read
84
+
85
+ ### user
86
+ Look up a single user's profile (bio, metrics, ID).
87
+ - When: "who is @x", "show me this account", or to resolve a handle into a numeric user ID for `followers`/`following`.
88
+ - Provide either `username` or `id`.
89
+
90
+ ### users
91
+ Look up several users at once by numeric ID (one batched call).
92
+ - When: you already hold a list of numeric user IDs (e.g. from `followers`/`retweeters`) and want their profiles together.
93
+ - `ids` (required) — comma-separated **numeric user IDs** (not handles).
94
+
95
+ ### user-search
96
+ Search for users by keyword.
97
+ - When: "find accounts about X", "search for people who do Y".
98
+ - `query` (required) — the search keywords.
99
+
100
+ ### followers
101
+ List a user's followers.
102
+ - When: "who follows this account".
103
+ - `id` (required) — the **numeric user ID** (not a handle). If you only have a handle, call `user` first to get the ID.
104
+
105
+ ### following
106
+ List who a user follows.
107
+ - When: "who does this account follow".
108
+ - `id` (required) — the **numeric user ID**; resolve a handle via `user` first.
109
+
110
+ ## Users — write
111
+
112
+ ### follow / unfollow
113
+ Follow or unfollow an account.
114
+ - Provide either `username` or `id`.
115
+
116
+ ### set-profile
117
+ Update the agent's own X profile. **All four fields are required** — a profile update replaces the whole profile, so always include every field, even ones you're keeping the same (carry over the current value).
118
+ - `name` (required) — display name. **Cannot be empty.**
119
+ - `bio` (required) — profile bio/description. Pass an empty string `""` to clear it.
120
+ - `location` (required) — profile location. Pass an empty string `""` to clear it.
121
+ - `url` (required) — profile website URL. Pass an empty string `""` to clear it.
122
+
123
+ ## Other
124
+
125
+ ### article
126
+ Get the full text of an X article (long-form post).
127
+ - When: the tweet you're asked about is an X article — its `entities.urls` expands to an `x.com/i/article/…` link.
128
+ - `id` (required) — the **tweet ID of the article tweet**, resolved like any other tweet ID:
129
+ - If that article tweet is the one in front of you (its own `entities.urls` expands to `x.com/i/article/…`), use that tweet's own `id` field.
130
+ - If you're following a link to the article, use the `…/status/<id>` ID extracted from that URL (the same as for the `tweet` action).
131
+ - ⚠️ Never use the number inside the `x.com/i/article/<id>` link itself — that is a separate internal article ID, not a tweet ID, and the fetch will fail.
132
+
133
+ ### list
134
+ Get a list's details.
135
+ - `id` (required) — the list ID.
136
+
137
+ ### list-members
138
+ Members of a list.
139
+ - `id` (required) — the list ID.
140
+
141
+ ### list-followers
142
+ Followers of a list.
143
+ - `id` (required) — the list ID.
144
+
145
+ ### list-tweets
146
+ Recent tweets posted to a list.
147
+ - `id` (required) — the list ID.
148
+
149
+ ### community
150
+ Get a community's details.
151
+ - `id` (required) — the community ID.
152
+
153
+ ### community-members
154
+ Members of a community.
155
+ - `id` (required) — the community ID.
156
+
157
+ ### community-posts
158
+ Recent posts in a community.
159
+ - `id` (required) — the community ID.
160
+
161
+ ### user-insights
162
+ Deep analysis of a user, including monthly tweet volume. Slow — takes 30–90s, so only use it when the user explicitly asks for an in-depth breakdown of an account.
163
+ - `username` (required).
@@ -0,0 +1,2 @@
1
+ import type { AgentHooks } from "yappr";
2
+ export declare const hooks: AgentHooks;
@@ -0,0 +1,37 @@
1
+ export const hooks = {
2
+ // onMention: async (tweet) => {
3
+ // console.log(`mention from @${tweet.author?.username}: ${tweet.text.slice(0, 80)}`);
4
+ // },
5
+ // Return false to skip replying to this tweet.
6
+ // shouldReply: (tweet) => {
7
+ // const blocked = ["spambot"];
8
+ // return !blocked.includes(tweet.author?.username?.toLowerCase() ?? "");
9
+ // },
10
+ // Mutate the question or context before it reaches the LLM. Also receives the
11
+ // asker tweet, for per-user logic — see user-memory.ts for a real example.
12
+ // onBeforeInference: async ({ tweet, question, context }) => {
13
+ // return { question, context };
14
+ // },
15
+ // Post-process the LLM output before it is sent.
16
+ // onAfterInference: async ({ output }) => {
17
+ // return output;
18
+ // },
19
+ // Mutate the reply text or return null to cancel sending.
20
+ // onBeforeReply: async ({ text }) => {
21
+ // return text;
22
+ // // return `${text}\n\npowered by x402`; // append footer
23
+ // // return null; // veto
24
+ // },
25
+ // onAfterReply: async ({ tweet, text }) => {
26
+ // console.log(`replied to ${tweet.id}: ${text.slice(0, 80)}`);
27
+ // },
28
+ // onBeforeClaim: async (balances) => {
29
+ // console.log("treasury cycle starting", balances);
30
+ // },
31
+ // onAfterClaim: async (result) => {
32
+ // console.log("treasury cycle done", result);
33
+ // },
34
+ // onSwap: async ({ kind, amount }) => {
35
+ // console.log(`treasury swap: ${kind} ${amount.toString()}`);
36
+ // },
37
+ };
@@ -0,0 +1,2 @@
1
+ import { type AgentHooks } from "yappr";
2
+ export declare const hooks: AgentHooks;
@@ -0,0 +1,147 @@
1
+ import { agentPrompt, skillStore, log, config } from "yappr";
2
+ // Holder context. Resolves the asker's Bankr wallet address (via a Bankr
3
+ // agent job — their wallet is custodied by Bankr, keyed to their X handle) and
4
+ // their on-chain balance of the agent's own token, then injects both into the
5
+ // prompt context so the model knows whether it's talking to a holder.
6
+ // Delete this file to disable — nothing in the engine depends on it.
7
+ //
8
+ // Cost model:
9
+ // - The agent-job lookup runs in Max Mode (billed from LLM credits per
10
+ // request — see agent-prompt.ts), so it fires ONCE per user: a resolved
11
+ // address is stored forever; a "no Bankr account" result is retried only
12
+ // after NO_WALLET_RETRY_MS.
13
+ // - The balance is a free public-RPC read, cached for BALANCE_TTL_MS so a
14
+ // chatty user doesn't trigger a call per mention.
15
+ // Storage is the shared SQLite DB via skillStore("bankr-wallet"), so both
16
+ // caches survive restarts/redeploys and ride along in backups.
17
+ const BALANCE_TTL_MS = 3_600_000; // re-check holdings at most hourly
18
+ const NO_WALLET_RETRY_MS = 24 * 3_600_000; // re-ask Bankr for no-wallet users daily
19
+ // Cap on how long a reply waits for a first-time wallet lookup. The Bankr job
20
+ // usually answers in ~10-20s; past the cap the reply proceeds without the block
21
+ // and the still-running job stores its result for the user's next mention.
22
+ const RESOLVE_TIMEOUT_MS = 45_000;
23
+ const BASE_RPC = process.env.BASE_RPC_URL || "https://mainnet.base.org";
24
+ const TOKEN_DECIMALS = 18; // Bankr launches are fixed 18-decimal Clanker deploys
25
+ const store = skillStore("bankr-wallet");
26
+ // One lookup per user even when mentions arrive back-to-back.
27
+ const inflight = new Map();
28
+ function resolveWallet(userId, handle) {
29
+ const cached = store.getJSON(`wallet:${userId}`);
30
+ if (cached?.address)
31
+ return Promise.resolve(cached.address);
32
+ if (cached && Date.now() - cached.at < NO_WALLET_RETRY_MS)
33
+ return Promise.resolve(null);
34
+ const running = inflight.get(userId);
35
+ if (running)
36
+ return running;
37
+ const p = (async () => {
38
+ try {
39
+ const text = await agentPrompt(`What is the EVM wallet address of the X user @${handle}? ` +
40
+ `Reply with only the address, or the word "none" if they have no Bankr account.`);
41
+ const address = text.match(/0x[a-fA-F0-9]{40}/)?.[0] ?? null;
42
+ // Negative results are cached too (with a retry window) — without that,
43
+ // every mention from a wallet-less user would burn a paid agent job.
44
+ store.setJSON(`wallet:${userId}`, { address, at: Date.now() });
45
+ log.info({ user: handle, address }, "holder: resolved bankr wallet");
46
+ return address;
47
+ }
48
+ catch (err) {
49
+ // Transient failure — store nothing so the next mention retries.
50
+ log.warn({ user: handle, err: err instanceof Error ? err.message : String(err) }, "holder: wallet lookup failed");
51
+ return null;
52
+ }
53
+ finally {
54
+ inflight.delete(userId);
55
+ }
56
+ })();
57
+ inflight.set(userId, p);
58
+ return p;
59
+ }
60
+ // Bare-bones eth_call helper (no client dependency needed for two views).
61
+ async function ethCall(to, data) {
62
+ const res = await fetch(BASE_RPC, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to, data }, "latest"] }),
66
+ signal: AbortSignal.timeout(10_000),
67
+ });
68
+ const body = (await res.json());
69
+ return body.result && /^0x[0-9a-fA-F]*$/.test(body.result) ? body.result : null;
70
+ }
71
+ // The token's ticker via symbol() — fetched once, then stored forever (it's
72
+ // immutable on-chain). Falls back to "your token" until the read succeeds.
73
+ async function tokenSymbol() {
74
+ const cached = store.get("meta:symbol");
75
+ if (cached)
76
+ return cached;
77
+ try {
78
+ const hex = await ethCall(config.tokenAddress, "0x95d89b41"); // symbol()
79
+ if (hex && hex.length >= 2 + 64 * 2) {
80
+ // Standard ABI string return: 32-byte offset, 32-byte length, then data.
81
+ const len = Number(BigInt("0x" + hex.slice(2 + 64, 2 + 128)));
82
+ const symbol = Buffer.from(hex.slice(2 + 128, 2 + 128 + len * 2), "hex").toString("utf8").trim();
83
+ if (symbol) {
84
+ store.set("meta:symbol", symbol);
85
+ return symbol;
86
+ }
87
+ }
88
+ }
89
+ catch { /* fall through */ }
90
+ return "your token";
91
+ }
92
+ // balanceOf(holder) via raw eth_call.
93
+ async function fetchBalance(holder) {
94
+ const data = "0x70a08231" + holder.slice(2).toLowerCase().padStart(64, "0");
95
+ const result = await ethCall(config.tokenAddress, data);
96
+ return result && result !== "0x" ? BigInt(result) : null;
97
+ }
98
+ async function tokenBalance(holder) {
99
+ const cached = store.getJSON(`balance:${holder}`);
100
+ if (cached && Date.now() - cached.at < BALANCE_TTL_MS)
101
+ return BigInt(cached.raw);
102
+ try {
103
+ const bal = await fetchBalance(holder);
104
+ if (bal !== null) {
105
+ store.setJSON(`balance:${holder}`, { raw: bal.toString(), at: Date.now() });
106
+ return bal;
107
+ }
108
+ }
109
+ catch { /* fall through to stale */ }
110
+ return cached ? BigInt(cached.raw) : null; // a stale figure beats none
111
+ }
112
+ // "12.34M" / "5.6K" / "0.42" — compact, the model doesn't need full precision.
113
+ function fmtTokens(v) {
114
+ const n = Number(v) / 10 ** TOKEN_DECIMALS;
115
+ if (n === 0)
116
+ return "0";
117
+ if (n >= 1e9)
118
+ return `${(n / 1e9).toFixed(2)}B`;
119
+ if (n >= 1e6)
120
+ return `${(n / 1e6).toFixed(2)}M`;
121
+ if (n >= 1e3)
122
+ return `${(n / 1e3).toFixed(2)}K`;
123
+ if (n >= 1)
124
+ return n.toFixed(2);
125
+ return n.toPrecision(2);
126
+ }
127
+ export const hooks = {
128
+ // PREPENDED like the user-memory block, so the current ask (ASKER TWEET)
129
+ // stays last and most salient.
130
+ async onBeforeInference({ tweet, question, context }) {
131
+ const userId = tweet.author?.id;
132
+ const handle = tweet.author?.username;
133
+ if (!userId || !handle)
134
+ return { question, context };
135
+ const address = await Promise.race([
136
+ resolveWallet(userId, handle),
137
+ new Promise((res) => setTimeout(res, RESOLVE_TIMEOUT_MS)),
138
+ ]);
139
+ if (!address)
140
+ return { question, context };
141
+ const [balance, symbol] = await Promise.all([tokenBalance(address), tokenSymbol()]);
142
+ const block = `=== ASKER BANKR WALLET ===\n` +
143
+ `@${handle}'s Bankr wallet address: ${address}\n` +
144
+ `Their balance of ${symbol === "your token" ? symbol : `$${symbol}`} (your token): ${balance !== null ? fmtTokens(balance) : "unknown"}`;
145
+ return { question, context: context ? `${block}\n\n${context}` : block };
146
+ },
147
+ };
@@ -0,0 +1,2 @@
1
+ import { type AgentHooks } from "yappr";
2
+ export declare const hooks: AgentHooks;
@@ -0,0 +1,79 @@
1
+ import { skillStore, log } from "yappr";
2
+ const MAX_EXCHANGES = 50; // per user, oldest dropped first
3
+ const MAX_TEXT = 280; // per side of an exchange, to bound prompt tokens
4
+ const mem = skillStore("user-memory");
5
+ const load = (userId) => mem.getJSON(userId) ?? [];
6
+ const clip = (s) => s.length > MAX_TEXT ? s.slice(0, MAX_TEXT - 1) + "…" : s;
7
+ // "2026-06-11 14:03" — minute granularity is plenty for the model.
8
+ const stamp = (at) => new Date(at).toISOString().slice(0, 16).replace("T", " ");
9
+ function toExchange(t) {
10
+ const parsed = Date.parse(t.created_at);
11
+ return {
12
+ id: t.id,
13
+ at: Number.isNaN(parsed) ? Date.now() : parsed,
14
+ text: clip(t.text),
15
+ conversationId: t.conversation_id || undefined,
16
+ replyToId: t.referenced_tweets?.find((r) => r.type === "replied_to")?.id,
17
+ replyToUser: t.in_reply_to_screen_name ?? undefined,
18
+ };
19
+ }
20
+ // One exchange → one or two compact lines:
21
+ // [2026-06-11 14:03] them (tweet 12, conv 10, replying to @bob in 11): gm what's mcap?
22
+ // you replied: …
23
+ function render(e) {
24
+ const where = [
25
+ `tweet ${e.id}`,
26
+ e.conversationId ? `conv ${e.conversationId}` : null,
27
+ e.replyToId || e.replyToUser
28
+ ? `replying to ${[e.replyToUser && `@${e.replyToUser}`, e.replyToId && `in ${e.replyToId}`].filter(Boolean).join(" ")}`
29
+ : null,
30
+ ].filter(Boolean).join(", ");
31
+ const lines = [`[${stamp(e.at)}] them (${where}): ${e.text}`];
32
+ if (e.agent)
33
+ lines.push(` you replied: ${e.agent}`);
34
+ return lines.join("\n");
35
+ }
36
+ export const hooks = {
37
+ // Record every incoming mention (even ones gating later skips) under the
38
+ // author's id — stable across handle renames.
39
+ onMention(t) {
40
+ const uid = t.author?.id;
41
+ if (!uid)
42
+ return;
43
+ const exchanges = load(uid);
44
+ if (exchanges.some((e) => e.id === t.id))
45
+ return; // already recorded — skip
46
+ exchanges.push(toExchange(t));
47
+ exchanges.sort((a, b) => a.at - b.at);
48
+ mem.setJSON(uid, exchanges.slice(-MAX_EXCHANGES));
49
+ },
50
+ // Inject the user's past exchanges as a context block, PREPENDED so the
51
+ // current ask (ASKER TWEET) stays last and most salient — appended memory
52
+ // reads like the newest message and the model answers it instead. The current
53
+ // ask is already in memory (onMention ran first) but the model sees it as the
54
+ // ASKER TWEET block, so it's excluded here.
55
+ onBeforeInference({ tweet, question, context }) {
56
+ const uid = tweet.author?.id;
57
+ const past = uid ? load(uid).filter((e) => e.id !== tweet.id) : [];
58
+ if (past.length === 0)
59
+ return { question, context };
60
+ log.debug({ user: tweet.author?.username, exchanges: past.length }, "user-memory: injecting");
61
+ const block = `=== USER MEMORY: PAST exchanges between you and @${tweet.author?.username} (oldest first) ===\n` +
62
+ `These happened BEFORE the current request — background for continuity and recall, not part of the current ask.\n` +
63
+ past.map(render).join("\n");
64
+ return { question, context: context ? `${block}\n\n${context}` : block };
65
+ },
66
+ // Attach our posted answer (the final, possibly hook-edited text) to the
67
+ // exchange it answered, so memory holds dialogue rather than monologue.
68
+ onAfterReply({ tweet, text }) {
69
+ const uid = tweet.author?.id;
70
+ if (!uid)
71
+ return;
72
+ const exchanges = load(uid);
73
+ const entry = exchanges.find((e) => e.id === tweet.id);
74
+ if (!entry)
75
+ return; // trimmed out already (user is past the 50-exchange cap)
76
+ entry.agent = clip(text);
77
+ mem.setJSON(uid, exchanges);
78
+ },
79
+ };
@@ -0,0 +1,2 @@
1
+ import { type SkillHandler } from "yappr";
2
+ export declare const handler: SkillHandler;
@@ -0,0 +1,5 @@
1
+ import { getTreasury } from "yappr";
2
+ export const handler = async (_params, _tweet) => {
3
+ await getTreasury().extendCompute();
4
+ return { text: `compute extended by 24h` };
5
+ };
@@ -0,0 +1,2 @@
1
+ import { type SkillHandler } from "yappr";
2
+ export declare const handler: SkillHandler;
@@ -0,0 +1,84 @@
1
+ import { addCronJob, listCronJobs, getCronJob, setCronJobEnabled, resumeCronJob, removeCronJob, validateSchedule, describeSchedule, checkCronCapability, config, } from "yappr";
2
+ // Thin dispatcher over the engine's cron store ("yappr" public API). All
3
+ // validation/caps live engine-side (validateSchedule/addCronJob) and their error
4
+ // strings are written for the model — return them verbatim as the observation so
5
+ // the agent can relay them (e.g. the ask-the-user-for-a-timezone message).
6
+ //
7
+ // Ownership model (safe to flip the skill to `access: all`): every check below
8
+ // uses the ASKING tweet's author — id for ownership (stable across renames),
9
+ // handle for admin status (how the engine gates admin everywhere) — never model
10
+ // params, so a prompt can't impersonate another user. Non-admins can only see
11
+ // and manage their OWN jobs; admins can see (scope=all) and manage everything.
12
+ const iso = (ms) => new Date(ms).toISOString().slice(0, 16) + "Z";
13
+ const trunc = (s, n = 120) => (s.length > n ? s.slice(0, n) + "…" : s);
14
+ const isAdmin = (tweet) => config.adminHandles.includes(tweet.author?.username?.toLowerCase() ?? "");
15
+ // One line per job. Prompts are shown VERBATIM on purpose: stored prompts are
16
+ // replayed through the agent later, so `list` is their audit surface. The
17
+ // creating tweet id is included so the model can link the origin when asked
18
+ // (https://x.com/i/status/<id>).
19
+ function formatJob(j) {
20
+ const status = j.enabled ? `next ${iso(j.nextRunAt)}` : "disabled";
21
+ const origin = j.sourceTweet?.id ? `, from tweet ${j.sourceTweet.id}` : "";
22
+ const last = j.lastError
23
+ ? ` | last error: ${trunc(j.lastError)}`
24
+ : j.lastResult ? ` | last result: ${trunc(j.lastResult)}` : "";
25
+ return `#${j.id} [${status}] ${describeSchedule(j.schedule)} (by @${j.creatorHandle}${origin}, ${j.runs} runs) — "${j.prompt}"${last}`;
26
+ }
27
+ export const handler = async (params, tweet) => {
28
+ switch (params.action) {
29
+ case "add": {
30
+ const schedule = validateSchedule(params);
31
+ if ("error" in schedule)
32
+ return { text: schedule.error };
33
+ // Refuse jobs whose instruction needs skills the creator can't use — they
34
+ // would burn inference on every run only to hit the access denial. No-op
35
+ // for admins; see src/cron/capability.ts for the why and the limits.
36
+ const cap = await checkCronCapability(params.prompt ?? "", isAdmin(tweet));
37
+ if (!cap.ok)
38
+ return { text: `cannot create this job — it would fail on every run: ${cap.reason}` };
39
+ const res = addCronJob({ prompt: params.prompt ?? "", schedule, tweet });
40
+ if ("error" in res)
41
+ return { text: res.error };
42
+ const j = res.job;
43
+ return { text: `cron #${j.id} created: ${describeSchedule(j.schedule)} — "${j.prompt}" (next run ${iso(j.nextRunAt)})` };
44
+ }
45
+ case "list": {
46
+ // Non-admins always see only their own jobs (other users' stored prompts
47
+ // are not theirs to read); admins get scope=all on request.
48
+ const mine = params.scope !== "all" || !isAdmin(tweet);
49
+ const jobs = listCronJobs(mine ? { creatorId: tweet.author?.id } : {});
50
+ if (jobs.length === 0)
51
+ return { text: mine ? `no cron jobs for @${tweet.author?.username ?? "you"}` : "no cron jobs" };
52
+ return { text: jobs.map(formatJob).join("\n") };
53
+ }
54
+ case "remove":
55
+ case "pause":
56
+ case "resume": {
57
+ const id = Number(params.id);
58
+ if (!Number.isInteger(id))
59
+ return { text: `missing or invalid "id" (got "${params.id ?? ""}") — use "list" to see job ids` };
60
+ const job = getCronJob(id);
61
+ // Not-found and not-owned return the SAME message for non-admins, so job
62
+ // ids can't be probed for existence.
63
+ if (!job || (job.creatorId !== tweet.author?.id && !isAdmin(tweet))) {
64
+ return { text: `no cron job #${id} of yours — use "list" to see your jobs` };
65
+ }
66
+ if (params.action === "remove") {
67
+ removeCronJob(id);
68
+ return { text: `cron #${id} removed (was: ${describeSchedule(job.schedule)} — "${job.prompt}")` };
69
+ }
70
+ if (params.action === "resume") {
71
+ // resumeCronJob re-checks the active-job caps (a paused job freed its
72
+ // slot) and re-arms next_run_at; its errors are written for the model.
73
+ const res = resumeCronJob(id);
74
+ if ("error" in res)
75
+ return { text: res.error };
76
+ return { text: `cron #${id} resumed (next run ${iso(res.job.nextRunAt)})` };
77
+ }
78
+ const ok = setCronJobEnabled(id, false);
79
+ return { text: ok ? `cron #${id} paused` : `cron #${id} pause failed` };
80
+ }
81
+ default:
82
+ return { text: `unknown action "${params.action}" — try: add, list, remove, pause, resume` };
83
+ }
84
+ };
@@ -0,0 +1,2 @@
1
+ import { type SkillHandler } from "yappr";
2
+ export declare const handler: SkillHandler;
@@ -0,0 +1,122 @@
1
+ import { payFetch, log } from "yappr";
2
+ // Generate an image from a prompt via BlockRun's x402 image gateway, then hand the
3
+ // asker the resulting URL. payFetch signs the EIP-3009 payment on Base automatically
4
+ // (same wallet as every other agent spend), so this module just speaks plain HTTP.
5
+ const ORIGIN = "https://blockrun.ai";
6
+ const ENDPOINT = `${ORIGIN}/api/v1/images/generations`;
7
+ // Available image models and their x402 price (USDC on Base, quoted at 1024x1024, 1
8
+ // image — prices are dynamic and non-square sizes may cost more). gpt-image-1 is the
9
+ // cheapest frontier model and what we use here; swap MODEL to trade cost for quality:
10
+ // openai/gpt-image-1 $0.021
11
+ // openai/gpt-image-2 $0.063
12
+ // google/nano-banana $0.053
13
+ // google/nano-banana-pro $0.105
14
+ // zai/cogview-4 $0.016
15
+ // xai/grok-imagine-image $0.021
16
+ // xai/grok-imagine-image-pro $0.074
17
+ const MODEL = "openai/gpt-image-1"; // $0.021 / image (1024x1024)
18
+ // Orientation keyword → the pixel dimensions sent to the endpoint as `size`. Square is
19
+ // the default when the caller gives no (or an unrecognised) size.
20
+ // gpt-image-1 only accepts these three sizes (1024x1024, 1536x1024, 1024x1536) — the
21
+ // 1792-wide DALL·E 3 sizes are rejected. Update these if you switch MODEL.
22
+ const SIZES = {
23
+ square: "1024x1024",
24
+ landscape: "1536x1024",
25
+ portrait: "1024x1536",
26
+ };
27
+ const DEFAULT_SIZE = "square";
28
+ // Map a caller-supplied size to a dimension string: a known orientation keyword, an
29
+ // explicit WxH the endpoint already understands, or — failing both — the square default.
30
+ function resolveSize(raw) {
31
+ const key = (raw ?? "").trim().toLowerCase();
32
+ if (key in SIZES)
33
+ return SIZES[key];
34
+ if (/^\d{3,4}x\d{3,4}$/.test(key))
35
+ return key;
36
+ return SIZES[DEFAULT_SIZE];
37
+ }
38
+ // gpt-image-1 routinely runs past the server's 30s inline window: the POST then returns
39
+ // a queued job instead of the image, and we poll it to completion. Bound the wait so a
40
+ // stuck job can't hang the reply loop forever.
41
+ const POST_TIMEOUT_MS = 60_000; // POST holds inline up to ~30s before handing back a job
42
+ const POLL_TIMEOUT_MS = 30_000; // per-poll request timeout
43
+ const POLL_INTERVAL_MS = 5_000; // pause between polls
44
+ const POLL_MAX_ATTEMPTS = 24; // ~2 min of polling after the inline window
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
+ // BlockRun returns the image under data[0].url on both the inline and the completed-poll
47
+ // responses; absent until the job finishes.
48
+ function imageUrl(body) {
49
+ return body?.data?.[0]?.url;
50
+ }
51
+ // Returned as `mediaUrl` on success: the reply pipeline uploads it to X and attaches it
52
+ // to the reply, so the model should caption the image rather than paste a URL.
53
+ const GENERATED_NOTE = "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.";
54
+ export const handler = async (params) => {
55
+ const prompt = (params.prompt ?? "").trim();
56
+ if (!prompt)
57
+ return { text: "missing prompt — describe the image to generate" };
58
+ const size = resolveSize(params.size ?? params.orientation);
59
+ // 1) Kick off generation. The server holds the request inline for up to ~30s: if the
60
+ // image is ready it comes back directly, otherwise we get a queued job to poll.
61
+ let body;
62
+ try {
63
+ const res = await payFetch(ENDPOINT, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({ prompt, model: MODEL, size }),
67
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS),
68
+ });
69
+ if (!res.ok) {
70
+ const detail = await res.text().catch(() => "");
71
+ log.warn({ status: res.status, detail }, "generate-image: POST failed");
72
+ return { text: `image generation failed (HTTP ${res.status})` };
73
+ }
74
+ body = await res.json();
75
+ }
76
+ catch (err) {
77
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "generate-image: POST errored");
78
+ return { text: "image generation failed — the request errored before any image was produced" };
79
+ }
80
+ // 2a) Fast path — the image came back inline within the 30s window.
81
+ const inline = imageUrl(body);
82
+ if (inline) {
83
+ log.info({ model: MODEL, size, url: inline }, "generate-image: inline result");
84
+ return { text: GENERATED_NOTE, mediaUrl: inline };
85
+ }
86
+ // 2b) Slow path — poll the job until it completes. payFetch re-signs the poll's x402
87
+ // challenge each time; BlockRun only settles the charge on the first completed poll
88
+ // (the unused authorizations for in-progress polls are never submitted on-chain), so
89
+ // polling does not double-bill. The poll must come from the same wallet as the POST,
90
+ // which it always does (one agent wallet).
91
+ const pollPath = body?.poll_url;
92
+ if (!pollPath) {
93
+ log.warn({ body }, "generate-image: no image and no poll_url to follow");
94
+ return { text: "image generation failed — no image and no job to poll" };
95
+ }
96
+ const pollUrl = new URL(pollPath, ORIGIN).toString();
97
+ log.info({ jobId: body?.id, status: body?.status, size, pollUrl }, "generate-image: job queued, polling");
98
+ for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
99
+ await sleep(POLL_INTERVAL_MS);
100
+ try {
101
+ const pr = await payFetch(pollUrl, { method: "GET", signal: AbortSignal.timeout(POLL_TIMEOUT_MS) });
102
+ const pb = await pr.json().catch(() => null);
103
+ const url = imageUrl(pb);
104
+ if (url) {
105
+ log.info({ attempt, url }, "generate-image: completed");
106
+ return { text: GENERATED_NOTE, mediaUrl: url };
107
+ }
108
+ if (pb?.status === "failed") {
109
+ log.warn({ attempt, body: pb }, "generate-image: job reported failed");
110
+ return { text: "image generation failed while processing the job" };
111
+ }
112
+ log.info({ attempt, status: pb?.status }, "generate-image: still generating");
113
+ }
114
+ catch (err) {
115
+ // A transient poll error (timeout/network) shouldn't abort — the job may still
116
+ // finish, so log and try the next tick.
117
+ log.warn({ attempt, err: err instanceof Error ? err.message : String(err) }, "generate-image: poll errored — retrying");
118
+ }
119
+ }
120
+ log.warn({ jobId: body?.id }, "generate-image: polling exhausted before completion");
121
+ return { text: "image generation timed out — the job did not finish in time, try again" };
122
+ };