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