@ghostpaw/questlog 0.9.1 → 0.10.1

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/dist/mcp.js CHANGED
@@ -1,9 +1,33 @@
1
1
  import { mkdirSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { basename, extname, dirname, join } from 'node:path';
3
+ import { randomBytes, createHmac } from 'node:crypto';
3
4
  import { homedir } from 'node:os';
4
5
 
5
6
  // src/surface/open_questlog_db.ts
6
7
 
8
+ // src/hooks/init_hook_tables.ts
9
+ function initHookTables(db) {
10
+ db.exec(`
11
+ CREATE TABLE IF NOT EXISTS hook_targets (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, secret TEXT NOT NULL,
13
+ tag TEXT, events TEXT NOT NULL, min_gap INTEGER NOT NULL, created_at INTEGER NOT NULL,
14
+ last_wake INTEGER, lease_until INTEGER, claim TEXT);
15
+ CREATE TABLE IF NOT EXISTS hook_events (
16
+ id TEXT PRIMARY KEY, occurred_at INTEGER NOT NULL, event TEXT NOT NULL);
17
+ CREATE TABLE IF NOT EXISTS hook_assignments (
18
+ target_id INTEGER NOT NULL REFERENCES hook_targets(id) ON DELETE CASCADE,
19
+ event_id TEXT NOT NULL REFERENCES hook_events(id), delivery_id TEXT,
20
+ PRIMARY KEY(target_id, event_id));
21
+ CREATE TABLE IF NOT EXISTS hook_deliveries (
22
+ id TEXT PRIMARY KEY, target_id INTEGER NOT NULL REFERENCES hook_targets(id) ON DELETE CASCADE,
23
+ events TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
24
+ last_attempt_at INTEGER, http_status INTEGER, error_class TEXT,
25
+ next_attempt_at INTEGER NOT NULL);
26
+ CREATE INDEX IF NOT EXISTS idx_hook_deliveries_target ON hook_deliveries(target_id, status, next_attempt_at);
27
+ CREATE TABLE IF NOT EXISTS hook_settings (id INTEGER PRIMARY KEY CHECK(id = 1), label TEXT NOT NULL);
28
+ `);
29
+ }
30
+
7
31
  // src/questlines/init_questline_tables.ts
8
32
  function initQuestlineTables(db) {
9
33
  db.exec(`
@@ -352,6 +376,7 @@ function initQuestlogTables(db) {
352
376
  initTagTables(db);
353
377
  initQuestSearch(db);
354
378
  initRumorSearch(db);
379
+ initHookTables(db);
355
380
  }
356
381
 
357
382
  // src/surface/open_questlog_db.ts
@@ -384,7 +409,7 @@ async function openQuestlogDb(path) {
384
409
  }
385
410
 
386
411
  // src/version.ts
387
- var questlogVersion = "0.9.1";
412
+ var questlogVersion = "0.10.1";
388
413
 
389
414
  // src/surface/questlog_mcp_instructions.ts
390
415
  var questlogMcpInstructions = "Questlog manages local tasks, calendar commitments, and recurring work. Search before creating work, inspect before changing it, and keep uncertain work as rumors. Use unlock prerequisites to sequence work. run_quest refuses blocked starts unless force is true. Review commitments and resolve work with an honest outcome.";
@@ -630,8 +655,154 @@ function translateToolError(error, options = {}) {
630
655
  );
631
656
  }
632
657
 
658
+ // src/surfaces/schema_actions.ts
659
+ var requirements = /* @__PURE__ */ new WeakMap();
660
+ function registerSchemaActions(schema, actions) {
661
+ requirements.set(schema, actions);
662
+ }
663
+ function requiredForAction(schema, action) {
664
+ return requirements.get(schema)?.[String(action)] ?? [];
665
+ }
666
+
667
+ // src/surfaces/schema_examples.ts
668
+ function minimalInput(schema, action, name = "") {
669
+ const constant = "const" in schema ? schema.const : void 0;
670
+ if (constant !== void 0) return constant;
671
+ if (schema.enum)
672
+ return action !== void 0 && schema.enum.includes(action) ? action : schema.enum[0];
673
+ const branch = schema.oneOf?.[0];
674
+ if (schema.type === "object" || schema.properties) {
675
+ action ??= schema.properties?.action?.enum?.[0];
676
+ const properties = { ...schema.properties, ...branch?.properties };
677
+ return Object.fromEntries(
678
+ [
679
+ .../* @__PURE__ */ new Set([
680
+ ...schema.required ?? [],
681
+ ...requiredForAction(schema, action),
682
+ ...schema.properties?.outcome && ["finish", "abandon", "abandon_and_spawn_followups"].includes(String(action)) ? ["outcome"] : [],
683
+ ...branch?.required ?? []
684
+ ])
685
+ ].map((key) => [
686
+ key,
687
+ minimalInput(properties[key] ?? {}, key === "action" ? action : void 0, key)
688
+ ])
689
+ );
690
+ }
691
+ if (branch) return minimalInput(branch, action, name);
692
+ if (schema.type === "array") return [minimalInput(schema.items ?? {}, void 0, name)];
693
+ if (schema.type === "integer" || schema.type === "number")
694
+ return name === "anchorAt" ? 18e11 : name === "toQuestId" ? 2 : 1;
695
+ if (schema.type === "boolean") return false;
696
+ if (schema.type === "null") return null;
697
+ if (name === "rrule") return "FREQ=WEEKLY";
698
+ if (name === "title") return "Review release notes";
699
+ if (name === "objective") return "Check changes against the release";
700
+ return "release";
701
+ }
702
+
703
+ // src/surfaces/validate_input.ts
704
+ function validateInput(schema, input, path = "$") {
705
+ const errors = [];
706
+ const matchesType = (type) => {
707
+ switch (type) {
708
+ case "object":
709
+ return typeof input === "object" && input !== null && !Array.isArray(input);
710
+ case "array":
711
+ return Array.isArray(input);
712
+ case "integer":
713
+ return typeof input === "number" && Number.isInteger(input);
714
+ case "number":
715
+ return typeof input === "number" && Number.isFinite(input);
716
+ case "null":
717
+ return input === null;
718
+ default:
719
+ return typeof input === type;
720
+ }
721
+ };
722
+ if (schema.type && !(Array.isArray(schema.type) ? schema.type : [schema.type]).some(matchesType)) {
723
+ return [`${path}: expected ${String(schema.type)}`];
724
+ }
725
+ if (schema.enum && !schema.enum.some((value) => JSON.stringify(value) === JSON.stringify(input)))
726
+ errors.push(`${path}: expected one of ${JSON.stringify(schema.enum)}`);
727
+ const constant = "const" in schema ? schema.const : void 0;
728
+ if (constant !== void 0 && constant !== input)
729
+ errors.push(`${path}: expected ${JSON.stringify(constant)}`);
730
+ if (schema.oneOf) {
731
+ const branches = schema.oneOf.map((branch) => validateInput(branch, input, path));
732
+ if (!branches.some((errors2) => errors2.length === 0)) {
733
+ errors.push(
734
+ ...branches.sort((a, b) => a.length - b.length)[0] ?? [
735
+ `${path}: must match at least one schema branch`
736
+ ]
737
+ );
738
+ }
739
+ }
740
+ if (typeof input === "object" && input !== null && !Array.isArray(input)) {
741
+ const record = input;
742
+ for (const name of [...schema.required ?? [], ...requiredForAction(schema, record.action)])
743
+ if (!Object.hasOwn(record, name)) errors.push(`${path}.${name}: required`);
744
+ for (const [name, property] of Object.entries(schema.properties ?? {}))
745
+ if (Object.hasOwn(record, name))
746
+ errors.push(...validateInput(property, record[name], `${path}.${name}`));
747
+ }
748
+ if (Array.isArray(input) && schema.items)
749
+ input.forEach((value, index) => {
750
+ errors.push(...validateInput(schema.items, value, `${path}[${index}]`));
751
+ });
752
+ return errors;
753
+ }
754
+
755
+ // src/tools/action_requirements.ts
756
+ var actionRequirements = {
757
+ manage_hooks: { remove: ["id"] },
758
+ shape_work: {
759
+ create_quest: ["quest"],
760
+ create_questline: ["questline"],
761
+ attach_quest_to_questline: ["questId", "questlineId"],
762
+ detach_quest_from_questline: ["questId"],
763
+ dismiss_rumor: ["rumorId"],
764
+ reopen_rumor: ["rumorId"],
765
+ settle_rumor: ["rumorId"],
766
+ update_rumor: ["rumorId"]
767
+ },
768
+ manage_repeatable: {
769
+ create: ["repeatableQuest"],
770
+ archive: ["repeatableQuestId"],
771
+ update: ["repeatableQuestId"],
772
+ spawn_due: ["now"]
773
+ },
774
+ organize_work: {
775
+ add_unlock: ["fromQuestId", "toQuestId"],
776
+ remove_unlock: ["fromQuestId", "toQuestId"],
777
+ replace_unlocks: ["fromQuestIds", "toQuestId"],
778
+ archive_questline: ["questlineId"],
779
+ set_questline_fields: ["questlineId"]
780
+ },
781
+ plan_quest: { revise_objective: ["objective"] },
782
+ run_quest: {
783
+ abandon_and_spawn_followups: ["followups"],
784
+ log_effort: ["effortSeconds"]
785
+ },
786
+ reward_work: { add: ["reward"], update: ["reward"], replace_repeatable_template: ["rewards"] }
787
+ };
788
+
633
789
  // src/tools/tool_metadata.ts
634
790
  function defineQuestlogTool(definition) {
791
+ const requirements2 = actionRequirements[definition.name];
792
+ if (requirements2) registerSchemaActions(definition.inputSchema, requirements2);
793
+ const handler = definition.handler;
794
+ definition.handler = (db, input) => {
795
+ const errors = validateInput(definition.inputSchema, input);
796
+ if (errors.length) {
797
+ const action = input?.action;
798
+ const example = minimalInput(definition.inputSchema, action);
799
+ const message = errors.join("; ");
800
+ return toolFailure("protocol", "invalid_input", message, message, {
801
+ recovery: `${message}; pass ${JSON.stringify(example)}`
802
+ });
803
+ }
804
+ return handler(db, input);
805
+ };
635
806
  return definition;
636
807
  }
637
808
  function stringSchema(description, options = {}) {
@@ -875,6 +1046,27 @@ var captureRumorTool = defineQuestlogTool({
875
1046
  handler: captureRumorToolHandler
876
1047
  });
877
1048
 
1049
+ // src/hooks/list_deliveries.ts
1050
+ function listDeliveries(db, options = {}) {
1051
+ return db.prepare(
1052
+ `SELECT * FROM hook_deliveries ${options.includeDelivered ? "" : "WHERE status <> 'delivered'"} ORDER BY target_id, next_attempt_at, id`
1053
+ ).all().map(
1054
+ (row) => ({
1055
+ id: String(row.id),
1056
+ targetId: Number(row.target_id),
1057
+ events: JSON.parse(String(row.events)),
1058
+ status: row.status,
1059
+ attempts: Number(row.attempts),
1060
+ lastAttemptAt: row.last_attempt_at == null ? null : Number(row.last_attempt_at),
1061
+ httpStatus: row.http_status == null ? null : Number(row.http_status),
1062
+ errorClass: row.error_class == null ? null : String(row.error_class),
1063
+ nextAttemptAt: Number(row.next_attempt_at)
1064
+ })
1065
+ ).filter(
1066
+ (d) => options.questId === void 0 || d.events.some((e) => e.questId === options.questId)
1067
+ );
1068
+ }
1069
+
878
1070
  // src/questlines/map_questline_row.ts
879
1071
  function mapQuestlineRow(row) {
880
1072
  return {
@@ -1352,7 +1544,8 @@ function inspectQuestlogItemToolHandler(db, input) {
1352
1544
  detail: detailLevel === "full" ? detail : toQuestCompactDetail(detail)
1353
1545
  },
1354
1546
  related,
1355
- detailLevel
1547
+ detailLevel,
1548
+ deliveries: listDeliveries(db, { questId: detail.id })
1356
1549
  },
1357
1550
  { entities }
1358
1551
  );
@@ -1457,13 +1650,39 @@ var inspectQuestlogItemTool = defineQuestlogTool({
1457
1650
  ),
1458
1651
  handler: inspectQuestlogItemToolHandler
1459
1652
  });
1460
-
1461
- // src/assert_non_negative_seconds.ts
1462
- function assertNonNegativeSeconds(value, label) {
1463
- if (value != null && value < 0) {
1464
- throw new Error(`${label} must not be negative.`);
1465
- }
1466
- return value;
1653
+ function hookLabel(db, name) {
1654
+ const saved = db.prepare("SELECT label FROM hook_settings WHERE id = 1").get();
1655
+ if (saved) return String(saved.label);
1656
+ const path = String(
1657
+ db.prepare("PRAGMA database_list").all().find((row) => row.name === "main")?.file ?? ""
1658
+ );
1659
+ return path ? basename(path, extname(path)) : "questlog";
1660
+ }
1661
+
1662
+ // src/hooks/list_hooks.ts
1663
+ function listHooks(db) {
1664
+ return db.prepare(`SELECT id, url, tag, events, min_gap, created_at, last_wake,
1665
+ (SELECT COUNT(*) FROM hook_assignments a LEFT JOIN hook_deliveries d ON d.id = a.delivery_id WHERE a.target_id = t.id AND (a.delivery_id IS NULL OR d.status = 'pending')) AS pending,
1666
+ (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
1667
+ FROM hook_targets t ORDER BY id`).all().map((row) => {
1668
+ const url = new URL(String(row.url));
1669
+ url.username = "";
1670
+ url.password = "";
1671
+ url.search = "";
1672
+ url.hash = "";
1673
+ return {
1674
+ id: Number(row.id),
1675
+ scope: row.tag == null ? "database" : "tag",
1676
+ url: url.href,
1677
+ tag: row.tag == null ? null : String(row.tag),
1678
+ events: JSON.parse(String(row.events)),
1679
+ minGap: Number(row.min_gap),
1680
+ createdAt: Number(row.created_at),
1681
+ lastWake: row.last_wake == null ? null : Number(row.last_wake),
1682
+ pending: Number(row.pending),
1683
+ failed: Number(row.failed)
1684
+ };
1685
+ });
1467
1686
  }
1468
1687
 
1469
1688
  // src/with_transaction.ts
@@ -1501,6 +1720,135 @@ function withTransaction(db, fn) {
1501
1720
  }
1502
1721
  }
1503
1722
 
1723
+ // src/hooks/remove_hook.ts
1724
+ function removeHook(db, id) {
1725
+ withTransaction(db, () => {
1726
+ db.prepare("DELETE FROM hook_assignments WHERE target_id = ?").run(id);
1727
+ db.prepare("DELETE FROM hook_deliveries WHERE target_id = ?").run(id);
1728
+ db.prepare("DELETE FROM hook_targets WHERE id = ?").run(id);
1729
+ });
1730
+ }
1731
+ async function sendHookDelivery(db, delivery, target, options) {
1732
+ const now = options.now ?? options.clock?.() ?? Date.now();
1733
+ const timestamp = String(Math.floor(now / 1e3));
1734
+ const body = JSON.stringify({
1735
+ type: "questlog.events",
1736
+ db: hookLabel(db),
1737
+ deliveryId: delivery.id,
1738
+ sentAt: new Date(now).toISOString(),
1739
+ events: delivery.events
1740
+ });
1741
+ const signature = createHmac("sha256", target.secret).update(`${timestamp}.${body}`).digest("hex");
1742
+ let status = null;
1743
+ let errorClass = null;
1744
+ try {
1745
+ const response = await (options.fetch ?? globalThis.fetch)(target.url, {
1746
+ method: "POST",
1747
+ redirect: "error",
1748
+ headers: {
1749
+ "content-type": "application/json",
1750
+ "user-agent": `questlog/${questlogVersion}`,
1751
+ "x-questlog-delivery": delivery.id,
1752
+ "x-questlog-timestamp": timestamp,
1753
+ "x-questlog-signature": `sha256=${signature}`
1754
+ },
1755
+ body,
1756
+ signal: AbortSignal.timeout(Math.max(1, Math.min(options.timeoutMs ?? 5e3, 5e3)))
1757
+ });
1758
+ status = response.status;
1759
+ await response.body?.cancel();
1760
+ } catch (error) {
1761
+ errorClass = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name) ? error.name : "NetworkError";
1762
+ }
1763
+ const delivered = status != null && status >= 200 && status < 300;
1764
+ const completedAt = options.now ?? options.clock?.() ?? Date.now();
1765
+ withTransaction(db, () => {
1766
+ if (!db.prepare("SELECT id FROM hook_targets WHERE id = ? AND claim = ?").get(target.id, target.claim))
1767
+ return;
1768
+ const delay = Math.min(36e5, 3e4 * 2 ** (delivery.attempts - 1));
1769
+ const jitter = Number.parseInt(randomBytes(2).toString("hex"), 16) % 1e3;
1770
+ db.prepare(
1771
+ "UPDATE hook_deliveries SET status = ?, http_status = ?, error_class = ?, next_attempt_at = ? WHERE id = ?"
1772
+ ).run(
1773
+ delivered ? "delivered" : delivery.attempts >= 8 ? "failed" : "pending",
1774
+ status,
1775
+ errorClass,
1776
+ completedAt + delay + jitter,
1777
+ delivery.id
1778
+ );
1779
+ db.prepare(
1780
+ "UPDATE hook_targets SET claim = NULL, lease_until = NULL WHERE id = ? AND claim = ?"
1781
+ ).run(target.id, target.claim);
1782
+ db.prepare(
1783
+ "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)"
1784
+ ).run(target.id, target.id);
1785
+ });
1786
+ return delivered;
1787
+ }
1788
+
1789
+ // src/quests/get_quest_or_throw.ts
1790
+ function getQuestOrThrow(db, questId) {
1791
+ const row = db.prepare("SELECT * FROM quests WHERE id = ? AND deleted_at IS NULL").get(questId);
1792
+ if (!row) {
1793
+ throw new Error(`Quest ${questId} was not found.`);
1794
+ }
1795
+ return mapQuestRow(row);
1796
+ }
1797
+
1798
+ // src/rewards/copy_repeatable_quest_rewards_to_quest.ts
1799
+ function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
1800
+ const effectiveNow = resolveNow(now);
1801
+ const rows = db.prepare(
1802
+ `SELECT kind, name, description, quantity
1803
+ FROM repeatable_quest_rewards
1804
+ WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
1805
+ ).all(repeatableQuestId);
1806
+ for (const row of rows) {
1807
+ db.prepare(
1808
+ `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
1809
+ VALUES (?, ?, ?, ?, ?, ?)`
1810
+ ).run(
1811
+ questId,
1812
+ String(row.kind),
1813
+ String(row.name),
1814
+ row.description == null ? null : String(row.description),
1815
+ row.quantity == null ? null : Number(row.quantity),
1816
+ effectiveNow
1817
+ );
1818
+ }
1819
+ }
1820
+
1821
+ // src/tags/copy_repeatable_quest_tags_to_quest.ts
1822
+ function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
1823
+ const effectiveNow = resolveNow(now);
1824
+ const rows = db.prepare(
1825
+ `SELECT tag_id
1826
+ FROM repeatable_quest_tags
1827
+ WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
1828
+ ).all(repeatableQuestId);
1829
+ for (const row of rows) {
1830
+ db.prepare(
1831
+ `INSERT INTO quest_tags (quest_id, tag_id, created_at)
1832
+ VALUES (?, ?, ?)`
1833
+ ).run(questId, Number(row.tag_id), effectiveNow);
1834
+ }
1835
+ }
1836
+
1837
+ // src/repeatable_quests/advance_anchor.ts
1838
+ var WEEK_MS = 7 * 864e5;
1839
+ function advanceAnchor(anchorAt, freq, interval) {
1840
+ const date = new Date(anchorAt);
1841
+ if (freq === "MONTHLY") {
1842
+ date.setUTCMonth(date.getUTCMonth() + interval);
1843
+ return date.getTime();
1844
+ }
1845
+ if (freq === "YEARLY") {
1846
+ date.setUTCFullYear(date.getUTCFullYear() + interval);
1847
+ return date.getTime();
1848
+ }
1849
+ return anchorAt + interval * WEEK_MS;
1850
+ }
1851
+
1504
1852
  // src/repeatable_quests/parse_rrule.ts
1505
1853
  var WEEKDAY = {
1506
1854
  SU: 0,
@@ -1544,195 +1892,6 @@ function parseRRule(rrule) {
1544
1892
  return { freq, interval, count, until, byDay };
1545
1893
  }
1546
1894
 
1547
- // src/repeatable_quests/update_repeatable_quest.ts
1548
- function updateRepeatableQuest(db, repeatableQuestId, input) {
1549
- return withTransaction(db, () => {
1550
- const current = getRepeatableQuestOrThrow(db, repeatableQuestId);
1551
- const now = resolveNow(input.now);
1552
- const rrule = input.rrule ?? current.rrule;
1553
- parseRRule(rrule);
1554
- db.prepare(
1555
- `UPDATE repeatable_quests
1556
- SET questline_id = ?,
1557
- title = ?,
1558
- objective = ?,
1559
- rrule = ?,
1560
- anchor_at = ?,
1561
- not_before_offset_seconds = ?,
1562
- due_offset_seconds = ?,
1563
- scheduled_start_offset_seconds = ?,
1564
- scheduled_end_offset_seconds = ?,
1565
- all_day = ?,
1566
- estimate_seconds = ?,
1567
- archived_at = ?,
1568
- updated_at = ?
1569
- WHERE id = ? AND deleted_at IS NULL`
1570
- ).run(
1571
- input.questlineId === void 0 ? current.questlineId : input.questlineId,
1572
- input.title == null ? current.title : assertNonEmpty(input.title, "Repeatable quest title"),
1573
- input.objective == null ? current.objective : assertNonEmpty(input.objective, "Repeatable quest objective"),
1574
- rrule,
1575
- input.anchorAt ?? current.anchorAt,
1576
- input.notBeforeOffsetSeconds === void 0 ? current.notBeforeOffsetSeconds : assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
1577
- input.dueOffsetSeconds === void 0 ? current.dueOffsetSeconds : assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
1578
- input.scheduledStartOffsetSeconds === void 0 ? current.scheduledStartOffsetSeconds : input.scheduledStartOffsetSeconds,
1579
- input.scheduledEndOffsetSeconds === void 0 ? current.scheduledEndOffsetSeconds : input.scheduledEndOffsetSeconds,
1580
- input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
1581
- input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
1582
- input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
1583
- now,
1584
- repeatableQuestId
1585
- );
1586
- return getRepeatableQuestOrThrow(db, repeatableQuestId);
1587
- });
1588
- }
1589
-
1590
- // src/repeatable_quests/archive_repeatable_quest.ts
1591
- function archiveRepeatableQuest(db, repeatableQuestId, archivedAt) {
1592
- return updateRepeatableQuest(db, repeatableQuestId, { archivedAt: resolveNow(archivedAt) });
1593
- }
1594
-
1595
- // src/assert_active_row_exists.ts
1596
- function assertActiveRowExists(db, table, id, label) {
1597
- const row = db.prepare(`SELECT id FROM ${table} WHERE id = ? AND deleted_at IS NULL`).get(id);
1598
- if (!row) {
1599
- throw new Error(`${label} ${id} was not found.`);
1600
- }
1601
- }
1602
-
1603
- // src/assert_non_negative_number.ts
1604
- function assertNonNegativeNumber(value, label) {
1605
- if (value != null && value < 0) {
1606
- throw new Error(`${label} must not be negative.`);
1607
- }
1608
- return value;
1609
- }
1610
-
1611
- // src/rewards/replace_active_repeatable_quest_rewards.ts
1612
- function replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now) {
1613
- const effectiveNow = resolveNow(now);
1614
- db.prepare(
1615
- "UPDATE repeatable_quest_rewards SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
1616
- ).run(effectiveNow, repeatableQuestId);
1617
- for (const reward of rewards) {
1618
- db.prepare(
1619
- `INSERT INTO repeatable_quest_rewards (
1620
- repeatable_quest_id,
1621
- kind,
1622
- name,
1623
- description,
1624
- quantity,
1625
- created_at
1626
- ) VALUES (?, ?, ?, ?, ?, ?)`
1627
- ).run(
1628
- repeatableQuestId,
1629
- assertNonEmpty(reward.kind, "Reward kind"),
1630
- assertNonEmpty(reward.name, "Reward name"),
1631
- reward.description?.trim() || null,
1632
- assertNonNegativeNumber(reward.quantity, "Reward quantity") ?? null,
1633
- effectiveNow
1634
- );
1635
- }
1636
- }
1637
-
1638
- // src/tags/replace_active_repeatable_quest_tags.ts
1639
- function getOrCreateTagId(db, name, now) {
1640
- const normalizedName = normalizeTagName(name);
1641
- if (!normalizedName) {
1642
- throw new Error("Tag name must not be empty.");
1643
- }
1644
- const existing = db.prepare("SELECT id FROM tags WHERE normalized_name = ? AND deleted_at IS NULL").get(normalizedName);
1645
- if (existing) {
1646
- return Number(existing.id);
1647
- }
1648
- const created = db.prepare(
1649
- `INSERT INTO tags (name, normalized_name, created_at)
1650
- VALUES (?, ?, ?)`
1651
- ).run(name.trim(), normalizedName, now);
1652
- return Number(created.lastInsertRowid);
1653
- }
1654
- function replaceActiveRepeatableQuestTags(db, repeatableQuestId, tagNames, now) {
1655
- const effectiveNow = resolveNow(now);
1656
- db.prepare(
1657
- "UPDATE repeatable_quest_tags SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
1658
- ).run(effectiveNow, repeatableQuestId);
1659
- const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
1660
- for (const name of uniqueNames) {
1661
- const tagId = getOrCreateTagId(db, name, effectiveNow);
1662
- db.prepare(
1663
- `INSERT INTO repeatable_quest_tags (repeatable_quest_id, tag_id, created_at)
1664
- VALUES (?, ?, ?)`
1665
- ).run(repeatableQuestId, tagId, effectiveNow);
1666
- }
1667
- }
1668
-
1669
- // src/repeatable_quests/create_repeatable_quest.ts
1670
- function createRepeatableQuest(db, input) {
1671
- return withTransaction(db, () => {
1672
- const now = resolveNow(input.now);
1673
- const title = assertNonEmpty(input.title, "Repeatable quest title");
1674
- const objective = assertNonEmpty(input.objective, "Repeatable quest objective");
1675
- parseRRule(input.rrule);
1676
- if (input.questlineId != null) {
1677
- assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
1678
- }
1679
- const created = db.prepare(
1680
- `INSERT INTO repeatable_quests (
1681
- questline_id,
1682
- title,
1683
- objective,
1684
- rrule,
1685
- anchor_at,
1686
- not_before_offset_seconds,
1687
- due_offset_seconds,
1688
- scheduled_start_offset_seconds,
1689
- scheduled_end_offset_seconds,
1690
- all_day,
1691
- estimate_seconds,
1692
- created_at,
1693
- updated_at
1694
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1695
- ).run(
1696
- input.questlineId ?? null,
1697
- title,
1698
- objective,
1699
- input.rrule,
1700
- input.anchorAt,
1701
- assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
1702
- assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
1703
- input.scheduledStartOffsetSeconds ?? null,
1704
- input.scheduledEndOffsetSeconds ?? null,
1705
- input.allDay ? 1 : 0,
1706
- assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
1707
- now,
1708
- now
1709
- );
1710
- const repeatableQuestId = Number(created.lastInsertRowid);
1711
- if (input.tags && input.tags.length > 0) {
1712
- replaceActiveRepeatableQuestTags(db, repeatableQuestId, input.tags, now);
1713
- }
1714
- if (input.rewards && input.rewards.length > 0) {
1715
- replaceActiveRepeatableQuestRewards(db, repeatableQuestId, input.rewards, now);
1716
- }
1717
- return getRepeatableQuestOrThrow(db, repeatableQuestId);
1718
- });
1719
- }
1720
-
1721
- // src/repeatable_quests/advance_anchor.ts
1722
- var WEEK_MS = 7 * 864e5;
1723
- function advanceAnchor(anchorAt, freq, interval) {
1724
- const date = new Date(anchorAt);
1725
- if (freq === "MONTHLY") {
1726
- date.setUTCMonth(date.getUTCMonth() + interval);
1727
- return date.getTime();
1728
- }
1729
- if (freq === "YEARLY") {
1730
- date.setUTCFullYear(date.getUTCFullYear() + interval);
1731
- return date.getTime();
1732
- }
1733
- return anchorAt + interval * WEEK_MS;
1734
- }
1735
-
1736
1895
  // src/repeatable_quests/start_of_utc_day.ts
1737
1896
  function startOfUtcDay(timestamp) {
1738
1897
  const date = new Date(timestamp);
@@ -1850,115 +2009,460 @@ function listDueRepeatableQuestAnchors(db, now) {
1850
2009
  );
1851
2010
  }
1852
2011
 
1853
- // src/repeatable_quests/soft_delete_repeatable_quest.ts
1854
- function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
1855
- const now = resolveNow(deletedAt);
1856
- getRepeatableQuestOrThrow(db, repeatableQuestId);
1857
- db.prepare(
1858
- `UPDATE repeatable_quests
1859
- SET deleted_at = ?, updated_at = ?
1860
- WHERE id = ? AND deleted_at IS NULL`
1861
- ).run(now, now, repeatableQuestId);
2012
+ // src/repeatable_quests/spawn_due_repeatable_quests.ts
2013
+ function spawnDueRepeatableQuests(db, now) {
2014
+ return withTransaction(db, () => {
2015
+ const anchors = listDueRepeatableQuestAnchors(db, now);
2016
+ const quests = [];
2017
+ for (const anchor of anchors) {
2018
+ const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
2019
+ const created = db.prepare(
2020
+ `INSERT INTO quests (
2021
+ questline_id,
2022
+ source_rumor_id,
2023
+ spawned_by_quest_id,
2024
+ spawned_from_repeatable_id,
2025
+ spawned_for_at,
2026
+ title,
2027
+ objective,
2028
+ created_at,
2029
+ updated_at,
2030
+ effort_seconds,
2031
+ estimate_seconds,
2032
+ not_before_at,
2033
+ due_at,
2034
+ scheduled_start_at,
2035
+ scheduled_end_at,
2036
+ all_day
2037
+ ) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
2038
+ ).run(
2039
+ repeatable.questlineId ?? null,
2040
+ repeatable.id,
2041
+ anchor.anchorAt,
2042
+ repeatable.title,
2043
+ repeatable.objective,
2044
+ now,
2045
+ now,
2046
+ repeatable.estimateSeconds ?? null,
2047
+ repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
2048
+ repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
2049
+ repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
2050
+ repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
2051
+ repeatable.allDay ? 1 : 0
2052
+ );
2053
+ const questId = Number(created.lastInsertRowid);
2054
+ copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
2055
+ copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
2056
+ quests.push(getQuestOrThrow(db, questId));
2057
+ }
2058
+ return quests;
2059
+ });
2060
+ }
2061
+
2062
+ // src/hooks/detect_hook_events.ts
2063
+ function detectHookEvents(db, now) {
2064
+ const events = [];
2065
+ const add = (type, at, ids) => {
2066
+ events.push({
2067
+ id: `e_${type}_${ids.questId ?? ids.rumorId}_${ids.repeatableId ?? ""}_${at}`,
2068
+ type,
2069
+ ...ids,
2070
+ occurredAt: new Date(at).toISOString()
2071
+ });
2072
+ };
2073
+ for (const { detail: q } of queryQuestDetailRecords(db, {}, now)) {
2074
+ if (q.resolvedAt == null && q.effectiveDueAt != null && q.effectiveDueAt <= now) {
2075
+ add("quest.due", q.effectiveDueAt, { questId: q.id });
2076
+ if (q.effectiveDueAt < now) add("quest.overdue", q.effectiveDueAt, { questId: q.id });
2077
+ }
2078
+ if (q.available) {
2079
+ const prerequisites = db.prepare(
2080
+ `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`
2081
+ ).all(q.id);
2082
+ if (prerequisites.some((p) => Number(p.resolved_at) >= Number(p.created_at))) {
2083
+ const at = Math.max(q.notBeforeAt ?? 0, ...prerequisites.map((p) => Number(p.resolved_at)));
2084
+ if (at <= now) add("quest.unblocked", at, { questId: q.id });
2085
+ }
2086
+ }
2087
+ if (q.spawnedFromRepeatableId != null && q.createdAt <= now)
2088
+ add("repeatable.spawned", q.createdAt, {
2089
+ questId: q.id,
2090
+ repeatableId: q.spawnedFromRepeatableId
2091
+ });
2092
+ }
2093
+ for (const row of db.prepare("SELECT id, created_at FROM rumors WHERE deleted_at IS NULL AND created_at <= ?").all(now))
2094
+ add("rumor.captured", Number(row.created_at), { rumorId: Number(row.id) });
2095
+ return events.sort(
2096
+ (a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id)
2097
+ );
2098
+ }
2099
+
2100
+ // src/hooks/tick_questlog.ts
2101
+ async function tickQuestlog(db, options = {}) {
2102
+ const now = options.now ?? options.clock?.() ?? Date.now();
2103
+ const result = {
2104
+ events: [],
2105
+ batches: [],
2106
+ sent: 0,
2107
+ queued: 0,
2108
+ retrying: 0,
2109
+ failed: 0
2110
+ };
2111
+ const claims = [];
2112
+ const collect = () => {
2113
+ spawnDueRepeatableQuests(db, now);
2114
+ const detected = detectHookEvents(db, now);
2115
+ result.events = detected.filter(
2116
+ (e) => !db.prepare("SELECT id FROM hook_events WHERE id = ?").get(e.id)
2117
+ );
2118
+ for (const event of result.events)
2119
+ db.prepare("INSERT INTO hook_events(id, occurred_at, event) VALUES (?, ?, ?)").run(
2120
+ event.id,
2121
+ Date.parse(event.occurredAt),
2122
+ JSON.stringify(event)
2123
+ );
2124
+ for (const target of listHooks(db)) {
2125
+ const events = db.prepare(
2126
+ "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"
2127
+ ).all(target.createdAt, target.id).map((row2) => JSON.parse(String(row2.event))).filter((event) => {
2128
+ if (!target.events.includes(event.type)) return false;
2129
+ if (target.tag == null) return true;
2130
+ return event.questId !== void 0 && db.prepare(
2131
+ "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 = ?"
2132
+ ).get(event.questId, normalizeTagName(target.tag)) !== void 0;
2133
+ });
2134
+ for (const event of events)
2135
+ db.prepare("INSERT INTO hook_assignments(target_id, event_id) VALUES (?, ?)").run(
2136
+ target.id,
2137
+ event.id
2138
+ );
2139
+ const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(target.id);
2140
+ if (!row || row.lease_until != null && Number(row.lease_until) > now) continue;
2141
+ let pending = listDeliveries(db).find(
2142
+ (d) => d.targetId === target.id && d.status === "pending"
2143
+ );
2144
+ const queued = db.prepare(
2145
+ "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"
2146
+ ).all(target.id).map((r) => JSON.parse(String(r.event)));
2147
+ if (!pending && queued.length) {
2148
+ const id = `d_${randomBytes(16).toString("hex")}`;
2149
+ db.prepare(
2150
+ "INSERT INTO hook_deliveries(id, target_id, events, status, next_attempt_at) VALUES (?, ?, ?, 'pending', ?)"
2151
+ ).run(
2152
+ id,
2153
+ target.id,
2154
+ JSON.stringify(queued),
2155
+ Math.max(now, (target.lastWake ?? -Infinity) + target.minGap * 1e3)
2156
+ );
2157
+ db.prepare(
2158
+ "UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
2159
+ ).run(id, target.id);
2160
+ pending = listDeliveries(db).find((d) => d.id === id);
2161
+ }
2162
+ if (!pending) continue;
2163
+ if (pending.attempts === 0 && queued.length && !queued.every((e) => pending?.events.some((p) => p.id === e.id))) {
2164
+ pending.events.push(...queued);
2165
+ db.prepare("UPDATE hook_deliveries SET events = ? WHERE id = ?").run(
2166
+ JSON.stringify(pending.events),
2167
+ pending.id
2168
+ );
2169
+ db.prepare(
2170
+ "UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
2171
+ ).run(pending.id, target.id);
2172
+ }
2173
+ if (pending.nextAttemptAt > now || target.lastWake != null && target.lastWake + target.minGap * 1e3 > now)
2174
+ continue;
2175
+ if (pending.attempts >= 8) {
2176
+ db.prepare("UPDATE hook_deliveries SET status = 'failed' WHERE id = ?").run(pending.id);
2177
+ continue;
2178
+ }
2179
+ const claim = randomBytes(16).toString("hex");
2180
+ db.prepare(
2181
+ "UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
2182
+ ).run(claim, now + 12e4, now, target.id);
2183
+ db.prepare(
2184
+ "UPDATE hook_deliveries SET attempts = attempts + 1, last_attempt_at = ? WHERE id = ?"
2185
+ ).run(now, pending.id);
2186
+ pending.attempts++;
2187
+ pending.lastAttemptAt = now;
2188
+ result.batches.push({ targetId: target.id, events: pending.events });
2189
+ claims.push({
2190
+ delivery: pending,
2191
+ target: { id: target.id, url: String(row.url), secret: String(row.secret), claim }
2192
+ });
2193
+ }
2194
+ };
2195
+ if (options.dryRun) {
2196
+ const rollback = new Error("preview rollback");
2197
+ try {
2198
+ withTransaction(db, () => {
2199
+ collect();
2200
+ throw rollback;
2201
+ });
2202
+ } catch (error) {
2203
+ if (error !== rollback) throw error;
2204
+ }
2205
+ } else withTransaction(db, collect);
2206
+ if (!options.dryRun) {
2207
+ const sent = await Promise.all(
2208
+ claims.map((claim) => sendHookDelivery(db, claim.delivery, claim.target, options))
2209
+ );
2210
+ result.sent = sent.filter(Boolean).length;
2211
+ }
2212
+ const deliveries = listDeliveries(db);
2213
+ result.queued = deliveries.filter((d) => d.status === "pending" && d.attempts === 0).length + Number(
2214
+ db.prepare(
2215
+ "SELECT COUNT(DISTINCT target_id) AS n FROM hook_assignments WHERE delivery_id IS NULL"
2216
+ ).get()?.n ?? 0
2217
+ );
2218
+ result.retrying = deliveries.filter((d) => d.status === "pending" && d.attempts > 0).length;
2219
+ result.failed = deliveries.filter((d) => d.status === "failed").length;
2220
+ return result;
2221
+ }
2222
+
2223
+ // src/tools/manage_hooks_tool.ts
2224
+ var manageHooksTool = defineQuestlogTool({
2225
+ name: "manage_hooks",
2226
+ description: "List or remove outbound webhook targets, run a tick, or inspect pending and failed deliveries.",
2227
+ whenToUse: "Use to operate configured outbound webhooks. Add and test targets with the CLI.",
2228
+ whenNotToUse: "Use quest tools to change work itself.",
2229
+ sideEffects: "writes_state",
2230
+ readOnly: false,
2231
+ supportsClarification: false,
2232
+ targetKinds: ["hook"],
2233
+ inputDescriptions: {
2234
+ action: "Operation to perform.",
2235
+ id: "Target id for removal.",
2236
+ dry_run: "Preview a tick without recording or sending."
2237
+ },
2238
+ outputDescription: "Returns redacted targets, delivery metadata, or a tick summary. Never returns signing secrets.",
2239
+ inputSchema: objectSchema(
2240
+ {
2241
+ action: enumSchema("Operation.", ["list", "remove", "tick", "deliveries"]),
2242
+ id: integerSchema("Target id."),
2243
+ dry_run: booleanSchema("Preview only.")
2244
+ },
2245
+ ["action"]
2246
+ ),
2247
+ handler: async (db, input) => {
2248
+ switch (input.action) {
2249
+ case "list":
2250
+ return toolSuccess("Webhook targets.", listHooks(db));
2251
+ case "deliveries":
2252
+ return toolSuccess("Pending and failed deliveries.", listDeliveries(db));
2253
+ case "remove":
2254
+ if (!Number.isSafeInteger(input.id) || Number(input.id) < 1)
2255
+ return toolFailure(
2256
+ "protocol",
2257
+ "invalid_input",
2258
+ "Target id required.",
2259
+ "Provide a positive target id.",
2260
+ { recovery: 'Pass {"action":"remove","id":1}' }
2261
+ );
2262
+ removeHook(db, Number(input.id));
2263
+ return toolSuccess("Webhook target removed.", {});
2264
+ case "tick":
2265
+ return toolSuccess(
2266
+ "Webhook tick completed.",
2267
+ await tickQuestlog(db, { dryRun: input.dry_run ?? false })
2268
+ );
2269
+ }
2270
+ }
2271
+ });
2272
+
2273
+ // src/assert_non_negative_seconds.ts
2274
+ function assertNonNegativeSeconds(value, label) {
2275
+ if (value != null && value < 0) {
2276
+ throw new Error(`${label} must not be negative.`);
2277
+ }
2278
+ return value;
2279
+ }
2280
+
2281
+ // src/repeatable_quests/update_repeatable_quest.ts
2282
+ function updateRepeatableQuest(db, repeatableQuestId, input) {
2283
+ return withTransaction(db, () => {
2284
+ const current = getRepeatableQuestOrThrow(db, repeatableQuestId);
2285
+ const now = resolveNow(input.now);
2286
+ const rrule = input.rrule ?? current.rrule;
2287
+ parseRRule(rrule);
2288
+ db.prepare(
2289
+ `UPDATE repeatable_quests
2290
+ SET questline_id = ?,
2291
+ title = ?,
2292
+ objective = ?,
2293
+ rrule = ?,
2294
+ anchor_at = ?,
2295
+ not_before_offset_seconds = ?,
2296
+ due_offset_seconds = ?,
2297
+ scheduled_start_offset_seconds = ?,
2298
+ scheduled_end_offset_seconds = ?,
2299
+ all_day = ?,
2300
+ estimate_seconds = ?,
2301
+ archived_at = ?,
2302
+ updated_at = ?
2303
+ WHERE id = ? AND deleted_at IS NULL`
2304
+ ).run(
2305
+ input.questlineId === void 0 ? current.questlineId : input.questlineId,
2306
+ input.title == null ? current.title : assertNonEmpty(input.title, "Repeatable quest title"),
2307
+ input.objective == null ? current.objective : assertNonEmpty(input.objective, "Repeatable quest objective"),
2308
+ rrule,
2309
+ input.anchorAt ?? current.anchorAt,
2310
+ input.notBeforeOffsetSeconds === void 0 ? current.notBeforeOffsetSeconds : assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
2311
+ input.dueOffsetSeconds === void 0 ? current.dueOffsetSeconds : assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
2312
+ input.scheduledStartOffsetSeconds === void 0 ? current.scheduledStartOffsetSeconds : input.scheduledStartOffsetSeconds,
2313
+ input.scheduledEndOffsetSeconds === void 0 ? current.scheduledEndOffsetSeconds : input.scheduledEndOffsetSeconds,
2314
+ input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
2315
+ input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
2316
+ input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
2317
+ now,
2318
+ repeatableQuestId
2319
+ );
2320
+ return getRepeatableQuestOrThrow(db, repeatableQuestId);
2321
+ });
2322
+ }
2323
+
2324
+ // src/repeatable_quests/archive_repeatable_quest.ts
2325
+ function archiveRepeatableQuest(db, repeatableQuestId, archivedAt) {
2326
+ return updateRepeatableQuest(db, repeatableQuestId, { archivedAt: resolveNow(archivedAt) });
1862
2327
  }
1863
2328
 
1864
- // src/quests/get_quest_or_throw.ts
1865
- function getQuestOrThrow(db, questId) {
1866
- const row = db.prepare("SELECT * FROM quests WHERE id = ? AND deleted_at IS NULL").get(questId);
2329
+ // src/assert_active_row_exists.ts
2330
+ function assertActiveRowExists(db, table, id, label) {
2331
+ const row = db.prepare(`SELECT id FROM ${table} WHERE id = ? AND deleted_at IS NULL`).get(id);
1867
2332
  if (!row) {
1868
- throw new Error(`Quest ${questId} was not found.`);
2333
+ throw new Error(`${label} ${id} was not found.`);
1869
2334
  }
1870
- return mapQuestRow(row);
1871
2335
  }
1872
2336
 
1873
- // src/rewards/copy_repeatable_quest_rewards_to_quest.ts
1874
- function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
2337
+ // src/assert_non_negative_number.ts
2338
+ function assertNonNegativeNumber(value, label) {
2339
+ if (value != null && value < 0) {
2340
+ throw new Error(`${label} must not be negative.`);
2341
+ }
2342
+ return value;
2343
+ }
2344
+
2345
+ // src/rewards/replace_active_repeatable_quest_rewards.ts
2346
+ function replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now) {
1875
2347
  const effectiveNow = resolveNow(now);
1876
- const rows = db.prepare(
1877
- `SELECT kind, name, description, quantity
1878
- FROM repeatable_quest_rewards
1879
- WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
1880
- ).all(repeatableQuestId);
1881
- for (const row of rows) {
2348
+ db.prepare(
2349
+ "UPDATE repeatable_quest_rewards SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
2350
+ ).run(effectiveNow, repeatableQuestId);
2351
+ for (const reward of rewards) {
1882
2352
  db.prepare(
1883
- `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
1884
- VALUES (?, ?, ?, ?, ?, ?)`
2353
+ `INSERT INTO repeatable_quest_rewards (
2354
+ repeatable_quest_id,
2355
+ kind,
2356
+ name,
2357
+ description,
2358
+ quantity,
2359
+ created_at
2360
+ ) VALUES (?, ?, ?, ?, ?, ?)`
1885
2361
  ).run(
1886
- questId,
1887
- String(row.kind),
1888
- String(row.name),
1889
- row.description == null ? null : String(row.description),
1890
- row.quantity == null ? null : Number(row.quantity),
2362
+ repeatableQuestId,
2363
+ assertNonEmpty(reward.kind, "Reward kind"),
2364
+ assertNonEmpty(reward.name, "Reward name"),
2365
+ reward.description?.trim() || null,
2366
+ assertNonNegativeNumber(reward.quantity, "Reward quantity") ?? null,
1891
2367
  effectiveNow
1892
2368
  );
1893
2369
  }
1894
2370
  }
1895
2371
 
1896
- // src/tags/copy_repeatable_quest_tags_to_quest.ts
1897
- function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
2372
+ // src/tags/replace_active_repeatable_quest_tags.ts
2373
+ function getOrCreateTagId(db, name, now) {
2374
+ const normalizedName = normalizeTagName(name);
2375
+ if (!normalizedName) {
2376
+ throw new Error("Tag name must not be empty.");
2377
+ }
2378
+ const existing = db.prepare("SELECT id FROM tags WHERE normalized_name = ? AND deleted_at IS NULL").get(normalizedName);
2379
+ if (existing) {
2380
+ return Number(existing.id);
2381
+ }
2382
+ const created = db.prepare(
2383
+ `INSERT INTO tags (name, normalized_name, created_at)
2384
+ VALUES (?, ?, ?)`
2385
+ ).run(name.trim(), normalizedName, now);
2386
+ return Number(created.lastInsertRowid);
2387
+ }
2388
+ function replaceActiveRepeatableQuestTags(db, repeatableQuestId, tagNames, now) {
1898
2389
  const effectiveNow = resolveNow(now);
1899
- const rows = db.prepare(
1900
- `SELECT tag_id
1901
- FROM repeatable_quest_tags
1902
- WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
1903
- ).all(repeatableQuestId);
1904
- for (const row of rows) {
2390
+ db.prepare(
2391
+ "UPDATE repeatable_quest_tags SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
2392
+ ).run(effectiveNow, repeatableQuestId);
2393
+ const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
2394
+ for (const name of uniqueNames) {
2395
+ const tagId = getOrCreateTagId(db, name, effectiveNow);
1905
2396
  db.prepare(
1906
- `INSERT INTO quest_tags (quest_id, tag_id, created_at)
2397
+ `INSERT INTO repeatable_quest_tags (repeatable_quest_id, tag_id, created_at)
1907
2398
  VALUES (?, ?, ?)`
1908
- ).run(questId, Number(row.tag_id), effectiveNow);
2399
+ ).run(repeatableQuestId, tagId, effectiveNow);
1909
2400
  }
1910
2401
  }
1911
2402
 
1912
- // src/repeatable_quests/spawn_due_repeatable_quests.ts
1913
- function spawnDueRepeatableQuests(db, now) {
2403
+ // src/repeatable_quests/create_repeatable_quest.ts
2404
+ function createRepeatableQuest(db, input) {
1914
2405
  return withTransaction(db, () => {
1915
- const anchors = listDueRepeatableQuestAnchors(db, now);
1916
- const quests = [];
1917
- for (const anchor of anchors) {
1918
- const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
1919
- const created = db.prepare(
1920
- `INSERT INTO quests (
1921
- questline_id,
1922
- source_rumor_id,
1923
- spawned_by_quest_id,
1924
- spawned_from_repeatable_id,
1925
- spawned_for_at,
1926
- title,
1927
- objective,
1928
- created_at,
1929
- updated_at,
1930
- effort_seconds,
1931
- estimate_seconds,
1932
- not_before_at,
1933
- due_at,
1934
- scheduled_start_at,
1935
- scheduled_end_at,
1936
- all_day
1937
- ) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
1938
- ).run(
1939
- repeatable.questlineId ?? null,
1940
- repeatable.id,
1941
- anchor.anchorAt,
1942
- repeatable.title,
1943
- repeatable.objective,
1944
- now,
1945
- now,
1946
- repeatable.estimateSeconds ?? null,
1947
- repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
1948
- repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
1949
- repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
1950
- repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
1951
- repeatable.allDay ? 1 : 0
1952
- );
1953
- const questId = Number(created.lastInsertRowid);
1954
- copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
1955
- copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
1956
- quests.push(getQuestOrThrow(db, questId));
2406
+ const now = resolveNow(input.now);
2407
+ const title = assertNonEmpty(input.title, "Repeatable quest title");
2408
+ const objective = assertNonEmpty(input.objective, "Repeatable quest objective");
2409
+ parseRRule(input.rrule);
2410
+ if (input.questlineId != null) {
2411
+ assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
1957
2412
  }
1958
- return quests;
2413
+ const created = db.prepare(
2414
+ `INSERT INTO repeatable_quests (
2415
+ questline_id,
2416
+ title,
2417
+ objective,
2418
+ rrule,
2419
+ anchor_at,
2420
+ not_before_offset_seconds,
2421
+ due_offset_seconds,
2422
+ scheduled_start_offset_seconds,
2423
+ scheduled_end_offset_seconds,
2424
+ all_day,
2425
+ estimate_seconds,
2426
+ created_at,
2427
+ updated_at
2428
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2429
+ ).run(
2430
+ input.questlineId ?? null,
2431
+ title,
2432
+ objective,
2433
+ input.rrule,
2434
+ input.anchorAt,
2435
+ assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
2436
+ assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
2437
+ input.scheduledStartOffsetSeconds ?? null,
2438
+ input.scheduledEndOffsetSeconds ?? null,
2439
+ input.allDay ? 1 : 0,
2440
+ assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
2441
+ now,
2442
+ now
2443
+ );
2444
+ const repeatableQuestId = Number(created.lastInsertRowid);
2445
+ if (input.tags && input.tags.length > 0) {
2446
+ replaceActiveRepeatableQuestTags(db, repeatableQuestId, input.tags, now);
2447
+ }
2448
+ if (input.rewards && input.rewards.length > 0) {
2449
+ replaceActiveRepeatableQuestRewards(db, repeatableQuestId, input.rewards, now);
2450
+ }
2451
+ return getRepeatableQuestOrThrow(db, repeatableQuestId);
1959
2452
  });
1960
2453
  }
1961
2454
 
2455
+ // src/repeatable_quests/soft_delete_repeatable_quest.ts
2456
+ function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
2457
+ const now = resolveNow(deletedAt);
2458
+ getRepeatableQuestOrThrow(db, repeatableQuestId);
2459
+ db.prepare(
2460
+ `UPDATE repeatable_quests
2461
+ SET deleted_at = ?, updated_at = ?
2462
+ WHERE id = ? AND deleted_at IS NULL`
2463
+ ).run(now, now, repeatableQuestId);
2464
+ }
2465
+
1962
2466
  // src/tools/manage_repeatable_tool.ts
1963
2467
  function manageRepeatableToolHandler(db, input) {
1964
2468
  try {
@@ -3178,7 +3682,7 @@ function validateReviewInput(input) {
3178
3682
  'Use `filters.dayAt` only with `view: "quests.scheduled_for_day"`.'
3179
3683
  );
3180
3684
  }
3181
- if ((input.view === "rumors" || input.view === "questlines") && hasQuestFilters(input.filters)) {
3685
+ if ((input.view === "rumors" || input.view === "questlines" || input.view === "deliveries") && hasQuestFilters(input.filters)) {
3182
3686
  return toolFailure(
3183
3687
  "protocol",
3184
3688
  "invalid_input",
@@ -3246,6 +3750,15 @@ function reviewQuestlogToolHandler(db, input) {
3246
3750
  if (invalid) {
3247
3751
  return invalid;
3248
3752
  }
3753
+ if (input.view === "deliveries")
3754
+ return toolSuccess("Pending and failed deliveries.", {
3755
+ view: input.view,
3756
+ items: [],
3757
+ deliveries: listDeliveries(db),
3758
+ count: listDeliveries(db).length,
3759
+ appliedFilters: input.filters,
3760
+ evaluatedAt: input.now ?? Date.now()
3761
+ });
3249
3762
  const now = input.now ?? Date.now();
3250
3763
  let items = [];
3251
3764
  switch (input.view) {
@@ -3305,6 +3818,7 @@ var reviewQuestlogTool = defineQuestlogTool({
3305
3818
  inputSchema: objectSchema(
3306
3819
  {
3307
3820
  view: enumSchema("Which operational review surface to return.", [
3821
+ "deliveries",
3308
3822
  "questlines",
3309
3823
  "quests.active",
3310
3824
  "quests.available",
@@ -3581,7 +4095,7 @@ var rewardWorkTool = defineQuestlogTool({
3581
4095
  name: stringSchema("Reward name."),
3582
4096
  now: integerSchema("Optional update timestamp for the update action.")
3583
4097
  },
3584
- []
4098
+ ["kind", "name"]
3585
4099
  ),
3586
4100
  rewards: arraySchema(
3587
4101
  objectSchema(
@@ -4408,7 +4922,7 @@ var shapeWorkTool = defineQuestlogTool({
4408
4922
  title: stringSchema("Questline title."),
4409
4923
  description: stringSchema("Optional questline description.")
4410
4924
  },
4411
- []
4925
+ ["title"]
4412
4926
  ),
4413
4927
  rumorId: integerSchema("Rumor to settle, dismiss, or reopen."),
4414
4928
  dismissedAt: integerSchema("Optional dismissal timestamp."),
@@ -4667,6 +5181,7 @@ var tagWorkTool = defineQuestlogTool({
4667
5181
 
4668
5182
  // src/tools/tool_registry.ts
4669
5183
  var questlogTools = [
5184
+ manageHooksTool,
4670
5185
  searchQuestlogTool,
4671
5186
  reviewQuestlogTool,
4672
5187
  inspectQuestlogItemTool,
@@ -4690,7 +5205,7 @@ var questlogSurfaceTools = questlogTools.map(
4690
5205
  whenNotToUse: tool.whenNotToUse ?? "",
4691
5206
  readOnly: tool.readOnly,
4692
5207
  sideEffects: tool.sideEffects,
4693
- inputSchema: { ...tool.inputSchema, type: "object" },
5208
+ inputSchema: tool.inputSchema,
4694
5209
  handler: tool.handler
4695
5210
  })
4696
5211
  );
@@ -4725,47 +5240,6 @@ function isErrorResult(result) {
4725
5240
  return typeof result === "object" && result !== null && "ok" in result && result.ok === false;
4726
5241
  }
4727
5242
 
4728
- // src/surfaces/validate_input.ts
4729
- function validateInput(schema, input, path = "$") {
4730
- const errors = [];
4731
- const matchesType = (type) => {
4732
- switch (type) {
4733
- case "object":
4734
- return typeof input === "object" && input !== null && !Array.isArray(input);
4735
- case "array":
4736
- return Array.isArray(input);
4737
- case "integer":
4738
- return typeof input === "number" && Number.isInteger(input);
4739
- case "number":
4740
- return typeof input === "number" && Number.isFinite(input);
4741
- case "null":
4742
- return input === null;
4743
- default:
4744
- return typeof input === type;
4745
- }
4746
- };
4747
- if (schema.type && !(Array.isArray(schema.type) ? schema.type : [schema.type]).some(matchesType)) {
4748
- return [`${path}: expected ${String(schema.type)}`];
4749
- }
4750
- if (schema.enum && !schema.enum.some((value) => JSON.stringify(value) === JSON.stringify(input)))
4751
- errors.push(`${path}: expected one of ${JSON.stringify(schema.enum)}`);
4752
- if (schema.oneOf && !schema.oneOf.some((branch) => validateInput(branch, input, path).length === 0))
4753
- errors.push(`${path}: must match at least one schema branch`);
4754
- if (typeof input === "object" && input !== null && !Array.isArray(input)) {
4755
- const record = input;
4756
- for (const name of schema.required ?? [])
4757
- if (!Object.hasOwn(record, name)) errors.push(`${path}.${name}: required`);
4758
- for (const [name, property] of Object.entries(schema.properties ?? {}))
4759
- if (Object.hasOwn(record, name))
4760
- errors.push(...validateInput(property, record[name], `${path}.${name}`));
4761
- }
4762
- if (Array.isArray(input) && schema.items)
4763
- input.forEach((value, index) => {
4764
- errors.push(...validateInput(schema.items, value, `${path}[${index}]`));
4765
- });
4766
- return errors;
4767
- }
4768
-
4769
5243
  // src/surfaces/mcp_server.ts
4770
5244
  var MCP_LATEST_PROTOCOL_VERSION = "2026-07-28";
4771
5245
  var MCP_SUPPORTED_PROTOCOL_VERSIONS = [
@@ -4865,7 +5339,15 @@ Not for: ${tool.whenNotToUse}` : ""),
4865
5339
  const input = params.arguments === void 0 ? {} : params.arguments;
4866
5340
  const errors = validateInput(tool.inputSchema, input);
4867
5341
  if (errors.length)
4868
- return result({ content: [{ type: "text", text: errors.join("\n") }], isError: true });
5342
+ return result({
5343
+ content: [
5344
+ {
5345
+ type: "text",
5346
+ text: `${errors.join("\n")}; pass ${JSON.stringify(minimalInput(tool.inputSchema, input?.action))}`
5347
+ }
5348
+ ],
5349
+ isError: true
5350
+ });
4869
5351
  try {
4870
5352
  context ??= Promise.resolve().then(options.context);
4871
5353
  const value = await tool.handler(await context, input);