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,154 @@
1
+ import { config } from "../config.js";
2
+ const TIME_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/; // HH:MM, 24h
3
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; // YYYY-MM-DD
4
+ function validTimezone(tz) {
5
+ // Intl is the source of truth for zone names: constructing a formatter with an
6
+ // unknown timeZone throws a RangeError. "UTC" is itself a valid IANA zone.
7
+ try {
8
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ // Build a validated Schedule from the raw string params the LLM passed to the
16
+ // skill. Returns `{ error }` with a message written FOR the model (it becomes the
17
+ // skill observation), so a bad request turns into a helpful reply to the user.
18
+ export function validateSchedule(raw) {
19
+ const type = raw.schedule;
20
+ if (type === "interval") {
21
+ const minutes = Number(raw.minutes);
22
+ if (!Number.isFinite(minutes) || minutes <= 0) {
23
+ return { error: `interval schedules need a positive "minutes" param (got "${raw.minutes}")` };
24
+ }
25
+ if (minutes < config.cronMinIntervalMin) {
26
+ return { error: `interval too short — the minimum is every ${config.cronMinIntervalMin} minute${config.cronMinIntervalMin === 1 ? "" : "s"}` };
27
+ }
28
+ return { type: "interval", minutes: Math.round(minutes) };
29
+ }
30
+ if (type === "once") {
31
+ if (raw.minutes !== undefined && raw.minutes !== "") {
32
+ const minutes = Number(raw.minutes);
33
+ if (!Number.isFinite(minutes) || minutes <= 0) {
34
+ return { error: `one-shot relative schedules need a positive "minutes" param (got "${raw.minutes}")` };
35
+ }
36
+ return { type: "once", minutes: Math.round(minutes) };
37
+ }
38
+ // Absolute one-shot: time (+ optional date) in an explicit timezone.
39
+ const abs = validateWallClock(raw, "a one-shot at a specific time");
40
+ if ("error" in abs)
41
+ return abs;
42
+ return { type: "once", date: raw.date || undefined, time: abs.time, timezone: abs.timezone };
43
+ }
44
+ if (type === "daily") {
45
+ const abs = validateWallClock(raw, "a daily schedule");
46
+ if ("error" in abs)
47
+ return abs;
48
+ return { type: "daily", time: abs.time, timezone: abs.timezone };
49
+ }
50
+ return { error: `unknown schedule type "${type}" — use "interval", "once" or "daily"` };
51
+ }
52
+ // Shared validation for wall-clock (time + timezone) schedules. The timezone is
53
+ // REQUIRED by design: if the user didn't state one, the agent must ask, not guess.
54
+ function validateWallClock(raw, what) {
55
+ if (!raw.time || !TIME_RE.test(raw.time)) {
56
+ return { error: `${what} needs a "time" param in 24h HH:MM format (got "${raw.time ?? ""}")` };
57
+ }
58
+ if (!raw.timezone) {
59
+ return {
60
+ error: `${what} needs an explicit timezone — ask the user which timezone they mean (an IANA name like Europe/Paris, America/New_York, or UTC)`,
61
+ };
62
+ }
63
+ if (!validTimezone(raw.timezone)) {
64
+ return { error: `unknown timezone "${raw.timezone}" — ask the user for an IANA timezone name like Europe/Paris or UTC` };
65
+ }
66
+ if (raw.date && !DATE_RE.test(raw.date)) {
67
+ return { error: `"date" must be YYYY-MM-DD (got "${raw.date}")` };
68
+ }
69
+ return { time: raw.time, timezone: raw.timezone };
70
+ }
71
+ // ── Wall-clock → instant conversion ─────────────────────────────────────────
72
+ //
73
+ // JS has no native "instant for 09:00 on 2026-06-12 in Europe/Paris". Intl can
74
+ // only go the other way (instant → zone-local wall time), so we invert it by
75
+ // fixed-point iteration:
76
+ //
77
+ // 1. guess: treat the wall time as if it were UTC → t0
78
+ // 2. format t0 in the target zone, measure how far the result is from the
79
+ // wanted wall time, and shift the guess by that difference
80
+ // 3. repeat once — the zone offset is piecewise-constant, so this converges
81
+ // in ≤2 steps even across DST transitions.
82
+ //
83
+ // DST edge cases (documented, accepted): a nonexistent local time (the
84
+ // spring-forward gap, e.g. 02:30 the night clocks jump) resolves ~1h shifted;
85
+ // an ambiguous time (fall-back hour) resolves to one of the two instants.
86
+ // Wall-clock fields of `t` in `timeZone`, read via Intl (en-CA gives YYYY-MM-DD).
87
+ function wallParts(t, timeZone) {
88
+ const parts = new Intl.DateTimeFormat("en-CA", {
89
+ timeZone, year: "numeric", month: "2-digit", day: "2-digit",
90
+ hour: "2-digit", minute: "2-digit", hourCycle: "h23",
91
+ }).formatToParts(t);
92
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
93
+ return { y: get("year"), mo: get("month"), d: get("day"), h: get("hour"), mi: get("minute") };
94
+ }
95
+ // Epoch ms of `y-mo-d hh:mm` wall time in `timeZone` (fixed-point, see above).
96
+ function zonedTimeToInstant(y, mo, d, hh, mm, timeZone) {
97
+ let t = Date.UTC(y, mo - 1, d, hh, mm);
98
+ for (let i = 0; i < 2; i++) {
99
+ const w = wallParts(t, timeZone);
100
+ const diff = Date.UTC(w.y, w.mo - 1, w.d, w.h, w.mi) - Date.UTC(y, mo - 1, d, hh, mm);
101
+ t -= diff;
102
+ }
103
+ return t;
104
+ }
105
+ const DAY_MS = 24 * 60 * 60 * 1000;
106
+ // Next occurrence of `s` strictly after `after` (epoch ms). Returns null for a
107
+ // spent one-shot (absolute time already in the past — the runner treats overdue
108
+ // one-shots separately; at creation the store rejects null).
109
+ export function nextRunAt(s, after) {
110
+ if (s.type === "interval")
111
+ return after + s.minutes * 60_000;
112
+ if (s.type === "once") {
113
+ if (s.minutes !== undefined)
114
+ return after + s.minutes * 60_000;
115
+ const [hh, mm] = s.time.split(":").map(Number);
116
+ if (s.date) {
117
+ const [y, mo, d] = s.date.split("-").map(Number);
118
+ const t = zonedTimeToInstant(y, mo, d, hh, mm, s.timezone);
119
+ return t > after ? t : null;
120
+ }
121
+ // No date: the next time the wall clock reads HH:MM in that zone.
122
+ return nextWallClock(hh, mm, s.timezone, after);
123
+ }
124
+ // daily
125
+ const [hh, mm] = s.time.split(":").map(Number);
126
+ return nextWallClock(hh, mm, s.timezone, after);
127
+ }
128
+ // Next instant after `after` at which it is HH:MM in `timeZone`: try today (in
129
+ // the zone), then advance day by day. The +DAY_MS probe moves the wall-clock
130
+ // date forward; the exact instant is recomputed from the new date each step, so
131
+ // DST days (23h/25h long) can't drift the result.
132
+ function nextWallClock(hh, mm, timeZone, after) {
133
+ let probe = after;
134
+ for (let i = 0; i < 3; i++) {
135
+ const w = wallParts(probe, timeZone);
136
+ const t = zonedTimeToInstant(w.y, w.mo, w.d, hh, mm, timeZone);
137
+ if (t > after)
138
+ return t;
139
+ probe += DAY_MS;
140
+ }
141
+ // Unreachable (within 2 days there is always a next HH:MM), but never loop forever.
142
+ return after + DAY_MS;
143
+ }
144
+ // Human/one-line form for logs and the skill's `list` output.
145
+ export function describeSchedule(s) {
146
+ if (s.type === "interval")
147
+ return `every ${s.minutes} min`;
148
+ if (s.type === "once") {
149
+ if (s.minutes !== undefined)
150
+ return `once, ${s.minutes} min after creation`;
151
+ return `once at ${s.date ? `${s.date} ` : ""}${s.time} ${s.timezone}`;
152
+ }
153
+ return `daily at ${s.time} ${s.timezone}`;
154
+ }
@@ -0,0 +1,46 @@
1
+ import type { Tweet } from "../x/types.js";
2
+ import { type Schedule, describeSchedule } from "./schedule.js";
3
+ export type CronJob = {
4
+ id: number;
5
+ prompt: string;
6
+ schedule: Schedule;
7
+ creatorId: string;
8
+ creatorHandle: string;
9
+ sourceTweet: Tweet | null;
10
+ enabled: boolean;
11
+ nextRunAt: number;
12
+ lastRunAt: number | null;
13
+ lastResult: string | null;
14
+ lastError: string | null;
15
+ runs: number;
16
+ consecutiveFailures: number;
17
+ createdAt: number;
18
+ };
19
+ export declare function addCronJob(input: {
20
+ prompt: string;
21
+ schedule: Schedule;
22
+ tweet: Tweet;
23
+ }): {
24
+ job: CronJob;
25
+ } | {
26
+ error: string;
27
+ };
28
+ export declare function getCronJob(id: number): CronJob | null;
29
+ export declare function listCronJobs(opts?: {
30
+ includeDisabled?: boolean;
31
+ creatorId?: string;
32
+ }): CronJob[];
33
+ export declare function setCronJobEnabled(id: number, enabled: boolean): boolean;
34
+ export declare function resumeCronJob(id: number): {
35
+ job: CronJob;
36
+ } | {
37
+ error: string;
38
+ };
39
+ export declare function removeCronJob(id: number): boolean;
40
+ export declare function dueCronJobs(now: number): CronJob[];
41
+ export declare function armCronJob(id: number, nextRunAt: number | null): void;
42
+ export declare function markCronRun(id: number, outcome: {
43
+ result?: string;
44
+ error?: string;
45
+ }): void;
46
+ export { describeSchedule };
@@ -0,0 +1,220 @@
1
+ import { withSchema } from "../db.js";
2
+ import { config } from "../config.js";
3
+ import { nextRunAt, describeSchedule } from "./schedule.js";
4
+ // Persistent cron jobs in the shared SQLite DB (see db.ts) — same pattern as
5
+ // state.ts/stats.ts: the feature owns its table, the DB survives redeploys (it
6
+ // lives at DB_PATH outside the wiped project dir) and rides the dashboard's
7
+ // backup system. The DB row is the ONLY scheduler state: there is no in-memory
8
+ // timer registry to keep in sync — the runner just reads `next_run_at` (see
9
+ // runner.ts for why that design).
10
+ //
11
+ // Security model: the stored prompt is replayed later through an LLM that can
12
+ // call skills, so treat it like code — it is shown verbatim by `list`, logged on
13
+ // every run, and grants nothing by itself: the creator's privileges are
14
+ // re-derived from ADMIN_HANDLES at each run (runner.ts), never from this table.
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS cron_jobs (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ prompt TEXT NOT NULL,
19
+ schedule TEXT NOT NULL,
20
+ creator_id TEXT NOT NULL,
21
+ creator_handle TEXT NOT NULL,
22
+ source_tweet TEXT,
23
+ enabled INTEGER NOT NULL DEFAULT 1,
24
+ next_run_at INTEGER NOT NULL,
25
+ last_run_at INTEGER,
26
+ last_result TEXT,
27
+ last_error TEXT,
28
+ runs INTEGER NOT NULL DEFAULT 0,
29
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
30
+ created_at INTEGER NOT NULL
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_cron_due ON cron_jobs(enabled, next_run_at);
33
+ `;
34
+ const conn = () => withSchema(SCHEMA);
35
+ function rowToJob(r) {
36
+ let sourceTweet = null;
37
+ try {
38
+ sourceTweet = r.source_tweet ? JSON.parse(r.source_tweet) : null;
39
+ }
40
+ catch { /* keep null */ }
41
+ return {
42
+ id: r.id,
43
+ prompt: r.prompt,
44
+ schedule: JSON.parse(r.schedule),
45
+ creatorId: r.creator_id,
46
+ creatorHandle: r.creator_handle,
47
+ sourceTweet,
48
+ enabled: r.enabled === 1,
49
+ nextRunAt: r.next_run_at,
50
+ lastRunAt: r.last_run_at,
51
+ lastResult: r.last_result,
52
+ lastError: r.last_error,
53
+ runs: r.runs,
54
+ consecutiveFailures: r.consecutive_failures,
55
+ createdAt: r.created_at,
56
+ };
57
+ }
58
+ // Create a job. Error strings are written for the LLM observation (the skill
59
+ // returns them verbatim), so they read as something the model can relay to the
60
+ // user. The creator is snapshotted from the CREATING tweet's author — params are
61
+ // model-controlled, the tweet author is not.
62
+ // Caps on ACTIVE (enabled = 1) jobs — every run costs money, so disabled jobs
63
+ // don't count. Checked at creation AND on resume (resumeCronJob): if resume
64
+ // skipped it, pause-create-resume cycles would bypass the limits. The per-user
65
+ // cap on top of the global one matters once the skill is opened to non-admins —
66
+ // a single user must not be able to exhaust the pool.
67
+ function checkCaps(d, creatorId) {
68
+ const active = d.prepare("SELECT COUNT(*) AS n FROM cron_jobs WHERE enabled = 1").get();
69
+ if (active.n >= config.cronMaxJobs) {
70
+ return { error: `cron job limit reached (${config.cronMaxJobs} active jobs) — remove one first` };
71
+ }
72
+ const own = d.prepare("SELECT COUNT(*) AS n FROM cron_jobs WHERE enabled = 1 AND creator_id = ?")
73
+ .get(creatorId);
74
+ if (own.n >= config.cronMaxJobsPerUser) {
75
+ return { error: `you already have ${own.n} active cron jobs (limit ${config.cronMaxJobsPerUser}) — remove one first` };
76
+ }
77
+ return { ok: true };
78
+ }
79
+ export function addCronJob(input) {
80
+ const d = conn();
81
+ if (!d)
82
+ return { error: "cron storage unavailable" };
83
+ const prompt = input.prompt?.trim();
84
+ if (!prompt)
85
+ return { error: 'missing "prompt" — the self-contained instruction to run on schedule' };
86
+ const author = input.tweet.author;
87
+ if (!author?.id || !author?.username) {
88
+ return { error: "could not identify the requesting user from the tweet" };
89
+ }
90
+ const caps = checkCaps(d, author.id);
91
+ if ("error" in caps)
92
+ return caps;
93
+ const now = Date.now();
94
+ // Relative schedules ("in/every N minutes") anchor at the asking tweet's
95
+ // created_at, not at job creation: poll lag + the creating agent loop add
96
+ // ~15-40s before this row exists, and the user counts from when they tweeted.
97
+ // Falls back to `now` when created_at is missing/unparseable (or ahead of our
98
+ // clock). If processing was so slow that anchor+delay is already past, the
99
+ // job simply fires on the next tick, like any overdue job.
100
+ const isRelative = input.schedule.type === "interval" ||
101
+ (input.schedule.type === "once" && input.schedule.minutes !== undefined);
102
+ const tweetAt = Date.parse(input.tweet.created_at ?? "");
103
+ const anchor = isRelative && Number.isFinite(tweetAt) && tweetAt <= now ? tweetAt : now;
104
+ const next = nextRunAt(input.schedule, anchor);
105
+ // null = an absolute one-shot already in the past — refuse rather than store a
106
+ // job that would either never fire or fire "late" immediately.
107
+ if (next === null)
108
+ return { error: "that time is already in the past — pick a future time" };
109
+ const res = d.prepare(`
110
+ INSERT INTO cron_jobs (prompt, schedule, creator_id, creator_handle, source_tweet, next_run_at, created_at)
111
+ VALUES (?, ?, ?, ?, ?, ?, ?)
112
+ `).run(prompt, JSON.stringify(input.schedule), author.id, author.username.toLowerCase(), JSON.stringify(input.tweet), next, now);
113
+ const job = getCronJob(Number(res.lastInsertRowid));
114
+ return job ? { job } : { error: "failed to store the cron job" };
115
+ }
116
+ export function getCronJob(id) {
117
+ const d = conn();
118
+ if (!d)
119
+ return null;
120
+ const row = d.prepare("SELECT * FROM cron_jobs WHERE id = ?").get(id);
121
+ return row ? rowToJob(row) : null;
122
+ }
123
+ // Disabled jobs (spent one-shots, paused, auto-paused after repeated failures)
124
+ // stay listed until removed — `list` is the audit surface for stored prompts.
125
+ // `creatorId` filters to one user's jobs (matched on the stable X user id, not
126
+ // the handle, so renames don't detach a user from their jobs).
127
+ export function listCronJobs(opts = {}) {
128
+ const d = conn();
129
+ if (!d)
130
+ return [];
131
+ const { includeDisabled = true, creatorId } = opts;
132
+ const where = [];
133
+ const args = [];
134
+ if (!includeDisabled)
135
+ where.push("enabled = 1");
136
+ if (creatorId) {
137
+ where.push("creator_id = ?");
138
+ args.push(creatorId);
139
+ }
140
+ const sql = `SELECT * FROM cron_jobs${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id`;
141
+ return d.prepare(sql).all(...args).map(rowToJob);
142
+ }
143
+ // Disable only — resuming goes through resumeCronJob (cap re-check + re-arm).
144
+ export function setCronJobEnabled(id, enabled) {
145
+ const d = conn();
146
+ if (!d)
147
+ return false;
148
+ if (enabled)
149
+ return "job" in resumeCronJob(id);
150
+ return d.prepare("UPDATE cron_jobs SET enabled = 0 WHERE id = ?").run(id).changes > 0;
151
+ }
152
+ // Re-arm a paused job. Subject to the same active-job caps as creation —
153
+ // otherwise pause-create-resume cycles would bypass the limits — and
154
+ // next_run_at is recomputed from now, otherwise a job paused past its slot
155
+ // would fire immediately on resume.
156
+ export function resumeCronJob(id) {
157
+ const d = conn();
158
+ if (!d)
159
+ return { error: "cron storage unavailable" };
160
+ const job = getCronJob(id);
161
+ if (!job)
162
+ return { error: `no cron job #${id}` };
163
+ if (job.enabled)
164
+ return { job }; // already running — nothing to do
165
+ const caps = checkCaps(d, job.creatorId);
166
+ if ("error" in caps)
167
+ return caps;
168
+ const next = nextRunAt(job.schedule, Date.now());
169
+ if (next === null)
170
+ return { error: `cron #${id} can't be resumed — its one-shot time is already spent` };
171
+ d.prepare("UPDATE cron_jobs SET enabled = 1, next_run_at = ?, consecutive_failures = 0 WHERE id = ?")
172
+ .run(next, id);
173
+ return { job: getCronJob(id) };
174
+ }
175
+ export function removeCronJob(id) {
176
+ const d = conn();
177
+ if (!d)
178
+ return false;
179
+ return d.prepare("DELETE FROM cron_jobs WHERE id = ?").run(id).changes > 0;
180
+ }
181
+ // Runner internals ───────────────────────────────────────────────────────────
182
+ export function dueCronJobs(now) {
183
+ const d = conn();
184
+ if (!d)
185
+ return [];
186
+ return d.prepare("SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at")
187
+ .all(now).map(rowToJob);
188
+ }
189
+ // Advance the job's clock — called by the runner BEFORE executing, so a crash
190
+ // mid-run skips the slot instead of double-firing on restart (at-most-once).
191
+ // `enabled = 0` here is how one-shots are spent.
192
+ export function armCronJob(id, nextRunAt) {
193
+ const d = conn();
194
+ if (!d)
195
+ return;
196
+ if (nextRunAt === null) {
197
+ d.prepare("UPDATE cron_jobs SET enabled = 0 WHERE id = ?").run(id);
198
+ }
199
+ else {
200
+ d.prepare("UPDATE cron_jobs SET next_run_at = ? WHERE id = ?").run(nextRunAt, id);
201
+ }
202
+ }
203
+ export function markCronRun(id, outcome) {
204
+ const d = conn();
205
+ if (!d)
206
+ return;
207
+ if (outcome.error !== undefined) {
208
+ d.prepare(`
209
+ UPDATE cron_jobs SET last_run_at = ?, last_error = ?, runs = runs + 1,
210
+ consecutive_failures = consecutive_failures + 1 WHERE id = ?
211
+ `).run(Date.now(), outcome.error, id);
212
+ }
213
+ else {
214
+ d.prepare(`
215
+ UPDATE cron_jobs SET last_run_at = ?, last_result = ?, last_error = NULL, runs = runs + 1,
216
+ consecutive_failures = 0 WHERE id = ?
217
+ `).run(Date.now(), outcome.result ?? "", id);
218
+ }
219
+ }
220
+ export { describeSchedule };
@@ -0,0 +1,4 @@
1
+ import "dotenv/config";
2
+ import Database from "better-sqlite3";
3
+ export declare function getDb(): Database.Database | null;
4
+ export declare function withSchema(ddl: string): Database.Database | null;
package/dist/src/db.js ADDED
@@ -0,0 +1,53 @@
1
+ import "dotenv/config";
2
+ import { resolve } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ // The single SQLite database for the whole app (yappr.db). One shared connection that
5
+ // every feature needing storage opens through getDb() — stats today, more tables
6
+ // later. Each feature owns its own `CREATE TABLE IF NOT EXISTS` (see stats.ts), so
7
+ // adding a table is local to that feature; this module just owns the connection and
8
+ // pragmas.
9
+ //
10
+ // Path comes from DB_PATH. On the server that points *outside* the redeploy-wiped
11
+ // /yappr dir (so data survives deploys); locally it defaults to ./yappr.db. Opening
12
+ // is best-effort: a DB that won't open returns null, and callers degrade to no-ops
13
+ // rather than crashing the agent.
14
+ const DB_PATH = process.env.DB_PATH || resolve(process.cwd(), "yappr.db");
15
+ let db = null;
16
+ let initFailed = false;
17
+ export function getDb() {
18
+ if (db)
19
+ return db;
20
+ if (initFailed)
21
+ return null;
22
+ try {
23
+ const handle = new Database(DB_PATH);
24
+ handle.pragma("journal_mode = WAL"); // concurrent readers (the CLI) alongside the writer
25
+ handle.pragma("busy_timeout = 5000"); // wait out a brief writer lock instead of erroring
26
+ db = handle;
27
+ return db;
28
+ }
29
+ catch {
30
+ initFailed = true;
31
+ return null;
32
+ }
33
+ }
34
+ const ensured = new Set();
35
+ // Get the shared connection with a feature's tables guaranteed to exist. Pass the
36
+ // feature's `CREATE TABLE IF NOT EXISTS …` DDL; it runs once per process (memoised on
37
+ // the DDL text). Returns null if the DB can't be opened, so callers stay best-effort.
38
+ // This is how each feature owns its own schema while sharing one connection.
39
+ export function withSchema(ddl) {
40
+ const d = getDb();
41
+ if (!d)
42
+ return null;
43
+ if (!ensured.has(ddl)) {
44
+ try {
45
+ d.exec(ddl);
46
+ ensured.add(ddl);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ return d;
53
+ }
@@ -0,0 +1 @@
1
+ export declare function loadHooks(): Promise<void>;
@@ -0,0 +1,17 @@
1
+ import { registerHooks } from "./registry.js";
2
+ import { log } from "../log.js";
3
+ import { listHooks, importConfigModule } from "../config-loader.js";
4
+ export async function loadHooks() {
5
+ for (const { name, modulePath } of await listHooks()) {
6
+ try {
7
+ const mod = await importConfigModule(modulePath);
8
+ if (mod.hooks && typeof mod.hooks === "object") {
9
+ registerHooks(mod.hooks);
10
+ log.info({ file: name }, `hook loaded: ${name}`);
11
+ }
12
+ }
13
+ catch (err) {
14
+ log.error({ file: name, err: err.message }, `hook load failed: ${name}`);
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ import type { AgentHooks } from "./types.js";
2
+ import type { Tweet } from "../x/types.js";
3
+ import type { TreasuryBalances } from "../treasury/index.js";
4
+ import type { TreasuryCycleResult } from "../treasury/cycle.js";
5
+ export declare function registerHooks(hooks: AgentHooks): void;
6
+ export declare function runOnMention(tweet: Tweet): Promise<void>;
7
+ export declare function runShouldReply(tweet: Tweet): Promise<boolean>;
8
+ export declare function runOnBeforeInference(tweet: Tweet, question: string, context: string | undefined): Promise<{
9
+ question: string;
10
+ context: string | undefined;
11
+ }>;
12
+ export declare function runOnAfterInference(question: string, output: string): Promise<string>;
13
+ export declare function runOnBeforeReply(tweet: Tweet, text: string): Promise<string | null>;
14
+ export declare function runOnAfterReply(tweet: Tweet, text: string): Promise<void>;
15
+ export declare function runOnBeforeClaim(balances: TreasuryBalances): Promise<void>;
16
+ export declare function runOnAfterClaim(result: TreasuryCycleResult): Promise<void>;
17
+ export declare function runOnSwap(kind: "burn" | "swap", amount: bigint): Promise<void>;
@@ -0,0 +1,78 @@
1
+ // Every loaded hook file registers its own AgentHooks set; they COMPOSE rather
2
+ // than overwrite (multiple files can implement the same hook — e.g. user-memory
3
+ // and holder both use onBeforeInference; a spread-merge here used to let the
4
+ // last file silently clobber the others). Sets run in registration order (the
5
+ // loader's alphabetical file order):
6
+ // - observers (onMention, onAfterReply, treasury hooks) all run;
7
+ // - shouldReply is a veto chain — any false skips the reply;
8
+ // - transformers (onBeforeInference, onAfterInference, onBeforeReply) thread
9
+ // their value through each set in turn; onBeforeReply short-circuits on null.
10
+ const _hookSets = [];
11
+ export function registerHooks(hooks) {
12
+ _hookSets.push(hooks);
13
+ }
14
+ export async function runOnMention(tweet) {
15
+ for (const h of _hookSets) {
16
+ if (h.onMention)
17
+ await h.onMention(tweet);
18
+ }
19
+ }
20
+ export async function runShouldReply(tweet) {
21
+ for (const h of _hookSets) {
22
+ if (h.shouldReply && !(await h.shouldReply(tweet)))
23
+ return false;
24
+ }
25
+ return true;
26
+ }
27
+ export async function runOnBeforeInference(tweet, question, context) {
28
+ let cur = { question, context };
29
+ for (const h of _hookSets) {
30
+ if (h.onBeforeInference)
31
+ cur = await h.onBeforeInference({ tweet, ...cur });
32
+ }
33
+ return cur;
34
+ }
35
+ export async function runOnAfterInference(question, output) {
36
+ let cur = output;
37
+ for (const h of _hookSets) {
38
+ if (h.onAfterInference)
39
+ cur = await h.onAfterInference({ question, output: cur });
40
+ }
41
+ return cur;
42
+ }
43
+ export async function runOnBeforeReply(tweet, text) {
44
+ let cur = text;
45
+ for (const h of _hookSets) {
46
+ if (!h.onBeforeReply)
47
+ continue;
48
+ const next = await h.onBeforeReply({ tweet, text: cur });
49
+ if (next === null)
50
+ return null; // vetoed — later sets don't resurrect it
51
+ cur = next;
52
+ }
53
+ return cur;
54
+ }
55
+ export async function runOnAfterReply(tweet, text) {
56
+ for (const h of _hookSets) {
57
+ if (h.onAfterReply)
58
+ await h.onAfterReply({ tweet, text });
59
+ }
60
+ }
61
+ export async function runOnBeforeClaim(balances) {
62
+ for (const h of _hookSets) {
63
+ if (h.onBeforeClaim)
64
+ await h.onBeforeClaim(balances);
65
+ }
66
+ }
67
+ export async function runOnAfterClaim(result) {
68
+ for (const h of _hookSets) {
69
+ if (h.onAfterClaim)
70
+ await h.onAfterClaim(result);
71
+ }
72
+ }
73
+ export async function runOnSwap(kind, amount) {
74
+ for (const h of _hookSets) {
75
+ if (h.onSwap)
76
+ await h.onSwap({ kind, amount });
77
+ }
78
+ }
@@ -0,0 +1,45 @@
1
+ import type { Tweet } from "../x/types.js";
2
+ import type { TreasuryBalances } from "../treasury/index.js";
3
+ import type { TreasuryCycleResult } from "../treasury/cycle.js";
4
+ export type OnMentionHook = (tweet: Tweet) => Promise<void> | void;
5
+ export type ShouldReplyHook = (tweet: Tweet) => Promise<boolean> | boolean;
6
+ export type OnBeforeInferenceHook = (input: {
7
+ tweet: Tweet;
8
+ question: string;
9
+ context: string | undefined;
10
+ }) => Promise<{
11
+ question: string;
12
+ context: string | undefined;
13
+ }> | {
14
+ question: string;
15
+ context: string | undefined;
16
+ };
17
+ export type OnAfterInferenceHook = (input: {
18
+ question: string;
19
+ output: string;
20
+ }) => Promise<string> | string;
21
+ export type OnBeforeReplyHook = (input: {
22
+ tweet: Tweet;
23
+ text: string;
24
+ }) => Promise<string | null> | string | null;
25
+ export type OnAfterReplyHook = (input: {
26
+ tweet: Tweet;
27
+ text: string;
28
+ }) => Promise<void> | void;
29
+ export type OnBeforeClaimHook = (balances: TreasuryBalances) => Promise<void> | void;
30
+ export type OnAfterClaimHook = (result: TreasuryCycleResult) => Promise<void> | void;
31
+ export type OnSwapHook = (input: {
32
+ kind: "burn" | "swap";
33
+ amount: bigint;
34
+ }) => Promise<void> | void;
35
+ export type AgentHooks = {
36
+ onMention?: OnMentionHook;
37
+ shouldReply?: ShouldReplyHook;
38
+ onBeforeInference?: OnBeforeInferenceHook;
39
+ onAfterInference?: OnAfterInferenceHook;
40
+ onBeforeReply?: OnBeforeReplyHook;
41
+ onAfterReply?: OnAfterReplyHook;
42
+ onBeforeClaim?: OnBeforeClaimHook;
43
+ onAfterClaim?: OnAfterClaimHook;
44
+ onSwap?: OnSwapHook;
45
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ export type { SkillHandler, SkillResult, SkillDef, SkillAccess } from "./skills/types.js";
2
+ export type { AgentHooks } from "./hooks/types.js";
3
+ export type { Tweet, SearchResponse } from "./x/types.js";
4
+ export type { Treasury, TreasuryBalances } from "./treasury/index.js";
5
+ export type { TreasuryCycleResult } from "./treasury/cycle.js";
6
+ export type { CronJob } from "./cron/store.js";
7
+ export type { Schedule } from "./cron/schedule.js";
8
+ export type { SkillStore, SkillStoreEntry } from "./storage.js";
9
+ export type { Database } from "better-sqlite3";
10
+ export { agentPrompt } from "./agent-prompt.js";
11
+ export { getTreasury } from "./treasury/index.js";
12
+ export { log } from "./log.js";
13
+ export { config } from "./config.js";
14
+ export { payFetch, paidUsd, walletAddress } from "./wallet.js";
15
+ export { chat, llmCreditBalance } from "./llm/index.js";
16
+ export type { ChatMessage, ContentPart } from "./llm/index.js";
17
+ export { summary } from "./stats.js";
18
+ export type { Summary, SpendType } from "./stats.js";
19
+ export { checkHolderAccess } from "./skills/holder-access.js";
20
+ export { skillStore } from "./storage.js";
21
+ export { withSchema } from "./db.js";
22
+ export { addCronJob, listCronJobs, getCronJob, setCronJobEnabled, resumeCronJob, removeCronJob, describeSchedule } from "./cron/store.js";
23
+ export { validateSchedule } from "./cron/schedule.js";
24
+ export { checkCronCapability } from "./cron/capability.js";
25
+ export * from "./x/client.js";