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,12 @@
1
+ export declare const BLOCK: {
2
+ readonly asker: "ASKER TWEET";
3
+ readonly replyTo: "REPLY-TO TWEET";
4
+ readonly root: "CONVERSATION ROOT TWEET";
5
+ };
6
+ export declare function referencedBlockLabel(id: string, type: string): string;
7
+ export declare function contextBlock(label: string, body: string): string;
8
+ export type ContextImage = {
9
+ url: string;
10
+ source: string;
11
+ };
12
+ export declare function imageCaption(index: number, source: string): string;
@@ -0,0 +1,22 @@
1
+ // Shared labels and format for the context blocks fed to the LLM. Used by
2
+ // pipeline.ts (which emits the blocks) and by the agent instructions in agent.ts
3
+ // (which tell the model how to read them) — kept here so the two can never drift.
4
+ export const BLOCK = {
5
+ asker: "ASKER TWEET",
6
+ replyTo: "REPLY-TO TWEET",
7
+ root: "CONVERSATION ROOT TWEET",
8
+ };
9
+ // Header for a tweet referenced by the asker tweet (e.g. a quoted tweet). The id
10
+ // and type come straight from the asker tweet's referenced_tweets entry.
11
+ export function referencedBlockLabel(id, type) {
12
+ return `REFERENCED TWEET IN THE ASKER TWEET (ID: ${id}, TYPE: ${type})`;
13
+ }
14
+ // Wrap a label + body into a "=== LABEL ===\n<body>" context block.
15
+ export function contextBlock(label, body) {
16
+ return `=== ${label} ===\n${body}`;
17
+ }
18
+ // Caption emitted as a text part immediately before image N, so the model knows
19
+ // which tweet each attached image belongs to (ids tie back to the JSON blocks above).
20
+ export function imageCaption(index, source) {
21
+ return `Image ${index} — attached to the ${source}:`;
22
+ }
@@ -0,0 +1,3 @@
1
+ import type { Tweet } from "../x/types.js";
2
+ export declare function replyToScreenName(t: Tweet): string | undefined;
3
+ export declare function shouldReply(text: string, handle: string, replyToAuthorHandle?: string | null): boolean;
@@ -0,0 +1,35 @@
1
+ // Pure helpers for deciding whether/how to engage a mention — no I/O, no payments.
2
+ // `shouldReply` is the positional filter described below.
3
+ // The handle (without @) this tweet replies to, read straight off the tweet — no
4
+ // extra fetch. Feeds shouldReply's "agent is first mention" branch so gating can
5
+ // run before we pay to fetch conversation context.
6
+ export function replyToScreenName(t) {
7
+ return t.in_reply_to_screen_name ?? undefined;
8
+ }
9
+ // Decide whether a mention is actually directed at us, to avoid replying when X
10
+ // auto-prepends our handle into an unrelated reply chain. We reply if our handle
11
+ // appears in the tweet body, or is the last of the leading @mentions, or is the
12
+ // first leading mention only when the tweet replies to us (or replies to nobody).
13
+ export function shouldReply(text, handle, replyToAuthorHandle) {
14
+ const trimmed = text.trim();
15
+ const leadingMentions = trimmed.match(/^(@\w+\s*)+/)?.[0] ?? "";
16
+ const body = trimmed.slice(leadingMentions.length);
17
+ const handleRe = new RegExp(`@${handle}\\b`, "i");
18
+ const h = handle.toLowerCase();
19
+ if (handleRe.test(body))
20
+ return true;
21
+ const mentions = [...leadingMentions.matchAll(/@(\w+)/g)].map((m) => m[1].toLowerCase());
22
+ if (mentions.length === 0)
23
+ return false;
24
+ if (mentions[mentions.length - 1] === h)
25
+ return true;
26
+ if (mentions[0] === h) {
27
+ // Agent is first in leading mentions — only reply if this tweet is itself
28
+ // a reply to the agent (i.e. agent started the thread), not when the agent
29
+ // handle was auto-prepended by X into someone else's reply chain.
30
+ if (!replyToAuthorHandle)
31
+ return true;
32
+ return replyToAuthorHandle.toLowerCase() === h;
33
+ }
34
+ return false;
35
+ }
@@ -0,0 +1,3 @@
1
+ import type { Logger } from "pino";
2
+ import type { Tweet } from "../x/types.js";
3
+ export declare function processTweet(t: Tweet, log: Logger): Promise<void>;
@@ -0,0 +1,144 @@
1
+ import { getTweets, postReply, tweetImageUrls, uploadMediaFromUrl } from "../x/client.js";
2
+ import { runAgentLoop } from "./agent.js";
3
+ import { runOnMention, runShouldReply, runOnBeforeInference, runOnAfterInference, runOnBeforeReply, runOnAfterReply, } from "../hooks/registry.js";
4
+ import { shouldReply, replyToScreenName } from "./gating.js";
5
+ import { BLOCK, referencedBlockLabel, contextBlock } from "./context-blocks.js";
6
+ import { config } from "../config.js";
7
+ import { recordReply } from "../stats.js";
8
+ // Handles one mention end-to-end: decide whether to reply (positional gating +
9
+ // hooks) → gather thread context → run the agent loop → post the reply. Gating runs
10
+ // before the (paid) context fetch, so mentions we skip cost nothing. User hooks run
11
+ // at each stage so forks can observe or veto without touching this file.
12
+ export async function processTweet(t, log) {
13
+ await runOnMention(t);
14
+ // Who the asker tweet replies to is carried on the tweet itself, so positional
15
+ // gating needs no extra fetch — we only pay for context once we'll actually reply.
16
+ if (!shouldReply(t.text, config.agentHandle, replyToScreenName(t))) {
17
+ log.info({ id: t.id, text: t.text }, "skipping: mention not in valid position");
18
+ return;
19
+ }
20
+ const hookVeto = await runShouldReply(t);
21
+ if (!hookVeto) {
22
+ log.info({ id: t.id }, "skipping: hook vetoed reply");
23
+ return;
24
+ }
25
+ log.info({ id: t.id, author: t.author?.username, text: t.text }, "processing mention");
26
+ const ctx = await fetchContext(t, log);
27
+ const askingTweet = contextBlock(BLOCK.asker, JSON.stringify(t, null, 2));
28
+ let context = ctx?.body ? `${ctx.body}\n\n${askingTweet}` : askingTweet;
29
+ // Images to show the vision model: the asker's own photos plus any on the tweets
30
+ // it references (e.g. "what's in the image above"), each tagged with its source
31
+ // tweet and deduped by URL (asker wins when a photo appears in more than one).
32
+ const askerImages = tweetImageUrls(t).map((url) => ({ url, source: `${BLOCK.asker} (id ${t.id})` }));
33
+ const images = dedupeImages([...askerImages, ...(ctx?.images ?? [])]);
34
+ try {
35
+ // onBeforeInference receives the asker tweet (for per-user logic like memory
36
+ // injection) and its raw text. The model reads the ask from the ASKER TWEET
37
+ // in `context`, so a hook steers inference by rewriting `context`.
38
+ const inferred = await runOnBeforeInference(t, t.text, context);
39
+ context = inferred.context ?? context;
40
+ const isAdmin = config.adminHandles.length > 0 &&
41
+ config.adminHandles.includes(t.author?.username?.toLowerCase() ?? "");
42
+ // deniedSkills is ignored for live mentions: the model's reply already
43
+ // tells the asker about the denial; it only drives cron failure handling.
44
+ const result = await runAgentLoop(context, isAdmin, t, log, images);
45
+ let replyText = result.text;
46
+ replyText = await runOnAfterInference(t.text, replyText);
47
+ const finalText = await runOnBeforeReply(t, replyText);
48
+ if (finalText === null) {
49
+ log.info({ id: t.id }, "skipping: onBeforeReply hook vetoed reply");
50
+ return;
51
+ }
52
+ // Attach any images the skills produced this turn (e.g. generate-image).
53
+ const mediaIds = await uploadReplyMedia(result.mediaUrls, t.id, log);
54
+ await postReply(t.id, finalText, mediaIds);
55
+ await runOnAfterReply(t, finalText);
56
+ log.info({ id: t.id }, "replied");
57
+ recordReply();
58
+ }
59
+ catch (err) {
60
+ log.error({ err, id: t.id }, "reply failed");
61
+ }
62
+ }
63
+ // Upload each image a skill produced this turn to X and return the media_ids to attach
64
+ // to the reply. Best-effort and bounded to X's 4-images-per-tweet limit: a failed upload
65
+ // is logged and skipped so the reply still goes out (as text) rather than failing because
66
+ // one image couldn't be attached.
67
+ async function uploadReplyMedia(urls, tweetId, log) {
68
+ if (urls.length === 0)
69
+ return undefined;
70
+ const ids = [];
71
+ for (const url of urls.slice(0, 4)) {
72
+ try {
73
+ ids.push(await uploadMediaFromUrl(url));
74
+ }
75
+ catch (err) {
76
+ log.warn({ err: err instanceof Error ? err.message : String(err), id: tweetId, url }, "reply media upload failed — skipping");
77
+ }
78
+ }
79
+ return ids.length ? ids : undefined;
80
+ }
81
+ // Drop duplicate images by URL, keeping the first occurrence (and thus its source
82
+ // label) — so a photo on both the asker tweet and a tweet it quotes is sent once,
83
+ // attributed to the asker.
84
+ function dedupeImages(images) {
85
+ const seen = new Set();
86
+ return images.filter((img) => !seen.has(img.url) && (seen.add(img.url), true));
87
+ }
88
+ // Fetch the conversation root and/or the reply-to tweet (one paid getTweets call)
89
+ // and render them into the context blocks the model reads, plus any images they
90
+ // carry (tagged with their source tweet, for the vision path). Returns undefined
91
+ // when the mention is a standalone tweet with nothing to fetch.
92
+ async function fetchContext(t, log) {
93
+ const refs = t.referenced_tweets ?? [];
94
+ const replyToId = refs.find((r) => r.type === "replied_to")?.id;
95
+ // Other references carried on the asker tweet (e.g. a "quoted" tweet) — fetched
96
+ // and shown individually with their id + type.
97
+ const otherRefs = refs.filter((r) => r.type !== "replied_to" && r.id);
98
+ // The tweet the asker replied to is always shown as REPLY-TO TWEET. We also show
99
+ // the CONVERSATION ROOT TWEET only when the reply-to tweet isn't itself the root —
100
+ // i.e. its conversation_id differs from its id (equivalently: replyToId differs
101
+ // from the thread's conversation_id, which is invariant across the thread).
102
+ const wantsRoot = !!replyToId && !!t.conversation_id && replyToId !== t.conversation_id;
103
+ // One batched (paid) fetch for everything we need. Dedupe in case ids overlap.
104
+ const idsToFetch = [...new Set([
105
+ ...(wantsRoot ? [t.conversation_id] : []),
106
+ ...(replyToId ? [replyToId] : []),
107
+ ...otherRefs.map((r) => r.id),
108
+ ])];
109
+ if (idsToFetch.length === 0)
110
+ return undefined;
111
+ let fetched = [];
112
+ try {
113
+ fetched = await getTweets(idsToFetch);
114
+ }
115
+ catch (err) {
116
+ log.error({ err }, "context fetch failed; proceeding without context");
117
+ return undefined;
118
+ }
119
+ const parts = [];
120
+ const images = [];
121
+ const collect = (tweet, source) => images.push(...tweetImageUrls(tweet).map((url) => ({ url, source })));
122
+ if (wantsRoot) {
123
+ const root = fetched.find((x) => x.id === t.conversation_id);
124
+ if (root) {
125
+ parts.push(contextBlock(BLOCK.root, JSON.stringify(root, null, 2)));
126
+ collect(root, `${BLOCK.root} (id ${root.id})`);
127
+ }
128
+ }
129
+ if (replyToId) {
130
+ const replyTo = fetched.find((x) => x.id === replyToId);
131
+ if (replyTo) {
132
+ parts.push(contextBlock(BLOCK.replyTo, JSON.stringify(replyTo, null, 2)));
133
+ collect(replyTo, `${BLOCK.replyTo} (id ${replyTo.id})`);
134
+ }
135
+ }
136
+ for (const ref of otherRefs) {
137
+ const refTweet = fetched.find((x) => x.id === ref.id);
138
+ if (refTweet) {
139
+ parts.push(contextBlock(referencedBlockLabel(ref.id, ref.type), JSON.stringify(refTweet, null, 2)));
140
+ collect(refTweet, referencedBlockLabel(ref.id, ref.type));
141
+ }
142
+ }
143
+ return parts.length || images.length ? { body: parts.length ? parts.join("\n\n") : undefined, images } : undefined;
144
+ }
@@ -0,0 +1,5 @@
1
+ import type { Logger } from "pino";
2
+ export declare function createPoller(log: Logger): {
3
+ start: () => Promise<void>;
4
+ stop: () => void;
5
+ };
@@ -0,0 +1,79 @@
1
+ import { config } from "../config.js";
2
+ import { searchMentions } from "../x/client.js";
3
+ import { loadState, saveState } from "../state.js";
4
+ import { recordMention } from "../stats.js";
5
+ import { processTweet } from "./pipeline.js";
6
+ // The ingest loop: every POLL_INTERVAL_MS it searches for new @mentions, tracks
7
+ // the highest tweet id seen (persisted in the state table) so each mention is handled
8
+ // once, and hands every fresh mention to processTweet(). Tweet ids are snowflakes
9
+ // (monotonically increasing), so we compare them as BigInts to order by recency.
10
+ function idGt(a, b) {
11
+ return BigInt(a) > BigInt(b);
12
+ }
13
+ function idMax(a, b) {
14
+ return idGt(a, b) ? a : b;
15
+ }
16
+ // Twitter snowflake ids embed a millisecond timestamp in their high bits (epoch
17
+ // 2010-11-04). This builds the smallest id for "now", used to anchor the baseline
18
+ // at startup when there are no existing mentions to anchor to — so we still reply
19
+ // to mentions that arrive afterward instead of treating the first one as backfill.
20
+ const TWITTER_EPOCH_MS = 1288834974657n;
21
+ function snowflakeForNow() {
22
+ return ((BigInt(Date.now()) - TWITTER_EPOCH_MS) << 22n).toString();
23
+ }
24
+ export function createPoller(log) {
25
+ let state = { lastSeenId: null };
26
+ let isRunning = false;
27
+ let timer = null;
28
+ async function cycle() {
29
+ if (isRunning) {
30
+ log.warn("previous poll still running, skipping tick");
31
+ return;
32
+ }
33
+ isRunning = true;
34
+ try {
35
+ log.info("poll cycle start");
36
+ const response = await searchMentions(config.agentHandle);
37
+ const tweets = (response.data || []).slice().sort((a, b) => (idGt(a.id, b.id) ? 1 : -1));
38
+ // No baseline yet: skip pre-existing mentions by anchoring the baseline — to the
39
+ // newest mention if any exist, or to startup time otherwise. Keyed on
40
+ // lastSeenId === null (not a one-shot flag) so an empty first poll just retries
41
+ // next tick instead of leaving it unset.
42
+ if (state.lastSeenId === null) {
43
+ state.lastSeenId = tweets.at(-1)?.id ?? snowflakeForNow();
44
+ await saveState(state);
45
+ log.info({ lastSeenId: state.lastSeenId }, "baseline established on startup; skipping backfill");
46
+ return;
47
+ }
48
+ const baseline = state.lastSeenId;
49
+ const fresh = tweets.filter((t) => idGt(t.id, baseline));
50
+ if (fresh.length === 0)
51
+ return;
52
+ log.info({ count: fresh.length }, "new mentions found");
53
+ recordMention(fresh.length);
54
+ state.lastSeenId = fresh.reduce((max, t) => idMax(max, t.id), fresh[0].id);
55
+ await saveState(state);
56
+ for (const t of fresh) {
57
+ void processTweet(t, log);
58
+ }
59
+ }
60
+ catch (err) {
61
+ log.error({ err }, "poll cycle failed");
62
+ }
63
+ finally {
64
+ isRunning = false;
65
+ }
66
+ }
67
+ async function start() {
68
+ state = await loadState();
69
+ log.info({ lastSeenId: state.lastSeenId }, "poller starting");
70
+ void cycle();
71
+ timer = setInterval(() => void cycle(), config.pollIntervalMs);
72
+ }
73
+ function stop() {
74
+ if (timer)
75
+ clearInterval(timer);
76
+ timer = null;
77
+ }
78
+ return { start, stop };
79
+ }
@@ -0,0 +1,7 @@
1
+ import type { Tweet } from "../x/types.js";
2
+ export declare function checkHolderAccess(tweet: Tweet, minHolding: number): {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ reason: string;
7
+ };
@@ -0,0 +1,53 @@
1
+ import { skillStore } from "../storage.js";
2
+ // Code-side gate for `access: holder` skills, mirroring the admin check in
3
+ // reply/agent.ts: enforced HERE, never trusted to the LLM. Every input is
4
+ // outside the model's reach — the asker's identity comes from the pipeline's
5
+ // tweet object (tweet.author.id, set by the X API, not by skill params), the
6
+ // threshold comes from skill.md frontmatter, and the holdings come from the
7
+ // shared DB — so a prompt-injected "I hold a billion tokens" changes nothing.
8
+ //
9
+ // The holdings themselves are the ones the holder hook (config/hooks/holder.ts)
10
+ // cached in skillStore("bankr-wallet"): the asker's Bankr wallet (resolved once
11
+ // per user) and their token balance (refreshed at most hourly). The hook runs
12
+ // before the agent loop on every mention, so by the time a skill call is being
13
+ // gated the asker's entries are as fresh as they'll get. If the hook was
14
+ // removed, no holdings exist in the DB and every holder skill denies — closed
15
+ // by default, never open.
16
+ // Mirrors the hook's storage layout (namespace + key shapes + entry types).
17
+ const STORE_NS = "bankr-wallet";
18
+ const TOKEN_DECIMALS = 18n; // Bankr launches are fixed 18-decimal Clanker deploys
19
+ // Whole tokens → wei, via BigInt(string) so large thresholds (e.g. 1e9 tokens)
20
+ // don't round through float math.
21
+ function toWei(wholeTokens) {
22
+ return BigInt(Math.floor(wholeTokens)) * 10n ** TOKEN_DECIMALS;
23
+ }
24
+ const fmtWhole = (wei) => (wei / 10n ** TOKEN_DECIMALS).toLocaleString("en-US");
25
+ export function checkHolderAccess(tweet, minHolding) {
26
+ const userId = tweet.author?.id;
27
+ if (!userId)
28
+ return { ok: false, reason: "could not identify the requesting user" };
29
+ const store = skillStore(STORE_NS);
30
+ const wallet = store.getJSON(`wallet:${userId}`);
31
+ if (!wallet?.address) {
32
+ return { ok: false, reason: "this skill is for holders of the agent's token, and no Bankr wallet is known for you yet" };
33
+ }
34
+ const required = toWei(minHolding);
35
+ if (required === 0n)
36
+ return { ok: true }; // wallet on record is the whole bar
37
+ const balance = store.getJSON(`balance:${wallet.address}`);
38
+ let held = null;
39
+ try {
40
+ held = balance ? BigInt(balance.raw) : null;
41
+ }
42
+ catch { /* malformed → unknown */ }
43
+ if (held === null) {
44
+ return { ok: false, reason: "this skill is for holders of the agent's token, and your holdings aren't known yet — try again in a moment" };
45
+ }
46
+ if (held < required) {
47
+ return {
48
+ ok: false,
49
+ reason: `this skill requires holding at least ${fmtWhole(required)} of the agent's token — you hold ${fmtWhole(held)}`,
50
+ };
51
+ }
52
+ return { ok: true };
53
+ }
@@ -0,0 +1,2 @@
1
+ import type { SkillDef } from "./types.js";
2
+ export declare function loadSkills(): Promise<SkillDef[]>;
@@ -0,0 +1,64 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { log } from "../log.js";
3
+ import { listSkills, importConfigModule } from "../config-loader.js";
4
+ function parseFrontmatter(raw) {
5
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
6
+ if (!match)
7
+ return { name: "", description: "", body: raw.trim(), access: "all" };
8
+ const meta = match[1];
9
+ const body = match[2].trim();
10
+ const name = meta.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? "";
11
+ const description = meta.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? "";
12
+ const accessRaw = meta.match(/^access:\s*(.+)$/m)?.[1]?.trim();
13
+ const access = accessRaw === "admin" ? "admin" : accessRaw === "holder" ? "holder" : "all";
14
+ // `min_holding: <whole tokens>` — only meaningful with `access: holder`.
15
+ // A malformed value falls back to 0 (wallet required, no minimum) with a warn,
16
+ // rather than silently granting "all" access.
17
+ const minRaw = meta.match(/^min_holding:\s*(.+)$/m)?.[1]?.trim();
18
+ let minHolding;
19
+ if (minRaw !== undefined) {
20
+ const n = Number(minRaw.replace(/[_,]/g, ""));
21
+ if (Number.isFinite(n) && n >= 0) {
22
+ minHolding = n;
23
+ }
24
+ else {
25
+ log.warn({ name, min_holding: minRaw }, "skill: invalid min_holding — treating as 0");
26
+ minHolding = 0;
27
+ }
28
+ }
29
+ return { name, description, body, access, minHolding };
30
+ }
31
+ export async function loadSkills() {
32
+ const skills = [];
33
+ // Discovery (which skills exist, and where their skill.md + handler live) is owned
34
+ // by config-loader, so user and built-in config layer the same way everywhere.
35
+ for (const entry of await listSkills()) {
36
+ try {
37
+ const raw = await readFile(entry.mdPath, "utf8");
38
+ const { name, description, body, access, minHolding } = parseFrontmatter(raw);
39
+ if (!name || !description) {
40
+ log.warn({ entry: entry.name }, "skill skipped: missing name or description");
41
+ continue;
42
+ }
43
+ // A skill with no handler file is context-only. If a handler file is present
44
+ // but fails to import, that's a real error worth surfacing (not silent).
45
+ let handler;
46
+ if (entry.handlerPath) {
47
+ try {
48
+ const mod = await importConfigModule(entry.handlerPath);
49
+ if (typeof mod.handler === "function")
50
+ handler = mod.handler;
51
+ }
52
+ catch (err) {
53
+ log.warn({ entry: entry.name, err: err?.message }, "skill handler failed to load; treating as context-only");
54
+ }
55
+ }
56
+ skills.push({ name, description, body, access, minHolding, handler });
57
+ log.info({ name, access, ...(access === "holder" ? { minHolding: minHolding ?? 0 } : {}), type: handler ? "handler" : "context-only" }, `skill loaded: ${name}`);
58
+ }
59
+ catch (err) {
60
+ log.error({ entry: entry.name, err: err.message }, `skill load failed: ${entry.name}`);
61
+ }
62
+ }
63
+ return skills;
64
+ }
@@ -0,0 +1,4 @@
1
+ import type { SkillDef } from "./types.js";
2
+ export declare function initSkills(skills: SkillDef[]): void;
3
+ export declare function getSkill(name: string): SkillDef | undefined;
4
+ export declare function listSkills(): SkillDef[];
@@ -0,0 +1,10 @@
1
+ let _skills = [];
2
+ export function initSkills(skills) {
3
+ _skills = skills;
4
+ }
5
+ export function getSkill(name) {
6
+ return _skills.find((s) => s.name === name);
7
+ }
8
+ export function listSkills() {
9
+ return _skills;
10
+ }
@@ -0,0 +1,16 @@
1
+ import type { Tweet } from "../x/types.js";
2
+ export type SkillResult = {
3
+ text?: string;
4
+ data?: unknown;
5
+ mediaUrl?: string;
6
+ };
7
+ export type SkillHandler = (params: Record<string, string>, tweet: Tweet) => Promise<SkillResult>;
8
+ export type SkillAccess = "all" | "admin" | "holder";
9
+ export type SkillDef = {
10
+ name: string;
11
+ description: string;
12
+ body: string;
13
+ access: SkillAccess;
14
+ minHolding?: number;
15
+ handler?: SkillHandler;
16
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export type State = {
2
+ lastSeenId: string | null;
3
+ };
4
+ export declare function loadState(): Promise<State>;
5
+ export declare function saveState(state: State): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { withSchema } from "./db.js";
2
+ const LAST_SEEN = "last_seen_id";
3
+ const SCHEMA = "CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT)";
4
+ const conn = () => withSchema(SCHEMA);
5
+ export async function loadState() {
6
+ const d = conn();
7
+ if (!d)
8
+ return { lastSeenId: null };
9
+ try {
10
+ const row = d.prepare("SELECT value FROM state WHERE key = ?").get(LAST_SEEN);
11
+ return { lastSeenId: row?.value ?? null };
12
+ }
13
+ catch {
14
+ return { lastSeenId: null };
15
+ }
16
+ }
17
+ export async function saveState(state) {
18
+ const d = conn();
19
+ if (!d)
20
+ return;
21
+ try {
22
+ d.prepare("INSERT INTO state(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
23
+ .run(LAST_SEEN, state.lastSeenId);
24
+ }
25
+ catch { /* best-effort */ }
26
+ }
@@ -0,0 +1 @@
1
+ import "dotenv/config";
@@ -0,0 +1,82 @@
1
+ import "dotenv/config";
2
+ import { summary, recordSpend } from "./stats.js";
3
+ import { getDb } from "./db.js";
4
+ // Tiny command-line face on the stats DB, compiled to dist so it can be invoked over
5
+ // SSH without tsx. Used by the dashboard:
6
+ // node node_modules/yappr/dist/src/stats-cli.js summary → prints Summary JSON
7
+ // node node_modules/yappr/dist/src/stats-cli.js cron → prints the cron_jobs
8
+ // rows as JSON (schedule pre-rendered, source tweet dropped) for the CRON page
9
+ // node node_modules/yappr/dist/src/stats-cli.js backup <dest> → writes a consistent
10
+ // snapshot of the DB to <dest> (pulled down as a local backup)
11
+ // node node_modules/yappr/dist/src/stats-cli.js record-spend <type> <usd> → append one
12
+ // spend event (used by `yappr deploy` to book the deploy-time compute payment,
13
+ // which is paid locally — outside the agent's payFetch — so it'd otherwise be lost)
14
+ // It opens the same DB the agent uses (via db.ts, which reads DB_PATH), so its reads
15
+ // land in the same database.
16
+ async function main() {
17
+ const [cmd, ...args] = process.argv.slice(2);
18
+ switch (cmd) {
19
+ case "summary":
20
+ process.stdout.write(JSON.stringify(summary()));
21
+ return;
22
+ case "cron": {
23
+ // Lazy import: cron/store pulls in config.ts, which requires the full agent
24
+ // env at import time — summary/backup must keep working without it.
25
+ const { listCronJobs, describeSchedule } = await import("./cron/store.js");
26
+ const jobs = listCronJobs().map((j) => ({
27
+ id: j.id,
28
+ prompt: j.prompt,
29
+ schedule: describeSchedule(j.schedule),
30
+ creator: j.creatorHandle,
31
+ enabled: j.enabled,
32
+ nextRunAt: j.nextRunAt,
33
+ lastRunAt: j.lastRunAt,
34
+ lastResult: j.lastResult,
35
+ lastError: j.lastError,
36
+ runs: j.runs,
37
+ consecutiveFailures: j.consecutiveFailures,
38
+ createdAt: j.createdAt,
39
+ }));
40
+ process.stdout.write(JSON.stringify(jobs));
41
+ return;
42
+ }
43
+ case "backup": {
44
+ const dest = args[0];
45
+ if (!dest) {
46
+ process.stderr.write("backup: expected a destination path\n");
47
+ process.exit(1);
48
+ }
49
+ const db = getDb();
50
+ if (!db) {
51
+ process.stderr.write("backup: could not open the stats DB\n");
52
+ process.exit(1);
53
+ }
54
+ // VACUUM INTO writes a single consistent, compacted copy — safe while the agent
55
+ // is writing (WAL readers see a stable snapshot) and free of -wal/-shm sidecars.
56
+ db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
57
+ process.stdout.write(JSON.stringify({ ok: true, dest }));
58
+ return;
59
+ }
60
+ case "record-spend": {
61
+ // Append one spend event to the ledger. Used by `yappr deploy` to book the
62
+ // deploy-time compute payment (paid locally, outside the agent's payFetch).
63
+ const [type, usdRaw] = args;
64
+ const usd = Number(usdRaw);
65
+ const valid = ["x-api", "compute", "inference", "x402"];
66
+ if (!type || !valid.includes(type) || !Number.isFinite(usd) || usd <= 0) {
67
+ process.stderr.write(`record-spend: expected <type: ${valid.join(" | ")}> <usd> (positive number)\n`);
68
+ process.exit(1);
69
+ }
70
+ recordSpend(type, usd);
71
+ process.stdout.write(JSON.stringify({ ok: true, type, usd }));
72
+ return;
73
+ }
74
+ default:
75
+ process.stderr.write(`stats-cli: unknown command ${JSON.stringify(cmd)} (expected: summary | cron | backup | record-spend)\n`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+ main().catch((err) => {
80
+ process.stderr.write(`stats-cli: ${err instanceof Error ? err.message : String(err)}\n`);
81
+ process.exit(1);
82
+ });
@@ -0,0 +1,41 @@
1
+ export type SpendType = "x-api" | "compute" | "inference" | "x402";
2
+ export type Summary = {
3
+ mentions: number;
4
+ replies: number;
5
+ llm: number;
6
+ warns: number;
7
+ errors: number;
8
+ spentUsd: number;
9
+ spentByType: Record<SpendType, number>;
10
+ earnedWeth: number;
11
+ devWeth: number;
12
+ spentUsdWindow: number;
13
+ inferenceUsdWindow: number;
14
+ earnedWethWindow: number;
15
+ rateWindowHours: number;
16
+ chart: {
17
+ day: {
18
+ spendUsd: number[];
19
+ earnedWeth: number[];
20
+ startMs: number;
21
+ endMs: number;
22
+ };
23
+ byType: {
24
+ startMs: number;
25
+ xapi: number[];
26
+ inference: number[];
27
+ compute: number[];
28
+ x402: number[];
29
+ earned: number[];
30
+ };
31
+ };
32
+ };
33
+ export declare function recordSpend(type: SpendType, usdc: number): void;
34
+ export declare function recordMention(n: number): void;
35
+ export declare function recordReply(): void;
36
+ export declare function recordLlm(): void;
37
+ export declare function recordWarn(): void;
38
+ export declare function recordError(): void;
39
+ export declare function recordEarned(weth: number): void;
40
+ export declare function recordDevWeth(weth: number): void;
41
+ export declare function summary(): Summary;