@ghostpaw/questlog 0.9.2 → 0.11.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 (33) hide show
  1. package/README.md +144 -4
  2. package/SKILL.md +95 -40
  3. package/dist/chunk-7KTRNXTU.js +21 -0
  4. package/dist/chunk-D6EGZCT7.js +23 -0
  5. package/dist/chunk-GVD6A3RR.js +108 -0
  6. package/dist/{chunk-6KTQK3ZE.js → chunk-I2AMIXGP.js} +8 -0
  7. package/dist/{chunk-3B6FMHZU.js → chunk-IA2GVCZS.js} +35 -2
  8. package/dist/{chunk-DKH623HU.js → chunk-IL2QRKZL.js} +1 -4
  9. package/dist/{chunk-UTUQPSGO.js → chunk-KOWIVBG6.js} +68 -588
  10. package/dist/chunk-NBCCA6AX.js +31 -0
  11. package/dist/chunk-QK3H2AL5.js +36 -0
  12. package/dist/chunk-RBGQK5WP.js +949 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/{cli_main-XNZP4TY2.js → cli_main-TEG3QTDP.js} +147 -128
  15. package/dist/index.cjs +1152 -627
  16. package/dist/index.d.cts +142 -11
  17. package/dist/index.d.ts +142 -11
  18. package/dist/index.js +1152 -628
  19. package/dist/list_deliveries-WJEJUZIQ.js +2 -0
  20. package/dist/list_hooks-C5CAA2C7.js +2 -0
  21. package/dist/mcp-server.js +840 -389
  22. package/dist/mcp.cjs +709 -322
  23. package/dist/mcp.js +711 -324
  24. package/dist/open_questlog_db-6NS6BVBC.js +2 -0
  25. package/dist/{questlog_skill_spec-EM6DGGVN.js → questlog_skill_spec-ILTCTRYS.js} +16 -8
  26. package/dist/run_hook_command-BOENYNDT.js +155 -0
  27. package/dist/{serve_questlog_mcp-KFUYJB2P.js → serve_questlog_mcp-X524X34F.js} +6 -3
  28. package/dist/{skill_markdown-HXYQ3K6U.js → skill_markdown-EGKYZ3W2.js} +4 -20
  29. package/dist/text_output-X7F3K23Q.js +2 -0
  30. package/package.json +1 -1
  31. package/skills/questlog/SKILL.md +95 -40
  32. package/skills/questlog/references/webhooks.md +94 -0
  33. package/dist/open_questlog_db-SH3DAU4C.js +0 -2
@@ -0,0 +1,949 @@
1
+ #!/usr/bin/env node
2
+ import { listDeliveries } from './chunk-D6EGZCT7.js';
3
+ import { listHooks } from './chunk-NBCCA6AX.js';
4
+ import { randomBytes, createHmac } from 'node:crypto';
5
+ import { basename, extname } from 'node:path';
6
+
7
+ // src/with_transaction.ts
8
+ var transactionDepth = /* @__PURE__ */ new WeakMap();
9
+ function withTransaction(db, fn) {
10
+ const depth = transactionDepth.get(db) ?? 0;
11
+ const savepoint = `questlog_${depth}`;
12
+ if (depth === 0) {
13
+ db.exec("BEGIN IMMEDIATE");
14
+ } else {
15
+ db.exec(`SAVEPOINT ${savepoint}`);
16
+ }
17
+ transactionDepth.set(db, depth + 1);
18
+ try {
19
+ const result = fn();
20
+ if (depth === 0) {
21
+ db.exec("COMMIT");
22
+ } else {
23
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
24
+ }
25
+ transactionDepth.set(db, depth);
26
+ return result;
27
+ } catch (error) {
28
+ try {
29
+ if (depth === 0) {
30
+ db.exec("ROLLBACK");
31
+ } else {
32
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
33
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
34
+ }
35
+ } catch {
36
+ }
37
+ transactionDepth.set(db, depth);
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ // src/tags/normalize_tag_name.ts
43
+ function normalizeTagName(name) {
44
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
45
+ }
46
+
47
+ // src/hooks/types.ts
48
+ var hookEventTypes = [
49
+ "quest.due",
50
+ "quest.overdue",
51
+ "quest.unblocked",
52
+ "repeatable.spawned",
53
+ "rumor.captured"
54
+ ];
55
+
56
+ // src/hooks/add_hook.ts
57
+ function addHook(db, input) {
58
+ let url;
59
+ try {
60
+ url = new URL(input.url);
61
+ } catch {
62
+ throw new Error("Expected an HTTP or HTTPS URL");
63
+ }
64
+ if (!["http:", "https:"].includes(url.protocol))
65
+ throw new Error(`unsupported scheme ${url.protocol}; use http or https`);
66
+ if (url.username || url.password)
67
+ throw new Error(
68
+ "URL userinfo is not supported; use a custom header (--header or --header-stdin) or a query key instead"
69
+ );
70
+ const minGap = input.minGap ?? 60;
71
+ if (!Number.isSafeInteger(minGap) || minGap < 0)
72
+ throw new Error("Min gap must be a nonnegative integer");
73
+ const events = input.events ?? [...hookEventTypes];
74
+ if (!events.length || events.some((e) => !hookEventTypes.includes(e)))
75
+ throw new Error(
76
+ `unknown event ${events.find((e) => !hookEventTypes.includes(e)) ?? "(empty)"}; valid: ${hookEventTypes.join(", ")}`
77
+ );
78
+ if (input.tag !== void 0 && !normalizeTagName(input.tag))
79
+ throw new Error("Expected a tag name");
80
+ const secret = input.secret ?? randomBytes(32).toString("hex");
81
+ if (!secret.trim()) throw new Error("Expected a nonempty signing secret");
82
+ const result = db.prepare(
83
+ "INSERT INTO hook_targets(url, secret, tag, events, min_gap, created_at) VALUES (?, ?, ?, ?, ?, ?)"
84
+ ).run(
85
+ url.href,
86
+ secret,
87
+ input.tag === void 0 ? null : normalizeTagName(input.tag),
88
+ JSON.stringify(events),
89
+ minGap,
90
+ input.now ?? Date.now()
91
+ );
92
+ return { id: Number(result.lastInsertRowid), secret };
93
+ }
94
+ function hookLabel(db, name) {
95
+ if (name !== void 0) {
96
+ if (!name.trim()) throw new Error("Expected a nonempty database label");
97
+ db.prepare(
98
+ "INSERT INTO hook_settings(id, label) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET label = excluded.label"
99
+ ).run(name);
100
+ }
101
+ const saved = db.prepare("SELECT label FROM hook_settings WHERE id = 1").get();
102
+ if (saved) return String(saved.label);
103
+ const path = String(
104
+ db.prepare("PRAGMA database_list").all().find((row) => row.name === "main")?.file ?? ""
105
+ );
106
+ return path ? basename(path, extname(path)) : "questlog";
107
+ }
108
+
109
+ // src/hooks/remove_hook.ts
110
+ function removeHook(db, id) {
111
+ withTransaction(db, () => {
112
+ db.prepare("DELETE FROM hook_assignments WHERE target_id = ?").run(id);
113
+ db.prepare("DELETE FROM hook_deliveries WHERE target_id = ?").run(id);
114
+ db.prepare("DELETE FROM hook_targets WHERE id = ?").run(id);
115
+ });
116
+ }
117
+
118
+ // src/version.ts
119
+ var questlogVersion = "0.11.0";
120
+
121
+ // src/hooks/send_hook_delivery.ts
122
+ async function sendHookDelivery(db, delivery, target, options) {
123
+ const now = options.now ?? options.clock?.() ?? Date.now();
124
+ const timestamp = String(Math.floor(now / 1e3));
125
+ const body = JSON.stringify({
126
+ type: "questlog.events",
127
+ db: hookLabel(db),
128
+ deliveryId: delivery.id,
129
+ sentAt: new Date(now).toISOString(),
130
+ events: delivery.events
131
+ });
132
+ const signature = createHmac("sha256", target.secret).update(`${timestamp}.${body}`).digest("hex");
133
+ let status = null;
134
+ let errorClass = null;
135
+ try {
136
+ const response = await (options.fetch ?? globalThis.fetch)(target.url, {
137
+ method: "POST",
138
+ redirect: "error",
139
+ headers: {
140
+ "user-agent": `questlog/${questlogVersion}`,
141
+ ...JSON.parse(
142
+ String(
143
+ db.prepare("SELECT headers FROM hook_targets WHERE id = ?").get(target.id)?.headers ?? "{}"
144
+ )
145
+ ),
146
+ "content-type": "application/json",
147
+ "x-questlog-delivery": delivery.id,
148
+ "x-questlog-timestamp": timestamp,
149
+ "x-questlog-signature": `sha256=${signature}`
150
+ },
151
+ body,
152
+ signal: AbortSignal.timeout(Math.max(1, Math.min(options.timeoutMs ?? 5e3, 5e3)))
153
+ });
154
+ status = response.status;
155
+ await response.body?.cancel();
156
+ } catch (error) {
157
+ errorClass = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name) ? error.name : "NetworkError";
158
+ }
159
+ const delivered = status != null && status >= 200 && status < 300;
160
+ const completedAt = options.now ?? options.clock?.() ?? Date.now();
161
+ withTransaction(db, () => {
162
+ if (!db.prepare("SELECT id FROM hook_targets WHERE id = ? AND claim = ?").get(target.id, target.claim))
163
+ return;
164
+ const delay = Math.min(36e5, 3e4 * 2 ** (delivery.attempts - 1));
165
+ const jitter = Number.parseInt(randomBytes(2).toString("hex"), 16) % 1e3;
166
+ db.prepare(
167
+ "UPDATE hook_deliveries SET status = ?, http_status = ?, error_class = ?, next_attempt_at = ? WHERE id = ?"
168
+ ).run(
169
+ delivered ? "delivered" : delivery.attempts >= 8 ? "failed" : "pending",
170
+ status,
171
+ errorClass,
172
+ completedAt + delay + jitter,
173
+ delivery.id
174
+ );
175
+ db.prepare(
176
+ "UPDATE hook_targets SET claim = NULL, lease_until = NULL WHERE id = ? AND claim = ?"
177
+ ).run(target.id, target.claim);
178
+ db.prepare(
179
+ "DELETE FROM hook_deliveries WHERE target_id = ? AND status = 'delivered' AND id NOT IN (SELECT id FROM hook_deliveries WHERE target_id = ? AND status = 'delivered' ORDER BY last_attempt_at DESC, id DESC LIMIT 500)"
180
+ ).run(target.id, target.id);
181
+ });
182
+ return delivered;
183
+ }
184
+
185
+ // src/hooks/test_hook.ts
186
+ async function testHook(db, id, options = {}) {
187
+ const now = options.now ?? options.clock?.() ?? Date.now();
188
+ const claim = randomBytes(16).toString("hex");
189
+ const delivery = {
190
+ id: `d_${randomBytes(16).toString("hex")}`,
191
+ targetId: id,
192
+ events: [
193
+ {
194
+ id: `e_test_${randomBytes(16).toString("hex")}`,
195
+ type: "hook.test",
196
+ occurredAt: new Date(now).toISOString()
197
+ }
198
+ ],
199
+ status: "pending",
200
+ attempts: 1,
201
+ lastAttemptAt: now,
202
+ httpStatus: null,
203
+ errorClass: null,
204
+ nextAttemptAt: now
205
+ };
206
+ const target = withTransaction(db, () => {
207
+ const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(id);
208
+ if (!row) throw new Error("Hook not found");
209
+ if (row.lease_until != null && Number(row.lease_until) > now)
210
+ throw new Error("Hook delivery is in progress");
211
+ db.prepare(
212
+ "INSERT INTO hook_deliveries(id, target_id, events, status, attempts, last_attempt_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
213
+ ).run(delivery.id, id, JSON.stringify(delivery.events), "pending", 1, now, now);
214
+ db.prepare(
215
+ "UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
216
+ ).run(claim, now + 12e4, now, id);
217
+ return { id, url: String(row.url), secret: String(row.secret), claim };
218
+ });
219
+ return { id: delivery.id, delivered: await sendHookDelivery(db, delivery, target, options) };
220
+ }
221
+
222
+ // src/quests/map_quest_row.ts
223
+ function asBoolean(value) {
224
+ return Number(value) === 1;
225
+ }
226
+ function mapQuestRow(row) {
227
+ return {
228
+ id: Number(row.id),
229
+ questlineId: row.questline_id == null ? null : Number(row.questline_id),
230
+ sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
231
+ spawnedByQuestId: row.spawned_by_quest_id == null ? null : Number(row.spawned_by_quest_id),
232
+ spawnedFromRepeatableId: row.spawned_from_repeatable_id == null ? null : Number(row.spawned_from_repeatable_id),
233
+ spawnedForAt: row.spawned_for_at == null ? null : Number(row.spawned_for_at),
234
+ title: String(row.title),
235
+ objective: String(row.objective),
236
+ outcome: row.outcome == null ? null : String(row.outcome),
237
+ createdAt: Number(row.created_at),
238
+ updatedAt: Number(row.updated_at),
239
+ startedAt: row.started_at == null ? null : Number(row.started_at),
240
+ resolvedAt: row.resolved_at == null ? null : Number(row.resolved_at),
241
+ success: row.success == null ? null : asBoolean(row.success),
242
+ effortSeconds: Number(row.effort_seconds),
243
+ estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
244
+ notBeforeAt: row.not_before_at == null ? null : Number(row.not_before_at),
245
+ dueAt: row.due_at == null ? null : Number(row.due_at),
246
+ scheduledStartAt: row.scheduled_start_at == null ? null : Number(row.scheduled_start_at),
247
+ scheduledEndAt: row.scheduled_end_at == null ? null : Number(row.scheduled_end_at),
248
+ allDay: asBoolean(row.all_day),
249
+ deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
250
+ };
251
+ }
252
+
253
+ // src/quests/get_quest_or_throw.ts
254
+ function getQuestOrThrow(db, questId) {
255
+ const row = db.prepare("SELECT * FROM quests WHERE id = ? AND deleted_at IS NULL").get(questId);
256
+ if (!row) {
257
+ throw new Error(`Quest ${questId} was not found.`);
258
+ }
259
+ return mapQuestRow(row);
260
+ }
261
+
262
+ // src/resolve_now.ts
263
+ function resolveNow(now) {
264
+ return now ?? Date.now();
265
+ }
266
+
267
+ // src/rewards/copy_repeatable_quest_rewards_to_quest.ts
268
+ function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
269
+ const effectiveNow = resolveNow(now);
270
+ const rows = db.prepare(
271
+ `SELECT kind, name, description, quantity
272
+ FROM repeatable_quest_rewards
273
+ WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
274
+ ).all(repeatableQuestId);
275
+ for (const row of rows) {
276
+ db.prepare(
277
+ `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
278
+ VALUES (?, ?, ?, ?, ?, ?)`
279
+ ).run(
280
+ questId,
281
+ String(row.kind),
282
+ String(row.name),
283
+ row.description == null ? null : String(row.description),
284
+ row.quantity == null ? null : Number(row.quantity),
285
+ effectiveNow
286
+ );
287
+ }
288
+ }
289
+
290
+ // src/tags/copy_repeatable_quest_tags_to_quest.ts
291
+ function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
292
+ const effectiveNow = resolveNow(now);
293
+ const rows = db.prepare(
294
+ `SELECT tag_id
295
+ FROM repeatable_quest_tags
296
+ WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
297
+ ).all(repeatableQuestId);
298
+ for (const row of rows) {
299
+ db.prepare(
300
+ `INSERT INTO quest_tags (quest_id, tag_id, created_at)
301
+ VALUES (?, ?, ?)`
302
+ ).run(questId, Number(row.tag_id), effectiveNow);
303
+ }
304
+ }
305
+
306
+ // src/repeatable_quests/map_repeatable_quest_row.ts
307
+ function asBoolean2(value) {
308
+ return Number(value) === 1;
309
+ }
310
+ function mapRepeatableQuestRow(row) {
311
+ return {
312
+ id: Number(row.id),
313
+ questlineId: row.questline_id == null ? null : Number(row.questline_id),
314
+ title: String(row.title),
315
+ objective: String(row.objective),
316
+ rrule: String(row.rrule),
317
+ anchorAt: Number(row.anchor_at),
318
+ notBeforeOffsetSeconds: row.not_before_offset_seconds == null ? null : Number(row.not_before_offset_seconds),
319
+ dueOffsetSeconds: row.due_offset_seconds == null ? null : Number(row.due_offset_seconds),
320
+ scheduledStartOffsetSeconds: row.scheduled_start_offset_seconds == null ? null : Number(row.scheduled_start_offset_seconds),
321
+ scheduledEndOffsetSeconds: row.scheduled_end_offset_seconds == null ? null : Number(row.scheduled_end_offset_seconds),
322
+ allDay: asBoolean2(row.all_day),
323
+ estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
324
+ createdAt: Number(row.created_at),
325
+ updatedAt: Number(row.updated_at),
326
+ archivedAt: row.archived_at == null ? null : Number(row.archived_at),
327
+ deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
328
+ };
329
+ }
330
+
331
+ // src/repeatable_quests/get_repeatable_quest_or_throw.ts
332
+ function getRepeatableQuestOrThrow(db, repeatableQuestId) {
333
+ const row = db.prepare("SELECT * FROM repeatable_quests WHERE id = ? AND deleted_at IS NULL").get(repeatableQuestId);
334
+ if (!row) {
335
+ throw new Error(`Repeatable quest ${repeatableQuestId} was not found.`);
336
+ }
337
+ return mapRepeatableQuestRow(row);
338
+ }
339
+
340
+ // src/repeatable_quests/advance_anchor.ts
341
+ var WEEK_MS = 7 * 864e5;
342
+ function advanceAnchor(anchorAt, freq, interval) {
343
+ const date = new Date(anchorAt);
344
+ if (freq === "MONTHLY") {
345
+ date.setUTCMonth(date.getUTCMonth() + interval);
346
+ return date.getTime();
347
+ }
348
+ if (freq === "YEARLY") {
349
+ date.setUTCFullYear(date.getUTCFullYear() + interval);
350
+ return date.getTime();
351
+ }
352
+ return anchorAt + interval * WEEK_MS;
353
+ }
354
+
355
+ // src/repeatable_quests/parse_rrule.ts
356
+ var WEEKDAY = {
357
+ SU: 0,
358
+ MO: 1,
359
+ TU: 2,
360
+ WE: 3,
361
+ TH: 4,
362
+ FR: 5,
363
+ SA: 6
364
+ };
365
+ function parseRRule(rrule) {
366
+ const parts = Object.fromEntries(
367
+ rrule.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
368
+ const [key, value] = part.split("=");
369
+ return [key, value];
370
+ })
371
+ );
372
+ const freq = parts.FREQ;
373
+ if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") {
374
+ throw new Error(`Unsupported RRULE FREQ: ${freq ?? "missing"}`);
375
+ }
376
+ const interval = parts.INTERVAL == null ? 1 : Number(parts.INTERVAL);
377
+ if (!Number.isFinite(interval) || interval < 1) {
378
+ throw new Error("RRULE INTERVAL must be a positive integer.");
379
+ }
380
+ const count = parts.COUNT == null ? null : Number(parts.COUNT);
381
+ if (count != null && (!Number.isFinite(count) || count < 1)) {
382
+ throw new Error("RRULE COUNT must be a positive integer.");
383
+ }
384
+ const until = parts.UNTIL == null ? null : Date.parse(parts.UNTIL);
385
+ if (parts.UNTIL != null && Number.isNaN(until)) {
386
+ throw new Error("RRULE UNTIL must be parseable as a date.");
387
+ }
388
+ const byDay = parts.BYDAY == null ? null : parts.BYDAY.split(",").map((token) => {
389
+ const value = WEEKDAY[token];
390
+ if (value == null) {
391
+ throw new Error(`Unsupported RRULE BYDAY token: ${token}`);
392
+ }
393
+ return value;
394
+ });
395
+ return { freq, interval, count, until, byDay };
396
+ }
397
+
398
+ // src/repeatable_quests/start_of_utc_day.ts
399
+ function startOfUtcDay(timestamp) {
400
+ const date = new Date(timestamp);
401
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
402
+ }
403
+
404
+ // src/repeatable_quests/start_of_utc_week.ts
405
+ var DAY_MS = 864e5;
406
+ function startOfUtcWeek(dayStart) {
407
+ const date = new Date(dayStart);
408
+ return dayStart - date.getUTCDay() * DAY_MS;
409
+ }
410
+
411
+ // src/repeatable_quests/generate_anchors_until.ts
412
+ var DAY_MS2 = 864e5;
413
+ var WEEK_MS2 = 7 * DAY_MS2;
414
+ function generateAnchorsUntil(repeatable, now) {
415
+ const rule = parseRRule(repeatable.rrule);
416
+ const anchors = [];
417
+ const until = rule.until == null ? now : Math.min(now, rule.until);
418
+ if (repeatable.anchorAt > until) {
419
+ return anchors;
420
+ }
421
+ if (rule.freq === "DAILY") {
422
+ let occurrence2 = 0;
423
+ for (let current2 = repeatable.anchorAt; current2 <= until; current2 += rule.interval * DAY_MS2) {
424
+ occurrence2 += 1;
425
+ if (rule.count != null && occurrence2 > rule.count) {
426
+ break;
427
+ }
428
+ anchors.push(current2);
429
+ }
430
+ return anchors;
431
+ }
432
+ if (rule.freq === "WEEKLY" && rule.byDay && rule.byDay.length > 0) {
433
+ const anchorDayStart = startOfUtcDay(repeatable.anchorAt);
434
+ const anchorWeekStart = startOfUtcWeek(anchorDayStart);
435
+ const timeOffset = repeatable.anchorAt - anchorDayStart;
436
+ let occurrence2 = 0;
437
+ for (let day = anchorDayStart; day <= until; day += DAY_MS2) {
438
+ const weeksSince = Math.floor((startOfUtcWeek(day) - anchorWeekStart) / WEEK_MS2);
439
+ if (weeksSince < 0 || weeksSince % rule.interval !== 0) {
440
+ continue;
441
+ }
442
+ if (!rule.byDay.includes(new Date(day).getUTCDay())) {
443
+ continue;
444
+ }
445
+ const anchor = day + timeOffset;
446
+ if (anchor < repeatable.anchorAt || anchor > until) {
447
+ continue;
448
+ }
449
+ occurrence2 += 1;
450
+ if (rule.count != null && occurrence2 > rule.count) {
451
+ break;
452
+ }
453
+ anchors.push(anchor);
454
+ }
455
+ return anchors;
456
+ }
457
+ let occurrence = 0;
458
+ let current = repeatable.anchorAt;
459
+ while (current <= until) {
460
+ occurrence += 1;
461
+ if (rule.count != null && occurrence > rule.count) {
462
+ break;
463
+ }
464
+ anchors.push(current);
465
+ current = advanceAnchor(current, rule.freq, rule.interval);
466
+ }
467
+ return anchors;
468
+ }
469
+
470
+ // src/repeatable_quests/list_due_repeatable_quest_anchors.ts
471
+ function listDueRepeatableQuestAnchors(db, now) {
472
+ const repeatables = db.prepare(
473
+ `SELECT *
474
+ FROM repeatable_quests
475
+ WHERE deleted_at IS NULL
476
+ AND archived_at IS NULL`
477
+ ).all().map(mapRepeatableQuestRow);
478
+ const anchors = [];
479
+ if (repeatables.length === 0) {
480
+ return anchors;
481
+ }
482
+ const repeatableIds = repeatables.map((repeatable) => repeatable.id);
483
+ const maxAnchorAt = Math.max(
484
+ ...repeatables.map((repeatable) => Math.max(repeatable.anchorAt, now))
485
+ );
486
+ const existingRows = db.prepare(
487
+ `SELECT spawned_from_repeatable_id, spawned_for_at
488
+ FROM quests
489
+ WHERE spawned_from_repeatable_id IN (${repeatableIds.map(() => "?").join(", ")})
490
+ AND spawned_for_at IS NOT NULL
491
+ AND spawned_for_at <= ?`
492
+ ).all(...repeatableIds, maxAnchorAt);
493
+ const existingAnchors = new Set(
494
+ existingRows.map(
495
+ (row) => `${Number(row.spawned_from_repeatable_id)}:${Number(row.spawned_for_at)}`
496
+ )
497
+ );
498
+ for (const repeatable of repeatables) {
499
+ const dueAnchors = generateAnchorsUntil(repeatable, now);
500
+ for (const anchorAt of dueAnchors) {
501
+ if (!existingAnchors.has(`${repeatable.id}:${anchorAt}`)) {
502
+ anchors.push({
503
+ repeatableQuestId: repeatable.id,
504
+ anchorAt,
505
+ markerId: "attention.available.repeatable"
506
+ });
507
+ }
508
+ }
509
+ }
510
+ return anchors.sort(
511
+ (a, b) => a.anchorAt - b.anchorAt || a.repeatableQuestId - b.repeatableQuestId
512
+ );
513
+ }
514
+
515
+ // src/repeatable_quests/spawn_due_repeatable_quests.ts
516
+ function spawnDueRepeatableQuests(db, now) {
517
+ return withTransaction(db, () => {
518
+ const anchors = listDueRepeatableQuestAnchors(db, now);
519
+ const quests = [];
520
+ for (const anchor of anchors) {
521
+ const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
522
+ const created = db.prepare(
523
+ `INSERT INTO quests (
524
+ questline_id,
525
+ source_rumor_id,
526
+ spawned_by_quest_id,
527
+ spawned_from_repeatable_id,
528
+ spawned_for_at,
529
+ title,
530
+ objective,
531
+ created_at,
532
+ updated_at,
533
+ effort_seconds,
534
+ estimate_seconds,
535
+ not_before_at,
536
+ due_at,
537
+ scheduled_start_at,
538
+ scheduled_end_at,
539
+ all_day
540
+ ) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
541
+ ).run(
542
+ repeatable.questlineId ?? null,
543
+ repeatable.id,
544
+ anchor.anchorAt,
545
+ repeatable.title,
546
+ repeatable.objective,
547
+ now,
548
+ now,
549
+ repeatable.estimateSeconds ?? null,
550
+ repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
551
+ repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
552
+ repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
553
+ repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
554
+ repeatable.allDay ? 1 : 0
555
+ );
556
+ const questId = Number(created.lastInsertRowid);
557
+ copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
558
+ copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
559
+ quests.push(getQuestOrThrow(db, questId));
560
+ }
561
+ return quests;
562
+ });
563
+ }
564
+
565
+ // src/markers/derive_quest_marker_id.ts
566
+ function deriveQuestMarkerId({
567
+ state,
568
+ available,
569
+ hasUnclaimedReward,
570
+ notBeforeAt,
571
+ blockerCount,
572
+ now
573
+ }) {
574
+ if (state === "done") {
575
+ return hasUnclaimedReward ? "progress.complete" : null;
576
+ }
577
+ if (state === "abandoned") {
578
+ return null;
579
+ }
580
+ if (state === "in_progress") {
581
+ return "progress.incomplete";
582
+ }
583
+ if (available) {
584
+ return "attention.available";
585
+ }
586
+ if (notBeforeAt != null && notBeforeAt > now) {
587
+ return "attention.available.future";
588
+ }
589
+ if (blockerCount > 0) {
590
+ return "progress.incomplete";
591
+ }
592
+ return null;
593
+ }
594
+
595
+ // src/rewards/map_quest_reward_row.ts
596
+ function mapQuestRewardRow(row) {
597
+ return {
598
+ id: Number(row.id),
599
+ questId: Number(row.quest_id),
600
+ kind: String(row.kind),
601
+ name: String(row.name),
602
+ description: row.description == null ? null : String(row.description),
603
+ quantity: row.quantity == null ? null : Number(row.quantity),
604
+ createdAt: Number(row.created_at),
605
+ claimedAt: row.claimed_at == null ? null : Number(row.claimed_at),
606
+ deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
607
+ };
608
+ }
609
+
610
+ // src/quests/derive_quest_state.ts
611
+ function deriveQuestState(quest) {
612
+ if (quest.resolvedAt != null && quest.success === true) {
613
+ return "done";
614
+ }
615
+ if (quest.resolvedAt != null && quest.success === false) {
616
+ return "abandoned";
617
+ }
618
+ if (quest.startedAt != null) {
619
+ return "in_progress";
620
+ }
621
+ return "open";
622
+ }
623
+
624
+ // src/quests/query_quest_detail_records.ts
625
+ function queryQuestDetailRecords(db, filters = {}, now = Date.now(), questIds) {
626
+ if (questIds && questIds.length === 0) {
627
+ return [];
628
+ }
629
+ const clauses = [];
630
+ const params = [];
631
+ if (!filters.includeDeleted) {
632
+ clauses.push("q.deleted_at IS NULL");
633
+ }
634
+ if (questIds && questIds.length > 0) {
635
+ clauses.push(`q.id IN (${questIds.map(() => "?").join(", ")})`);
636
+ params.push(...questIds);
637
+ }
638
+ if (filters.questlineId != null) {
639
+ clauses.push("q.questline_id = ?");
640
+ params.push(filters.questlineId);
641
+ }
642
+ if (filters.tagNames && filters.tagNames.length > 0) {
643
+ const normalized = [...new Set(filters.tagNames.map(normalizeTagName).filter(Boolean))];
644
+ for (const tag of normalized) {
645
+ clauses.push(
646
+ `EXISTS (
647
+ SELECT 1
648
+ FROM quest_tags qt
649
+ JOIN tags t ON t.id = qt.tag_id
650
+ WHERE qt.quest_id = q.id
651
+ AND qt.deleted_at IS NULL
652
+ AND t.deleted_at IS NULL
653
+ AND t.normalized_name = ?
654
+ )`
655
+ );
656
+ params.push(tag);
657
+ }
658
+ }
659
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
660
+ const rows = db.prepare(
661
+ `SELECT q.*,
662
+ COALESCE(q.due_at, ql.due_at) AS effective_due_at,
663
+ (
664
+ SELECT COUNT(*)
665
+ FROM quest_unlocks qu
666
+ JOIN quests blocker ON blocker.id = qu.from_quest_id
667
+ WHERE qu.to_quest_id = q.id
668
+ AND qu.deleted_at IS NULL
669
+ AND blocker.deleted_at IS NULL
670
+ AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
671
+ ) AS blocker_count
672
+ FROM quests q
673
+ LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
674
+ ${where}
675
+ ORDER BY q.created_at DESC, q.id DESC`
676
+ ).all(...params);
677
+ if (rows.length === 0) {
678
+ return [];
679
+ }
680
+ const ids = rows.map((row) => Number(row.id));
681
+ const placeholders = ids.map(() => "?").join(", ");
682
+ const tagNamesByQuestId = /* @__PURE__ */ new Map();
683
+ const rewardsByQuestId = /* @__PURE__ */ new Map();
684
+ const unlocksByQuestId = /* @__PURE__ */ new Map();
685
+ const unlockedByQuestId = /* @__PURE__ */ new Map();
686
+ for (const row of db.prepare(
687
+ `SELECT qt.quest_id, t.name
688
+ FROM quest_tags qt
689
+ JOIN tags t ON t.id = qt.tag_id
690
+ WHERE qt.quest_id IN (${placeholders})
691
+ AND qt.deleted_at IS NULL
692
+ AND t.deleted_at IS NULL
693
+ ORDER BY qt.quest_id, t.name`
694
+ ).all(...ids)) {
695
+ const questId = Number(row.quest_id);
696
+ const tagNames = tagNamesByQuestId.get(questId) ?? [];
697
+ tagNames.push(String(row.name));
698
+ tagNamesByQuestId.set(questId, tagNames);
699
+ }
700
+ for (const row of db.prepare(
701
+ `SELECT *
702
+ FROM quest_rewards
703
+ WHERE quest_id IN (${placeholders})
704
+ AND deleted_at IS NULL
705
+ ORDER BY quest_id, created_at, id`
706
+ ).all(...ids)) {
707
+ const reward = mapQuestRewardRow(row);
708
+ const rewards = rewardsByQuestId.get(reward.questId) ?? [];
709
+ rewards.push(reward);
710
+ rewardsByQuestId.set(reward.questId, rewards);
711
+ }
712
+ for (const row of db.prepare(
713
+ `SELECT from_quest_id, to_quest_id
714
+ FROM quest_unlocks
715
+ WHERE from_quest_id IN (${placeholders})
716
+ AND deleted_at IS NULL
717
+ ORDER BY from_quest_id, to_quest_id`
718
+ ).all(...ids)) {
719
+ const questId = Number(row.from_quest_id);
720
+ const unlocks = unlocksByQuestId.get(questId) ?? [];
721
+ unlocks.push(Number(row.to_quest_id));
722
+ unlocksByQuestId.set(questId, unlocks);
723
+ }
724
+ for (const row of db.prepare(
725
+ `SELECT from_quest_id, to_quest_id
726
+ FROM quest_unlocks
727
+ WHERE to_quest_id IN (${placeholders})
728
+ AND deleted_at IS NULL
729
+ ORDER BY to_quest_id, from_quest_id`
730
+ ).all(...ids)) {
731
+ const questId = Number(row.to_quest_id);
732
+ const unlockedBy = unlockedByQuestId.get(questId) ?? [];
733
+ unlockedBy.push(Number(row.from_quest_id));
734
+ unlockedByQuestId.set(questId, unlockedBy);
735
+ }
736
+ return rows.map((row) => {
737
+ const quest = mapQuestRow(row);
738
+ const blockerCount = Number(row.blocker_count ?? 0);
739
+ const effectiveDueAt = row.effective_due_at == null ? null : Number(row.effective_due_at);
740
+ const rewards = rewardsByQuestId.get(quest.id) ?? [];
741
+ const state = deriveQuestState(quest);
742
+ const available = quest.deletedAt == null && quest.resolvedAt == null && (quest.notBeforeAt == null || quest.notBeforeAt <= now) && blockerCount === 0;
743
+ const markerId = deriveQuestMarkerId({
744
+ state,
745
+ available,
746
+ hasUnclaimedReward: rewards.some((reward) => reward.claimedAt == null),
747
+ notBeforeAt: quest.notBeforeAt,
748
+ blockerCount,
749
+ now
750
+ });
751
+ return {
752
+ detail: {
753
+ ...quest,
754
+ state,
755
+ markerId,
756
+ effectiveDueAt,
757
+ available,
758
+ tagNames: tagNamesByQuestId.get(quest.id) ?? [],
759
+ rewards,
760
+ unlocksQuestIds: unlocksByQuestId.get(quest.id) ?? [],
761
+ unlockedByQuestIds: unlockedByQuestId.get(quest.id) ?? []
762
+ },
763
+ blockerCount
764
+ };
765
+ });
766
+ }
767
+
768
+ // src/hooks/detect_hook_events.ts
769
+ function detectHookEvents(db, now) {
770
+ const events = [];
771
+ const add = (type, at, ids) => {
772
+ events.push({
773
+ id: `e_${type}_${ids.questId ?? ids.rumorId}_${ids.repeatableId ?? ""}_${at}`,
774
+ type,
775
+ ...ids,
776
+ occurredAt: new Date(at).toISOString()
777
+ });
778
+ };
779
+ for (const { detail: q } of queryQuestDetailRecords(db, {}, now)) {
780
+ if (q.resolvedAt == null && q.effectiveDueAt != null && q.effectiveDueAt <= now) {
781
+ add("quest.due", q.effectiveDueAt, { questId: q.id });
782
+ if (q.effectiveDueAt < now) add("quest.overdue", q.effectiveDueAt, { questId: q.id });
783
+ }
784
+ if (q.available) {
785
+ const prerequisites = db.prepare(
786
+ `SELECT p.id, p.resolved_at, u.created_at FROM quest_unlocks u JOIN quests p ON p.id = u.from_quest_id WHERE u.to_quest_id = ? AND u.deleted_at IS NULL AND p.deleted_at IS NULL AND p.success = 1 AND p.resolved_at IS NOT NULL`
787
+ ).all(q.id);
788
+ if (prerequisites.some((p) => Number(p.resolved_at) >= Number(p.created_at))) {
789
+ const at = Math.max(q.notBeforeAt ?? 0, ...prerequisites.map((p) => Number(p.resolved_at)));
790
+ if (at <= now) add("quest.unblocked", at, { questId: q.id });
791
+ }
792
+ }
793
+ if (q.spawnedFromRepeatableId != null && q.createdAt <= now)
794
+ add("repeatable.spawned", q.createdAt, {
795
+ questId: q.id,
796
+ repeatableId: q.spawnedFromRepeatableId
797
+ });
798
+ }
799
+ for (const row of db.prepare("SELECT id, created_at FROM rumors WHERE deleted_at IS NULL AND created_at <= ?").all(now))
800
+ add("rumor.captured", Number(row.created_at), { rumorId: Number(row.id) });
801
+ return events.sort(
802
+ (a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id)
803
+ );
804
+ }
805
+
806
+ // src/hooks/tick_questlog.ts
807
+ async function tickQuestlog(db, options = {}) {
808
+ const now = options.now ?? options.clock?.() ?? Date.now();
809
+ const result = {
810
+ events: [],
811
+ batches: [],
812
+ sent: 0,
813
+ queued: 0,
814
+ retrying: 0,
815
+ failed: 0
816
+ };
817
+ const claims = [];
818
+ const collect = () => {
819
+ spawnDueRepeatableQuests(db, now);
820
+ const detected = detectHookEvents(db, now);
821
+ result.events = detected.filter(
822
+ (e) => !db.prepare("SELECT id FROM hook_events WHERE id = ?").get(e.id)
823
+ );
824
+ for (const event of result.events)
825
+ db.prepare("INSERT INTO hook_events(id, occurred_at, event) VALUES (?, ?, ?)").run(
826
+ event.id,
827
+ Date.parse(event.occurredAt),
828
+ JSON.stringify(event)
829
+ );
830
+ for (const target of listHooks(db)) {
831
+ const events = db.prepare(
832
+ "SELECT e.event FROM hook_events e WHERE occurred_at >= ? AND NOT EXISTS (SELECT 1 FROM hook_assignments a WHERE a.target_id = ? AND a.event_id = e.id) ORDER BY occurred_at, id"
833
+ ).all(target.createdAt, target.id).map((row2) => JSON.parse(String(row2.event))).filter((event) => {
834
+ if (!target.events.includes(event.type)) return false;
835
+ if (target.tag == null) return true;
836
+ return event.questId !== void 0 && db.prepare(
837
+ "SELECT 1 FROM quest_tags qt JOIN tags t ON t.id = qt.tag_id WHERE qt.quest_id = ? AND qt.deleted_at IS NULL AND t.deleted_at IS NULL AND t.normalized_name = ?"
838
+ ).get(event.questId, normalizeTagName(target.tag)) !== void 0;
839
+ });
840
+ for (const event of events)
841
+ db.prepare("INSERT INTO hook_assignments(target_id, event_id) VALUES (?, ?)").run(
842
+ target.id,
843
+ event.id
844
+ );
845
+ const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(target.id);
846
+ if (!row || row.lease_until != null && Number(row.lease_until) > now) continue;
847
+ let pending = listDeliveries(db).find(
848
+ (d) => d.targetId === target.id && d.status === "pending"
849
+ );
850
+ const queued = db.prepare(
851
+ "SELECT e.event FROM hook_assignments a JOIN hook_events e ON e.id = a.event_id WHERE a.target_id = ? AND a.delivery_id IS NULL ORDER BY e.occurred_at, e.id"
852
+ ).all(target.id).map((r) => JSON.parse(String(r.event)));
853
+ if (!pending && queued.length) {
854
+ const id = `d_${randomBytes(16).toString("hex")}`;
855
+ db.prepare(
856
+ "INSERT INTO hook_deliveries(id, target_id, events, status, next_attempt_at) VALUES (?, ?, ?, 'pending', ?)"
857
+ ).run(
858
+ id,
859
+ target.id,
860
+ JSON.stringify(queued),
861
+ Math.max(now, (target.lastWake ?? -Infinity) + target.minGap * 1e3)
862
+ );
863
+ db.prepare(
864
+ "UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
865
+ ).run(id, target.id);
866
+ pending = listDeliveries(db).find((d) => d.id === id);
867
+ }
868
+ if (!pending) continue;
869
+ if (pending.attempts === 0 && queued.length && !queued.every((e) => pending?.events.some((p) => p.id === e.id))) {
870
+ pending.events.push(...queued);
871
+ db.prepare("UPDATE hook_deliveries SET events = ? WHERE id = ?").run(
872
+ JSON.stringify(pending.events),
873
+ pending.id
874
+ );
875
+ db.prepare(
876
+ "UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
877
+ ).run(pending.id, target.id);
878
+ }
879
+ if (pending.nextAttemptAt > now || target.lastWake != null && target.lastWake + target.minGap * 1e3 > now)
880
+ continue;
881
+ if (pending.attempts >= 8) {
882
+ db.prepare("UPDATE hook_deliveries SET status = 'failed' WHERE id = ?").run(pending.id);
883
+ continue;
884
+ }
885
+ const claim = randomBytes(16).toString("hex");
886
+ db.prepare(
887
+ "UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
888
+ ).run(claim, now + 12e4, now, target.id);
889
+ db.prepare(
890
+ "UPDATE hook_deliveries SET attempts = attempts + 1, last_attempt_at = ? WHERE id = ?"
891
+ ).run(now, pending.id);
892
+ pending.attempts++;
893
+ pending.lastAttemptAt = now;
894
+ result.batches.push({ targetId: target.id, events: pending.events });
895
+ claims.push({
896
+ delivery: pending,
897
+ target: { id: target.id, url: String(row.url), secret: String(row.secret), claim }
898
+ });
899
+ }
900
+ };
901
+ if (options.dryRun) {
902
+ const rollback = new Error("preview rollback");
903
+ try {
904
+ withTransaction(db, () => {
905
+ collect();
906
+ throw rollback;
907
+ });
908
+ } catch (error) {
909
+ if (error !== rollback) throw error;
910
+ }
911
+ } else withTransaction(db, collect);
912
+ if (!options.dryRun) {
913
+ const sent = await Promise.all(
914
+ claims.map((claim) => sendHookDelivery(db, claim.delivery, claim.target, options))
915
+ );
916
+ result.sent = sent.filter(Boolean).length;
917
+ }
918
+ const deliveries = listDeliveries(db);
919
+ result.queued = deliveries.filter((d) => d.status === "pending" && d.attempts === 0).length + Number(
920
+ db.prepare(
921
+ "SELECT COUNT(DISTINCT target_id) AS n FROM hook_assignments WHERE delivery_id IS NULL"
922
+ ).get()?.n ?? 0
923
+ );
924
+ result.retrying = deliveries.filter((d) => d.status === "pending" && d.attempts > 0).length;
925
+ result.failed = deliveries.filter((d) => d.status === "failed").length;
926
+ return result;
927
+ }
928
+
929
+ // src/hooks/watch_questlog.ts
930
+ async function watchQuestlog(db, options) {
931
+ const interval = options.interval ?? 60;
932
+ if (!Number.isFinite(interval) || interval < 5)
933
+ throw new Error("Interval must be at least 5 seconds");
934
+ while (!options.signal.aborted) {
935
+ options.onTick?.(await tickQuestlog(db, options));
936
+ if (options.signal.aborted) break;
937
+ await new Promise((resolve) => {
938
+ const finish = () => {
939
+ clearTimeout(timer);
940
+ options.signal.removeEventListener("abort", finish);
941
+ resolve();
942
+ };
943
+ const timer = setTimeout(finish, interval * 1e3);
944
+ options.signal.addEventListener("abort", finish, { once: true });
945
+ });
946
+ }
947
+ }
948
+
949
+ export { addHook, deriveQuestMarkerId, deriveQuestState, getQuestOrThrow, getRepeatableQuestOrThrow, hookLabel, listDueRepeatableQuestAnchors, mapQuestRewardRow, normalizeTagName, parseRRule, queryQuestDetailRecords, questlogVersion, removeHook, resolveNow, spawnDueRepeatableQuests, testHook, tickQuestlog, watchQuestlog, withTransaction };