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.
- package/.env.example +115 -0
- package/config/context/personality.md +7 -0
- package/config/context/security.md +10 -0
- package/config/hooks/example.ts +47 -0
- package/config/hooks/holder.ts +154 -0
- package/config/hooks/user-memory.ts +102 -0
- package/config/skills/compute/handler.ts +6 -0
- package/config/skills/compute/skill.md +7 -0
- package/config/skills/cron/handler.ts +89 -0
- package/config/skills/cron/skill.md +36 -0
- package/config/skills/generate-image/handler.ts +133 -0
- package/config/skills/generate-image/skill.md +20 -0
- package/config/skills/generate-meme-prompt/handler.ts +40 -0
- package/config/skills/generate-meme-prompt/skill.md +23 -0
- package/config/skills/stats/handler.ts +76 -0
- package/config/skills/stats/skill.md +18 -0
- package/config/skills/wallet/handler.ts +56 -0
- package/config/skills/wallet/skill.md +17 -0
- package/config/skills/x/handler.ts +135 -0
- package/config/skills/x/skill.md +163 -0
- package/dist/config/hooks/example.d.ts +2 -0
- package/dist/config/hooks/example.js +37 -0
- package/dist/config/hooks/holder.d.ts +2 -0
- package/dist/config/hooks/holder.js +147 -0
- package/dist/config/hooks/user-memory.d.ts +2 -0
- package/dist/config/hooks/user-memory.js +79 -0
- package/dist/config/skills/compute/handler.d.ts +2 -0
- package/dist/config/skills/compute/handler.js +5 -0
- package/dist/config/skills/cron/handler.d.ts +2 -0
- package/dist/config/skills/cron/handler.js +84 -0
- package/dist/config/skills/generate-image/handler.d.ts +2 -0
- package/dist/config/skills/generate-image/handler.js +122 -0
- package/dist/config/skills/generate-meme/handler.d.ts +2 -0
- package/dist/config/skills/generate-meme/handler.js +121 -0
- package/dist/config/skills/generate-meme-prompt/handler.d.ts +2 -0
- package/dist/config/skills/generate-meme-prompt/handler.js +38 -0
- package/dist/config/skills/stats/handler.d.ts +2 -0
- package/dist/config/skills/stats/handler.js +71 -0
- package/dist/config/skills/wallet/handler.d.ts +2 -0
- package/dist/config/skills/wallet/handler.js +54 -0
- package/dist/config/skills/x/handler.d.ts +2 -0
- package/dist/config/skills/x/handler.js +115 -0
- package/dist/src/agent-prompt.d.ts +1 -0
- package/dist/src/agent-prompt.js +45 -0
- package/dist/src/bankr.d.ts +41 -0
- package/dist/src/bankr.js +76 -0
- package/dist/src/cli/backup.d.ts +7 -0
- package/dist/src/cli/backup.js +78 -0
- package/dist/src/cli/charts.d.ts +32 -0
- package/dist/src/cli/charts.js +222 -0
- package/dist/src/cli/config-sync.d.ts +7 -0
- package/dist/src/cli/config-sync.js +71 -0
- package/dist/src/cli/deploy.d.ts +2 -0
- package/dist/src/cli/deploy.js +1059 -0
- package/dist/src/cli/env.d.ts +4 -0
- package/dist/src/cli/env.js +50 -0
- package/dist/src/cli/host-key.d.ts +4 -0
- package/dist/src/cli/host-key.js +50 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +71 -0
- package/dist/src/cli/init.d.ts +1 -0
- package/dist/src/cli/init.js +51 -0
- package/dist/src/cli/ssh.d.ts +2 -0
- package/dist/src/cli/ssh.js +141 -0
- package/dist/src/cli/status.d.ts +7 -0
- package/dist/src/cli/status.js +1184 -0
- package/dist/src/cli/tui.d.ts +18 -0
- package/dist/src/cli/tui.js +115 -0
- package/dist/src/cli/ui.d.ts +30 -0
- package/dist/src/cli/ui.js +164 -0
- package/dist/src/cli/update.d.ts +1 -0
- package/dist/src/cli/update.js +263 -0
- package/dist/src/cli/x-login.d.ts +6 -0
- package/dist/src/cli/x-login.js +70 -0
- package/dist/src/compute.d.ts +11 -0
- package/dist/src/compute.js +109 -0
- package/dist/src/config-loader.d.ts +19 -0
- package/dist/src/config-loader.js +82 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +68 -0
- package/dist/src/cron/capability.d.ts +6 -0
- package/dist/src/cron/capability.js +66 -0
- package/dist/src/cron/runner.d.ts +2 -0
- package/dist/src/cron/runner.js +113 -0
- package/dist/src/cron/schedule.d.ts +19 -0
- package/dist/src/cron/schedule.js +154 -0
- package/dist/src/cron/store.d.ts +46 -0
- package/dist/src/cron/store.js +220 -0
- package/dist/src/db.d.ts +4 -0
- package/dist/src/db.js +53 -0
- package/dist/src/hooks/loader.d.ts +1 -0
- package/dist/src/hooks/loader.js +17 -0
- package/dist/src/hooks/registry.d.ts +17 -0
- package/dist/src/hooks/registry.js +78 -0
- package/dist/src/hooks/types.d.ts +45 -0
- package/dist/src/hooks/types.js +1 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +35 -0
- package/dist/src/llm/index.d.ts +23 -0
- package/dist/src/llm/index.js +213 -0
- package/dist/src/llm/prompts.d.ts +6 -0
- package/dist/src/llm/prompts.js +99 -0
- package/dist/src/log.d.ts +2 -0
- package/dist/src/log.js +30 -0
- package/dist/src/reply/agent.d.ts +20 -0
- package/dist/src/reply/agent.js +215 -0
- package/dist/src/reply/context-blocks.d.ts +12 -0
- package/dist/src/reply/context-blocks.js +22 -0
- package/dist/src/reply/gating.d.ts +3 -0
- package/dist/src/reply/gating.js +35 -0
- package/dist/src/reply/pipeline.d.ts +3 -0
- package/dist/src/reply/pipeline.js +144 -0
- package/dist/src/reply/poller.d.ts +5 -0
- package/dist/src/reply/poller.js +79 -0
- package/dist/src/skills/holder-access.d.ts +7 -0
- package/dist/src/skills/holder-access.js +53 -0
- package/dist/src/skills/loader.d.ts +2 -0
- package/dist/src/skills/loader.js +64 -0
- package/dist/src/skills/registry.d.ts +4 -0
- package/dist/src/skills/registry.js +10 -0
- package/dist/src/skills/types.d.ts +16 -0
- package/dist/src/skills/types.js +1 -0
- package/dist/src/state.d.ts +5 -0
- package/dist/src/state.js +26 -0
- package/dist/src/stats-cli.d.ts +1 -0
- package/dist/src/stats-cli.js +82 -0
- package/dist/src/stats.d.ts +41 -0
- package/dist/src/stats.js +236 -0
- package/dist/src/storage.d.ts +16 -0
- package/dist/src/storage.js +107 -0
- package/dist/src/treasury/abi.d.ts +99 -0
- package/dist/src/treasury/abi.js +71 -0
- package/dist/src/treasury/cycle.d.ts +16 -0
- package/dist/src/treasury/cycle.js +154 -0
- package/dist/src/treasury/index.d.ts +28 -0
- package/dist/src/treasury/index.js +222 -0
- package/dist/src/util.d.ts +3 -0
- package/dist/src/util.js +18 -0
- package/dist/src/wallet.d.ts +5 -0
- package/dist/src/wallet.js +241 -0
- package/dist/src/x/client.d.ts +74 -0
- package/dist/src/x/client.js +323 -0
- package/dist/src/x/types.d.ts +61 -0
- package/dist/src/x/types.js +1 -0
- package/dist/src/x402.d.ts +6 -0
- package/dist/src/x402.js +11 -0
- package/dist/src/yappr.d.ts +1 -0
- package/dist/src/yappr.js +85 -0
- 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,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,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,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,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,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,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,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;
|